From 2dcfaf09a06fddbfc84b0b1b5b1e007e2a4f05ef Mon Sep 17 00:00:00 2001 From: Bob Dickinson Date: Sun, 19 Jul 2026 10:09:06 -0700 Subject: [PATCH 01/11] docs(cli): add session-oriented CLI v2 design from #1432 Land the living design doc under specification/ so we can iterate there; cross-link from the as-built CLI/TUI launcher and catalog specs. Co-authored-by: Cursor --- specification/v2_catalog_launch_config.md | 2 +- specification/v2_cli_tui_launcher.md | 4 +- specification/v2_cli_v2.md | 434 ++++++++++++++++++++++ 3 files changed, 437 insertions(+), 3 deletions(-) create mode 100644 specification/v2_cli_v2.md diff --git a/specification/v2_catalog_launch_config.md b/specification/v2_catalog_launch_config.md index 6fa0b9a7e..4c99f2336 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 — living design: [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 fb42b296b..44be7ec11 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) — design in [v2_cli_v2.md](v2_cli_v2.md); 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). @@ -94,7 +94,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 JSON to stdout, disconnects, exits. Same surface as v1.5; session-oriented CLI v2 is future work ([design](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 000000000..4a9c60a14 --- /dev/null +++ b/specification/v2_cli_v2.md @@ -0,0 +1,434 @@ +# Inspector CLI v2 (session-oriented design) + +### [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 (as-built)](v2_cli_tui_launcher.md) | CLI v2 design | [Catalog / launch config](v2_catalog_launch_config.md) + +Living design doc for the **session-oriented Inspector CLI** (connect once, many subcommands). Tracked by [#1432](https://github.com/modelcontextprotocol/inspector/issues/1432). Sourced from that issue; **iterate here** — the issue remains the board card / umbrella. + +The following describes the **planned next-generation Inspector command-line client**: what exists today, what we want to build, why, and how. CLI v2 is new work in **`clients/cli`** on shared **`core/`** (`@inspector/core`). + +Our goal is for the Inspector v2 CLI to fully support all aspects and modalities of MCP (as supported by the TUI and Web Inspectors), including complex operations and multiple operations per server connection. In addition, the Inspector v2 CLI will expose all aspects of MCP in a way that can be leveraged by production hosts to access those MCP aspects and modalities even when the host does not directly support them via its MCP implementation (with the host using the Inspector CLI via its CLI mechanism). + +This approach was inspired by [mcpc](https://github.com/apify/mcpc) from Apify. + +**Related docs:** [CLI, TUI, and Launcher](v2_cli_tui_launcher.md) (as-built one-shot CLI), [Catalog and Launch Configuration](v2_catalog_launch_config.md), [Storage](v2_storage.md), [Auth](v2_auth.md), [`clients/cli/README.md`](../clients/cli/README.md). + +--- + +## 1. Introduction + +The Inspector CLI today is a **one-shot** client: each command connects to an MCP server, performs **one** operation, prints JSON, and exits. CLI v2 adds a **session-oriented** model—**connect once**, run **many** commands against a **named session**, then **disconnect**—on the same **`InspectorClient`** stack the web and TUI already use. + +### 1.1 How the Inspector CLI works today + +The launcher selects CLI mode with `--cli`. Every invocation is self-contained: + +1. Parse **which server** to use (`--config` + `--server`, or a URL, plus transport/headers/env—see [v2_catalog_launch_config.md](v2_catalog_launch_config.md)). +2. Require **`--method`** naming a single MCP operation. +3. **`connect()`** → perform that operation → print **`JSON.stringify(result)`** to stdout → **`disconnect()`** → exit. + +There is no persistent session and no subcommands—only “pick method + args.” + +**Example: list tools, then call one** + +Each line below is a **separate process** with its own spawn, handshake, and teardown: + +```bash +# List tools (connect → tools/list → disconnect) +inspector --cli \ + --config mcp.json --server myserver \ + --method tools/list + +# Call a tool (connect → tools/call → disconnect) +inspector --cli \ + --config mcp.json --server myserver \ + --method tools/call \ + --tool-name search \ + --tool-arg query=hello +``` + +That works well for **scripts and CI** that need one JSON blob per run. It is awkward when you want to **explore a server**, run **several related steps**, or use MCP features that span **multiple RPCs over one connection** (tasks, subscriptions, OAuth that you do not want to repeat every time). + +Supported `--method` values and flags are listed in §3.2; see [clients/cli/README.md](../clients/cli/README.md) for more examples. + +### 1.2 CLI v2 session workflow + +CLI v2 supports a **session-oriented** workflow alongside v1 one-shots: + +1. **Connect** to a server and register a **named session** (conventionally `@name`). +2. Run **many subcommands** against that session without reconnecting. +3. **Disconnect** when finished. + +Unlike v1, connection setup happens **once per session**; subsequent commands reuse that MCP connection. A background **session daemon** (§5.3) holds each **`InspectorClient`**; it starts **automatically**—users run `connect` and session commands only, with no separate “start daemon” step. + +**Example: connect, run commands, disconnect** (illustrative command names; see §5.4) + +```bash +mcp connect --config mcp.json --server myserver @myserver +mcp @myserver tools list +mcp @myserver tools call search query=hello +mcp @myserver tasks get task-abc123 +mcp @myserver disconnect +``` + +v1 **`--method`** one-shots remain for backward compatibility **and** work alongside session mode—see §2, §5, and §6. + +### 1.3 Why session-oriented CLI matters + +**Exercise a server across multiple operations.** Real workflows are rarely a single RPC: list tools, inspect a schema, call a tool, read a resource, adjust log level, then call again. Session mode matches how people use `kubectl`, `docker`, or database CLIs—a stable **context** and many commands—instead of repeating server selection and handshake on every line of a script or manual debugging session. + +**Exercise advanced MCP client behavior.** Some protocol features only make sense **across calls on one connection**: + +- **Tasks** — launch a long-running operation (often via a task-augmented tool call), poll **`tasks/get`**, fetch **`tasks/result`**, or **`tasks/cancel`** while the server keeps state. +- **Subscriptions** — **`resources/subscribe`**, then observe **`notifications/resources/updated`** over time. +- **Logging** — set level once, then **`logging tail`** (follow **`notifications/message`**) during later tool calls. +- **OAuth** — authenticate once per session instead of on every ephemeral invocation. + +**Example: long-running task over one session** (illustrative CLI v2 ergonomics; exact command names TBD): + +```bash +mcp connect --config mcp.json --server batch @batch +mcp @batch tools call start_export format=json # may return a task id +mcp @batch tasks get export-task-42 # poll status +mcp @batch tasks result export-task-42 # fetch result when done +mcp @batch disconnect +``` + +That sequence is impossible to express cleanly with v1’s one-shot **`--method`** model—you would need a custom script that reconnects and somehow preserves server-side task identity across separate processes. + +The web and TUI Inspector clients already do this interactively. CLI v1 exposes only a narrow one-shot subset; CLI v2 closes the **ergonomics** gap, not a gap in core protocol support (§7). + +**Access full MCP via CLI when the host lacks native support.** Agent hosts often implement only part of the MCP **client** protocol—commonly tools, sometimes resources or prompts, rarely tasks, subscriptions, roots, completions, or sampling/elicitation. The **server** may still expose the full feature set. Inspector CLI v2’s job is to let an agent reach **any** of those server capabilities through **documented CLI commands**, using a shell tool the host already provides, instead of requiring the host to add native MCP client support for every feature and modality. + +The model does not need a **`tasks/get`** (or **`prompts/get`**, **`resources/subscribe`**, …) RPC on its primary MCP connection. It runs Inspector CLI; the CLI uses **`InspectorClient`** to speak MCP to the server and returns JSON the model can parse. Native MCP where the host supports it; CLI bridge where it does not—same server, full protocol surface available to the agent. + +**Example: tasks via CLI when the host has tools MCP only** + +The agent’s MCP connection supports **`tools/*`** but not tasks. The user asks for a long export. The model uses the CLI session to call the tool, poll task status, and fetch the result: + +```bash +mcp connect --config mcp.json --server jobs @jobs +mcp @jobs tools call start_export format=json +mcp @jobs tasks get export-task-42 +mcp @jobs tasks result export-task-42 +``` + +The same pattern applies to any modality the host omits: **`prompts get`**, **`resources subscribe`**, **`logging tail`**, and so on—whatever **`InspectorClient`** supports and the server advertises, exposed as CLI subcommands rather than native MCP in the agent runtime. + +--- + +## 2. Summary + +CLI v2 evolves **`clients/cli` in place**: session-oriented subcommands on **`InspectorClient`**, with v1 **`--method`** one-shots preserved (§6). + +| | **Today (CLI v1)** | **Target (CLI v2)** | +| --------------------- | ---------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Package** | `clients/cli` → `@modelcontextprotocol/inspector-cli` | Same package and launcher; add **`mcp`** command (§5.2) | +| **Process model** | Connect → **one** MCP operation → disconnect **per invocation** | **Session:** connect once, many commands on a named `@session` (implicit daemon, §5.3); **ephemeral:** v1 one-shots unchanged | +| **Protocol coverage** | tools, resources, prompts, `logging/setLevel` (one-shot only) | Full client surface aligned with **TUI / web** (`InspectorClient` + state managers)—tools, resources, prompts, tasks, logging, roots, subscriptions, OAuth, completions, etc. (phased) | +| **Use cases** | Scripts, CI, agents needing a single JSON result per run | Multi-step server exercise; advanced MCP over one connection (tasks, subscriptions, …); agents reach **any server capability via CLI** when the host lacks native MCP support for that feature | +| **Output** | JSON to stdout | JSON by default; human-readable mode where useful | +| **Implementation** | Commander wrapper over **`InspectorClient`** | Same package; **`clients/cli/src/daemon/`** for session daemon + IPC (§7.2); subcommands, v1 compat layer | + +--- + +## 3. What we have today (CLI v1) + +§1.1 covers the user-facing behavior; this section is the implementation reference. + +### 3.1 Code location and shape + +| Piece | Location | +| ----------------------------- | ------------------------------------------------------------------------- | +| Entry / argument parsing | `clients/cli/src/cli.ts` | +| Launcher forwarding | `clients/launcher/src/index.ts` (`--cli` → CLI workspace) | +| MCP client | `@inspector/core` → **`InspectorClient`** | +| Transport + config resolution | `core/mcp/node/config.ts` (`resolveServerConfigs`), `createTransportNode` | +| Tests | `clients/cli/__tests__/` (Vitest, stdio + HTTP against test servers) | + +Each invocation: + +1. Parses **which server** to use (shared flags: `--config`, `--server`, positional command/URL, `--transport`, headers, env—see [v2_catalog_launch_config.md](v2_catalog_launch_config.md)). +2. Requires **`--method`** naming an MCP-style operation. +3. Constructs **`InspectorClient`**, **`connect()`**, performs **exactly one** operation, prints **`JSON.stringify(result)`**, **`disconnect()`**, exits. + +There is **no** persistent session, **no** subcommands beyond “pick method + args”, and **no** interactive shell. + +### 3.2 Supported operations (v1) + +`--method` must be one of (see error text in `cli.ts`): + +| `--method` | Purpose | Extra flags | +| -------------------------- | ----------------------- | ------------------------------------------------------------ | +| `tools/list` | List tools | `--metadata` | +| `tools/call` | Call a tool by name | `--tool-name`, `--tool-arg`, `--tool-metadata`, `--metadata` | +| `resources/list` | List resources | `--metadata` | +| `resources/read` | Read a resource | `--uri`, `--metadata` | +| `resources/templates/list` | List resource templates | `--metadata` | +| `prompts/list` | List prompts | `--metadata` | +| `prompts/get` | Get a prompt | `--prompt-name`, `--prompt-args`, `--metadata` | +| `logging/setLevel` | Set server log level | `--log-level` | + +Anything else throws **Unsupported method**. + +Internally, list operations use core **state managers** (`ManagedToolsState`, etc.) for a single refresh; **`tools/call`** resolves the tool **by name** from that list, then calls **`InspectorClient.callTool(tool, …)`**—the same pattern TUI uses when invoking a tool from the managed list. + +### 3.3 What v1 does **not** cover (but core already does) + +`InspectorClient` and the TUI/web clients already support (or are adding) capabilities **not** exposed on the CLI today, for example: + +- **Tasks** (list/get/cancel, task-augmented tool calls, streaming progress) +- **Sampling / elicitation** (client-as-requestor flows) +- **Resource subscriptions** and **list-changed** notifications +- **Roots** (`getRoots`, `setRoots` on the client; answers server `roots/list` requests) +- **Completions** (prompt/resource argument completion) +- **OAuth** flows and token management as first-class UX +- **Pagination** (`listTools` / `listResources` with cursors) +- **Structured tool results**, metadata on prompts/resources beyond what v1 passes through + +CLI v1 is intentionally minimal. The gap is **CLI surface area and session ergonomics** (§1.3), not missing protocol support in core. + +--- + +## 4. Goals and principles + +1. **One CLI product** — Evolve v1 inside **`clients/cli`** (same npm package and launcher entry). +2. **Core-first** — All MCP I/O through **`InspectorClient`** and the same **state managers** TUI/web use (`ManagedToolsState`, `MessageLogState`, etc.). CLI v2 **composes** existing APIs (list → resolve entity → call/read/get); it does not depend on new core helpers. Do not bypass core with a raw SDK `Client` in the CLI layer. +3. **Two lifecycles, one implementation** + - **Ephemeral:** connect → one operation (or fixed sequence) → disconnect — **backward compatible** with today’s `inspector --cli … --method …`. + - **Session:** named session, many commands; **implicit session daemon** (§5.3) holds the connection—no user-managed start step. +4. **Parity trajectory** — CLI v2 should be able to expose the same MCP **client** capabilities as TUI/web over time (phased commands, not a big-bang). +5. **Shared configuration and auth** — Same **`mcp.json`** / CLI flags as today ([v2_catalog_launch_config.md](v2_catalog_launch_config.md)); same **`OAuthManager`** and Node credential storage as other Node clients ([v2_storage.md](v2_storage.md) / [v2_auth.md](v2_auth.md)). +6. **Automation-safe** — JSON output, stable exit codes; in **scripts and CI**, require an explicit `@session` (or `--session @name`)—do not rely on default-session resolution unless opted in via env (§5.6). +7. **No interactive shell** — Each command is a separate **`mcp …`** invocation (session commands talk to the implicit daemon over IPC). No REPL, no **`shell`** subcommand—now or later. Interactive exploration remains the job of **TUI / web**; automation and agents use subprocess + JSON. + +--- + +## 5. Target architecture + +V2 examples in this section use **`mcp`** (§5.2). **`inspector --cli`** and **`mcp-inspector --cli`** remain supported for backward compatibility (§6). + +### 5.1 High-level picture + +``` + ┌─────────────────────────────────────┐ + │ mcp (primary CLI command, §5.2) │ + │ inspector --cli (launcher, compat)│ + └─────────────────┬───────────────────┘ + │ + ┌─────────────────▼──────────-─────────┐ + │ CLI v2 (Commander / subcommands) │ + │ • session admin (connect, list, …) │ + │ • MCP commands (tools, resources, …)│ + │ • compat layer (--method …) │ + └─────────┬───────────────┬──────-─────┘ + │ │ + ephemeral │ │ session mode + (in-process) │ │ + ▼ ▼ + ┌───────────---───┐ ┌───────────────-───┐ + │ InspectorClient │ │ Session daemon │ + │ connect → op │ │ (one Client each) │ + │ → disconnect │ │ ◄── IPC ──► CLI │ + └───────────---───┘ └─────────┬─────────┘ + │ │ + └────-──-───┬─────────┘ + ▼ + @inspector/core + (transport, OAuth, state managers) +``` + +### 5.2 The `mcp` command + +Today the CLI is reached through the **launcher**: `inspector --cli …` (installed binary **`mcp-inspector`**, plus **`mcp-inspector-cli`** from the CLI workspace). CLI v2 adds **`mcp`** as the primary command—it runs the **same CLI entrypoint** the launcher forwards to, without the `--cli` flag. + +| Command | What runs | +| ------- | --------- | +| **`mcp …`** | **`clients/cli`** directly—primary v2 UX | +| `inspector --cli …` | Launcher → `clients/cli` (unchanged) | +| `mcp-inspector --cli …` | Same as above (unchanged) | + +**Implementation** — no change to how we publish **`@modelcontextprotocol/inspector`**. Add a **`bin`** entry on the root package (and matching entry on **`@modelcontextprotocol/inspector-cli`**) pointing at **`clients/cli/build/index.js`**, alongside existing bins. Update Commander’s program name to **`mcp`** for help text. + +### 5.3 Session daemon (implicit) + +Session mode uses a **session daemon**—a long-lived Node process that owns live MCP connections. **Users do not start or manage it in normal use.** The first session-mode need (typically **`connect`**, or a command against an existing session) **auto-spawns** the daemon in the background if it is not already running; later **`mcp`** invocations attach over IPC (Unix domain socket or platform equivalent). + +**Daemon responsibilities** + +- Own **one `InspectorClient` per named session** +- Accept **IPC** from short-lived CLI front-end processes +- Forward **list/call/read/…** to the right client and return JSON (or structured errors) +- Handle **notifications** (logging, list-changed) and optionally broadcast them to attached clients + +**Lifecycle** + +- **`mcp connect`** may bootstrap **daemon + session in one step**—no prior daemon required. +- **Single instance** per user/machine (socket + lock file); stale socket after crash is detected and recovered on next command. +- **Shutdown:** daemon exits when all sessions are **`disconnect`**ed and an **idle timeout** elapses (duration TBD), or when the last open session closes—whichever policy we implement. +- **Ephemeral mode** (v1 **`--method`** one-shots) **never** uses the daemon; **`InspectorClient`** runs in-process and exits. + +**When users need visibility** (optional, not required for everyday use) + +- **`mcp daemon status`** — is the daemon running, active sessions, socket path +- **`mcp daemon stop`** — tear down daemon and all sessions (troubleshooting) +- **`mcp daemon run`** (optional) — run daemon in foreground for debugging + +The daemon uses **`InspectorClient`** with the same OAuth and transport setup as TUI. Secrets are **not** passed on **`argv`** when spawning sessions over IPC (see §5.7). + +### 5.4 Command surface (planned categories) + +Exact names are TBD; illustrative grouping: + +| Category | Examples | Core backing | +| ------------- | ------------------------------------------------------------------------------------- | ------------------------------------------------------- | +| **Session** | `connect`, `disconnect`, `sessions list`, `session use`; optional `daemon status` / `daemon stop` | Implicit daemon (§5.3) + session store | +| **Tools** | `tools list`, `tools call`, `tools call --task` | **`ManagedToolsState`**, `callTool`, `callToolStream` | +| **Resources** | `resources list`, `resources read`, `resources templates list`, `resources subscribe` | `listResources`, `readResource`, subscriptions | +| **Prompts** | `prompts list`, `prompts get`, `prompts complete` | `listPrompts`, `getPrompt`, `getCompletions` | +| **Logging** | `logging set-level`, `logging tail` (follow notifications) | `setLoggingLevel`, **`MessageLogState`** | +| **Tasks** | `tasks list`, `tasks get`, `tasks cancel`, `tasks result` | **`ManagedRequestorTasksState`**, `callToolStream`, … | +| **Auth** | `auth login`, `auth logout`, `auth status` | **`OAuthManager`** (via `connect`), Node storage | +| **Roots** | `roots list`, `roots set` | **`getRoots`**, **`setRoots`** | +| **Compat** | Hidden or documented: `--method tools/list` … | Maps to same handlers as subcommands | + +Global flags (all modes): `--json`, `--config`, `--server`, verbosity, `--session @name` (or positional `@name`). + +### 5.5 Config and server selection + +**Unchanged from v1:** resolution via `resolveServerConfigs` and shared flags documented in [v2_catalog_launch_config.md](v2_catalog_launch_config.md). + +**Session records** store at minimum: + +- Session **name** (user-chosen, `@`-prefixed in UX) +- **Server identity** (config path + server id, or normalized ad-hoc target) +- **Connection state** (in daemon) +- **Last accessed** timestamp — updated whenever the user (or agent) runs a command against that session; drives default session for `@`-less commands (§5.6) + +### 5.6 Default session (`@`-less commands) + +When a session-mode command omits `@name` (e.g. `mcp tools list` instead of `mcp @myserver tools list`), the CLI targets the **most recently accessed session**—the session last used by any session-scoped command, including **`connect`**, an explicit **`@name …`**, or **`sessions use @name`**. + +- **Update last-accessed** on every command that successfully targets a named session (including `connect`, which creates or reopens that session). +- **No sessions** → clear error telling the user to `connect` first. +- **Automation / CI** → default session is **disabled** unless explicitly opted in (non-TTY, or `MCP_SESSION=@name` set, or `--session @name` passed). Pipelines must not accidentally hit the wrong server because an interactive user connected to something else earlier on the same machine. +- **Explicit `@name` always wins** when provided; default session is a convenience for interactive use and agent sessions where the model has been working in one context. + +```bash +mcp connect --config mcp.json --server a @alpha +mcp connect --config mcp.json --server b @beta +mcp @alpha tools list # last accessed → @alpha +mcp tools list # same (still @alpha) +mcp @beta resources list # last accessed → @beta +mcp tools call search q=hello # uses @beta +``` + +### 5.7 OAuth and secrets + +CLI v2 must use the same paths as other Node clients: + +- **`OAuthManager`** inside **`InspectorClient.connect()`** — no CLI-only OAuth stack +- **Node `OAuthStorage`** in core (same store as TUI; see [v2_storage.md](v2_storage.md) / [v2_auth.md](v2_auth.md)) + +Daemon startup must **not** put secrets on **`argv`** (stdio `env`/`args`, HTTP headers): pass full server config over IPC after the socket is listening, before `connect()`. + +--- + +## 6. Backward compatibility (CLI v1) + +### 6.1 Requirement + +Existing scripts and docs that use: + +```bash +inspector --cli … --method tools/list +``` + +must **keep working** through a **compatibility entrypoint** (same flags, same JSON shape on stdout, same exit behavior on error). **`mcp … --method …`** is equivalent (§5.2). + +### 6.2 Implementation approach + +- **Ephemeral handler** shared with v2 subcommands: parse v1 argv → build `MCPServerConfig` → `InspectorClient` → dispatch by `--method` → print JSON. +- v1 **`--method`** table (§3.2) remains supported; new methods may be added under `--method` **or** only under subcommands (document per method). +- Deprecation: if we later prefer subcommands in docs, v1 flags stay until a **major** Inspector release with a published migration guide. + +### 6.3 Mapping (v1 → v2 ergonomics) + +| CLI v1 | CLI v2 (illustrative) | +| --------------------------------------------------------------- | ------------------------------------------------------------- | +| `inspector --cli … --method tools/list` | `mcp tools list` (ephemeral) **or** `mcp @s tools list` (session) | +| `inspector --cli … --method tools/call --tool-name X --tool-arg k=v` | `mcp tools call X k=v` | +| `… --config mcp.json --server myserver --method resources/list` | `mcp connect myserver` then `mcp @myserver resources list` | + +Session mode is **opt-in**; v1 one-liners remain valid without `connect`. + +--- + +## 7. Building on core (same stack as TUI / web) + +CLI v2 does **not** require new MCP capabilities in **`@inspector/core`** / `core/`. Web and TUI already cover the full client feature set through **`InspectorClient`** and optional **state managers** (`core/mcp/state/` + [v2_cli_tui_launcher.md](v2_cli_tui_launcher.md)). CLI v2 adds **subcommands**, **session lifecycle**, **output formatting**, and optionally a **session daemon**—composition work in **`clients/cli`**, not protocol gaps in core. + +### 7.1 Patterns to reuse (not reimplement) + +| CLI need | How v1 / TUI / web do it today | +| -------- | ------------------------------ | +| **Call tool by name** | **`ManagedToolsState`** (or `listTools`) → find by `name` → **`callTool(tool, args, …)`**. v1 already does this in `clients/cli/src/cli.ts`; TUI passes the selected **`Tool`** from the managed list. Scripts pass a name; the CLI resolves it from the cached list—the same pattern as picking a tool in the UI. | +| **List tools / resources / prompts** | **`Managed*State`** for full lists (auto-refresh on `*ListChanged`), or **`Paged*State`** / cursor loops on **`listTools(cursor)`** etc. v1 uses managed states for a one-shot refresh per invocation. | +| **Logging tail** | **`MessageLogState`** subscribes to protocol **`message`** events (includes server logging via **`notifications/message`**). Stream or filter entries for `logging tail`. | +| **Tasks** | **`ManagedRequestorTasksState`** / **`PagedRequestorTasksState`**; streaming tool calls via **`callToolStream`**. | +| **Roots** | **`getRoots()`** / **`setRoots()`** on **`InspectorClient`**. | +| **Subscriptions, completions, OAuth** | Direct **`InspectorClient`** methods and events; Node credential storage per [v2_storage.md](v2_storage.md) / [v2_auth.md](v2_auth.md)—same as TUI. | + +### 7.2 Where CLI v2 work actually lives + +All implementation belongs in **`clients/cli`**—one **`npm`** package (**`@modelcontextprotocol/inspector-cli`**), same publish story as today. No separate **`cli-daemon`** workspace. + +**Layout** + +| Area | Location | +| ---- | -------- | +| CLI front-end (Commander, subcommands, v1 compat) | `clients/cli/src/` (existing + new command modules) | +| Session daemon (IPC server, session registry, auto-spawn) | **`clients/cli/src/daemon/`** | +| Shared command handlers | Callable from both ephemeral CLI and daemon IPC paths | + +The daemon subdirectory owns long-lived process management; the CLI front-end stays short-lived subprocess invocations talking to it over IPC (§5.3). **`mcp`** and the daemon share types and handlers in-process within the same package—no cross-workspace imports. + +**Responsibilities** + +- Subcommand tree and argv parsing (Commander) +- Ephemeral vs session-daemon process model and IPC +- JSON / human-readable output and exit codes +- v1 **`--method`** compatibility routing to shared handlers +- Per-session wiring of **`InspectorClient`**, **`createTransportNode`**, **`resolveServerConfigs`**, and the state managers above + +If a workflow feels awkward, check how TUI/web solve it with existing core APIs before proposing core changes. + +--- + +## 8. Phased delivery + +**Note:** The one-shot CLI/TUI/launcher from v1.5 are already on `v2/main` (see [v2_cli_tui_launcher.md](v2_cli_tui_launcher.md)). Session-oriented CLI v2 builds on that baseline. + +Suggested phases (adjust in planning): + +| Phase | Deliverable | +| -------------------------- | ---------------------------------------------------------------------------------------------- | +| **0 — Design** | This doc; command naming RFC; daemon IPC sketch | +| **1 — Compat + structure** | Commander subcommand tree; v1 `--method` routed through shared handlers; **`mcp` bin** (§5.2); tests ported/extended | +| **2 — Session daemon** | Implicit auto-spawn; `connect` / `disconnect` / `sessions list`; IPC; `@session` on subcommands | +| **3 — Breadth** | Tools/resources/prompts/logging subcommands via managed/paged state managers; pagination, structured content | +| **4 — Advanced** | Tasks, subscriptions, roots, completions, OAuth UX—same **`InspectorClient`** + managers as TUI | +| **5 — Polish** | Docs, launcher help text, ergonomics polish | + +Each phase should ship test coverage (Vitest + test-servers harness, same as v1). + +--- + +## 9. Testing and documentation + +- **Unit / integration:** Extend `clients/cli/__tests__/`; use `@modelcontextprotocol/inspector-test-server` composable fixtures where possible. +- **Session daemon:** Separate test suite with mock IPC or in-process daemon for CI. +- **Docs:** Update [clients/cli/README.md](../clients/cli/README.md) with v2 command reference; keep a **“Legacy `--method` interface”** section until removal. +- **Cross-links:** [v2_catalog_launch_config.md](v2_catalog_launch_config.md) unchanged except examples showing session commands. + From 10b6e25316fcdb2b5166d6716bb10fcfc08b8bb9 Mon Sep 17 00:00:00 2001 From: Bob Dickinson Date: Sun, 19 Jul 2026 17:32:15 -0700 Subject: [PATCH 02/11] Changes from prev branch (baseUrl fix) --- clients/cli/tsconfig.json | 1 - clients/tui/tsconfig.json | 1 - clients/web/tsconfig.app.json | 4 +++- clients/web/tsconfig.node.json | 4 +++- tsconfig.base.json | 3 +-- 5 files changed, 7 insertions(+), 6 deletions(-) diff --git a/clients/cli/tsconfig.json b/clients/cli/tsconfig.json index 6c9071844..3f1d5b5e2 100644 --- a/clients/cli/tsconfig.json +++ b/clients/cli/tsconfig.json @@ -2,7 +2,6 @@ "extends": "../../tsconfig.base.json", "compilerOptions": { "noEmit": true, - "baseUrl": ".", "paths": { "@inspector/core/*": ["../../core/*"] } diff --git a/clients/tui/tsconfig.json b/clients/tui/tsconfig.json index 531a0ddef..6f42313b1 100644 --- a/clients/tui/tsconfig.json +++ b/clients/tui/tsconfig.json @@ -4,7 +4,6 @@ "jsx": "react-jsx", "outDir": "./build", "rootDir": ".", - "baseUrl": ".", "paths": { "@inspector/core/*": ["../../core/*"] } diff --git a/clients/web/tsconfig.app.json b/clients/web/tsconfig.app.json index 62da4bd08..df982811f 100644 --- a/clients/web/tsconfig.app.json +++ b/clients/web/tsconfig.app.json @@ -29,8 +29,10 @@ * during `tsc -b`. We point at the @types declaration packages so the * imports type-check; Vite's `dedupe` handles the runtime resolution — * see vite.config.ts. + * + * Paths are relative to this config file (no `baseUrl` — deprecated in + * TypeScript 6 / removed in 7). */ - "baseUrl": ".", "paths": { "@inspector/core/*": ["../../core/*"], "react": ["./node_modules/@types/react"], diff --git a/clients/web/tsconfig.node.json b/clients/web/tsconfig.node.json index a2ff33462..813891214 100644 --- a/clients/web/tsconfig.node.json +++ b/clients/web/tsconfig.node.json @@ -31,8 +31,10 @@ * start-vite-dev-server.ts) live in src/ and import from @inspector/core/*; * tsc follows those transitive imports while type-checking vite.config.ts, * so this tsconfig needs the same alias tsconfig.app.json has. + * + * Paths are relative to this config file (no `baseUrl` — deprecated in + * TypeScript 6 / removed in 7). */ - "baseUrl": ".", "paths": { "@inspector/core/*": ["../../core/*"], "pino": ["./node_modules/pino"], diff --git a/tsconfig.base.json b/tsconfig.base.json index a3718b946..e91751f60 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -9,9 +9,8 @@ "forceConsistentCasingInFileNames": true, "resolveJsonModule": true, "noUncheckedIndexedAccess": true, - "baseUrl": ".", "paths": { - "@inspector/core/*": ["core/*"] + "@inspector/core/*": ["./core/*"] } } } From 82b10fe21f27266a14cba912079982ed2b8fb52c Mon Sep 17 00:00:00 2001 From: Bob Dickinson Date: Sun, 19 Jul 2026 17:32:35 -0700 Subject: [PATCH 03/11] First cut of session-oriented CLI --- AGENTS.md | 6 +- README.md | 7 +- clients/cli/README.md | 83 +- clients/cli/__tests__/README.md | 9 +- clients/cli/__tests__/authorize.test.ts | 66 ++ .../__tests__/cli-oauth-navigation.test.ts | 114 +++ clients/cli/__tests__/cliOAuth.test.ts | 71 ++ clients/cli/__tests__/daemon-coverage.test.ts | 747 ++++++++++++++++ clients/cli/__tests__/daemon-paths.test.ts | 99 +++ clients/cli/__tests__/daemon-private.test.ts | 191 ++++ clients/cli/__tests__/daemon-sessions.test.ts | 252 ++++++ clients/cli/__tests__/daemon-stream.test.ts | 294 +++++++ clients/cli/__tests__/dispatch.test.ts | 141 +++ clients/cli/__tests__/emit-result.test.ts | 35 + clients/cli/__tests__/format-session.test.ts | 665 ++++++++++++++ clients/cli/__tests__/helpers/mcp-runner.ts | 88 ++ clients/cli/__tests__/hoist-session.test.ts | 22 + .../cli/__tests__/mcp-auth-coverage.test.ts | 213 +++++ clients/cli/__tests__/mcp-coverage.test.ts | 368 ++++++++ clients/cli/__tests__/mcp-session.test.ts | 207 +++++ clients/cli/__tests__/open-url.test.ts | 28 + clients/cli/__tests__/parse-tool-args.test.ts | 119 +++ .../__tests__/programmatic-ergonomics.test.ts | 46 + .../cli/__tests__/run-method-mocks.test.ts | 232 +++++ clients/cli/__tests__/run-method.test.ts | 249 ++++++ clients/cli/__tests__/servers-list.test.ts | 248 ++++++ .../cli/__tests__/session-stored-auth.test.ts | 246 ++++++ clients/cli/__tests__/style.test.ts | 76 ++ clients/cli/package-lock.json | 150 ++++ clients/cli/package.json | 4 +- clients/cli/src/cli-oauth-navigation.ts | 46 + clients/cli/src/cli.ts | 444 ++-------- clients/cli/src/cliOAuth.ts | 36 + clients/cli/src/daemon/auth.ts | 43 + clients/cli/src/daemon/client.ts | 127 +++ clients/cli/src/daemon/ensure.ts | 147 ++++ clients/cli/src/daemon/framing.ts | 28 + clients/cli/src/daemon/index.ts | 36 + clients/cli/src/daemon/ipc-glue.ts | 125 +++ clients/cli/src/daemon/paths.ts | 67 ++ clients/cli/src/daemon/protocol.ts | 97 +++ clients/cli/src/daemon/run.ts | 29 + clients/cli/src/daemon/server.ts | 338 +++++++ clients/cli/src/daemon/sessions.ts | 346 ++++++++ clients/cli/src/daemon/stream-client.ts | 170 ++++ clients/cli/src/handlers/collect-app-info.ts | 35 + clients/cli/src/handlers/connect-timeout.ts | 35 + clients/cli/src/handlers/consume-outcome.ts | 37 + clients/cli/src/handlers/emit-result.ts | 46 + clients/cli/src/handlers/format-output.ts | 18 + clients/cli/src/handlers/method-types.ts | 82 ++ clients/cli/src/handlers/run-method.ts | 310 +++++++ clients/cli/src/handlers/servers-list.ts | 171 ++++ clients/cli/src/mcp-bin.ts | 19 + clients/cli/src/open-url.ts | 6 + clients/cli/src/session/authorize.ts | 93 ++ clients/cli/src/session/dispatch.ts | 114 +++ clients/cli/src/session/format-human.ts | 689 +++++++++++++++ clients/cli/src/session/format-session.ts | 224 +++++ clients/cli/src/session/mcp.ts | 822 ++++++++++++++++++ clients/cli/src/session/parse-tool-args.ts | 132 +++ clients/cli/src/session/private-env.ts | 37 + clients/cli/src/session/stored-auth.ts | 150 ++++ clients/cli/src/session/style.ts | 76 ++ clients/cli/tsup.config.ts | 6 +- clients/cli/vitest.config.ts | 11 + mcp | 4 + package.json | 3 +- specification/v2_catalog_launch_config.md | 2 +- specification/v2_cli_tui_launcher.md | 4 +- specification/v2_cli_v2.md | 504 +++-------- 71 files changed, 10010 insertions(+), 775 deletions(-) create mode 100644 clients/cli/__tests__/authorize.test.ts create mode 100644 clients/cli/__tests__/cli-oauth-navigation.test.ts create mode 100644 clients/cli/__tests__/daemon-coverage.test.ts create mode 100644 clients/cli/__tests__/daemon-paths.test.ts create mode 100644 clients/cli/__tests__/daemon-private.test.ts create mode 100644 clients/cli/__tests__/daemon-sessions.test.ts create mode 100644 clients/cli/__tests__/daemon-stream.test.ts create mode 100644 clients/cli/__tests__/dispatch.test.ts create mode 100644 clients/cli/__tests__/format-session.test.ts create mode 100644 clients/cli/__tests__/helpers/mcp-runner.ts create mode 100644 clients/cli/__tests__/hoist-session.test.ts create mode 100644 clients/cli/__tests__/mcp-auth-coverage.test.ts create mode 100644 clients/cli/__tests__/mcp-coverage.test.ts create mode 100644 clients/cli/__tests__/mcp-session.test.ts create mode 100644 clients/cli/__tests__/open-url.test.ts create mode 100644 clients/cli/__tests__/parse-tool-args.test.ts create mode 100644 clients/cli/__tests__/run-method-mocks.test.ts create mode 100644 clients/cli/__tests__/run-method.test.ts create mode 100644 clients/cli/__tests__/servers-list.test.ts create mode 100644 clients/cli/__tests__/session-stored-auth.test.ts create mode 100644 clients/cli/__tests__/style.test.ts create mode 100644 clients/cli/src/cli-oauth-navigation.ts create mode 100644 clients/cli/src/daemon/auth.ts create mode 100644 clients/cli/src/daemon/client.ts create mode 100644 clients/cli/src/daemon/ensure.ts create mode 100644 clients/cli/src/daemon/framing.ts create mode 100644 clients/cli/src/daemon/index.ts create mode 100644 clients/cli/src/daemon/ipc-glue.ts create mode 100644 clients/cli/src/daemon/paths.ts create mode 100644 clients/cli/src/daemon/protocol.ts create mode 100644 clients/cli/src/daemon/run.ts create mode 100644 clients/cli/src/daemon/server.ts create mode 100644 clients/cli/src/daemon/sessions.ts create mode 100644 clients/cli/src/daemon/stream-client.ts create mode 100644 clients/cli/src/handlers/collect-app-info.ts create mode 100644 clients/cli/src/handlers/connect-timeout.ts create mode 100644 clients/cli/src/handlers/consume-outcome.ts create mode 100644 clients/cli/src/handlers/emit-result.ts create mode 100644 clients/cli/src/handlers/format-output.ts create mode 100644 clients/cli/src/handlers/method-types.ts create mode 100644 clients/cli/src/handlers/run-method.ts create mode 100644 clients/cli/src/handlers/servers-list.ts create mode 100644 clients/cli/src/mcp-bin.ts create mode 100644 clients/cli/src/open-url.ts create mode 100644 clients/cli/src/session/authorize.ts create mode 100644 clients/cli/src/session/dispatch.ts create mode 100644 clients/cli/src/session/format-human.ts create mode 100644 clients/cli/src/session/format-session.ts create mode 100644 clients/cli/src/session/mcp.ts create mode 100644 clients/cli/src/session/parse-tool-args.ts create mode 100644 clients/cli/src/session/private-env.ts create mode 100644 clients/cli/src/session/stored-auth.ts create mode 100644 clients/cli/src/session/style.ts create mode 100755 mcp diff --git a/AGENTS.md b/AGENTS.md index 8cf77bb4c..ac5654172 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -19,6 +19,10 @@ inspector/ │ │ │ # vite-base-config.ts (shared optimizeDeps exclusions) │ │ └── static/ # sandbox_proxy.html (served by sandbox-controller for MCP Apps tab) │ ├── cli/ # CLI client (tsup bundle, @inspector/core alias) +│ │ ├── src/cli.ts # One-shot entry (`mcp-inspector --cli`) +│ │ ├── src/session/ # Session front-end (`mcp` → mcp-bin.ts) +│ │ ├── src/daemon/ # Implicit Unix-socket session daemon +│ │ └── src/handlers/ # Shared runMethod + method types (one-shot + daemon) │ ├── tui/ # TUI client (Ink + React, tsup bundle) │ ├── launcher/ # Shared launcher (relative imports into sibling build/ outputs) ├── core/ # Shared core code (no package.json — consumed via the `@inspector/core` vite alias) @@ -188,7 +192,7 @@ gh project item-edit --project-id PVT_kwDOCt2Azc4BJVxt --id "$ITEM_ID" --field-i - **`validate` is fast: it runs `test`, not `test:coverage`.** The coverage gate (slower — adds v8 instrumentation, and for web the integration project) is a **separate** top-level `npm run coverage` (and per-client `coverage:web` / `coverage:cli` / `coverage:tui` / `coverage:launcher`, each delegating to that client's `test:coverage`). Run `npm run coverage` when you want to reproduce the gate locally before pushing. **CI runs `coverage`** on every push (#1550): the per-file ≥90 gate is CI-enforced, so a PR that drops any file below 90 on lines/statements/functions/branches fails the job. CI runs `validate` (fast) for format/lint/build/unit tests, then `coverage` for the instrumented gate. Because web's `test:coverage` already runs the integration project, CI has no separate `test:integration` step — the integration paths are exercised inside the coverage gate. - Each client's `test:coverage` enforces a **uniform per-file gate of ≥ 90 on all four dimensions** — lines, statements, functions, and branches — across `clients/web`, `clients/cli`, `clients/tui`, and `clients/launcher` (CI enforces this gate). This is the result of a codebase-wide audit: the branch floor was first lifted 50 → 70 for web (#1271), then the whole gate raised to 90 with real tests added for every outlier. Genuinely-unreachable branches are **not** waved through by lowering the gate — they are annotated at the source with a justified `/* v8 ignore … -- */` comment. Acceptable reasons are happy-dom-inherent paths (Mantine portal mount points, `useMediaQuery` fallbacks, `typeof window` SSR guards), React StrictMode effect-replay blocks, and provably-dead defensive guards (e.g. a `?? fallback` for a value the types guarantee non-null, or a `Select.onChange` receiving a value outside the allowed list). New code must clear 90 on every dimension; reach for a justified `v8 ignore` only when a branch is genuinely impossible to exercise. - The **same per-file gate** is enforced for the CLI and TUI (#1484), not just web: - - **CLI** (`clients/cli`): tests run **in-process** by importing `runCli()` (see `__tests__/helpers/cli-runner.ts`) so `clients/cli/src` is measured under v8 instrumentation. A thin out-of-process layer (`__tests__/e2e.test.ts` + `scripts/smoke-cli.mjs`) still spawns the built binary for the shebang/`process.exit` paths; `src/index.ts` (binary bootstrap) is the only coverage exclusion. `commander` uses `.exitOverride()` so a parse error throws instead of tearing down the test worker. + - **CLI** (`clients/cli`): tests run **in-process** by importing `runCli()` / `runMcp()` (see `__tests__/helpers/cli-runner.ts`, `mcp-runner.ts`) so `clients/cli/src` is measured under v8 instrumentation. A thin out-of-process layer (`__tests__/e2e.test.ts` + `scripts/smoke-cli.mjs`) still spawns the built binary for the shebang/`process.exit` paths. Coverage exclusions (bootstraps + hard-to-stabilize accept/stream races): `src/index.ts`, `src/mcp-bin.ts`, `src/daemon/run.ts`, `src/daemon/ipc-glue.ts`, `src/daemon/stream-client.ts` — see `clients/cli/vitest.config.ts` and [specification/v2_cli_v2.md](specification/v2_cli_v2.md) §8.2 for follow-ups. `commander` uses `.exitOverride()` so a parse error throws instead of tearing down the test worker. - **TUI** (`clients/tui`): the gate covers the **non-React logic** only — `logger.ts`, `components/tabsConfig.ts`, and `utils/*` (server resolution lives in `core/` and is measured by the web suite). The Ink components, `App.tsx`, and `hooks/` are an **interim exclusion** in `clients/tui/vitest.config.ts` pending the renderer-based follow-up (#1501). When adding new **non-React** logic under `clients/tui/src`, it falls under the gate automatically — add tests for it. - Run `npm run test:integration` (also from `clients/web/`) for the InspectorClient + transport + auth integration suite. It runs under a separate `integration` vitest project in node env (no happy-dom) with 30s timeouts. The script builds `test-servers/` first via `tsc -p ../../test-servers --noCheck` so the stdio MCP test server can be spawned as a real subprocess. CI does not run `test:integration` as its own step — the integration project is covered by the CI `coverage` gate, whose web `test:coverage` runs `--project=unit --project=integration --coverage`. - Test files live alongside the source as `.test.tsx` (or `.test.ts` for non-React modules). Integration tests live under `clients/web/src/test/integration/`, mirroring the `core/` source layout (`mcp/`, `mcp/node/`, `mcp/remote/`, `auth/`, `auth/node/`, `storage/`). Any test file under that folder is automatically picked up by the `integration` vitest project (node env, 30s timeouts) via the folder glob in `vite.config.ts` — placement is the manifest, there is no enumeration to keep in sync. Tests outside the folder run in the `unit` project (happy-dom). When adding a new test for, e.g., `core/mcp/remote/foo.ts`, put it at `src/test/integration/mcp/remote/foo.test.ts`. diff --git a/README.md b/README.md index 845f4d7de..4c8813eee 100644 --- a/README.md +++ b/README.md @@ -6,12 +6,13 @@ A developer tool for inspecting [Model Context Protocol](https://modelcontextpro - **CLI** — a scriptable command-line client for automation, CI, and fast agent feedback loops. - **TUI** — an interactive terminal UI built with [Ink](https://github.com/vadimdemedes/ink). -All three run through one global `mcp-inspector` binary: +All three run through one global `mcp-inspector` binary. The package also ships a session-oriented `mcp` binary (connect once, many commands): ```bash npx @modelcontextprotocol/inspector # web UI (default) -npx @modelcontextprotocol/inspector --cli # CLI +npx @modelcontextprotocol/inspector --cli # one-shot CLI npx @modelcontextprotocol/inspector --tui # TUI +npx --package=@modelcontextprotocol/inspector mcp servers/list # session CLI ``` > **Repo status.** This is the **v2** line of the Inspector (branch `v2/main`). The `main` branch is the legacy v1 implementation (bug fixes only). v2 will eventually replace `main`. See [`AGENTS.md`](./AGENTS.md) for branch/board conventions. @@ -24,7 +25,7 @@ v2 is **not** an npm workspace. Each client under `clients/*` keeps its own `pac inspector/ ├── clients/ │ ├── web/ # Web client (Vite + React + Mantine). src/ = browser app; server/ = Node dev/prod backend -│ ├── cli/ # CLI client (tsup bundle, @inspector/core alias) +│ ├── cli/ # CLI client — one-shot (`cli.ts`) + session (`session/`, `daemon/`, `handlers/`) │ ├── 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) diff --git a/clients/cli/README.md b/clients/cli/README.md index 7c49d3a35..c6fd6b64a 100644 --- a/clients/cli/README.md +++ b/clients/cli/README.md @@ -1,16 +1,62 @@ # MCP Inspector CLI Client -The CLI mode enables programmatic interaction with MCP servers from the command line. It is ideal for scripting, automation, continuous integration, and establishing an efficient feedback loop with AI coding assistants. +The CLI package provides two entrypoints: -## Running the CLI +- **One-shot** — `mcp-inspector --cli` (frozen contract): connect → one `--method` → disconnect. +- **Session** — `mcp` (new): connect once, then run many commands against a named session. -You can run the CLI client directly via `npx`: +## Session CLI (`mcp`) + +```bash +# From a built tree / published package: +mcp servers/list --config path/to/mcp.json # marks entries with a live session (@name / MRU) +mcp servers/show test-stdio --config path/to/mcp.json +mcp connect test-stdio --config path/to/mcp.json +mcp connect test-stdio --config path/to/mcp.json --relogin # ignore stored OAuth; fresh login if required +mcp auth/list +mcp auth/clear https://example.com/mcp +mcp auth/clear --all --yes +mcp tools/list +mcp tools/call echo message:=hi +mcp tools/call echo '{"message":"hi"}' # or --tool-arg / --tool-args-json +mcp @test-stdio resources/list # explicit session via @name +mcp initialize --session test-stdio +mcp logging/setLevel info +mcp logging/tail # long-lived; Ctrl-C to stop +mcp sessions/list +mcp disconnect --session test-stdio +mcp daemon status +mcp daemon stop + +# Optional: private daemon for this shell only (ssh-agent style) +eval "$(mcp private)" +mcp connect test-stdio --config path/to/mcp.json +mcp tools/list +``` + +Slash methods match one-shot `--method` names (`tools/*`, `resources/*`, `prompts/*`, `logging/*`, `tasks/*`, `roots/*`, `initialize`). Streams (`logging/tail`, `resources/subscribe`) stay attached until Ctrl-C and honour `--format` (human lines or pretty JSON per event). Auth uses the shared `oauth.json` store. On connect, the daemon reuses stored tokens when it can; if auth is still required (or stored credentials cannot complete a silent refresh), `mcp` runs interactive OAuth then retries (pass `--stored-auth-only` to refuse interactive login — also available on one-shot). Use `connect --relogin` (or one-shot `--relogin`) to ignore stored credentials for that connect and run a fresh interactive login when the server requires auth (new tokens are saved). Manage the store with `auth/list` and `auth/clear ` / `auth/clear --all` (`--yes` required for `--all` when non-interactive). On a TTY, the CLI prints a clickable authorization URL (OSC 8) and opens the default browser; non-TTY / CI only prints the plain URL. On the session CLI, authentication is handled at **connect** time only; mid-session re-login / step-up during a later command is available on one-shot `--cli`, not on `mcp`. + +Sessions are kept by a background process under `~/.mcp-inspector/` by default (shared across terminals). Set `MCP_STORAGE_DIR` to isolate parallel runs (daemon + OAuth store). For a daemon that only this shell can use, run `eval "$(mcp private)"` once — later `mcp` commands in that shell inherit the private binding. + +**Output (session only — different from one-shot):** + +| Flag | Behaviour | +| ---- | --------- | +| `--format text` (default) | Human-readable views (tool lists, call results, sessions, …). On a TTY, uses ANSI styling (color, bold/dim, OSC 8 links). | +| `--format json` | Pretty-printed JSON of the payload (no `{ "result" }` envelope; never ANSI) | +| `--plain` | Disable ANSI styling in human text (also honours `NO_COLOR`) | + +Put global flags before the subcommand: `mcp --format json tools/list`, `mcp --plain tools/list`. Isolate parallel runs with `MCP_STORAGE_DIR` (scopes the daemon socket + OAuth store). One-shot `--cli` keeps its own format contract (`text` = pretty JSON, `json` = `{ result }` envelope). + +## One-shot CLI (`mcp-inspector --cli`) + +You can run the one-shot CLI via `npx`: ```bash npx @modelcontextprotocol/inspector --cli node build/index.js ``` -The CLI mode supports operations across tools, resources, and prompts, returning structured JSON output. +Supports tools, resources, and prompts (plus `--method servers/list` / `servers/show` for catalog entries without connecting). ### Examples @@ -140,13 +186,17 @@ A `tools/call` that returns `isError:true` still prints its payload but exits `5 ### CLI-specific (OAuth for HTTP servers) -The CLI runs the same loopback callback server as the TUI (`http://127.0.0.1:6276/oauth/callback` by default). On connect **401** or mid-session interactive auth (re-login / step-up), it: +The CLI runs the same loopback callback server as the TUI (`http://127.0.0.1:6276/oauth/callback` by default). + +**One-shot (`mcp-inspector --cli`):** on connect **401** or mid-session interactive auth (re-login / step-up), it: 1. Starts the callback listener on `--callback-url` (or `MCP_OAUTH_CALLBACK_URL`) 2. Prints the authorization URL to the console (`ConsoleNavigation`) 3. Waits for the browser redirect, exchanges the code, and retries connect or the failed RPC -**Step-up (standard OAuth):** when an RPC needs extra scopes, the CLI prompts on stderr: `Proceed with step-up authorization? [y/N]`. **y** continues; **N** exits with an error. EMA step-up re-mints silently (no prompt). +**Step-up (standard OAuth, one-shot only):** when an RPC needs extra scopes, the CLI prompts on stderr: `Proceed with step-up authorization? [y/N]`. **y** continues; **N** exits with an error. EMA step-up re-mints silently (no prompt). + +**Session (`mcp`):** if connect requires auth, interactive OAuth runs and connect is retried. Mid-session step-up during a later command (for example `tools/call`) is not available on the session CLI — use one-shot `--cli` when you need that, or disconnect and connect again after refreshing tokens. **Shared OAuth storage:** the CLI **reuses** tokens from `~/.mcp-inspector/storage/oauth.json` when they already exist (same file as other Inspector clients). That is passive file sharing, not launching another app. @@ -256,18 +306,17 @@ While the Web Client provides a rich visual interface, the CLI is designed for: Like the other clients, the CLI self-validates from its own folder: ```bash -npm run validate # format:check && lint && test:coverage +npm run validate # format:check && lint && test (fast; no coverage gate) npm test # build test-servers + binary, then run all tests -npm run test:coverage # build + tests under the per-file coverage gate +npm run test:coverage # build + tests under the per-file ≥90 coverage gate ``` -The CLI's `test:coverage` **builds the binary first** (its out-of-process -`e2e.test.ts` spawns it, so it must run against a fresh build). `validate` -therefore folds the build into `test:coverage` rather than repeating it — it is -`format:check && lint && test:coverage`, with no separate `build` step (the -other clients, whose tests don't spawn their bundle, keep an explicit `build`). -The repo-root `validate:cli` just delegates here. +The CLI's `test` / `test:coverage` **build the binary first** (out-of-process +`e2e.test.ts` spawns it). `validate` is `format:check && lint && test` with no +separate `build` step (`pretest` builds). Repo-root `validate:cli` delegates +here; the coverage gate is `npm run coverage` / `coverage:cli` (also in +`npm run ci`), matching AGENTS.md. -Tests run the CLI **in-process** (importing `runCli()`) so `src/` is measured -under coverage, with a thin out-of-process spawn layer for the real binary. See -[`__tests__/README.md`](./__tests__/README.md) for details. +Tests run the CLI **in-process** (importing `runCli()` / `runMcp()`) so `src/` +is measured under coverage, with a thin out-of-process spawn layer for the real +binary. See [`__tests__/README.md`](./__tests__/README.md) for details. diff --git a/clients/cli/__tests__/README.md b/clients/cli/__tests__/README.md index 09aec386f..a9e08d441 100644 --- a/clients/cli/__tests__/README.md +++ b/clients/cli/__tests__/README.md @@ -47,12 +47,15 @@ root provides a further end-to-end check of the binary.) - `cliOAuth.test.ts` - Unit tests for `cliOAuth.ts` (step-up confirm, helper wiring, retry) - `oauth-interactive.test.ts` - **Integration** smoke parity for CLI interactive OAuth: connect-time callback server + step-up **y/N** against composable `TestServerHttp` (auto-completes authorize URL programmatically; not a subprocess binary e2e) - `e2e.test.ts` - Out-of-process spawn of the built binary (exit codes + boot; no OAuth) +- `mcp-session.test.ts` / `mcp-coverage.test.ts` / `mcp-auth-coverage.test.ts` - Session CLI (`runMcp`) against the daemon +- `daemon-*.test.ts` / `daemon-private.test.ts` / `dispatch.test.ts` / `format-session.test.ts` / `parse-tool-args.test.ts` / `style.test.ts` - Session daemon (incl. `mcp private` IPC token), dispatch, and formatting ## Helpers The `helpers/` directory contains shared utilities: - `cli-runner.ts` - Invokes `runCli()` in-process and captures stdout/stderr + exit code +- `mcp-runner.ts` - Invokes `runMcp()` in-process for the session CLI - `assertions.ts` - Custom assertion helpers for CLI output validation - `fixtures.ts` - Test config/catalog file generators and temporary directory management @@ -67,7 +70,7 @@ and the bundled stdio server is launched via `getTestMcpServerCommand()`. a file run sequentially - Config files use `crypto.randomUUID()` for uniqueness - HTTP/SSE servers use dynamic port allocation to avoid conflicts -- Coverage is enforced per-file (lines ≥ 90, statements ≥ 85, functions ≥ 80, - branches ≥ 50). `src/index.ts` (the binary bootstrap) is excluded because it - only runs in the spawned binary, which coverage can't instrument +- Coverage is enforced per-file at ≥90 on lines, statements, functions, and + branches. Exclusions: `src/index.ts`, `src/mcp-bin.ts`, `src/daemon/run.ts`, + `src/daemon/ipc-glue.ts`, `src/daemon/stream-client.ts` (see `vitest.config.ts`) - All tests use the built-in MCP test servers — no external/registry dependencies diff --git a/clients/cli/__tests__/authorize.test.ts b/clients/cli/__tests__/authorize.test.ts new file mode 100644 index 000000000..97272cd1a --- /dev/null +++ b/clients/cli/__tests__/authorize.test.ts @@ -0,0 +1,66 @@ +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); + +vi.mock("../src/cliOAuth.js", () => ({ + connectInspectorWithOAuth: (...args: unknown[]) => connectSpy(...args), +})); + +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(); + }); + + 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(); + }); +}); diff --git a/clients/cli/__tests__/cli-oauth-navigation.test.ts b/clients/cli/__tests__/cli-oauth-navigation.test.ts new file mode 100644 index 000000000..4cc9a2dbf --- /dev/null +++ b/clients/cli/__tests__/cli-oauth-navigation.test.ts @@ -0,0 +1,114 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { createCliOAuthNavigation } from "../src/cli-oauth-navigation.js"; +import { openUrl } from "../src/open-url.js"; + +vi.mock("../src/open-url.js", () => ({ + openUrl: vi.fn().mockResolvedValue(undefined), +})); + +const openUrlMock = vi.mocked(openUrl); + +describe("createCliOAuthNavigation", () => { + afterEach(() => { + openUrlMock.mockClear(); + openUrlMock.mockResolvedValue(undefined); + }); + + it("prints OSC 8 link and opens browser on a TTY", async () => { + const lines: string[] = []; + const openBrowser = vi.fn().mockResolvedValue(undefined); + const nav = createCliOAuthNavigation({ + isTTY: true, + // Force ANSI on even when the test runner exports NO_COLOR. + noColorEnv: "", + write: (line) => lines.push(line), + openBrowser, + }); + const url = new URL("https://as.example/authorize?x=1"); + nav.navigateToAuthorization(url); + await vi.waitFor(() => expect(openBrowser).toHaveBeenCalledOnce()); + expect(lines.join("")).toContain("Please navigate to:"); + expect(lines.join("")).toContain( + "\u001b]8;;https://as.example/authorize?x=1\u0007", + ); + expect(openBrowser).toHaveBeenCalledWith( + "https://as.example/authorize?x=1", + ); + expect(openUrlMock).not.toHaveBeenCalled(); + }); + + it("prints a plain URL and does not open a browser when not a TTY", async () => { + const lines: string[] = []; + const openBrowser = vi.fn(); + const nav = createCliOAuthNavigation({ + isTTY: false, + write: (line) => lines.push(line), + openBrowser, + }); + nav.navigateToAuthorization(new URL("https://as.example/authorize")); + await vi.waitFor(() => expect(lines.length).toBe(1)); + expect(lines.join("")).toBe( + "Please navigate to: https://as.example/authorize\n", + ); + expect(lines.join("")).not.toContain("\u001b]8;;"); + expect(openBrowser).not.toHaveBeenCalled(); + expect(openUrlMock).not.toHaveBeenCalled(); + }); + + it("skips OSC 8 when NO_COLOR is set but still opens on a TTY", async () => { + const lines: string[] = []; + const openBrowser = vi.fn().mockResolvedValue(undefined); + const nav = createCliOAuthNavigation({ + isTTY: true, + noColorEnv: "1", + write: (line) => lines.push(line), + openBrowser, + }); + nav.navigateToAuthorization(new URL("https://as.example/a")); + await vi.waitFor(() => expect(openBrowser).toHaveBeenCalledOnce()); + expect(lines.join("")).toBe("Please navigate to: https://as.example/a\n"); + expect(lines.join("")).not.toContain("\u001b]8;;"); + }); + + it("swallows browser-open failures after printing the URL", async () => { + const lines: string[] = []; + const openBrowser = vi.fn().mockRejectedValue(new Error("no browser")); + const nav = createCliOAuthNavigation({ + isTTY: true, + noColorEnv: "1", + write: (line) => lines.push(line), + openBrowser, + }); + nav.navigateToAuthorization(new URL("https://as.example/a")); + await vi.waitFor(() => expect(openBrowser).toHaveBeenCalledOnce()); + expect(lines.join("")).toContain("Please navigate to:"); + }); + + it("writes to stderr and uses openUrl by default on a TTY", async () => { + const writeSpy = vi + .spyOn(process.stderr, "write") + .mockImplementation(() => true); + const ttyDesc = Object.getOwnPropertyDescriptor(process.stderr, "isTTY"); + Object.defineProperty(process.stderr, "isTTY", { + configurable: true, + get: () => true, + }); + try { + const nav = createCliOAuthNavigation({ + noColorEnv: "1", + }); + nav.navigateToAuthorization(new URL("https://as.example/default")); + await vi.waitFor(() => expect(openUrlMock).toHaveBeenCalledOnce()); + expect(openUrlMock).toHaveBeenCalledWith("https://as.example/default"); + const written = writeSpy.mock.calls.map((c) => String(c[0])).join(""); + expect(written).toContain( + "Please navigate to: https://as.example/default", + ); + } finally { + writeSpy.mockRestore(); + if (ttyDesc) { + Object.defineProperty(process.stderr, "isTTY", ttyDesc); + } + } + }); +}); diff --git a/clients/cli/__tests__/cliOAuth.test.ts b/clients/cli/__tests__/cliOAuth.test.ts index f9d191539..f075f13ca 100644 --- a/clients/cli/__tests__/cliOAuth.test.ts +++ b/clients/cli/__tests__/cliOAuth.test.ts @@ -453,5 +453,76 @@ describe("cliOAuth", () => { ), ).rejects.toThrow("nope (401)"); }); + + it("fails with AUTH_REQUIRED under --stored-auth-only without interactive OAuth", async () => { + const runSpy = vi.spyOn(runnerInteractive, "runRunnerInteractiveOAuth"); + const connect = vi.fn().mockRejectedValue( + new AuthRecoveryRequiredError(new URL("https://as.example/authorize"), { + reason: "token_expired", + }), + ); + const client = { + connect, + disconnect: vi.fn(), + checkAuthChallengeSatisfied: vi.fn().mockResolvedValue(false), + }; + + await expect( + connectInspectorWithOAuth( + client, + oauthServerConfig, + new MutableRedirectUrlProvider(), + CALLBACK_URL_CONFIG, + undefined, + { storedAuthOnly: true }, + ), + ).rejects.toMatchObject({ exitCode: 3 }); + expect(runSpy).not.toHaveBeenCalled(); + }); + + it("fails stored-auth-only on plain unauthorized errors", async () => { + const runSpy = vi.spyOn(runnerInteractive, "runRunnerInteractiveOAuth"); + const connect = vi + .fn() + .mockRejectedValue(new Error("Connection failed for server (401)")); + const client = { + connect, + disconnect: vi.fn(), + checkAuthChallengeSatisfied: vi.fn(), + }; + + await expect( + connectInspectorWithOAuth( + client, + oauthServerConfig, + new MutableRedirectUrlProvider(), + CALLBACK_URL_CONFIG, + undefined, + { storedAuthOnly: true }, + ), + ).rejects.toMatchObject({ exitCode: 3 }); + expect(runSpy).not.toHaveBeenCalled(); + }); + }); + + it("withCliAuthRecoveryRetry respects storedAuthOnly", async () => { + const fn = vi.fn().mockRejectedValue( + new AuthRecoveryRequiredError(new URL("https://as.example/authorize"), { + reason: "token_expired", + }), + ); + await expect( + withCliAuthRecoveryRetry( + { + checkAuthChallengeSatisfied: vi.fn(), + } as never, + new MutableRedirectUrlProvider(), + CALLBACK_URL_CONFIG, + undefined, + fn, + undefined, + { storedAuthOnly: true }, + ), + ).rejects.toMatchObject({ exitCode: 3 }); }); }); diff --git a/clients/cli/__tests__/daemon-coverage.test.ts b/clients/cli/__tests__/daemon-coverage.test.ts new file mode 100644 index 000000000..928025ea1 --- /dev/null +++ b/clients/cli/__tests__/daemon-coverage.test.ts @@ -0,0 +1,747 @@ +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 "../src/error-handler.js"; +import { runMcp } from "./helpers/mcp-runner.js"; +import { + createSampleTestConfig, + deleteConfigFile, +} from "./helpers/fixtures.js"; +import { expectCliSuccess, expectCliFailure } from "./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); + + 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: "login_required", + }), + ); + 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("idle timeout invokes onShutdown", async () => { + const d = freshDir(); + let shut = false; + server = new DaemonServer({ + dir: d, + idleMs: 30, + onShutdown: () => { + shut = true; + }, + }); + await server.start(); + const { command, args } = getTestMcpServerCommand(); + await callDaemon( + "connect", + { + name: "s", + serverConfig: { type: "stdio", command, args }, + serverIdentity: "s", + }, + { socketPath: server.socketPath, timeoutMs: 15000 }, + ); + await callDaemon( + "disconnect", + { name: "s" }, + { socketPath: server.socketPath }, + ); + await new Promise((r) => setTimeout(r, 80)); + expect(shut).toBe(true); + server = undefined; + }); + + it("connect failure for a dead stdio command is surfaced", async () => { + const registry = new SessionRegistry(0); + await expect( + registry.connect({ + name: "dead", + serverConfig: { + type: "stdio", + command: path.join(os.tmpdir(), "no-such-mcp-server-binary"), + args: [], + }, + serverIdentity: "dead", + }), + ).rejects.toThrow(); + }); + + 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(); + + 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, 1); + + const badFormat = await runMcp(["servers/list", "--format", "xml"], { + env: e, + }); + expectCliFailure(badFormat, 1); + + const badTransport = await runMcp(["connect", "x", "--transport", "ftp"], { + env: e, + }); + expectCliFailure(badTransport, 1); + + const badTimeout = await runMcp( + ["connect", "x", "--connect-timeout", "-1"], + { env: e }, + ); + expectCliFailure(badTimeout, 1); + + const emptyUse = await runMcp(["sessions/use", ""], { env: e }); + expectCliFailure(emptyUse, 1); + }); + + 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, 1); + }); + + 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, 1); + expect(disc.stderr).toMatch(/Explicit|--session|non-interactive/i); + + await runMcp(["disconnect", "--session", "test-stdio"], { env: e }); + }); +}); diff --git a/clients/cli/__tests__/daemon-paths.test.ts b/clients/cli/__tests__/daemon-paths.test.ts new file mode 100644 index 000000000..fc3b934c1 --- /dev/null +++ b/clients/cli/__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 "../src/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/cli/__tests__/daemon-private.test.ts b/clients/cli/__tests__/daemon-private.test.ts new file mode 100644 index 000000000..c9ea43afb --- /dev/null +++ b/clients/cli/__tests__/daemon-private.test.ts @@ -0,0 +1,191 @@ +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 "../src/error-handler.js"; +import { runMcp } from "./helpers/mcp-runner.js"; +import { expectCliSuccess } from "./helpers/assertions.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("mcp 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("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/cli/__tests__/daemon-sessions.test.ts b/clients/cli/__tests__/daemon-sessions.test.ts new file mode 100644 index 000000000..114b5eacc --- /dev/null +++ b/clients/cli/__tests__/daemon-sessions.test.ts @@ -0,0 +1,252 @@ +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 { 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, + isSessionAuthRequiredError, + SessionRegistry, +} from "../src/daemon/sessions.js"; +import { CliExitCodeError } from "../src/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("isSessionAuthRequiredError", () => { + 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("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); + 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); + }); +}); + +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/cli/__tests__/daemon-stream.test.ts b/clients/cli/__tests__/daemon-stream.test.ts new file mode 100644 index 000000000..591e52581 --- /dev/null +++ b/clients/cli/__tests__/daemon-stream.test.ts @@ -0,0 +1,294 @@ +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 "../src/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("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/cli/__tests__/dispatch.test.ts b/clients/cli/__tests__/dispatch.test.ts new file mode 100644 index 000000000..3aa44e2cf --- /dev/null +++ b/clients/cli/__tests__/dispatch.test.ts @@ -0,0 +1,141 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +const callDaemon = vi.fn(); +const ensureDaemon = vi.fn(); +const streamDaemon = vi.fn(); + +vi.mock("../src/daemon/index.js", () => ({ + callDaemon: (...args: unknown[]) => callDaemon(...args), + ensureDaemon: (...args: unknown[]) => ensureDaemon(...args), + streamDaemon: (...args: unknown[]) => streamDaemon(...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(); + }); + + 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(); + }); +}); + +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 is false on a TTY when allow-default is unset", 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 desc = Object.getOwnPropertyDescriptor(process.stdout, "isTTY"); + Object.defineProperty(process.stdout, "isTTY", { + configurable: true, + value: true, + }); + try { + expect(requireExplicitSession()).toBe(false); + } finally { + if (desc) Object.defineProperty(process.stdout, "isTTY", desc); + 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/cli/__tests__/emit-result.test.ts b/clients/cli/__tests__/emit-result.test.ts index df4e8fc6f..dfa16b379 100644 --- a/clients/cli/__tests__/emit-result.test.ts +++ b/clients/cli/__tests__/emit-result.test.ts @@ -31,6 +31,22 @@ describe("emitResult", () => { process.stdout.write = originalWrite; }); + it("uses a default appInfo shell when --app-info has no collected info", async () => { + const promise = emitResult(undefined as never, undefined, { + appInfo: true, + toolName: "missing-app", + format: "text", + }); + await expect(promise).rejects.toBeInstanceOf(CliExitCodeError); + await promise.catch((e: CliExitCodeError) => { + expect(e.exitCode).toBe(2); + }); + expect(JSON.parse(stdout.trim())).toMatchObject({ + hasApp: false, + toolName: "missing-app", + }); + }); + it("throws TOOL_ERROR (code:tool_is_error) when the result has isError:true", async () => { const promise = emitResult({ isError: true }, undefined, { toolName: "boom", @@ -108,6 +124,25 @@ describe("collectAppInfo", () => { expect(info.resourceError).toBe("string failure"); }); + it("stringifies a non-Error throw from extractAppInfo", async () => { + const extract = await import("@inspector/core/mcp/apps.js"); + const spy = vi + .spyOn(extract, "extractAppInfo") + .mockImplementationOnce(() => { + throw "boom-string"; + }); + try { + const info = await collectAppInfo( + { readResource: vi.fn() } as never, + { name: "t", inputSchema: { type: "object" as const } }, + undefined, + ); + expect(info.resourceError).toBe("boom-string"); + } finally { + spy.mockRestore(); + } + }); + it("folds a malformed _meta.ui.resourceUri into {hasApp:false, resourceError} instead of throwing", async () => { // extractAppInfo throws when the advertised UI URI is not a `ui://` string; // collectAppInfo must tolerate that so the tools/list --app-info NDJSON loop diff --git a/clients/cli/__tests__/format-session.test.ts b/clients/cli/__tests__/format-session.test.ts new file mode 100644 index 000000000..ebd6007fb --- /dev/null +++ b/clients/cli/__tests__/format-session.test.ts @@ -0,0 +1,665 @@ +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, + formatStreamEventHuman, + formatRpcResultHuman, +} from "../src/session/format-human.js"; +import { writeSessionOutput } from "../src/session/format-session.js"; +import { CliExitCodeError, EXIT_CODES } from "../src/error-handler.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)"); + expect( + formatSessionInfoHuman({ name: "a", isMru: true, serverIdentity: "id" }), + ).toContain("Session `@a`"); + + expect( + formatAppInfoListHuman([ + { toolName: "with", hasApp: true, resourceUri: "ui://x" }, + { toolName: "err", hasApp: false, resourceError: "boom" }, + { toolName: "no", hasApp: false }, + ]), + ).toContain("no app"); + + 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 original: typeof process.stdout.write; + + beforeEach(() => { + stdout = ""; + original = 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; + }); + + afterEach(() => { + process.stdout.write = original; + }); + + 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("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`"); + }); +}); diff --git a/clients/cli/__tests__/helpers/mcp-runner.ts b/clients/cli/__tests__/helpers/mcp-runner.ts new file mode 100644 index 000000000..c08a3a1df --- /dev/null +++ b/clients/cli/__tests__/helpers/mcp-runner.ts @@ -0,0 +1,88 @@ +import { runMcp as invokeMcp } from "../../src/session/mcp.js"; +import { formatErrorOutput } from "../../src/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", "mcp", ...args]; + const timeoutMs = options.timeout ?? 15000; + let timer: ReturnType | undefined; + const timeout = new Promise((_, reject) => { + timer = setTimeout( + () => reject(new Error(`mcp 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/cli/__tests__/hoist-session.test.ts b/clients/cli/__tests__/hoist-session.test.ts new file mode 100644 index 000000000..86eabcd0b --- /dev/null +++ b/clients/cli/__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", + "mcp", + "@alpha", + "tools/list", + "--format", + "json", + ]); + expect(sessionFromAt).toBe("alpha"); + expect(argv).toEqual(["node", "mcp", "tools/list", "--format", "json"]); + }); + + it("leaves argv unchanged when there is no @name", () => { + const input = ["node", "mcp", "tools/list"]; + expect(hoistAtSession(input)).toEqual({ argv: input }); + }); +}); diff --git a/clients/cli/__tests__/mcp-auth-coverage.test.ts b/clients/cli/__tests__/mcp-auth-coverage.test.ts new file mode 100644 index 000000000..230c701c9 --- /dev/null +++ b/clients/cli/__tests__/mcp-auth-coverage.test.ts @@ -0,0 +1,213 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { + createSampleTestConfig, + deleteConfigFile, +} from "./helpers/fixtures.js"; +import { CliExitCodeError, EXIT_CODES } from "../src/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.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("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", + "mcp", + "connect", + "test-stdio", + "--config", + configPath, + "--format", + "json", + ]); + + expect(authorizeInFrontend).toHaveBeenCalledOnce(); + expect(callDaemon).toHaveBeenCalledTimes(2); + expect(JSON.parse(stdout.trim()).name).toBe("test-stdio"); + }); + + it("rejects --relogin with --stored-auth-only", async () => { + configPath = createSampleTestConfig(); + const { runMcp } = await import("../src/session/mcp.js"); + await expect( + runMcp([ + "node", + "mcp", + "--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", + "mcp", + "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", + "mcp", + "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", "mcp", "daemon", "stop", "--format", "json"]), + ).rejects.toMatchObject({ + exitCode: EXIT_CODES.USAGE, + envelope: { code: "usage" }, + }); + }); +}); diff --git a/clients/cli/__tests__/mcp-coverage.test.ts b/clients/cli/__tests__/mcp-coverage.test.ts new file mode 100644 index 000000000..d53da6c29 --- /dev/null +++ b/clients/cli/__tests__/mcp-coverage.test.ts @@ -0,0 +1,368 @@ +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 "./helpers/fixtures.js"; +import { expectCliSuccess, expectCliFailure } from "./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", + "--plain", + "--format", + "json", + ], + { env: e, timeout: 20000 }, + ); + expectCliSuccess(withMeta); + + const badMeta = await runMcp(["tools/list", "--metadata", "novalue"], { + env: e, + }); + expectCliFailure(badMeta, 1); + + const emptyMeta = await runMcp(["tools/list", "--metadata", "k="], { + env: e, + }); + expectCliFailure(emptyMeta, 1); + + 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, 1); + + 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, 1); + + const taskGet = await runMcp( + ["tasks/get", "missing-task", "--format", "json"], + { + env: e, + timeout: 20000, + }, + ); + expectCliFailure(taskGet, 1); + + const taskCancel = await runMcp( + ["tasks/cancel", "--task-id", "missing-task", "--format", "json"], + { env: e, timeout: 20000 }, + ); + expectCliFailure(taskCancel, 1); + + const taskResult = await runMcp( + ["tasks/result", "missing-task", "--format", "json"], + { env: e, timeout: 20000 }, + ); + expectCliFailure(taskResult, 1); + + 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"); + + 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", + "--format", + "json", + command, + ...args, + ], + { env: e, timeout: 20000 }, + ); + expectCliSuccess(adHoc); + + 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 mcp / --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", + "mcp", + "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/cli/__tests__/mcp-session.test.ts b/clients/cli/__tests__/mcp-session.test.ts new file mode 100644 index 000000000..a292fdc66 --- /dev/null +++ b/clients/cli/__tests__/mcp-session.test.ts @@ -0,0 +1,207 @@ +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 "./helpers/cli-runner.js"; +import { + createSampleTestConfig, + deleteConfigFile, +} from "./helpers/fixtures.js"; +import { expectCliSuccess } from "./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 initialize 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 init = await runMcp( + ["@test-stdio", "initialize", "--format", "json"], + { env: e, timeout: 20000 }, + ); + expectCliSuccess(init); + const initBody = JSON.parse(init.stdout) as { + serverInfo?: { name?: string }; + protocolVersion?: string; + }; + expect(initBody.protocolVersion).toBeTruthy(); + + await runMcp( + ["disconnect", "--session", "test-stdio", "--format", "json"], + { + env: e, + }, + ); + await runMcp(["daemon", "stop", "--format", "json"], { env: e }); + }); +}); diff --git a/clients/cli/__tests__/open-url.test.ts b/clients/cli/__tests__/open-url.test.ts new file mode 100644 index 000000000..304dd2570 --- /dev/null +++ b/clients/cli/__tests__/open-url.test.ts @@ -0,0 +1,28 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const openMock = vi.fn().mockResolvedValue(undefined); + +vi.mock("open", () => ({ + default: (...args: unknown[]) => openMock(...args), +})); + +describe("openUrl", () => { + beforeEach(() => { + openMock.mockClear(); + openMock.mockResolvedValue(undefined); + }); + + it("forwards a string URL to open", async () => { + const { openUrl } = await import("../src/open-url.js"); + await openUrl("https://example.com/auth"); + expect(openMock).toHaveBeenCalledWith("https://example.com/auth"); + }); + + it("forwards URL.href for URL instances", async () => { + const { openUrl } = await import("../src/open-url.js"); + await openUrl(new URL("https://example.com/callback?code=1")); + expect(openMock).toHaveBeenCalledWith( + "https://example.com/callback?code=1", + ); + }); +}); diff --git a/clients/cli/__tests__/parse-tool-args.test.ts b/clients/cli/__tests__/parse-tool-args.test.ts new file mode 100644 index 000000000..971fa620d --- /dev/null +++ b/clients/cli/__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/cli/__tests__/programmatic-ergonomics.test.ts b/clients/cli/__tests__/programmatic-ergonomics.test.ts index 63ea39cbd..44f1a4aff 100644 --- a/clients/cli/__tests__/programmatic-ergonomics.test.ts +++ b/clients/cli/__tests__/programmatic-ergonomics.test.ts @@ -135,6 +135,52 @@ describe("--tool-args-json", () => { }); }); +describe("one-shot --relogin flag conflicts", () => { + it("rejects --relogin with --stored-auth-only", async () => { + const result = await runCli([ + "--relogin", + "--stored-auth-only", + "--server-url", + "https://example.com/mcp", + "--method", + "tools/list", + ]); + expectCliFailure(result); + expect(result.stderr).toMatch( + /--relogin cannot be combined with --stored-auth-only/, + ); + }); + + it("rejects --relogin with --use-stored-auth", async () => { + const result = await runCli([ + "--relogin", + "--use-stored-auth", + "--server-url", + "https://example.com/mcp", + "--method", + "tools/list", + ]); + expectCliFailure(result); + expect(result.stderr).toMatch(/--use-stored-auth/); + }); + + it("accepts --relogin and clears stored auth before connect", async () => { + // Unreachable port: proves --relogin runs (clears store) then fails connect. + const result = await runCli([ + "--relogin", + "--server-url", + "http://127.0.0.1:1/mcp", + "--transport", + "http", + "--connect-timeout", + "200", + "--method", + "initialize", + ]); + expectCliFailure(result); + }); +}); + describe("MCP_CATALOG_PATH with an ad-hoc target", () => { it("does not conflict with an ad-hoc target (env catalog is ignored)", async () => { const { command, args } = getTestMcpServerCommand(); diff --git a/clients/cli/__tests__/run-method-mocks.test.ts b/clients/cli/__tests__/run-method-mocks.test.ts new file mode 100644 index 000000000..4fda0c519 --- /dev/null +++ b/clients/cli/__tests__/run-method-mocks.test.ts @@ -0,0 +1,232 @@ +import { describe, it, expect, vi } from "vitest"; +import { runMethod } from "../src/handlers/run-method.js"; +import type { InspectorClient } from "@inspector/core/mcp/index.js"; + +function mockClient(overrides: Partial = {}): InspectorClient { + const base = { + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + subscribeToResource: vi.fn().mockResolvedValue(undefined), + unsubscribeFromResource: vi.fn().mockResolvedValue(undefined), + getCompletions: vi.fn().mockResolvedValue({ values: ["a"] }), + getRequestorTask: vi.fn().mockResolvedValue({ taskId: "t1" }), + cancelRequestorTask: vi.fn().mockResolvedValue(undefined), + getRequestorTaskResult: vi.fn().mockResolvedValue({ content: [] }), + getRoots: vi.fn().mockReturnValue([]), + setRoots: vi.fn().mockResolvedValue(undefined), + setLoggingLevel: vi.fn().mockResolvedValue(undefined), + getServerInfo: vi.fn().mockReturnValue({ name: "t" }), + getProtocolVersion: vi.fn().mockReturnValue("1"), + getCapabilities: vi.fn().mockReturnValue({}), + getInstructions: vi.fn().mockReturnValue(undefined), + callTool: vi.fn(), + callToolStream: vi.fn(), + readResource: vi.fn(), + getPrompt: vi.fn(), + }; + return { ...base, ...overrides } as unknown as InspectorClient; +} + +vi.mock("@inspector/core/mcp/state/index.js", async (importOriginal) => { + const actual = + await importOriginal(); + class FakeManaged { + setMetadata = vi.fn(); + refresh = vi.fn().mockResolvedValue(undefined); + destroy = vi.fn(); + getTools = vi + .fn() + .mockReturnValue([ + { name: "echo", description: "e", inputSchema: { type: "object" } }, + ]); + getResources = vi.fn().mockReturnValue([]); + getResourceTemplates = vi.fn().mockReturnValue([]); + getPrompts = vi.fn().mockReturnValue([{ name: "p" }]); + getTasks = vi.fn().mockReturnValue([{ taskId: "t1" }]); + } + class FakeLog { + static last: FakeLog | undefined; + addEventListener = vi.fn(); + removeEventListener = vi.fn(); + destroy = vi.fn(); + constructor() { + FakeLog.last = this; + } + } + return { + ...actual, + ManagedToolsState: FakeManaged, + ManagedResourcesState: FakeManaged, + ManagedResourceTemplatesState: FakeManaged, + ManagedPromptsState: FakeManaged, + ManagedRequestorTasksState: FakeManaged, + MessageLogState: FakeLog, + }; +}); + +describe("runMethod (mocked client)", () => { + it("covers subscribe stream, tasks, complete, and app-info call", async () => { + const client = mockClient({ + callTool: vi.fn().mockResolvedValue({ + result: { content: [{ type: "text", text: "ok" }] }, + toolName: "echo", + params: {}, + timestamp: new Date(), + success: true, + }), + callToolStream: vi.fn().mockResolvedValue({ + result: { content: [] }, + toolName: "echo", + params: {}, + timestamp: new Date(), + success: true, + }), + readResource: vi.fn().mockResolvedValue({ + result: { contents: [] }, + }), + }); + + const sub = await runMethod(client, { + method: "resources/subscribe", + uri: "test://x", + }); + expect(sub.kind).toBe("stream"); + if (sub.kind === "stream") { + const lines: unknown[] = []; + const stop = sub.start((o) => lines.push(o)); + expect(lines[0]).toMatchObject({ type: "subscribed" }); + // Simulate update event + const listener = ( + client.addEventListener as ReturnType + ).mock.calls.find((c) => c[0] === "resourceUpdated")?.[1] as ( + ev: Event, + ) => void; + listener?.( + new CustomEvent("resourceUpdated", { detail: { uri: "test://x" } }), + ); + expect( + lines.some( + (l) => (l as { type?: string }).type === "resources/updated", + ), + ).toBe(true); + stop(); + } + + const tasks = await runMethod(client, { method: "tasks/list" }); + expect(tasks.kind).toBe("result"); + + const got = await runMethod(client, { + method: "tasks/get", + taskId: "t1", + }); + expect(got.kind).toBe("result"); + + const cancelled = await runMethod(client, { + method: "tasks/cancel", + taskId: "t1", + }); + expect(cancelled.kind).toBe("result"); + + const result = await runMethod(client, { + method: "tasks/result", + taskId: "t1", + }); + expect(result.kind).toBe("result"); + + const complete = await runMethod(client, { + method: "prompts/complete", + completeRefType: "ref/prompt", + completeRef: "p", + completeArgName: "a", + completeArgValue: "x", + }); + expect(complete.kind).toBe("result"); + + const completeRes = await runMethod(client, { + method: "prompts/complete", + completeRefType: "ref/resource", + completeRef: "test://x", + completeArgName: "a", + }); + expect(completeRes.kind).toBe("result"); + + const appOnly = await runMethod(client, { + method: "tools/call", + toolName: "echo", + appInfo: true, + }); + expect(appOnly.kind).toBe("result"); + + const tasked = await runMethod(client, { + method: "tools/call", + toolName: "echo", + task: true, + toolArg: { message: "x" }, + }); + expect(tasked.kind).toBe("result"); + expect(client.callToolStream).toHaveBeenCalled(); + + vi.mocked(client.callTool).mockResolvedValueOnce({ + result: null, + error: "Tool call failed hard", + toolName: "echo", + params: {}, + timestamp: new Date(), + success: false, + } as never); + const nullResult = await runMethod(client, { + method: "tools/call", + toolName: "echo", + toolArg: { message: "x" }, + }); + expect(nullResult.kind).toBe("result"); + if (nullResult.kind === "result") { + expect(nullResult.result).toMatchObject({ isError: true }); + } + + await expect(runMethod(client, { method: "tasks/cancel" })).rejects.toThrow( + /tasks\/cancel/, + ); + await expect(runMethod(client, { method: "tasks/result" })).rejects.toThrow( + /tasks\/result/, + ); + + await expect( + runMethod(client, { + method: "roots/set", + rootsJson: "not-json", + }), + ).rejects.toThrow(/roots-json is invalid/); + + const tail = await runMethod(client, { method: "logging/tail" }); + expect(tail.kind).toBe("stream"); + if (tail.kind === "stream") { + const lines: unknown[] = []; + const stop = tail.start((o) => lines.push(o)); + const logMod = await import("@inspector/core/mcp/state/index.js"); + const FakeLog = logMod.MessageLogState as unknown as { + last?: { + addEventListener: ReturnType; + }; + }; + const listener = FakeLog.last?.addEventListener.mock.calls[0]?.[1] as + | ((ev: Event) => void) + | undefined; + listener?.( + new CustomEvent("message", { + detail: { + direction: "notification", + message: { method: "notifications/message" }, + }, + }), + ); + listener?.( + new CustomEvent("message", { + detail: { direction: "request", message: { method: "tools/list" } }, + }), + ); + expect(lines).toHaveLength(1); + stop(); + } + }); +}); diff --git a/clients/cli/__tests__/run-method.test.ts b/clients/cli/__tests__/run-method.test.ts new file mode 100644 index 000000000..242e0cd03 --- /dev/null +++ b/clients/cli/__tests__/run-method.test.ts @@ -0,0 +1,249 @@ +import { describe, it, expect, afterEach } from "vitest"; +import { getTestMcpServerCommand } from "@modelcontextprotocol/inspector-test-server"; +import { InspectorClient } from "@inspector/core/mcp/index.js"; +import { createTransportNode } from "@inspector/core/mcp/node/index.js"; +import { runMethod } from "../src/handlers/run-method.js"; +import { consumeMethodOutcome } from "../src/handlers/consume-outcome.js"; + +describe("runMethod", () => { + let client: InspectorClient | undefined; + + afterEach(async () => { + if (client) { + await client.disconnect().catch(() => {}); + client = undefined; + } + }); + + async function connectStdio(): Promise { + const { command, args } = getTestMcpServerCommand(); + client = new InspectorClient( + { type: "stdio", command, args }, + { + environment: { transport: createTransportNode }, + clientIdentity: { name: "test", version: "0.0.0" }, + progress: false, + sample: false, + elicit: false, + }, + ); + await client.connect(); + return client; + } + + it("lists tools and reads initialize fields", async () => { + const c = await connectStdio(); + const tools = await runMethod(c, { method: "tools/list" }); + expect(tools.kind).toBe("result"); + if (tools.kind === "result") { + expect( + (tools.result.tools as unknown[] | undefined)?.length, + ).toBeGreaterThan(0); + } + + const init = await runMethod(c, { method: "initialize" }); + expect(init.kind).toBe("result"); + }); + + it("returns ndjson lines for tools/list --app-info", async () => { + const c = await connectStdio(); + const outcome = await runMethod(c, { + method: "tools/list", + appInfo: true, + }); + expect(outcome.kind).toBe("ndjson"); + if (outcome.kind === "ndjson") { + expect(outcome.lines.length).toBeGreaterThan(0); + } + }); + + it("lists resources and prompts", async () => { + const c = await connectStdio(); + const resources = await runMethod(c, { method: "resources/list" }); + expect(resources.kind).toBe("result"); + const prompts = await runMethod(c, { method: "prompts/list" }); + expect(prompts.kind).toBe("result"); + const templates = await runMethod(c, { + method: "resources/templates/list", + }); + expect(templates.kind).toBe("result"); + }); + + it("sets logging level", async () => { + const c = await connectStdio(); + const outcome = await runMethod(c, { + method: "logging/setLevel", + logLevel: "info", + }); + expect(outcome.kind).toBe("result"); + }); + + it("roots/list and roots/set round-trip", async () => { + const c = await connectStdio(); + const listed = await runMethod(c, { method: "roots/list" }); + expect(listed.kind).toBe("result"); + const set = await runMethod(c, { + method: "roots/set", + rootsJson: JSON.stringify([{ uri: "file:///tmp", name: "tmp" }]), + }); + expect(set.kind).toBe("result"); + if (set.kind === "result") { + expect(set.result.roots).toEqual([{ uri: "file:///tmp", name: "tmp" }]); + } + }); + + it("consumeMethodOutcome writes result json", async () => { + let stdout = ""; + const original = 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; + try { + await consumeMethodOutcome( + { kind: "result", result: { ok: true } }, + { format: "json" }, + ); + expect(JSON.parse(stdout.trim())).toEqual({ result: { ok: true } }); + } finally { + process.stdout.write = original; + } + }); + + it("rejects unknown methods", async () => { + const c = await connectStdio(); + await expect(runMethod(c, { method: "nope/method" })).rejects.toThrow( + /Unsupported method/, + ); + }); + + it("calls echo tool and rejects missing tool name", async () => { + const c = await connectStdio(); + await expect(runMethod(c, { method: "tools/call" })).rejects.toThrow( + /Tool name is required/, + ); + + const missing = runMethod(c, { + method: "tools/call", + toolName: "does-not-exist", + }); + await expect(missing).rejects.toMatchObject({ exitCode: 5 }); + + const called = await runMethod(c, { + method: "tools/call", + toolName: "echo", + toolArg: { message: "hi" }, + format: "json", + }); + expect(called.kind).toBe("result"); + }); + + it("reads a resource and gets a prompt", async () => { + const c = await connectStdio(); + const read = await runMethod(c, { + method: "resources/read", + uri: "test://env", + }); + expect(read.kind).toBe("result"); + + await expect(runMethod(c, { method: "resources/read" })).rejects.toThrow( + /URI is required/, + ); + + const prompts = await runMethod(c, { method: "prompts/list" }); + expect(prompts.kind).toBe("result"); + if (prompts.kind === "result") { + const list = prompts.result.prompts as { name: string }[]; + if (list.length > 0) { + const got = await runMethod(c, { + method: "prompts/get", + promptName: list[0]!.name, + }); + expect(got.kind).toBe("result"); + } + } + }); + + it("returns a stream starter for logging/tail", async () => { + const c = await connectStdio(); + const outcome = await runMethod(c, { method: "logging/tail" }); + expect(outcome.kind).toBe("stream"); + if (outcome.kind === "stream") { + const stop = outcome.start(() => {}); + stop(); + } + }); + + it("requires uri for subscribe/unsubscribe", async () => { + const c = await connectStdio(); + await expect( + runMethod(c, { method: "resources/subscribe" }), + ).rejects.toThrow(/URI is required/); + await expect( + runMethod(c, { method: "resources/unsubscribe" }), + ).rejects.toThrow(/URI is required/); + }); + + it("rejects bad roots/set JSON and missing logging level", async () => { + const c = await connectStdio(); + await expect(runMethod(c, { method: "logging/setLevel" })).rejects.toThrow( + /Log level is required/, + ); + await expect( + runMethod(c, { method: "roots/set", rootsJson: "{}" }), + ).rejects.toThrow(/must be a JSON array/); + await expect(runMethod(c, { method: "roots/set" })).rejects.toThrow( + /roots\/set requires/, + ); + await expect(runMethod(c, { method: "tasks/get" })).rejects.toThrow( + /Task id is required/, + ); + await expect(runMethod(c, { method: "prompts/get" })).rejects.toThrow( + /Prompt name is required/, + ); + await expect(runMethod(c, { method: "prompts/complete" })).rejects.toThrow( + /prompts\/complete requires/, + ); + }); + + it("consumeMethodOutcome writes ndjson and stream until SIGINT", async () => { + let stdout = ""; + const original = 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; + try { + await consumeMethodOutcome( + { kind: "ndjson", lines: [{ a: 1 }, { b: 2 }] }, + {}, + ); + expect(stdout.trim().split("\n")).toHaveLength(2); + + const streamPromise = consumeMethodOutcome( + { + kind: "stream", + label: "t", + start: (write) => { + write({ hi: true }); + queueMicrotask(() => process.emit("SIGINT")); + return () => {}; + }, + }, + {}, + ); + await streamPromise; + expect(stdout).toContain('"hi":true'); + } finally { + process.stdout.write = original; + } + }); +}); diff --git a/clients/cli/__tests__/servers-list.test.ts b/clients/cli/__tests__/servers-list.test.ts new file mode 100644 index 000000000..6745de76e --- /dev/null +++ b/clients/cli/__tests__/servers-list.test.ts @@ -0,0 +1,248 @@ +import { describe, it, expect, afterEach } from "vitest"; +import { runCli } from "./helpers/cli-runner.js"; +import { + createSampleTestConfig, + deleteConfigFile, +} from "./helpers/fixtures.js"; +import { expectCliSuccess } from "./helpers/assertions.js"; +import { runMcp } from "./helpers/mcp-runner.js"; +import { + annotateServerEntriesWithSessions, + listServerEntries, + sanitizeServerConfig, + sanitizeServerSettings, + showServerEntry, + summarizeServerConfig, +} from "../src/handlers/servers-list.js"; +import type { + InspectorServerSettings, + MCPServerConfig, +} from "@inspector/core/mcp/types.js"; +import { expectCliFailure } from "./helpers/assertions.js"; + +describe("summarizeServerConfig", () => { + it("summarises stdio, sse, and streamable-http configs", () => { + expect( + summarizeServerConfig({ + type: "stdio", + command: "node", + args: ["server.js"], + }), + ).toEqual({ type: "stdio", detail: "node server.js" }); + expect( + summarizeServerConfig({ + type: "stdio", + command: "node", + }), + ).toEqual({ type: "stdio", detail: "node" }); + expect( + summarizeServerConfig({ + type: "sse", + url: "http://localhost/sse", + }), + ).toEqual({ type: "sse", detail: "http://localhost/sse" }); + expect( + summarizeServerConfig({ + type: "streamable-http", + url: "http://localhost/mcp", + } as MCPServerConfig), + ).toEqual({ type: "streamable-http", detail: "http://localhost/mcp" }); + expect( + summarizeServerConfig({ + type: "streamable-http", + } as MCPServerConfig), + ).toEqual({ type: "streamable-http", detail: "" }); + }); +}); + +describe("annotateServerEntriesWithSessions", () => { + const entries = [ + { name: "a", type: "stdio", detail: "node a" }, + { name: "b", type: "stdio", detail: "node b" }, + ]; + + it("returns entries unchanged when there are no sessions", () => { + expect(annotateServerEntriesWithSessions(entries, [])).toBe(entries); + }); + + it("marks matching entry names and MRU", () => { + expect( + annotateServerEntriesWithSessions(entries, [ + { name: "b", isMru: true }, + { name: "other" }, + ]), + ).toEqual([ + { name: "a", type: "stdio", detail: "node a" }, + { name: "b", type: "stdio", detail: "node b", session: "b", isMru: true }, + ]); + }); + + it("omits isMru when the session is not MRU", () => { + expect( + annotateServerEntriesWithSessions(entries, [{ name: "a", isMru: false }]), + ).toEqual([ + { name: "a", type: "stdio", detail: "node a", session: "a" }, + { name: "b", type: "stdio", detail: "node b" }, + ]); + }); +}); + +describe("listServerEntries / --method servers/list", () => { + let configPath: string | undefined; + + afterEach(() => { + if (configPath) { + deleteConfigFile(configPath); + configPath = undefined; + } + }); + + it("lists catalog entries without connecting", async () => { + configPath = createSampleTestConfig(); + const entries = await listServerEntries({ configPath }); + expect(entries.map((e) => e.name).sort()).toEqual([ + "test-http", + "test-stdio", + ]); + expect(entries.find((e) => e.name === "test-stdio")?.type).toBe("stdio"); + }); + + it("works via one-shot --method servers/list", async () => { + configPath = createSampleTestConfig(); + const result = await runCli([ + "--config", + configPath, + "--method", + "servers/list", + "--format", + "json", + ]); + expectCliSuccess(result); + const body = JSON.parse(result.stdout) as { + result: { servers: { name: string }[] }; + }; + expect(body.result.servers.map((s) => s.name).sort()).toEqual([ + "test-http", + "test-stdio", + ]); + }); +}); + +describe("showServerEntry / servers/show", () => { + let configPath: string | undefined; + + afterEach(() => { + if (configPath) { + deleteConfigFile(configPath); + configPath = undefined; + } + }); + + it("redacts env values and sensitive settings fields", () => { + expect( + sanitizeServerConfig({ + type: "stdio", + command: "node", + env: { SECRET: "value", HELLO: "world" }, + }), + ).toEqual({ + type: "stdio", + command: "node", + env: { SECRET: "[redacted]", HELLO: "[redacted]" }, + }); + + const settings: InspectorServerSettings = { + headers: [ + { key: "Authorization", value: "Bearer x" }, + { key: "X-Custom", value: "ok" }, + ], + metadata: [], + env: [{ key: "TOKEN", value: "secret" }], + connectionTimeout: 0, + requestTimeout: 0, + taskTtl: 60_000, + maxFetchRequests: 100, + roots: [], + oauthClientSecret: "shh", + oauthClientId: "cid", + }; + const sanitized = sanitizeServerSettings(settings); + expect(sanitized.oauthClientSecret).toBe("[redacted]"); + expect(sanitized.oauthClientId).toBe("cid"); + expect(sanitized.headers).toEqual([ + { key: "Authorization", value: "[redacted]" }, + { key: "X-Custom", value: "ok" }, + ]); + expect(sanitized.env).toEqual([{ key: "TOKEN", value: "[redacted]" }]); + }); + + it("shows one resolved entry without connecting", async () => { + configPath = createSampleTestConfig(); + const entry = await showServerEntry("test-stdio", { configPath }); + expect(entry.name).toBe("test-stdio"); + expect(entry.type).toBe("stdio"); + expect(entry.config).toMatchObject({ + type: "stdio", + command: expect.any(String), + }); + expect(entry.config.env).toEqual({ HELLO: "[redacted]" }); + }); + + it("works via session mcp servers/show", async () => { + configPath = createSampleTestConfig(); + const result = await runMcp([ + "servers/show", + "test-stdio", + "--config", + configPath, + "--format", + "json", + ]); + expectCliSuccess(result); + const body = JSON.parse(result.stdout) as { + name: string; + config: { env?: Record }; + }; + expect(body.name).toBe("test-stdio"); + expect(body.config.env?.HELLO).toBe("[redacted]"); + }); + + it("works via one-shot --method servers/show --server", async () => { + configPath = createSampleTestConfig(); + const result = await runCli([ + "--config", + configPath, + "--method", + "servers/show", + "--server", + "test-http", + "--format", + "json", + ]); + expectCliSuccess(result); + const body = JSON.parse(result.stdout) as { + result: { name: string; type: string }; + }; + expect(body.result.name).toBe("test-http"); + expect(body.result.type).toBe("streamable-http"); + }); + + it("rejects one-shot servers/show without --server", async () => { + configPath = createSampleTestConfig(); + const result = await runCli([ + "--config", + configPath, + "--method", + "servers/show", + ]); + expectCliFailure(result); + expect(result.stderr).toMatch(/--server/); + }); + + it("rejects unknown server names", async () => { + configPath = createSampleTestConfig(); + await expect(showServerEntry("nope", { configPath })).rejects.toThrow( + /not found/, + ); + }); +}); diff --git a/clients/cli/__tests__/session-stored-auth.test.ts b/clients/cli/__tests__/session-stored-auth.test.ts new file mode 100644 index 000000000..4396687f0 --- /dev/null +++ b/clients/cli/__tests__/session-stored-auth.test.ts @@ -0,0 +1,246 @@ +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 "../src/error-handler.js"; +import { runMcp } from "./helpers/mcp-runner.js"; +import { expectCliSuccess, expectCliFailure } from "./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/cli/__tests__/style.test.ts b/clients/cli/__tests__/style.test.ts new file mode 100644 index 000000000..9f2c58ce5 --- /dev/null +++ b/clients/cli/__tests__/style.test.ts @@ -0,0 +1,76 @@ +import { describe, it, expect } from "vitest"; +import { + createStyle, + resolveAnsiEnabled, + styleFromOpts, + PLAIN, +} from "../src/session/style.js"; +import { + formatToolsHuman, + formatStreamEventHuman, +} from "../src/session/format-human.js"; + +describe("resolveAnsiEnabled", () => { + it("is off for --plain, json, NO_COLOR, and non-TTY", () => { + expect(resolveAnsiEnabled({ plain: true, isTTY: true })).toBe(false); + expect(resolveAnsiEnabled({ format: "json", isTTY: true })).toBe(false); + expect(resolveAnsiEnabled({ isTTY: true, noColorEnv: "1" })).toBe(false); + expect(resolveAnsiEnabled({ isTTY: false, noColorEnv: "" })).toBe(false); + }); + + it("is on for TTY text without plain/NO_COLOR", () => { + expect( + resolveAnsiEnabled({ isTTY: true, noColorEnv: "", format: "text" }), + ).toBe(true); + expect(styleFromOpts({ isTTY: true, noColorEnv: "" }).ansi).toBe(true); + }); +}); + +describe("createStyle", () => { + it("PLAIN is identity; ansi wraps SGR and OSC 8", () => { + expect(PLAIN.bold("x")).toBe("x"); + expect(PLAIN.link("https://example.com")).toBe("https://example.com"); + + const s = createStyle(true); + expect(s.bold("x")).toContain("\u001b[1m"); + expect(s.dim("x")).toContain("\u001b[2m"); + expect(s.red("x")).toContain("\u001b[31m"); + expect(s.link("https://example.com", "ex")).toContain( + "\u001b]8;;https://example.com", + ); + expect(s.link("https://example.com", "ex")).toContain("ex"); + expect(s.link("", "ex")).toBe("ex"); + }); + + 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/cli/package-lock.json b/clients/cli/package-lock.json index b53941412..c0f6740cf 100644 --- a/clients/cli/package-lock.json +++ b/clients/cli/package-lock.json @@ -16,11 +16,13 @@ "ajv": "^8.17.1", "atomically": "^2.1.1", "commander": "^13.1.0", + "open": "^10.2.0", "pino": "^9.14.0", "undici": "^8.5.0", "zod": "^4.3.6" }, "bin": { + "mcp": "build/mcp-bin.js", "mcp-inspector-cli": "build/index.js" }, "devDependencies": { @@ -2449,6 +2451,21 @@ "concat-map": "0.0.1" } }, + "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", @@ -2726,6 +2743,46 @@ "dev": true, "license": "MIT" }, + "node_modules/default-browser": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.5.0.tgz", + "integrity": "sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw==", + "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", @@ -3583,6 +3640,21 @@ "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-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -3606,6 +3678,24 @@ "node": ">=0.10.0" } }, + "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", @@ -3613,6 +3703,21 @@ "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", @@ -4311,6 +4416,24 @@ "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/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -4832,6 +4955,18 @@ "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", @@ -5693,6 +5828,21 @@ "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/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/clients/cli/package.json b/clients/cli/package.json index e3042d708..555bd2ea7 100644 --- a/clients/cli/package.json +++ b/clients/cli/package.json @@ -9,7 +9,8 @@ ".": "./build/index.js" }, "bin": { - "mcp-inspector-cli": "./build/index.js" + "mcp-inspector-cli": "./build/index.js", + "mcp": "./build/mcp-bin.js" }, "files": [ "build" @@ -39,6 +40,7 @@ "ajv": "^8.17.1", "atomically": "^2.1.1", "commander": "^13.1.0", + "open": "^10.2.0", "pino": "^9.14.0", "undici": "^8.5.0", "zod": "^4.3.6" diff --git a/clients/cli/src/cli-oauth-navigation.ts b/clients/cli/src/cli-oauth-navigation.ts new file mode 100644 index 000000000..429127793 --- /dev/null +++ b/clients/cli/src/cli-oauth-navigation.ts @@ -0,0 +1,46 @@ +import { CallbackNavigation } from "@inspector/core/auth/index.js"; +import { openUrl } from "./open-url.js"; +import { createStyle, resolveAnsiEnabled } from "./session/style.js"; + +export type CliOAuthNavigationOptions = { + /** Override TTY detection (tests). Defaults to stderr.isTTY. */ + isTTY?: boolean; + /** Override NO_COLOR (tests). */ + noColorEnv?: string | undefined; + /** Write the prompt line (defaults to stderr). */ + write?: (line: string) => void; + /** Open the browser (defaults to {@link openUrl}). */ + openBrowser?: (url: string) => Promise; +}; + +/** + * CLI OAuth navigation: print the authorization URL (OSC 8 when TTY allows + * ANSI) and auto-open the default browser on a TTY. Non-TTY / CI: plain URL + * only — never launches a browser. + */ +export function createCliOAuthNavigation( + options: CliOAuthNavigationOptions = {}, +): CallbackNavigation { + return new CallbackNavigation(async (url) => { + const href = url.href; + const tty = + options.isTTY !== undefined + ? options.isTTY + : process.stderr.isTTY === true; + const style = createStyle( + resolveAnsiEnabled({ + isTTY: tty, + noColorEnv: options.noColorEnv, + }), + ); + const write = + options.write ?? ((line: string) => process.stderr.write(line)); + write(`Please navigate to: ${style.link(href)}\n`); + if (!tty) return; + try { + await (options.openBrowser ?? openUrl)(href); + } catch { + // URL already printed; browser open is best-effort. + } + }); +} diff --git a/clients/cli/src/cli.ts b/clients/cli/src/cli.ts index afb8c1fa5..7861be010 100644 --- a/clients/cli/src/cli.ts +++ b/clients/cli/src/cli.ts @@ -6,18 +6,15 @@ import type { MCPServerConfig, InspectorClientEnvironment, } from "@inspector/core/mcp/types.js"; +import { eraToVersionNegotiation } from "@inspector/core/mcp/types.js"; import { - DEFAULT_MAX_FETCH_REQUESTS, - DEFAULT_TASK_TTL_MS, - eraToVersionNegotiation, -} from "@inspector/core/mcp/types.js"; + DEFAULT_CONNECT_TIMEOUT_MS, + withConnectTimeout, +} from "./handlers/connect-timeout.js"; +import { listServerEntries, showServerEntry } from "./handlers/servers-list.js"; +import { writeFormattedResult } from "./handlers/format-output.js"; +import { clearStoredAuthForRelogin } from "./session/stored-auth.js"; import { InspectorClient } from "@inspector/core/mcp/index.js"; -import { - ManagedToolsState, - ManagedResourcesState, - ManagedResourceTemplatesState, - ManagedPromptsState, -} from "@inspector/core/mcp/state/index.js"; import { createTransportNode, loadServerEntries, @@ -26,9 +23,13 @@ import { parseHeaderPair, } from "@inspector/core/mcp/node/index.js"; import type { JsonValue } from "@inspector/core/mcp/index.js"; -import { extractAppInfo } from "@inspector/core/mcp/apps.js"; -import type { AppInfo } from "@inspector/core/mcp/apps.js"; import { getStateFilePath } from "@inspector/core/auth/node/storage-node.js"; +import { consumeMethodOutcome } from "./handlers/consume-outcome.js"; +import { runMethod } from "./handlers/run-method.js"; +import type { MethodArgs } from "./handlers/method-types.js"; +export type { CliAppInfo } from "./handlers/method-types.js"; +export { emitResult } from "./handlers/emit-result.js"; +export { collectAppInfo } from "./handlers/collect-app-info.js"; import { parseOAuthPersistBlob, serializeOAuthPersistBlob, @@ -46,11 +47,9 @@ import type { OAuthTokens, } from "@modelcontextprotocol/client"; import { CliExitCodeError, EXIT_CODES } from "./error-handler.js"; -import { - ConsoleNavigation, - MutableRedirectUrlProvider, -} from "@inspector/core/auth/index.js"; +import { MutableRedirectUrlProvider } from "@inspector/core/auth/index.js"; import { NodeOAuthStorage } from "@inspector/core/auth/node/index.js"; +import { createCliOAuthNavigation } from "./cli-oauth-navigation.js"; import { connectInspectorWithOAuth, withCliAuthRecoveryRetry, @@ -79,47 +78,10 @@ export const validLogLevels: LoggingLevel[] = Object.values( /** Client identity name the CLI reports to servers. */ const CLI_CLIENT_NAME = "inspector-cli"; -/** - * Default connect timeout (ms) for ad-hoc server invocations. Without this an - * unreachable server (e.g. a partner edge that drops the SYN) hangs the CLI - * indefinitely; the value is generous enough for cold-start OAuth discovery - * round-trips while still failing fast on a black-holed host. - */ -export const DEFAULT_CONNECT_TIMEOUT_MS = 15000; +export { DEFAULT_CONNECT_TIMEOUT_MS, withConnectTimeout }; type OutputFormat = "text" | "json"; -type MethodArgs = { - method?: string; - promptName?: string; - promptArgs?: Record; - uri?: string; - logLevel?: LoggingLevel; - toolName?: string; - toolArg?: Record; - toolMeta?: Record; - metadata?: Record; - appInfo?: boolean; - format?: OutputFormat; -}; - -/** - * {@link AppInfo} plus a CLI-only `resourceError` so a `resources/read` failure - * during the probe is reported instead of being silently swallowed (which would - * make "no CSP declared" indistinguishable from "resource unreadable"). - */ -export type CliAppInfo = AppInfo & { resourceError?: string }; - -/** - * Discriminated outcome from {@link callMethod}'s per-method runner. Most - * methods return a `result` (with optional collected `appInfo`) for - * {@link emitResult} to format; the `tools/list --app-info` NDJSON path writes - * its lines itself and reports `emitted` so the caller skips a second write. - */ -type MethodOutcome = - | { kind: "result"; result: McpResponse; appInfo?: CliAppInfo } - | { kind: "emitted" }; - async function callMethod( serverConfig: MCPServerConfig, serverSettings: InspectorServerSettings | undefined, @@ -127,6 +89,7 @@ async function callMethod( clientConfig: ClientConfig, cliAuthOverrides: RunnerClientConfigOverrides, callbackUrlConfig: RunnerOAuthCallbackConfig, + storedAuthOnly: boolean, ): Promise { // Version comes from the single source of truth — the root package.json — // via the shared core reader, not the CLI's own manifest. @@ -144,7 +107,7 @@ async function callMethod( formatRunnerOAuthRedirectUrl(callbackUrlConfig); environment.oauth = { storage: new NodeOAuthStorage(), - navigation: new ConsoleNavigation(), + navigation: createCliOAuthNavigation(), redirectUrlProvider, }; } @@ -171,182 +134,6 @@ async function callMethod( ...clientAuthOptions, }); - let managedToolsState: ManagedToolsState | null = null; - let managedResourcesState: ManagedResourcesState | null = null; - let managedResourceTemplatesState: ManagedResourceTemplatesState | null = - null; - let managedPromptsState: ManagedPromptsState | null = null; - - const runMethod = async (): Promise => { - let result: McpResponse; - let appInfo: CliAppInfo | undefined; - - if (args.method === "tools/list" || args.method === "tools/call") { - managedToolsState = new ManagedToolsState(inspectorClient); - managedToolsState.setMetadata(args.metadata); - await managedToolsState.refresh(); - } - - if (args.method === "resources/list") { - managedResourcesState = new ManagedResourcesState(inspectorClient); - managedResourcesState.setMetadata(args.metadata); - await managedResourcesState.refresh(); - } else if (args.method === "resources/templates/list") { - managedResourceTemplatesState = new ManagedResourceTemplatesState( - inspectorClient, - ); - managedResourceTemplatesState.setMetadata(args.metadata); - await managedResourceTemplatesState.refresh(); - } else if (args.method === "prompts/list") { - managedPromptsState = new ManagedPromptsState(inspectorClient); - managedPromptsState.setMetadata(args.metadata); - await managedPromptsState.refresh(); - } - - if (args.method === "tools/list") { - const tools = managedToolsState!.getTools(); - if (args.appInfo) { - // NDJSON: one app-info line per tool, all on a single connection. A - // caller that wants only the App tools can `| jq -c 'select(.hasApp)'`. - // collectAppInfo never throws — a tool with a malformed `_meta.ui` - // surfaces as `{hasApp:false, resourceError}` — so one bad tool can't - // abort the whole listing. Emitted verbatim as NDJSON regardless of - // --format (the list-probe shape is fixed; --format json only reshapes - // the single-result paths). - for (const tool of tools) { - const info = await collectAppInfo( - inspectorClient, - tool, - args.metadata, - ); - await awaitableLog(JSON.stringify(info) + "\n"); - } - return { kind: "emitted" }; - } - result = { tools }; - } else if (args.method === "tools/call") { - if (!args.toolName) { - throw new Error( - "Tool name is required for tools/call method. Use --tool-name to specify the tool name.", - ); - } - - const tool = managedToolsState! - .getTools() - .find((t) => t.name === args.toolName); - if (!tool) { - // Distinct from `isError:true` and (for --app-info) from "tool has no - // app": the named tool does not exist on the server. Exit TOOL_ERROR - // with `code: "tool_not_found"` so a caller can tell a typo/rename - // apart from a real tool failure or a no-app probe result. - throw new CliExitCodeError( - EXIT_CODES.TOOL_ERROR, - `Tool '${args.toolName}' not found on server.`, - { code: "tool_not_found" }, - ); - } - - // Only collect app-info when the caller asked for it (`--app-info` or - // `--format json`); a plain text-mode `tools/call` shouldn't fail just - // because the tool's `_meta.ui.resourceUri` is malformed or its resource - // is unreadable. - if (args.appInfo || args.format === "json") { - appInfo = await collectAppInfo(inspectorClient, tool, args.metadata); - } - if (args.appInfo) { - // --app-info: probe-only — emit the app metadata and skip the tool - // call entirely. The no-app exit code is handled in emitResult. - result = { ...appInfo }; - } else { - const invocation = await inspectorClient.callTool( - tool, - args.toolArg || {}, - args.metadata, - args.toolMeta, - ); - if (invocation.result !== null) { - result = invocation.result; - } else { - /* v8 ignore next 9 -- unreachable: InspectorClient.callTool either - throws on a tool-execution/transport error (caught by the outer - try) or returns an invocation whose `result` is guaranteed - non-null (attemptToolCall throws if finalResult is falsy). This - else is defensive only. */ - result = { - content: [ - { - type: "text" as const, - text: invocation.error || "Tool call failed", - }, - ], - isError: true, - }; - } - } - } else if (args.method === "resources/list") { - result = { - resources: managedResourcesState!.getResources(), - }; - } else if (args.method === "resources/read") { - if (!args.uri) { - throw new Error( - "URI is required for resources/read method. Use --uri to specify the resource URI.", - ); - } - - const invocation = await inspectorClient.readResource( - args.uri, - args.metadata, - ); - result = invocation.result; - } else if (args.method === "resources/templates/list") { - result = { - resourceTemplates: - managedResourceTemplatesState!.getResourceTemplates(), - }; - } else if (args.method === "prompts/list") { - result = { prompts: managedPromptsState!.getPrompts() }; - } else if (args.method === "prompts/get") { - if (!args.promptName) { - throw new Error( - "Prompt name is required for prompts/get method. Use --prompt-name to specify the prompt name.", - ); - } - - const invocation = await inspectorClient.getPrompt( - args.promptName, - args.promptArgs || {}, - args.metadata, - ); - result = invocation.result; - } else if (args.method === "initialize") { - // Connect-only probe: emit the cached InitializeResult fields so a - // caller can read serverInfo / protocolVersion / capabilities / - // instructions without picking a list method. - result = { - serverInfo: inspectorClient.getServerInfo(), - protocolVersion: inspectorClient.getProtocolVersion(), - capabilities: inspectorClient.getCapabilities(), - instructions: inspectorClient.getInstructions(), - }; - } else if (args.method === "logging/setLevel") { - if (!args.logLevel) { - throw new Error( - "Log level is required for logging/setLevel method. Use --log-level to specify the log level.", - ); - } - - await inspectorClient.setLoggingLevel(args.logLevel); - result = {}; - } else { - throw new Error( - `Unsupported method: ${args.method}. Supported methods include: initialize, tools/list, tools/call, resources/list, resources/read, resources/templates/list, prompts/list, prompts/get, logging/setLevel`, - ); - } - - return { kind: "result", result, appInfo }; - }; - try { await connectInspectorWithOAuth( inspectorClient, @@ -354,6 +141,7 @@ async function callMethod( redirectUrlProvider, callbackUrlConfig, serverSettings, + { storedAuthOnly }, ); const outcome = await withCliAuthRecoveryRetry( @@ -361,117 +149,17 @@ async function callMethod( redirectUrlProvider, callbackUrlConfig, serverSettings, - runMethod, + () => runMethod(inspectorClient, args), + undefined, + { storedAuthOnly }, ); - // The NDJSON `tools/list --app-info` path already wrote its lines; every - // other method hands its result to emitResult for format/exit handling. - if (outcome.kind === "result") { - await emitResult(outcome.result, outcome.appInfo, args); - } + await consumeMethodOutcome(outcome, args); } finally { - managedToolsState?.destroy(); - managedResourcesState?.destroy(); - managedResourceTemplatesState?.destroy(); - managedPromptsState?.destroy(); await inspectorClient.disconnect(); } } -/** - * Write the method result (and any app-info) to stdout, honouring `--format` - * and `--app-info`, then map `isError`/no-app outcomes onto the exit-code map. - * Extracted from `callMethod` so the format/exit handling is in one place. - */ -export async function emitResult( - result: McpResponse, - appInfo: CliAppInfo | undefined, - args: MethodArgs, -): Promise { - const json = args.format === "json"; - - if (args.appInfo) { - const info: CliAppInfo = appInfo ?? { - hasApp: false, - toolName: args.toolName ?? "", - }; - // Single-line JSON either way; --format json wraps it under an `appInfo` - // key so the envelope shape is uniform with the non-probe path. - await awaitableLog(JSON.stringify(json ? { appInfo: info } : info) + "\n"); - if (!info.hasApp) { - throw new CliExitCodeError( - EXIT_CODES.NO_APP, - `Tool '${args.toolName}' has no MCP App UI resource (_meta.ui.resourceUri).`, - ); - } - return; - } - - if (json) { - // One JSON object on stdout — `result` plus, when present, `appInfo` as a - // sibling key. No `--- MCP App Info ---` banner, so `| jq` works for App - // tools as well as plain ones. - const envelope: Record = { result }; - if (appInfo?.hasApp) envelope.appInfo = appInfo; - await awaitableLog(JSON.stringify(envelope) + "\n"); - } else { - // Text mode emits the result only; app-info is not collected on this path - // (use `--format json` or `--app-info` to get it). - await awaitableLog(JSON.stringify(result, null, 2) + "\n"); - } - - // A tool that returned `isError:true` (or whose call failed) is still printed - // above so the caller sees the payload, but the process exits TOOL_ERROR so - // `&&` chains don't proceed on a failed call. - if ((result as { isError?: unknown }).isError === true) { - throw new CliExitCodeError( - EXIT_CODES.TOOL_ERROR, - `Tool '${args.toolName}' returned isError:true.`, - { code: "tool_is_error" }, - ); - } -} - -/** - * Build the CLI's app-info for a tool: extract the tool-side `_meta.ui` and, - * when the tool advertises a UI resource, follow it with a `resources/read` so - * the resource-side csp/permissions/domain are included. - * - * Never throws — the two failure modes both fold into a `{hasApp:false, - * resourceError}` result rather than propagating: - * - a malformed `_meta.ui.resourceUri` (extractAppInfo throws), so the - * `tools/list --app-info` NDJSON loop stays per-tool tolerant (one bad tool - * can't abort the whole listing); - * - a `resources/read` failure, since "tool says it has an app but the - * resource is unreadable" is itself a useful probe result. - */ -export async function collectAppInfo( - client: Pick, - tool: Parameters[0], - metadata: Record | undefined, -): Promise { - let base: AppInfo; - try { - base = extractAppInfo(tool); - } catch (e) { - return { - hasApp: false, - toolName: tool.name, - resourceError: e instanceof Error ? e.message : String(e), - }; - } - if (!base.hasApp || base.resourceUri === undefined) return base; - try { - const read = await client.readResource(base.resourceUri, metadata); - return extractAppInfo(tool, read.result); - } catch (e) { - return { - ...base, - resourceError: e instanceof Error ? e.message : String(e), - }; - } -} - /** * Canonicalise a server URL the same way the web inspector does before storing * OAuth state (`new URL().href` lowercases the host, normalises the scheme, and @@ -740,32 +428,6 @@ function buildHandoff( }; } -/** - * Apply a connection timeout to a resolved server's settings, building a - * minimal {@link InspectorServerSettings} when none came from the file. Ad-hoc - * invocations get {@link DEFAULT_CONNECT_TIMEOUT_MS} so a black-holed host - * fails fast; catalog/config invocations keep their file-level timeout unless - * `--connect-timeout` is passed explicitly. - */ -export function withConnectTimeout( - settings: InspectorServerSettings | undefined, - connectionTimeout: number | undefined, -): InspectorServerSettings | undefined { - if (connectionTimeout === undefined) return settings; - if (settings) return { ...settings, connectionTimeout }; - return { - headers: [], - metadata: [], - env: [], - connectionTimeout, - requestTimeout: 0, - taskTtl: DEFAULT_TASK_TTL_MS, - maxFetchRequests: DEFAULT_MAX_FETCH_REQUESTS, - autoRefreshOnListChanged: false, - roots: [], - }; -} - function parseKeyValuePair( value: string, previous: Record = {}, @@ -801,6 +463,7 @@ type ParseResult = clientSecret?: string; clientMetadataUrl?: string; callbackUrl?: string; + storedAuthOnly?: boolean; } // Short-circuit modes (`--list-stored-auth`, `--print-handoff`) do their own // output and need no server connection; runCli returns immediately. @@ -984,6 +647,14 @@ async function parseArgs(argv?: string[]): Promise { "--use-stored-auth", "Read the OAuth access token for --server-url from the OAuth state file (written by the web inspector) and inject it as Authorization: Bearer.", ) + .option( + "--stored-auth-only", + "Never start interactive OAuth; use the shared store if present, otherwise fail with auth_required. No-op when the server does not require auth.", + ) + .option( + "--relogin", + "Ignore stored OAuth credentials for this run; run interactive login if the server requires auth, then save new tokens", + ) .option( "--wait-for-auth ", "Poll the OAuth state file until a token for --server-url appears (or the timeout elapses), then proceed as if --use-stored-auth were set. Use after handing off to a human to complete OAuth in a browser.", @@ -1036,11 +707,24 @@ async function parseArgs(argv?: string[]): Promise { clientMetadataUrl?: string; callbackUrl?: string; useStoredAuth?: boolean; + storedAuthOnly?: boolean; + relogin?: boolean; waitForAuth?: number; listStoredAuth?: boolean; printHandoff?: boolean; }; + if (options.relogin) { + if (options.storedAuthOnly) { + throw new Error("--relogin cannot be combined with --stored-auth-only"); + } + if (options.useStoredAuth || options.waitForAuth !== undefined) { + throw new Error( + "--relogin cannot be combined with --use-stored-auth or --wait-for-auth", + ); + } + } + // State-path precedence (getStateFilePath): MCP_INSPECTOR_OAUTH_STATE_PATH → // /oauth.json → ~/.mcp-inspector/storage/oauth.json — the // same file the web backend writes, so tokens are shared across surfaces. @@ -1158,6 +842,35 @@ async function parseArgs(argv?: string[]): Promise { }; } + if (!options.method) { + throw new Error( + "Method is required. Use --method to specify the method to invoke.", + ); + } + + // Catalog list / show — no MCP connection. + if (options.method === "servers/list") { + const servers = await listServerEntries(serverOptions); + await writeFormattedResult( + { servers }, + options.format === "json" ? "json" : "text", + ); + return { shortCircuit: true }; + } + if (options.method === "servers/show") { + if (!options.server?.trim()) { + throw new Error( + "servers/show requires --server to select a catalog entry.", + ); + } + const server = await showServerEntry(options.server, serverOptions); + await writeFormattedResult( + server, + options.format === "json" ? "json" : "text", + ); + return { shortCircuit: true }; + } + // Shared with the TUI: resolves the catalog/config source (or ad-hoc target), // enforces the conflict matrix, and lifts disk headers/timeouts/OAuth into // per-server settings. `--server` selects one when the file has several. @@ -1172,10 +885,8 @@ async function parseArgs(argv?: string[]): Promise { options.connectTimeout ?? (adHoc ? DEFAULT_CONNECT_TIMEOUT_MS : undefined), ); - if (!options.method) { - throw new Error( - "Method is required. Use --method to specify the method to invoke.", - ); + if (options.relogin && "url" in serverConfig && serverConfig.url) { + await clearStoredAuthForRelogin(serverConfig.url); } if ( @@ -1252,6 +963,7 @@ async function parseArgs(argv?: string[]): Promise { clientSecret: options.clientSecret, clientMetadataUrl: options.clientMetadataUrl, callbackUrl: options.callbackUrl, + storedAuthOnly: options.storedAuthOnly === true, }; } @@ -1268,6 +980,7 @@ export async function runCli(argv?: string[]): Promise { clientSecret, clientMetadataUrl, callbackUrl, + storedAuthOnly, } = parsed; const clientConfig = await loadRunnerClientConfig({ clientConfigPath }); const callbackUrlConfig = parseRunnerOAuthCallbackUrl(callbackUrl); @@ -1282,5 +995,6 @@ export async function runCli(argv?: string[]): Promise { clientMetadataUrl, }, callbackUrlConfig, + storedAuthOnly === true, ); } diff --git a/clients/cli/src/cliOAuth.ts b/clients/cli/src/cliOAuth.ts index 24aa60582..a15bc0c4f 100644 --- a/clients/cli/src/cliOAuth.ts +++ b/clients/cli/src/cliOAuth.ts @@ -17,6 +17,21 @@ import type { InspectorServerSettings } from "@inspector/core/mcp/types.js"; import { isOAuthCapableServerConfig } from "@inspector/core/client/runner.js"; import type { MCPServerConfig } from "@inspector/core/mcp/types.js"; import { createInterface } from "node:readline/promises"; +import { CliExitCodeError, EXIT_CODES } from "./error-handler.js"; + +export type CliOAuthConnectOptions = { + /** + * When true, never open interactive OAuth / step-up prompts. Use the shared + * store if it can satisfy the challenge; otherwise fail with AUTH_REQUIRED. + */ + storedAuthOnly?: boolean; +}; + +function storedAuthOnlyFailure(message: string): never { + throw new CliExitCodeError(EXIT_CODES.AUTH_REQUIRED, message, { + code: "auth_required", + }); +} /** Standard-OAuth step-up (not EMA silent re-mint). */ export function isStandardOAuthStepUp( @@ -111,6 +126,7 @@ export async function connectInspectorWithOAuth( redirectUrlProvider: MutableRedirectUrlProvider, callbackUrlConfig: RunnerOAuthCallbackConfig, serverSettings?: InspectorServerSettings, + options?: CliOAuthConnectOptions, ): Promise { try { await inspectorClient.connect(); @@ -126,6 +142,12 @@ export async function connectInspectorWithOAuth( await inspectorClient.connect(); return; } + if (options?.storedAuthOnly) { + storedAuthOnlyFailure( + err.message || + "Authentication required and --stored-auth-only is set; refusing interactive OAuth.", + ); + } await handleCliAuthRecoveryRequired( inspectorClient, err, @@ -138,6 +160,13 @@ export async function connectInspectorWithOAuth( } if (isUnauthorizedError(err)) { + if (options?.storedAuthOnly) { + storedAuthOnlyFailure( + err instanceof Error + ? err.message + : "Authentication required and --stored-auth-only is set; refusing interactive OAuth.", + ); + } await inspectorClient.disconnect().catch(() => {}); await runCliInteractiveOAuth( inspectorClient, @@ -163,6 +192,7 @@ export async function withCliAuthRecoveryRetry( serverSettings: InspectorServerSettings | undefined, fn: () => Promise, confirmStepUp: () => Promise = confirmStepUpFromStdin, + options?: CliOAuthConnectOptions, ): Promise { try { return await fn(); @@ -170,6 +200,12 @@ export async function withCliAuthRecoveryRetry( if (!(err instanceof AuthRecoveryRequiredError)) { throw err; } + if (options?.storedAuthOnly) { + storedAuthOnlyFailure( + err.message || + "Authentication required and --stored-auth-only is set; refusing interactive OAuth.", + ); + } await handleCliAuthRecoveryRequired( inspectorClient, err, diff --git a/clients/cli/src/daemon/auth.ts b/clients/cli/src/daemon/auth.ts new file mode 100644 index 000000000..daae12be5 --- /dev/null +++ b/clients/cli/src/daemon/auth.ts @@ -0,0 +1,43 @@ +import { timingSafeEqual } from "node:crypto"; +import { CliExitCodeError, EXIT_CODES } from "../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/cli/src/daemon/client.ts b/clients/cli/src/daemon/client.ts new file mode 100644 index 000000000..e6a37c92d --- /dev/null +++ b/clients/cli/src/daemon/client.ts @@ -0,0 +1,127 @@ +import { randomUUID } from "node:crypto"; +import * as net from "node:net"; +import { CliExitCodeError, EXIT_CODES } from "../error-handler.js"; +import { getDaemonTokenFromEnv } from "./auth.js"; +import { encodeRequest } from "./framing.js"; +import { getDaemonSocketPath } from "./paths.js"; +import type { DaemonOp, DaemonRequest, DaemonResponse } 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; +}; + +/** + * 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` so settle() can clearTimeout before the assignment if connect fails + // synchronously (prefer-const would put `timer` in the TDZ for that race). + // eslint-disable-next-line prefer-const -- see comment above + 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); + socket.removeAllListeners(); + socket.on("error", () => {}); + fn(); + } + + function fail(error: unknown) { + settle(() => { + socket.destroy(); + reject(error); + }); + } + + function succeed(value: T) { + settle(() => { + socket.end(); + resolve(value); + }); + } + + function handleLine(line: string) { + const trimmed = line.trim(); + if (!trimmed) return; + 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; + } + 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" }, + ), + ); + }); + + timer = setTimeout(() => { + fail( + new CliExitCodeError( + EXIT_CODES.UNREACHABLE, + `Daemon request '${op}' timed out after ${timeoutMs}ms`, + { code: "daemon_timeout" }, + ), + ); + }, timeoutMs); + + 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/cli/src/daemon/ensure.ts b/clients/cli/src/daemon/ensure.ts new file mode 100644 index 000000000..dc2ba68b3 --- /dev/null +++ b/clients/cli/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 "../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/cli.`, + { 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/cli/src/daemon/framing.ts b/clients/cli/src/daemon/framing.ts new file mode 100644 index 000000000..fb9822f00 --- /dev/null +++ b/clients/cli/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/cli/src/daemon/index.ts b/clients/cli/src/daemon/index.ts new file mode 100644 index 000000000..d7526945b --- /dev/null +++ b/clients/cli/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/cli/src/daemon/ipc-glue.ts b/clients/cli/src/daemon/ipc-glue.ts new file mode 100644 index 000000000..0187d0a58 --- /dev/null +++ b/clients/cli/src/daemon/ipc-glue.ts @@ -0,0 +1,125 @@ +/** + * 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, +} 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; +}; + +export type HandleRequest = (request: DaemonRequest) => Promise; + +export function acceptDaemonConnection( + socket: net.Socket, + handle: HandleRequest, +): void { + const rl = createInterface({ input: socket, crlfDelay: Infinity }); + rl.on("line", (line) => { + void (async () => { + 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); + 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 mcp 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/cli/src/daemon/paths.ts b/clients/cli/src/daemon/paths.ts new file mode 100644 index 000000000..d850b56af --- /dev/null +++ b/clients/cli/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/cli/src/daemon/protocol.ts b/clients/cli/src/daemon/protocol.ts new file mode 100644 index 000000000..a26ab2b45 --- /dev/null +++ b/clients/cli/src/daemon/protocol.ts @@ -0,0 +1,97 @@ +import type { + InspectorServerSettings, + MCPServerConfig, +} from "@inspector/core/mcp/types.js"; +import type { CliAppInfo, MethodArgs } from "../handlers/method-types.js"; + +/** Operations the session daemon accepts over IPC. */ +export type DaemonOp = + | "ping" + | "connect" + | "disconnect" + | "sessions/list" + | "sessions/use" + | "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.stdout.isTTY` unless opted out. + */ + 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" }; + +export type SessionInfo = { + name: string; + serverIdentity: string; + connectedAt: number; + lastAccessedAt: number; + isMru: boolean; +}; + +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[] }; diff --git a/clients/cli/src/daemon/run.ts b/clients/cli/src/daemon/run.ts new file mode 100644 index 000000000..d80752ffc --- /dev/null +++ b/clients/cli/src/daemon/run.ts @@ -0,0 +1,29 @@ +#!/usr/bin/env node +/** + * Session daemon entrypoint. Spawned detached by {@link ensureDaemon}. + * Optional foreground `mcp 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(`mcp daemon: ${message}\n`); + process.exit(1); +}); diff --git a/clients/cli/src/daemon/server.ts b/clients/cli/src/daemon/server.ts new file mode 100644 index 000000000..c4bf6ffac --- /dev/null +++ b/clients/cli/src/daemon/server.ts @@ -0,0 +1,338 @@ +import * as fs from "node:fs"; +import * as net from "node:net"; +import { + classifyError, + CliExitCodeError, + EXIT_CODES, +} from "../error-handler.js"; +import { runMethod } from "../handlers/run-method.js"; +import type { MethodArgs } from "../handlers/method-types.js"; +import { + acceptDaemonConnection, + removeStaleDaemonSocket, + type HandleOutcome, +} from "./ipc-glue.js"; +import { assertDaemonToken, getDaemonTokenFromEnv } from "./auth.js"; +import { + ensureDaemonDir, + getDaemonDir, + getDaemonLockPath, + getDaemonSocketPath, +} from "./paths.js"; +import type { + ConnectParams, + DaemonRequest, + DaemonResponse, + DaemonStatus, + RpcParams, + RpcResult, + SessionNameParams, +} from "./protocol.js"; +import { DEFAULT_IDLE_MS, SessionRegistry } from "./sessions.js"; + +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) => this.handleOutcome(req)); + }); + + 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). + } + } + + 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): Promise { + return (await this.handleOutcome(request)).response; + } + + /** Full handle including optional stream starter (socket accept path). */ + async handleOutcome(request: DaemonRequest): Promise { + try { + assertDaemonToken(this.requiredToken, request.token); + return await this.dispatch(request); + } 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): 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 "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.params as RpcParams), + }, + }; + 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(params: RpcParams): 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 outcome = await runMethod(client, methodArgs); + 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 }; + } + 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/cli/src/daemon/sessions.ts b/clients/cli/src/daemon/sessions.ts new file mode 100644 index 000000000..eb9399345 --- /dev/null +++ b/clients/cli/src/daemon/sessions.ts @@ -0,0 +1,346 @@ +import { InspectorClient } from "@inspector/core/mcp/index.js"; +import type { InspectorClientEnvironment } from "@inspector/core/mcp/types.js"; +import { + eraToVersionNegotiation, + type InspectorServerSettings, + type MCPServerConfig, +} from "@inspector/core/mcp/types.js"; +import { createTransportNode } from "@inspector/core/mcp/node/index.js"; +import { + ConsoleNavigation, + MutableRedirectUrlProvider, +} from "@inspector/core/auth/index.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 { CliExitCodeError, EXIT_CODES } from "../error-handler.js"; +import type { 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; +}; + +/** + * 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; + } + + 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, + })) + .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. mcp servers/list, mcp 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 mcp 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, touch MRU, and return its client. + */ + clientFor( + name: string | undefined, + requireExplicit: boolean | undefined, + ): InspectorClient { + const session = this.resolve(name, requireExplicit); + this.touch(session.name); + return session.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, + }; + } + + async connect(params: { + name: string; + serverConfig: MCPServerConfig; + serverSettings?: InspectorServerSettings; + serverIdentity: string; + }): Promise { + this.clearIdleTimer(); + + 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(); + this.sessions.set(params.name, { + name: params.name, + serverIdentity: params.serverIdentity, + connectedAt: now, + lastAccessedAt: now, + client, + }); + this.mruName = params.name; + + return { + name: params.name, + serverIdentity: params.serverIdentity, + connectedAt: now, + lastAccessedAt: now, + isMru: true, + }; + } + + 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; + } + 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) + ); +} + +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, + elicit: false, + 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/cli/src/daemon/stream-client.ts b/clients/cli/src/daemon/stream-client.ts new file mode 100644 index 000000000..62effd20a --- /dev/null +++ b/clients/cli/src/daemon/stream-client.ts @@ -0,0 +1,170 @@ +/** + * 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 "../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) succeed(); + }); + + 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/cli/src/handlers/collect-app-info.ts b/clients/cli/src/handlers/collect-app-info.ts new file mode 100644 index 000000000..aa2df1fc1 --- /dev/null +++ b/clients/cli/src/handlers/collect-app-info.ts @@ -0,0 +1,35 @@ +import { InspectorClient } from "@inspector/core/mcp/index.js"; +import { extractAppInfo } from "@inspector/core/mcp/apps.js"; +import type { AppInfo } from "@inspector/core/mcp/apps.js"; +import type { CliAppInfo } from "./method-types.js"; + +/** + * Build the CLI's app-info for a tool. Never throws — failures fold into + * `{hasApp:false, resourceError}` so list probes stay per-tool tolerant. + */ +export async function collectAppInfo( + client: Pick, + tool: Parameters[0], + metadata: Record | undefined, +): Promise { + let base: AppInfo; + try { + base = extractAppInfo(tool); + } catch (e) { + return { + hasApp: false, + toolName: tool.name, + resourceError: e instanceof Error ? e.message : String(e), + }; + } + if (!base.hasApp || base.resourceUri === undefined) return base; + try { + const read = await client.readResource(base.resourceUri, metadata); + return extractAppInfo(tool, read.result); + } catch (e) { + return { + ...base, + resourceError: e instanceof Error ? e.message : String(e), + }; + } +} diff --git a/clients/cli/src/handlers/connect-timeout.ts b/clients/cli/src/handlers/connect-timeout.ts new file mode 100644 index 000000000..7d5a7b2cc --- /dev/null +++ b/clients/cli/src/handlers/connect-timeout.ts @@ -0,0 +1,35 @@ +import { + DEFAULT_MAX_FETCH_REQUESTS, + DEFAULT_TASK_TTL_MS, + type InspectorServerSettings, +} from "@inspector/core/mcp/types.js"; + +/** + * Default connect timeout (ms) for ad-hoc server invocations. Without this an + * unreachable server hangs the CLI indefinitely. + */ +export const DEFAULT_CONNECT_TIMEOUT_MS = 15000; + +/** + * Apply a connection timeout to a resolved server's settings, building a + * minimal {@link InspectorServerSettings} when none came from the file. + */ +export function withConnectTimeout( + settings: InspectorServerSettings | undefined, + connectionTimeout: number | undefined, +): InspectorServerSettings | undefined { + if (connectionTimeout === undefined) return settings; + if (settings) return { ...settings, connectionTimeout }; + return { + headers: [], + metadata: [], + env: [], + connectionTimeout, + requestTimeout: 0, + taskTtl: DEFAULT_TASK_TTL_MS, + maxFetchRequests: DEFAULT_MAX_FETCH_REQUESTS, + autoRefreshOnListChanged: false, + paginatedLists: false, + roots: [], + }; +} diff --git a/clients/cli/src/handlers/consume-outcome.ts b/clients/cli/src/handlers/consume-outcome.ts new file mode 100644 index 000000000..89b4ac7a8 --- /dev/null +++ b/clients/cli/src/handlers/consume-outcome.ts @@ -0,0 +1,37 @@ +import { awaitableLog } from "../utils/awaitable-log.js"; +import { emitResult } from "./emit-result.js"; +import type { MethodArgs, MethodOutcome } from "./method-types.js"; + +/** + * Write a {@link MethodOutcome} to stdout (result / NDJSON / long-lived stream). + * Stream methods stay attached until SIGINT/SIGTERM. + */ +export async function consumeMethodOutcome( + outcome: MethodOutcome, + args: MethodArgs, +): Promise { + if (outcome.kind === "result") { + await emitResult(outcome.result, outcome.appInfo, args); + return; + } + if (outcome.kind === "ndjson") { + for (const line of outcome.lines) { + await awaitableLog(JSON.stringify(line) + "\n"); + } + return; + } + + await new Promise((resolve) => { + const stop = outcome.start((obj) => { + void awaitableLog(JSON.stringify(obj) + "\n"); + }); + const onSignal = () => { + stop(); + process.off("SIGINT", onSignal); + process.off("SIGTERM", onSignal); + resolve(); + }; + process.on("SIGINT", onSignal); + process.on("SIGTERM", onSignal); + }); +} diff --git a/clients/cli/src/handlers/emit-result.ts b/clients/cli/src/handlers/emit-result.ts new file mode 100644 index 000000000..b4d370deb --- /dev/null +++ b/clients/cli/src/handlers/emit-result.ts @@ -0,0 +1,46 @@ +import { awaitableLog } from "../utils/awaitable-log.js"; +import { CliExitCodeError, EXIT_CODES } from "../error-handler.js"; +import type { CliAppInfo, McpResponse, MethodArgs } from "./method-types.js"; + +/** + * Write the method result (and any app-info) to stdout, honouring `--format` + * and `--app-info`, then map `isError`/no-app outcomes onto the exit-code map. + */ +export async function emitResult( + result: McpResponse, + appInfo: CliAppInfo | undefined, + args: MethodArgs, +): Promise { + const json = args.format === "json"; + + if (args.appInfo) { + const info: CliAppInfo = appInfo ?? { + hasApp: false, + toolName: args.toolName ?? "", + }; + await awaitableLog(JSON.stringify(json ? { appInfo: info } : info) + "\n"); + if (!info.hasApp) { + throw new CliExitCodeError( + EXIT_CODES.NO_APP, + `Tool '${args.toolName}' has no MCP App UI resource (_meta.ui.resourceUri).`, + ); + } + return; + } + + if (json) { + const envelope: Record = { result }; + if (appInfo?.hasApp) envelope.appInfo = appInfo; + await awaitableLog(JSON.stringify(envelope) + "\n"); + } else { + await awaitableLog(JSON.stringify(result, null, 2) + "\n"); + } + + if ((result as { isError?: unknown }).isError === true) { + throw new CliExitCodeError( + EXIT_CODES.TOOL_ERROR, + `Tool '${args.toolName}' returned isError:true.`, + { code: "tool_is_error" }, + ); + } +} diff --git a/clients/cli/src/handlers/format-output.ts b/clients/cli/src/handlers/format-output.ts new file mode 100644 index 000000000..1bba0acd4 --- /dev/null +++ b/clients/cli/src/handlers/format-output.ts @@ -0,0 +1,18 @@ +import { awaitableLog } from "../utils/awaitable-log.js"; + +export type OutputFormat = "text" | "json"; + +/** + * Write a command result to stdout. Text = pretty-printed JSON; json = single + * `{ "result": … }` envelope (same family as one-shot `--format json`). + */ +export async function writeFormattedResult( + result: unknown, + format: OutputFormat = "text", +): Promise { + if (format === "json") { + await awaitableLog(JSON.stringify({ result }) + "\n"); + return; + } + await awaitableLog(JSON.stringify(result, null, 2) + "\n"); +} diff --git a/clients/cli/src/handlers/method-types.ts b/clients/cli/src/handlers/method-types.ts new file mode 100644 index 000000000..42c7670e3 --- /dev/null +++ b/clients/cli/src/handlers/method-types.ts @@ -0,0 +1,82 @@ +import type { JsonValue } from "@inspector/core/mcp/index.js"; +import type { AppInfo } from "@inspector/core/mcp/apps.js"; +import type { LoggingLevel } from "@modelcontextprotocol/client"; +import type { OutputFormat } from "./format-output.js"; + +export type { OutputFormat }; + +/** + * {@link AppInfo} plus a CLI-only `resourceError` so a `resources/read` failure + * during the probe is reported instead of being silently swallowed. + */ +export type CliAppInfo = AppInfo & { resourceError?: string }; + +/** Arguments for a single MCP method invocation (one-shot or session RPC). */ +export type MethodArgs = { + method?: string; + promptName?: string; + promptArgs?: Record; + uri?: string; + logLevel?: LoggingLevel; + toolName?: string; + toolArg?: Record; + toolMeta?: Record; + metadata?: Record; + appInfo?: boolean; + format?: OutputFormat; + /** Task id for tasks/get, tasks/cancel, tasks/result. */ + taskId?: string; + /** When true, tools/call uses callToolStream (task-augmented). */ + task?: boolean; + /** roots/set payload (JSON array of {uri, name?}). */ + rootsJson?: string; + /** prompts/complete: argument name / value / ref. */ + completeRefType?: "ref/prompt" | "ref/resource"; + completeRef?: string; + completeArgName?: string; + completeArgValue?: string; +}; + +export type McpResponse = Record; + +/** + * Discriminated outcome from {@link runMethod}. Most methods return a `result` + * for formatting; `tools/list --app-info` returns `ndjson` lines for the + * caller to write. Stream methods return `stream` for a long-lived consumer. + */ +export type MethodOutcome = + | { kind: "result"; result: McpResponse; appInfo?: CliAppInfo } + /** One JSON object per line (e.g. tools/list --app-info). Caller writes stdout. */ + | { kind: "ndjson"; lines: unknown[] } + | { + kind: "stream"; + /** Human label for errors. */ + label: string; + /** Subscribe and push NDJSON object lines; return an unsubscribe. */ + start: (writeLine: (obj: unknown) => void) => () => void; + }; + +/** Methods supported by {@link runMethod}. */ +export const SESSION_RPC_METHODS = [ + "initialize", + "tools/list", + "tools/call", + "resources/list", + "resources/read", + "resources/templates/list", + "resources/subscribe", + "resources/unsubscribe", + "prompts/list", + "prompts/get", + "prompts/complete", + "logging/setLevel", + "logging/tail", + "tasks/list", + "tasks/get", + "tasks/cancel", + "tasks/result", + "roots/list", + "roots/set", +] as const; + +export type SessionRpcMethod = (typeof SESSION_RPC_METHODS)[number]; diff --git a/clients/cli/src/handlers/run-method.ts b/clients/cli/src/handlers/run-method.ts new file mode 100644 index 000000000..fddd146e5 --- /dev/null +++ b/clients/cli/src/handlers/run-method.ts @@ -0,0 +1,310 @@ +import type { InspectorClient } from "@inspector/core/mcp/index.js"; +import type { Root } from "@modelcontextprotocol/client"; +import { + ManagedToolsState, + ManagedResourcesState, + ManagedResourceTemplatesState, + ManagedPromptsState, + ManagedRequestorTasksState, + MessageLogState, +} from "@inspector/core/mcp/state/index.js"; +import { CliExitCodeError, EXIT_CODES } from "../error-handler.js"; +import { collectAppInfo } from "./collect-app-info.js"; +import type { + CliAppInfo, + McpResponse, + MethodArgs, + MethodOutcome, +} from "./method-types.js"; + +/** + * Run one MCP method against a connected {@link InspectorClient}. + * Shared by one-shot CLI and the session daemon `rpc` path. + */ +export async function runMethod( + inspectorClient: InspectorClient, + args: MethodArgs & { method: string }, +): Promise { + let managedToolsState: ManagedToolsState | null = null; + let managedResourcesState: ManagedResourcesState | null = null; + let managedResourceTemplatesState: ManagedResourceTemplatesState | null = + null; + let managedPromptsState: ManagedPromptsState | null = null; + let managedTasksState: ManagedRequestorTasksState | null = null; + + try { + let result: McpResponse; + let appInfo: CliAppInfo | undefined; + + if (args.method === "tools/list" || args.method === "tools/call") { + managedToolsState = new ManagedToolsState(inspectorClient); + managedToolsState.setMetadata(args.metadata); + await managedToolsState.refresh(); + } + + if (args.method === "resources/list") { + managedResourcesState = new ManagedResourcesState(inspectorClient); + managedResourcesState.setMetadata(args.metadata); + await managedResourcesState.refresh(); + } else if (args.method === "resources/templates/list") { + managedResourceTemplatesState = new ManagedResourceTemplatesState( + inspectorClient, + ); + managedResourceTemplatesState.setMetadata(args.metadata); + await managedResourceTemplatesState.refresh(); + } else if (args.method === "prompts/list") { + managedPromptsState = new ManagedPromptsState(inspectorClient); + managedPromptsState.setMetadata(args.metadata); + await managedPromptsState.refresh(); + } else if (args.method === "tasks/list") { + managedTasksState = new ManagedRequestorTasksState(inspectorClient); + await managedTasksState.refresh(); + } + + if (args.method === "tools/list") { + const tools = managedToolsState!.getTools(); + if (args.appInfo) { + const lines: unknown[] = []; + for (const tool of tools) { + lines.push( + await collectAppInfo(inspectorClient, tool, args.metadata), + ); + } + return { kind: "ndjson", lines }; + } + result = { tools }; + } else if (args.method === "tools/call") { + if (!args.toolName) { + throw new Error( + "Tool name is required for tools/call method. Use --tool-name to specify the tool name.", + ); + } + + const tool = managedToolsState! + .getTools() + .find((t) => t.name === args.toolName); + if (!tool) { + throw new CliExitCodeError( + EXIT_CODES.TOOL_ERROR, + `Tool '${args.toolName}' not found on server.`, + { code: "tool_not_found" }, + ); + } + + if (args.appInfo || args.format === "json") { + appInfo = await collectAppInfo(inspectorClient, tool, args.metadata); + } + if (args.appInfo) { + result = { ...appInfo }; + } else { + const invocation = args.task + ? await inspectorClient.callToolStream( + tool, + args.toolArg || {}, + args.metadata, + args.toolMeta, + ) + : await inspectorClient.callTool( + tool, + args.toolArg || {}, + args.metadata, + args.toolMeta, + ); + if (invocation.result !== null) { + result = invocation.result; + } else { + result = { + content: [ + { + type: "text" as const, + text: invocation.error || "Tool call failed", + }, + ], + isError: true, + }; + } + } + } else if (args.method === "resources/list") { + result = { resources: managedResourcesState!.getResources() }; + } else if (args.method === "resources/read") { + if (!args.uri) { + throw new Error( + "URI is required for resources/read method. Use --uri to specify the resource URI.", + ); + } + const invocation = await inspectorClient.readResource( + args.uri, + args.metadata, + ); + result = invocation.result; + } else if (args.method === "resources/templates/list") { + result = { + resourceTemplates: + managedResourceTemplatesState!.getResourceTemplates(), + }; + } else if (args.method === "resources/subscribe") { + if (!args.uri) { + throw new Error( + "URI is required for resources/subscribe. Use --uri to specify the resource URI.", + ); + } + await inspectorClient.subscribeToResource(args.uri); + return { + kind: "stream", + label: "resources/subscribe", + start: (writeLine) => { + writeLine({ type: "subscribed", uri: args.uri }); + const onUpdate = (ev: Event) => { + const detail = (ev as CustomEvent<{ uri: string }>).detail; + writeLine({ + type: "resources/updated", + uri: detail?.uri ?? args.uri, + }); + }; + inspectorClient.addEventListener("resourceUpdated", onUpdate); + return () => { + inspectorClient.removeEventListener("resourceUpdated", onUpdate); + void inspectorClient.unsubscribeFromResource(args.uri!); + }; + }, + }; + } else if (args.method === "resources/unsubscribe") { + if (!args.uri) { + throw new Error( + "URI is required for resources/unsubscribe. Use --uri to specify the resource URI.", + ); + } + await inspectorClient.unsubscribeFromResource(args.uri); + result = { unsubscribed: true, uri: args.uri }; + } else if (args.method === "prompts/list") { + result = { prompts: managedPromptsState!.getPrompts() }; + } else if (args.method === "prompts/get") { + if (!args.promptName) { + throw new Error( + "Prompt name is required for prompts/get method. Use --prompt-name to specify the prompt name.", + ); + } + const invocation = await inspectorClient.getPrompt( + args.promptName, + args.promptArgs || {}, + args.metadata, + ); + result = invocation.result; + } else if (args.method === "prompts/complete") { + if (!args.completeRefType || !args.completeRef || !args.completeArgName) { + throw new Error( + "prompts/complete requires --complete-ref-type, --complete-ref, and --complete-arg-name.", + ); + } + const ref = + args.completeRefType === "ref/prompt" + ? ({ type: "ref/prompt", name: args.completeRef } as const) + : ({ type: "ref/resource", uri: args.completeRef } as const); + result = await inspectorClient.getCompletions( + ref, + args.completeArgName, + args.completeArgValue ?? "", + undefined, + args.metadata, + ); + } else if (args.method === "initialize") { + result = { + serverInfo: inspectorClient.getServerInfo(), + protocolVersion: inspectorClient.getProtocolVersion(), + capabilities: inspectorClient.getCapabilities(), + instructions: inspectorClient.getInstructions(), + }; + } else if (args.method === "logging/setLevel") { + if (!args.logLevel) { + throw new Error( + "Log level is required for logging/setLevel method. Use --log-level to specify the log level.", + ); + } + await inspectorClient.setLoggingLevel(args.logLevel); + result = {}; + } else if (args.method === "logging/tail") { + return { + kind: "stream", + label: "logging/tail", + start: (writeLine) => { + const log = new MessageLogState(inspectorClient); + const onMessage = (ev: Event) => { + const entry = (ev as CustomEvent).detail as { + direction?: string; + message?: { method?: string }; + }; + // Follow server logging notifications only (not full RPC history). + if ( + entry.direction === "notification" && + entry.message?.method === "notifications/message" + ) { + writeLine(entry); + } + }; + log.addEventListener("message", onMessage); + return () => { + log.removeEventListener("message", onMessage); + log.destroy(); + }; + }, + }; + } else if (args.method === "tasks/list") { + result = { tasks: managedTasksState!.getTasks() }; + } else if (args.method === "tasks/get") { + if (!args.taskId) { + throw new Error("Task id is required for tasks/get. Use --task-id."); + } + result = { + task: await inspectorClient.getRequestorTask(args.taskId), + }; + } else if (args.method === "tasks/cancel") { + if (!args.taskId) { + throw new Error("Task id is required for tasks/cancel. Use --task-id."); + } + await inspectorClient.cancelRequestorTask(args.taskId); + result = { cancelled: true, taskId: args.taskId }; + } else if (args.method === "tasks/result") { + if (!args.taskId) { + throw new Error("Task id is required for tasks/result. Use --task-id."); + } + // SDK return type is a protocol result object; we only need a JSON bag. + result = (await inspectorClient.getRequestorTaskResult( + args.taskId, + )) as McpResponse; + } else if (args.method === "roots/list") { + result = { roots: inspectorClient.getRoots() }; + } else if (args.method === "roots/set") { + if (!args.rootsJson) { + throw new Error( + "roots/set requires --roots-json ''.", + ); + } + let roots: Root[]; + try { + const parsed: unknown = JSON.parse(args.rootsJson); + if (!Array.isArray(parsed)) { + throw new Error("must be a JSON array"); + } + roots = parsed as Root[]; + } catch (e) { + throw new Error( + `--roots-json is invalid: ${e instanceof Error ? e.message : String(e)}`, + ); + } + await inspectorClient.setRoots(roots); + result = { roots: inspectorClient.getRoots() }; + } else { + throw new Error( + `Unsupported method: ${args.method}. Supported methods include: initialize, tools/*, resources/*, prompts/*, logging/*, tasks/*, roots/*.`, + ); + } + + return { kind: "result", result, appInfo }; + } finally { + managedToolsState?.destroy(); + managedResourcesState?.destroy(); + managedResourceTemplatesState?.destroy(); + managedPromptsState?.destroy(); + managedTasksState?.destroy(); + } +} diff --git a/clients/cli/src/handlers/servers-list.ts b/clients/cli/src/handlers/servers-list.ts new file mode 100644 index 000000000..70bda0b28 --- /dev/null +++ b/clients/cli/src/handlers/servers-list.ts @@ -0,0 +1,171 @@ +import type { + InspectorServerSettings, + MCPServerConfig, +} from "@inspector/core/mcp/types.js"; +import { + loadServerEntries, + selectServerEntry, + type ServerLoadOptions, +} from "@inspector/core/mcp/node/index.js"; + +/** One catalog/config entry as returned by `servers/list`. */ +export type ServerListEntry = { + name: string; + type: string; + /** Command line, URL, or other short identity for display. */ + detail: string; + /** + * Live session name when a daemon session’s name matches this entry + * (session CLI only; omitted on one-shot). + */ + session?: string; + /** True when that session is the daemon MRU. */ + isMru?: boolean; +}; + +/** Minimal session shape needed to annotate catalog entries. */ +export type SessionListRef = { + name: string; + isMru?: boolean; +}; + +/** + * Mark catalog entries that have a live session with the same name + * (`mcp connect ` default). Does not mutate `entries`. + */ +export function annotateServerEntriesWithSessions( + entries: ServerListEntry[], + sessions: SessionListRef[], +): ServerListEntry[] { + if (sessions.length === 0) return entries; + const byName = new Map(sessions.map((s) => [s.name, s] as const)); + return entries.map((entry) => { + const session = byName.get(entry.name); + if (!session) return entry; + return { + ...entry, + session: session.name, + ...(session.isMru === true ? { isMru: true } : {}), + }; + }); +} + +/** Detail view for `servers/show` (secrets redacted). */ +export type ServerShowEntry = { + name: string; + type: string; + detail: string; + config: Record; + settings?: Record; +}; + +const REDACTED = "[redacted]"; + +/** + * Summarise an {@link MCPServerConfig} for catalog listing (no connection). + */ +export function summarizeServerConfig(config: MCPServerConfig): { + type: string; + detail: string; +} { + if (config.type === "stdio") { + const args = config.args?.length ? ` ${config.args.join(" ")}` : ""; + return { type: "stdio", detail: `${config.command}${args}` }; + } + // sse | streamable-http — both carry `url` + return { type: config.type, detail: config.url ?? "" }; +} + +/** + * Load catalog/config entries and return a sorted name + summary list. + * Shared by one-shot `--method servers/list` and `mcp servers/list`. + */ +export async function listServerEntries( + serverOptions: ServerLoadOptions = {}, +): Promise { + const entries = await loadServerEntries(serverOptions); + return Object.entries(entries) + .map(([name, resolved]) => { + const { type, detail } = summarizeServerConfig(resolved.config); + return { name, type, detail }; + }) + .sort((a, b) => a.name.localeCompare(b.name)); +} + +/** + * Resolve one catalog/config entry for `servers/show` (no MCP connection). + * Secret-bearing fields (env values, OAuth client secret, sensitive headers) + * are replaced with {@link REDACTED}. + */ +export async function showServerEntry( + serverName: string, + serverOptions: ServerLoadOptions = {}, +): Promise { + const name = serverName.trim(); + if (!name) { + throw new Error("servers/show requires a server name."); + } + const entries = await loadServerEntries(serverOptions); + const selected = selectServerEntry(entries, name); + const { type, detail } = summarizeServerConfig(selected.config); + const result: ServerShowEntry = { + name, + type, + detail, + config: sanitizeServerConfig(selected.config), + }; + if (selected.settings) { + result.settings = sanitizeServerSettings(selected.settings); + } + return result; +} + +/** Visible for tests. */ +export function sanitizeServerConfig( + config: MCPServerConfig, +): Record { + const out: Record = { ...config }; + if ("env" in config && config.env) { + out.env = redactStringRecord(config.env); + } + return out; +} + +/** Visible for tests. */ +export function sanitizeServerSettings( + settings: InspectorServerSettings, +): Record { + const out: Record = { + ...settings, + headers: settings.headers.map((h) => ({ + key: h.key, + value: isSensitiveHeader(h.key) ? REDACTED : h.value, + })), + env: settings.env.map((e) => ({ + key: e.key, + value: e.key ? REDACTED : e.value, + })), + }; + if (settings.oauthClientSecret !== undefined) { + out.oauthClientSecret = REDACTED; + } + return out; +} + +function redactStringRecord( + record: Record, +): Record { + return Object.fromEntries(Object.keys(record).map((key) => [key, REDACTED])); +} + +function isSensitiveHeader(key: string): boolean { + const k = key.toLowerCase(); + return ( + k === "authorization" || + k.includes("secret") || + k.includes("token") || + k.includes("password") || + k.includes("api-key") || + k.includes("apikey") + ); +} diff --git a/clients/cli/src/mcp-bin.ts b/clients/cli/src/mcp-bin.ts new file mode 100644 index 000000000..9f58b9f3d --- /dev/null +++ b/clients/cli/src/mcp-bin.ts @@ -0,0 +1,19 @@ +#!/usr/bin/env node + +import { resolve } from "path"; +import { fileURLToPath } from "url"; +import { handleError } from "./error-handler.js"; +import { runMcp } from "./session/mcp.js"; + +export { runMcp }; + +const __filename = fileURLToPath(import.meta.url); +const isMain = + process.argv[1] !== undefined && + resolve(process.argv[1]) === resolve(__filename); + +if (isMain) { + runMcp(process.argv) + .then(() => process.exit(0)) + .catch(handleError); +} diff --git a/clients/cli/src/open-url.ts b/clients/cli/src/open-url.ts new file mode 100644 index 000000000..f5b42a8ac --- /dev/null +++ b/clients/cli/src/open-url.ts @@ -0,0 +1,6 @@ +import open from "open"; + +/** Open a URL in the user's default browser (best-effort). */ +export async function openUrl(url: string | URL): Promise { + await open(typeof url === "string" ? url : url.href); +} diff --git a/clients/cli/src/session/authorize.ts b/clients/cli/src/session/authorize.ts new file mode 100644 index 000000000..3bd2eb2e8 --- /dev/null +++ b/clients/cli/src/session/authorize.ts @@ -0,0 +1,93 @@ +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 "../cli-oauth-navigation.js"; +import { connectInspectorWithOAuth } from "../cliOAuth.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); + environment.oauth = { + storage: new NodeOAuthStorage(), + navigation: createCliOAuthNavigation(), + 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 }, + ); + } finally { + try { + await client.disconnect(); + } catch { + // best-effort + } + } +} diff --git a/clients/cli/src/session/dispatch.ts b/clients/cli/src/session/dispatch.ts new file mode 100644 index 000000000..bb0b4b124 --- /dev/null +++ b/clients/cli/src/session/dispatch.ts @@ -0,0 +1,114 @@ +import { callDaemon, ensureDaemon, streamDaemon } from "../daemon/index.js"; +import type { RpcParams, RpcResult } from "../daemon/protocol.js"; +import type { CliAppInfo, MethodArgs } from "../handlers/method-types.js"; +import type { OutputFormat } from "../handlers/format-output.js"; +import { writeSessionOutput } from "./format-session.js"; +import { styleFromOpts } from "./style.js"; + +const STREAM_METHODS = new Set(["logging/tail", "resources/subscribe"]); + +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 outcome = await callDaemon("rpc", params, { socketPath }); + if (outcome.kind === "ndjson") { + await writeSessionOutput( + { format, style }, + { + kind: "ndjson", + lines: outcome.lines, + }, + ); + return; + } + await writeSessionOutput( + { format, style }, + { + kind: "rpc", + method, + result: outcome.result, + appInfo: outcome.appInfo as CliAppInfo | undefined, + toolName: methodArgs.toolName, + }, + ); +} + +export function stripAt(name: string | undefined): string | undefined { + if (!name) return undefined; + return name.startsWith("@") ? name.slice(1) : name; +} + +/** Non-TTY (CI/pipelines) must pass an explicit session for MRU-targeting ops. */ +export function requireExplicitSession(): boolean { + if (process.env.MCP_ALLOW_DEFAULT_SESSION === "1") return false; + return !process.stdout.isTTY; +} + +/** + * Hoist a leading `@name` from argv so `mcp @alpha tools/list` works. + */ +export function hoistAtSession(argv: string[]): { + argv: string[]; + sessionFromAt?: string; +} { + const start = 2; + const user = argv.slice(start); + const token = user[0]; + if (token && /^@[A-Za-z0-9_.-]+$/.test(token)) { + return { + argv: [...argv.slice(0, start), ...user.slice(1)], + sessionFromAt: token.slice(1), + }; + } + return { argv }; +} diff --git a/clients/cli/src/session/format-human.ts b/clients/cli/src/session/format-human.ts new file mode 100644 index 000000000..40f146a4e --- /dev/null +++ b/clients/cli/src/session/format-human.ts @@ -0,0 +1,689 @@ +/** + * 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 "./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 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)") : ""; + lines.push( + `* ${code(style, `@${String(s.name)}`)}${mru}${style.dim(` — ${String(s.serverIdentity ?? "")}`)}`, + ); + } + if (sessions.length === 0) lines.push(style.dim("(none — connect first)")); + return lines.join("\n"); +} + +/** Format a single session info (connect / sessions/use). */ +export function formatSessionInfoHuman( + session: JsonObject, + style: Style = PLAIN, +): string { + const mru = session.isMru === true ? style.green(" (MRU)") : ""; + return [ + `${heading(style, "Session")} ${code(style, `@${String(session.name)}`)}${mru}`, + `Server: ${style.dim(String(session.serverIdentity ?? ""))}`, + ].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 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/cli/src/session/format-session.ts b/clients/cli/src/session/format-session.ts new file mode 100644 index 000000000..3f6fcf3f3 --- /dev/null +++ b/clients/cli/src/session/format-session.ts @@ -0,0 +1,224 @@ +import { awaitableLog } from "../utils/awaitable-log.js"; +import type { SessionInfo } from "../daemon/protocol.js"; +import { CliExitCodeError, EXIT_CODES } from "../error-handler.js"; +import type { OutputFormat } from "../handlers/format-output.js"; +import type { CliAppInfo } from "../handlers/method-types.js"; +import { + formatAppInfoHuman, + formatAppInfoListHuman, + formatAuthListHuman, + formatRpcResultHuman, + formatServersListHuman, + formatServerShowHuman, + formatSessionInfoHuman, + formatSessionsListHuman, + formatStreamEventHuman, +} from "./format-human.js"; +import { PLAIN, type Style } from "./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[] } + | { 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: "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))); + applyExitCodes(payload); + return; + } + + await awaitableLog(humanPayload(payload, style) + "\n"); + applyExitCodes(payload); +} + +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 "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 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 "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. + return result as CliAppInfo; +} + +function applyExitCodes(payload: SessionWriteKind): void { + 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/cli/src/session/mcp.ts b/clients/cli/src/session/mcp.ts new file mode 100644 index 000000000..d2f115928 --- /dev/null +++ b/clients/cli/src/session/mcp.ts @@ -0,0 +1,822 @@ +import { Command, type Command as CommandType } from "commander"; +import type { JsonValue } from "@inspector/core/mcp/index.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 "../error-handler.js"; +import { callDaemon, ensureDaemon } from "../daemon/index.js"; +import type { SessionInfo } from "../daemon/protocol.js"; +import { + annotateServerEntriesWithSessions, + listServerEntries, + showServerEntry, + summarizeServerConfig, +} from "../handlers/servers-list.js"; +import { type OutputFormat } from "../handlers/format-output.js"; +import { + DEFAULT_CONNECT_TIMEOUT_MS, + withConnectTimeout, +} from "../handlers/connect-timeout.js"; +import type { MethodArgs } from "../handlers/method-types.js"; +import { SESSION_RPC_METHODS } from "../handlers/method-types.js"; +import { authorizeInFrontend } from "./authorize.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 "./style.js"; +import { awaitableLog } from "../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 (`mcp`). 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 `mcp` 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("mcp") + .description( + "MCP Inspector session CLI — connect once, run many commands against a named session.", + ) + .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); + + 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( + "--relogin", + "Ignore stored OAuth credentials for this connect; run interactive login if the server requires auth, then save new tokens", + ) + .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 = withConnectTimeout( + selected.settings, + (cmdOpts.connectTimeout as number | undefined) ?? + (adHoc ? DEFAULT_CONNECT_TIMEOUT_MS : 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, + }); + result = await callDaemon("connect", connectParams, { + socketPath, + }); + } + 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 }, + }); + }); +} + +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, + }); + }); +} + +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 "$(mcp private)"). ' + + "Later mcp commands in that shell use an isolated, token-gated daemon.", + ) + .action(async () => { + const binding = createPrivateBinding(); + await awaitableLog(formatPrivateEnvExports(binding)); + }); +} + +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: stringifyMeta(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: stringifyMeta(o.toolMetadata), + metadata: stringifyMeta(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: stringifyMeta(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: stringifyMeta(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: stringifyMeta(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: stringifyMeta(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: stringifyMeta(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: stringifyMeta(o.metadata), + }); + }); + break; + default: + cmd.action(async (o) => { + await runRpc(program, method, { + metadata: stringifyMeta(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(), + }); +} + +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 stringifyMeta( + meta: Record | undefined, +): Record | undefined { + if (!meta || Object.keys(meta).length === 0) return undefined; + return Object.fromEntries( + Object.entries(meta).map(([k, v]) => [k, String(v)]), + ); +} + +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/cli/src/session/parse-tool-args.ts b/clients/cli/src/session/parse-tool-args.ts new file mode 100644 index 000000000..6b4bd931f --- /dev/null +++ b/clients/cli/src/session/parse-tool-args.ts @@ -0,0 +1,132 @@ +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)}`, + ); + } + 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)}`, + ); + } + 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/cli/src/session/private-env.ts b/clients/cli/src/session/private-env.ts new file mode 100644 index 000000000..236278f85 --- /dev/null +++ b/clients/cli/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 "$(mcp 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/cli/src/session/stored-auth.ts b/clients/cli/src/session/stored-auth.ts new file mode 100644 index 000000000..894cc3d0a --- /dev/null +++ b/clients/cli/src/session/stored-auth.ts @@ -0,0 +1,150 @@ +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 "../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. + */ +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/cli/src/session/style.ts b/clients/cli/src/session/style.ts new file mode 100644 index 000000000..856b3c1b5 --- /dev/null +++ b/clients/cli/src/session/style.ts @@ -0,0 +1,76 @@ +import type { OutputFormat } from "../handlers/format-output.js"; + +export type Style = { + /** Whether ANSI styling is enabled. */ + readonly ansi: boolean; + bold: (s: string) => string; + dim: (s: string) => string; + red: (s: string) => string; + yellow: (s: string) => string; + green: (s: string) => string; + cyan: (s: string) => string; + /** OSC 8 hyperlink when ansi; otherwise plain label (defaulting to uri). */ + link: (uri: string, label?: string) => string; +}; + +const identity = (s: string): string => s; + +function sgr(enabled: boolean, open: number, close: number) { + if (!enabled) return identity; + return (s: string): string => `\u001b[${open}m${s}\u001b[${close}m`; +} + +/** + * Build a style helper. When `ansi` is false, all methods are identity + * (and `link` returns the label only — no OSC 8). + */ +export function createStyle(ansi: boolean): Style { + return { + ansi, + bold: sgr(ansi, 1, 22), + dim: sgr(ansi, 2, 22), + red: sgr(ansi, 31, 39), + yellow: sgr(ansi, 33, 39), + green: sgr(ansi, 32, 39), + cyan: sgr(ansi, 36, 39), + link: (uri: string, label?: string): string => { + const text = label ?? uri; + if (!ansi || !uri) return text; + // OSC 8 hyperlink: ESC ] 8 ; ; URI BEL text ESC ] 8 ; ; BEL + return `\u001b]8;;${uri}\u0007${text}\u001b]8;;\u0007`; + }, + }; +} + +/** Plain (no ANSI) style — default for tests and `--plain`. */ +export const PLAIN: Style = createStyle(false); + +export type ResolveAnsiOptions = { + /** `--plain` — force no ANSI. */ + plain?: boolean; + /** `--format`; json never uses ANSI for the payload path. */ + format?: OutputFormat; + /** Override TTY detection (tests). */ + isTTY?: boolean; + /** Override `NO_COLOR` (tests). */ + noColorEnv?: string | undefined; +}; + +/** + * Decide whether session human output should use ANSI. + * Off when: `--plain`, `NO_COLOR` set, `--format json`, or stdout is not a TTY. + */ +export function resolveAnsiEnabled(opts: ResolveAnsiOptions = {}): boolean { + if (opts.plain) return false; + if (opts.format === "json") return false; + const noColor = + opts.noColorEnv !== undefined ? opts.noColorEnv : process.env.NO_COLOR; + if (noColor !== undefined && noColor !== "") return false; + const tty = + opts.isTTY !== undefined ? opts.isTTY : process.stdout.isTTY === true; + return tty; +} + +export function styleFromOpts(opts: ResolveAnsiOptions = {}): Style { + return createStyle(resolveAnsiEnabled(opts)); +} diff --git a/clients/cli/tsup.config.ts b/clients/cli/tsup.config.ts index d65d37ec3..de637fe10 100644 --- a/clients/cli/tsup.config.ts +++ b/clients/cli/tsup.config.ts @@ -6,7 +6,11 @@ const dirname = path.dirname(fileURLToPath(import.meta.url)); const repoRoot = path.resolve(dirname, '../..'); export default defineConfig({ - entry: ['src/index.ts'], + entry: { + index: 'src/index.ts', + 'mcp-bin': 'src/mcp-bin.ts', + daemon: 'src/daemon/run.ts', + }, format: ['esm'], outDir: 'build', clean: true, diff --git a/clients/cli/vitest.config.ts b/clients/cli/vitest.config.ts index 4480e1bd3..e68cd1600 100644 --- a/clients/cli/vitest.config.ts +++ b/clients/cli/vitest.config.ts @@ -31,6 +31,17 @@ export default defineConfig({ // which spawns build/index.js and so can't be measured under in-process // coverage. Mirrors web's `**/index.{ts,tsx}` exclusion. 'src/index.ts', + // Session CLI + daemon process bootstraps (same rationale as index.ts): + // shebang / isMain / process.exit / signal wiring only runs out-of-process. + 'src/mcp-bin.ts', + 'src/daemon/run.ts', + // Unix-socket accept / stream client: behavioral coverage lives in + // __tests__/daemon-stream.test.ts (ok/data/end, errors, timeout, abort, + // stale socket, startStream). Remaining branch matrix is connect/error/ + // half-close races that are not stably ≥90 under v8 — keep out of the + // per-file gate rather than papering over with broad ignores. + 'src/daemon/ipc-glue.ts', + 'src/daemon/stream-client.ts', ], thresholds: { perFile: true, diff --git a/mcp b/mcp new file mode 100755 index 000000000..de2a87608 --- /dev/null +++ b/mcp @@ -0,0 +1,4 @@ +#!/bin/sh +ROOT="$(CDPATH= cd -- "$(dirname "$0")" && pwd)" +exec node "$ROOT/clients/cli/build/mcp-bin.js" \ + "$@" diff --git a/package.json b/package.json index 4416e35fc..e74f0fd4a 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", + "mcp": "./clients/cli/build/mcp-bin.js" }, "files": [ "clients/launcher/build", diff --git a/specification/v2_catalog_launch_config.md b/specification/v2_catalog_launch_config.md index 4c99f2336..90ac5e7f0 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 — living design: [v2_cli_v2.md](v2_cli_v2.md) | +| [#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 44be7ec11..94f46fd47 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) — design in [v2_cli_v2.md](v2_cli_v2.md); tracked by [#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) (`mcp` 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). @@ -94,7 +94,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 ([design](v2_cli_v2.md), [#1432](https://github.com/modelcontextprotocol/inspector/issues/1432)). +**Model:** one-shot — each invocation connects, runs a single `--method`, prints a result to stdout, disconnects, exits. Same surface as v1.5. Session-oriented CLI v2 (`mcp`) is documented as-built in [v2_cli_v2.md](v2_cli_v2.md) ([#1432](https://github.com/modelcontextprotocol/inspector/issues/1432)). **Entry:** `clients/cli/src/index.ts` exports `runCli(argv)`; `src/cli.ts` owns Commander parsing and `InspectorClient` orchestration. diff --git a/specification/v2_cli_v2.md b/specification/v2_cli_v2.md index 4a9c60a14..26fb3c1fe 100644 --- a/specification/v2_cli_v2.md +++ b/specification/v2_cli_v2.md @@ -1,434 +1,178 @@ -# Inspector CLI v2 (session-oriented design) +# 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 (as-built)](v2_cli_tui_launcher.md) | CLI v2 design | [Catalog / launch config](v2_catalog_launch_config.md) +#### [CLI, TUI, Launcher](v2_cli_tui_launcher.md) | CLI v2 | [Catalog / launch config](v2_catalog_launch_config.md) -Living design doc for the **session-oriented Inspector CLI** (connect once, many subcommands). Tracked by [#1432](https://github.com/modelcontextprotocol/inspector/issues/1432). Sourced from that issue; **iterate here** — the issue remains the board card / umbrella. +Documentation of the session-oriented Inspector CLI (`mcp`) and how it relates to the frozen one-shot path (`mcp-inspector --cli`). Tracked by [#1432](https://github.com/modelcontextprotocol/inspector/issues/1432). -The following describes the **planned next-generation Inspector command-line client**: what exists today, what we want to build, why, and how. CLI v2 is new work in **`clients/cli`** on shared **`core/`** (`@inspector/core`). - -Our goal is for the Inspector v2 CLI to fully support all aspects and modalities of MCP (as supported by the TUI and Web Inspectors), including complex operations and multiple operations per server connection. In addition, the Inspector v2 CLI will expose all aspects of MCP in a way that can be leveraged by production hosts to access those MCP aspects and modalities even when the host does not directly support them via its MCP implementation (with the host using the Inspector CLI via its CLI mechanism). - -This approach was inspired by [mcpc](https://github.com/apify/mcpc) from Apify. - -**Related docs:** [CLI, TUI, and Launcher](v2_cli_tui_launcher.md) (as-built one-shot CLI), [Catalog and Launch Configuration](v2_catalog_launch_config.md), [Storage](v2_storage.md), [Auth](v2_auth.md), [`clients/cli/README.md`](../clients/cli/README.md). +**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/cli/README.md`](../clients/cli/README.md) (end-user reference). --- -## 1. Introduction - -The Inspector CLI today is a **one-shot** client: each command connects to an MCP server, performs **one** operation, prints JSON, and exits. CLI v2 adds a **session-oriented** model—**connect once**, run **many** commands against a **named session**, then **disconnect**—on the same **`InspectorClient`** stack the web and TUI already use. - -### 1.1 How the Inspector CLI works today - -The launcher selects CLI mode with `--cli`. Every invocation is self-contained: - -1. Parse **which server** to use (`--config` + `--server`, or a URL, plus transport/headers/env—see [v2_catalog_launch_config.md](v2_catalog_launch_config.md)). -2. Require **`--method`** naming a single MCP operation. -3. **`connect()`** → perform that operation → print **`JSON.stringify(result)`** to stdout → **`disconnect()`** → exit. - -There is no persistent session and no subcommands—only “pick method + args.” - -**Example: list tools, then call one** - -Each line below is a **separate process** with its own spawn, handshake, and teardown: - -```bash -# List tools (connect → tools/list → disconnect) -inspector --cli \ - --config mcp.json --server myserver \ - --method tools/list - -# Call a tool (connect → tools/call → disconnect) -inspector --cli \ - --config mcp.json --server myserver \ - --method tools/call \ - --tool-name search \ - --tool-arg query=hello -``` - -That works well for **scripts and CI** that need one JSON blob per run. It is awkward when you want to **explore a server**, run **several related steps**, or use MCP features that span **multiple RPCs over one connection** (tasks, subscriptions, OAuth that you do not want to repeat every time). - -Supported `--method` values and flags are listed in §3.2; see [clients/cli/README.md](../clients/cli/README.md) for more examples. - -### 1.2 CLI v2 session workflow - -CLI v2 supports a **session-oriented** workflow alongside v1 one-shots: +## Overview -1. **Connect** to a server and register a **named session** (conventionally `@name`). -2. Run **many subcommands** against that session without reconnecting. -3. **Disconnect** when finished. +| | **One-shot** | **Session** | +| --- | --- | --- | +| Entrypoint | `mcp-inspector --cli` | `mcp` | +| 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` (shared with session) | Same; root `bin.mcp` → `clients/cli/build/mcp-bin.js` | -Unlike v1, connection setup happens **once per session**; subsequent commands reuse that MCP connection. A background **session daemon** (§5.3) holds each **`InspectorClient`**; it starts **automatically**—users run `connect` and session commands only, with no separate “start daemon” step. - -**Example: connect, run commands, disconnect** (illustrative command names; see §5.4) +Both use `@inspector/core` `InspectorClient` and shared `handlers/run-method.ts`. One-shot never starts the daemon. `mcp` does not accept `--method`. ```bash -mcp connect --config mcp.json --server myserver @myserver -mcp @myserver tools list -mcp @myserver tools call search query=hello -mcp @myserver tasks get task-abc123 -mcp @myserver disconnect +mcp servers/list --config mcp.json +mcp servers/show my-server --config mcp.json +mcp connect myserver --config mcp.json +mcp tools/list +mcp tools/call search query:=hello +mcp @other resources/list +mcp disconnect ``` -v1 **`--method`** one-shots remain for backward compatibility **and** work alongside session mode—see §2, §5, and §6. - -### 1.3 Why session-oriented CLI matters - -**Exercise a server across multiple operations.** Real workflows are rarely a single RPC: list tools, inspect a schema, call a tool, read a resource, adjust log level, then call again. Session mode matches how people use `kubectl`, `docker`, or database CLIs—a stable **context** and many commands—instead of repeating server selection and handshake on every line of a script or manual debugging session. - -**Exercise advanced MCP client behavior.** Some protocol features only make sense **across calls on one connection**: - -- **Tasks** — launch a long-running operation (often via a task-augmented tool call), poll **`tasks/get`**, fetch **`tasks/result`**, or **`tasks/cancel`** while the server keeps state. -- **Subscriptions** — **`resources/subscribe`**, then observe **`notifications/resources/updated`** over time. -- **Logging** — set level once, then **`logging tail`** (follow **`notifications/message`**) during later tool calls. -- **OAuth** — authenticate once per session instead of on every ephemeral invocation. - -**Example: long-running task over one session** (illustrative CLI v2 ergonomics; exact command names TBD): +Optional private daemon for one shell (`ssh-agent` style): ```bash -mcp connect --config mcp.json --server batch @batch -mcp @batch tools call start_export format=json # may return a task id -mcp @batch tasks get export-task-42 # poll status -mcp @batch tasks result export-task-42 # fetch result when done -mcp @batch disconnect +eval "$(mcp private)" +mcp connect myserver --config mcp.json +mcp tools/list ``` -That sequence is impossible to express cleanly with v1’s one-shot **`--method`** model—you would need a custom script that reconnects and somehow preserves server-side task identity across separate processes. - -The web and TUI Inspector clients already do this interactively. CLI v1 exposes only a narrow one-shot subset; CLI v2 closes the **ergonomics** gap, not a gap in core protocol support (§7). - -**Access full MCP via CLI when the host lacks native support.** Agent hosts often implement only part of the MCP **client** protocol—commonly tools, sometimes resources or prompts, rarely tasks, subscriptions, roots, completions, or sampling/elicitation. The **server** may still expose the full feature set. Inspector CLI v2’s job is to let an agent reach **any** of those server capabilities through **documented CLI commands**, using a shell tool the host already provides, instead of requiring the host to add native MCP client support for every feature and modality. - -The model does not need a **`tasks/get`** (or **`prompts/get`**, **`resources/subscribe`**, …) RPC on its primary MCP connection. It runs Inspector CLI; the CLI uses **`InspectorClient`** to speak MCP to the server and returns JSON the model can parse. Native MCP where the host supports it; CLI bridge where it does not—same server, full protocol surface available to the agent. - -**Example: tasks via CLI when the host has tools MCP only** - -The agent’s MCP connection supports **`tools/*`** but not tasks. The user asks for a long export. The model uses the CLI session to call the tool, poll task status, and fetch the result: - -```bash -mcp connect --config mcp.json --server jobs @jobs -mcp @jobs tools call start_export format=json -mcp @jobs tasks get export-task-42 -mcp @jobs tasks result export-task-42 -``` - -The same pattern applies to any modality the host omits: **`prompts get`**, **`resources subscribe`**, **`logging tail`**, and so on—whatever **`InspectorClient`** supports and the server advertises, exposed as CLI subcommands rather than native MCP in the agent runtime. - ---- - -## 2. Summary - -CLI v2 evolves **`clients/cli` in place**: session-oriented subcommands on **`InspectorClient`**, with v1 **`--method`** one-shots preserved (§6). - -| | **Today (CLI v1)** | **Target (CLI v2)** | -| --------------------- | ---------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | -| **Package** | `clients/cli` → `@modelcontextprotocol/inspector-cli` | Same package and launcher; add **`mcp`** command (§5.2) | -| **Process model** | Connect → **one** MCP operation → disconnect **per invocation** | **Session:** connect once, many commands on a named `@session` (implicit daemon, §5.3); **ephemeral:** v1 one-shots unchanged | -| **Protocol coverage** | tools, resources, prompts, `logging/setLevel` (one-shot only) | Full client surface aligned with **TUI / web** (`InspectorClient` + state managers)—tools, resources, prompts, tasks, logging, roots, subscriptions, OAuth, completions, etc. (phased) | -| **Use cases** | Scripts, CI, agents needing a single JSON result per run | Multi-step server exercise; advanced MCP over one connection (tasks, subscriptions, …); agents reach **any server capability via CLI** when the host lacks native MCP support for that feature | -| **Output** | JSON to stdout | JSON by default; human-readable mode where useful | -| **Implementation** | Commander wrapper over **`InspectorClient`** | Same package; **`clients/cli/src/daemon/`** for session daemon + IPC (§7.2); subcommands, v1 compat layer | - ---- - -## 3. What we have today (CLI v1) - -§1.1 covers the user-facing behavior; this section is the implementation reference. - -### 3.1 Code location and shape - -| Piece | Location | -| ----------------------------- | ------------------------------------------------------------------------- | -| Entry / argument parsing | `clients/cli/src/cli.ts` | -| Launcher forwarding | `clients/launcher/src/index.ts` (`--cli` → CLI workspace) | -| MCP client | `@inspector/core` → **`InspectorClient`** | -| Transport + config resolution | `core/mcp/node/config.ts` (`resolveServerConfigs`), `createTransportNode` | -| Tests | `clients/cli/__tests__/` (Vitest, stdio + HTTP against test servers) | - -Each invocation: - -1. Parses **which server** to use (shared flags: `--config`, `--server`, positional command/URL, `--transport`, headers, env—see [v2_catalog_launch_config.md](v2_catalog_launch_config.md)). -2. Requires **`--method`** naming an MCP-style operation. -3. Constructs **`InspectorClient`**, **`connect()`**, performs **exactly one** operation, prints **`JSON.stringify(result)`**, **`disconnect()`**, exits. - -There is **no** persistent session, **no** subcommands beyond “pick method + args”, and **no** interactive shell. - -### 3.2 Supported operations (v1) - -`--method` must be one of (see error text in `cli.ts`): - -| `--method` | Purpose | Extra flags | -| -------------------------- | ----------------------- | ------------------------------------------------------------ | -| `tools/list` | List tools | `--metadata` | -| `tools/call` | Call a tool by name | `--tool-name`, `--tool-arg`, `--tool-metadata`, `--metadata` | -| `resources/list` | List resources | `--metadata` | -| `resources/read` | Read a resource | `--uri`, `--metadata` | -| `resources/templates/list` | List resource templates | `--metadata` | -| `prompts/list` | List prompts | `--metadata` | -| `prompts/get` | Get a prompt | `--prompt-name`, `--prompt-args`, `--metadata` | -| `logging/setLevel` | Set server log level | `--log-level` | - -Anything else throws **Unsupported method**. - -Internally, list operations use core **state managers** (`ManagedToolsState`, etc.) for a single refresh; **`tools/call`** resolves the tool **by name** from that list, then calls **`InspectorClient.callTool(tool, …)`**—the same pattern TUI uses when invoking a tool from the managed list. - -### 3.3 What v1 does **not** cover (but core already does) - -`InspectorClient` and the TUI/web clients already support (or are adding) capabilities **not** exposed on the CLI today, for example: - -- **Tasks** (list/get/cancel, task-augmented tool calls, streaming progress) -- **Sampling / elicitation** (client-as-requestor flows) -- **Resource subscriptions** and **list-changed** notifications -- **Roots** (`getRoots`, `setRoots` on the client; answers server `roots/list` requests) -- **Completions** (prompt/resource argument completion) -- **OAuth** flows and token management as first-class UX -- **Pagination** (`listTools` / `listResources` with cursors) -- **Structured tool results**, metadata on prompts/resources beyond what v1 passes through - -CLI v1 is intentionally minimal. The gap is **CLI surface area and session ergonomics** (§1.3), not missing protocol support in core. - --- -## 4. Goals and principles - -1. **One CLI product** — Evolve v1 inside **`clients/cli`** (same npm package and launcher entry). -2. **Core-first** — All MCP I/O through **`InspectorClient`** and the same **state managers** TUI/web use (`ManagedToolsState`, `MessageLogState`, etc.). CLI v2 **composes** existing APIs (list → resolve entity → call/read/get); it does not depend on new core helpers. Do not bypass core with a raw SDK `Client` in the CLI layer. -3. **Two lifecycles, one implementation** - - **Ephemeral:** connect → one operation (or fixed sequence) → disconnect — **backward compatible** with today’s `inspector --cli … --method …`. - - **Session:** named session, many commands; **implicit session daemon** (§5.3) holds the connection—no user-managed start step. -4. **Parity trajectory** — CLI v2 should be able to expose the same MCP **client** capabilities as TUI/web over time (phased commands, not a big-bang). -5. **Shared configuration and auth** — Same **`mcp.json`** / CLI flags as today ([v2_catalog_launch_config.md](v2_catalog_launch_config.md)); same **`OAuthManager`** and Node credential storage as other Node clients ([v2_storage.md](v2_storage.md) / [v2_auth.md](v2_auth.md)). -6. **Automation-safe** — JSON output, stable exit codes; in **scripts and CI**, require an explicit `@session` (or `--session @name`)—do not rely on default-session resolution unless opted in via env (§5.6). -7. **No interactive shell** — Each command is a separate **`mcp …`** invocation (session commands talk to the implicit daemon over IPC). No REPL, no **`shell`** subcommand—now or later. Interactive exploration remains the job of **TUI / web**; automation and agents use subprocess + JSON. +## As-built ---- +### Entrypoints and layout -## 5. Target architecture - -V2 examples in this section use **`mcp`** (§5.2). **`inspector --cli`** and **`mcp-inspector --cli`** remain supported for backward compatibility (§6). - -### 5.1 High-level picture +| Piece | Location | +| --- | --- | +| One-shot | `clients/cli/src/cli.ts`, `cliOAuth.ts`, `index.ts` | +| Session front-end | `clients/cli/src/session/` (`mcp.ts`, `dispatch.ts`, `authorize.ts`, `format-*.ts`, `private-env.ts`, `mcp-bin.ts`) | +| Daemon | `clients/cli/src/daemon/` | +| Shared handlers | `clients/cli/src/handlers/` (`run-method.ts`, `method-types.ts`, `servers-list.ts`, `emit-result.ts`, …) | ``` - ┌─────────────────────────────────────┐ - │ mcp (primary CLI command, §5.2) │ - │ inspector --cli (launcher, compat)│ - └─────────────────┬───────────────────┘ - │ - ┌─────────────────▼──────────-─────────┐ - │ CLI v2 (Commander / subcommands) │ - │ • session admin (connect, list, …) │ - │ • MCP commands (tools, resources, …)│ - │ • compat layer (--method …) │ - └─────────┬───────────────┬──────-─────┘ - │ │ - ephemeral │ │ session mode - (in-process) │ │ - ▼ ▼ - ┌───────────---───┐ ┌───────────────-───┐ - │ InspectorClient │ │ Session daemon │ - │ connect → op │ │ (one Client each) │ - │ → disconnect │ │ ◄── IPC ──► CLI │ - └───────────---───┘ └─────────┬─────────┘ - │ │ - └────-──-───┬─────────┘ - ▼ - @inspector/core - (transport, OAuth, state managers) +mcp-inspector --cli … mcp … + │ │ + ▼ ▼ + cli.ts session/mcp.ts + │ │ NDJSON IPC + │ daemon (build/daemon.js) + └──────────┬─────────────┘ + ▼ + handlers/run-method.ts → InspectorClient ``` -### 5.2 The `mcp` command - -Today the CLI is reached through the **launcher**: `inspector --cli …` (installed binary **`mcp-inspector`**, plus **`mcp-inspector-cli`** from the CLI workspace). CLI v2 adds **`mcp`** as the primary command—it runs the **same CLI entrypoint** the launcher forwards to, without the `--cli` flag. +### One-shot (`mcp-inspector --cli`) -| Command | What runs | -| ------- | --------- | -| **`mcp …`** | **`clients/cli`** directly—primary v2 UX | -| `inspector --cli …` | Launcher → `clients/cli` (unchanged) | -| `mcp-inspector --cli …` | Same as above (unchanged) | +Frozen automation contract. Each invocation: resolve server → connect → `runMethod` → print → disconnect. Never uses the session daemon. -**Implementation** — no change to how we publish **`@modelcontextprotocol/inspector`**. Add a **`bin`** entry on the root package (and matching entry on **`@modelcontextprotocol/inspector-cli`**) pointing at **`clients/cli/build/index.js`**, alongside existing bins. Update Commander’s program name to **`mcp`** for help text. +| `--method` | Notes | +| --- | --- | +| `initialize`, `tools/list`, `tools/call`, `resources/*`, `prompts/list`, `prompts/get`, `logging/setLevel` | Core one-shot surface | +| `servers/list`, `servers/show` | Catalog only (no MCP connect); `servers/show` needs `--server` | -### 5.3 Session daemon (implicit) +**Output:** `--format text` = pretty JSON of bare result; `json` = `{ result[, appInfo] }` envelope. Exit codes `0`–`5` + stderr `ErrorEnvelope`. -Session mode uses a **session daemon**—a long-lived Node process that owns live MCP connections. **Users do not start or manage it in normal use.** The first session-mode need (typically **`connect`**, or a command against an existing session) **auto-spawns** the daemon in the background if it is not already running; later **`mcp`** invocations attach over IPC (Unix domain socket or platform equivalent). +**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). -**Daemon responsibilities** +### Session CLI (`mcp`) -- Own **one `InspectorClient` per named session** -- Accept **IPC** from short-lived CLI front-end processes -- Forward **list/call/read/…** to the right client and return JSON (or structured errors) -- Handle **notifications** (logging, list-changed) and optionally broadcast them to attached clients +#### Commands -**Lifecycle** +| 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` | -- **`mcp connect`** may bootstrap **daemon + session in one step**—no prior daemon required. -- **Single instance** per user/machine (socket + lock file); stale socket after crash is detected and recovered on next command. -- **Shutdown:** daemon exits when all sessions are **`disconnect`**ed and an **idle timeout** elapses (duration TBD), or when the last open session closes—whichever policy we implement. -- **Ephemeral mode** (v1 **`--method`** one-shots) **never** uses the daemon; **`InspectorClient`** runs in-process and exits. +**Globals (before subcommand):** `--format text|json`, `--plain`, `--session `, `--catalog` / `--config`, `--stored-auth-only`. -**When users need visibility** (optional, not required for everyday use) +**Session select:** leading `@name` and/or `--session `. Tool args: `key:=value`, inline JSON, or `--tool-arg` / `--tool-args-json`. -- **`mcp daemon status`** — is the daemon running, active sessions, socket path -- **`mcp daemon stop`** — tear down daemon and all sessions (troubleshooting) -- **`mcp daemon run`** (optional) — run daemon in foreground for debugging +**Connect forms:** catalog entry / `--server` / ad-hoc URL or command; optional `@name` to override session name (default = entry id). -The daemon uses **`InspectorClient`** with the same OAuth and transport setup as TUI. Secrets are **not** passed on **`argv`** when spawning sessions over IPC (see §5.7). +#### Output -### 5.4 Command surface (planned categories) +| 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`. | -Exact names are TBD; illustrative grouping: +#### Default session (MRU) -| Category | Examples | Core backing | -| ------------- | ------------------------------------------------------------------------------------- | ------------------------------------------------------- | -| **Session** | `connect`, `disconnect`, `sessions list`, `session use`; optional `daemon status` / `daemon stop` | Implicit daemon (§5.3) + session store | -| **Tools** | `tools list`, `tools call`, `tools call --task` | **`ManagedToolsState`**, `callTool`, `callToolStream` | -| **Resources** | `resources list`, `resources read`, `resources templates list`, `resources subscribe` | `listResources`, `readResource`, subscriptions | -| **Prompts** | `prompts list`, `prompts get`, `prompts complete` | `listPrompts`, `getPrompt`, `getCompletions` | -| **Logging** | `logging set-level`, `logging tail` (follow notifications) | `setLoggingLevel`, **`MessageLogState`** | -| **Tasks** | `tasks list`, `tasks get`, `tasks cancel`, `tasks result` | **`ManagedRequestorTasksState`**, `callToolStream`, … | -| **Auth** | `auth login`, `auth logout`, `auth status` | **`OAuthManager`** (via `connect`), Node storage | -| **Roots** | `roots list`, `roots set` | **`getRoots`**, **`setRoots`** | -| **Compat** | Hidden or documented: `--method tools/list` … | Maps to same handlers as subcommands | +- 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. -Global flags (all modes): `--json`, `--config`, `--server`, verbosity, `--session @name` (or positional `@name`). +#### Daemon -### 5.5 Config and server selection +**IPC ops:** `ping`, `connect`, `disconnect`, `sessions/list`, `sessions/use`, `daemon/status`, `daemon/stop`, `rpc`, `stream`. -**Unchanged from v1:** resolution via `resolveServerConfigs` and shared flags documented in [v2_catalog_launch_config.md](v2_catalog_launch_config.md). +- One `InspectorClient` per named session; auto-spawn on first need; idle exit ~60s after last disconnect; `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). -**Session records** store at minimum: +| 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 `mcp private` | -- Session **name** (user-chosen, `@`-prefixed in UX) -- **Server identity** (config path + server id, or normalized ad-hoc target) -- **Connection state** (in daemon) -- **Last accessed** timestamp — updated whenever the user (or agent) runs a command against that session; drives default session for `@`-less commands (§5.6) +| Mode | Trust | +| --- | --- | +| **Shared (default)** | No token. Same-UID peer that can open the socket can drive sessions (intentional cross-terminal share). | +| **Private** | `eval "$(mcp 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. | -### 5.6 Default session (`@`-less commands) +#### Auth (session) -When a session-mode command omits `@name` (e.g. `mcp tools list` instead of `mcp @myserver tools list`), the CLI targets the **most recently accessed session**—the session last used by any session-scoped command, including **`connect`**, an explicit **`@name …`**, or **`sessions use @name`**. - -- **Update last-accessed** on every command that successfully targets a named session (including `connect`, which creates or reopens that session). -- **No sessions** → clear error telling the user to `connect` first. -- **Automation / CI** → default session is **disabled** unless explicitly opted in (non-TTY, or `MCP_SESSION=@name` set, or `--session @name` passed). Pipelines must not accidentally hit the wrong server because an interactive user connected to something else earlier on the same machine. -- **Explicit `@name` always wins** when provided; default session is a convenience for interactive use and agent sessions where the model has been working in one context. - -```bash -mcp connect --config mcp.json --server a @alpha -mcp connect --config mcp.json --server b @beta -mcp @alpha tools list # last accessed → @alpha -mcp tools list # same (still @alpha) -mcp @beta resources list # last accessed → @beta -mcp tools call search q=hello # uses @beta -``` +- Same `oauth.json` store as other Inspector clients. +- **Connect-time:** daemon connect → on `auth_required`, front-end `authorizeInFrontend()` (unless `--stored-auth-only`) → retry connect. +- **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. -### 5.7 OAuth and secrets +#### One-shot ↔ session mapping -CLI v2 must use the same paths as other Node clients: +| One-shot | Session | +| --- | --- | +| `… --catalog mcp.json --server s --method tools/list` | `mcp connect --catalog mcp.json s` then `mcp tools/list` | +| `… --method tools/call --tool-name X --tool-args-json '…'` | `mcp tools/call X key:=val` / `'{"…"}'` | +| `… --method servers/list` | `mcp servers/list` | +| `… --method servers/show --server ` | `mcp servers/show ` | -- **`OAuthManager`** inside **`InspectorClient.connect()`** — no CLI-only OAuth stack -- **Node `OAuthStorage`** in core (same store as TUI; see [v2_storage.md](v2_storage.md) / [v2_auth.md](v2_auth.md)) +### Testing -Daemon startup must **not** put secrets on **`argv`** (stdio `env`/`args`, HTTP headers): pass full server config over IPC after the socket is listening, before `connect()`. +- In-process `runCli()` / `runMcp()`; daemon IPC + stream + private-token tests; thin binary e2e. +- Per-file ≥90 coverage on measured `clients/cli/src`. +- Exclusions: `index.ts`, `mcp-bin.ts`, `daemon/run.ts`, `ipc-glue.ts`, `stream-client.ts`. `session/mcp.ts` is gated. --- -## 6. Backward compatibility (CLI v1) - -### 6.1 Requirement - -Existing scripts and docs that use: - -```bash -inspector --cli … --method tools/list -``` - -must **keep working** through a **compatibility entrypoint** (same flags, same JSON shape on stdout, same exit behavior on error). **`mcp … --method …`** is equivalent (§5.2). - -### 6.2 Implementation approach - -- **Ephemeral handler** shared with v2 subcommands: parse v1 argv → build `MCPServerConfig` → `InspectorClient` → dispatch by `--method` → print JSON. -- v1 **`--method`** table (§3.2) remains supported; new methods may be added under `--method` **or** only under subcommands (document per method). -- Deprecation: if we later prefer subcommands in docs, v1 flags stay until a **major** Inspector release with a published migration guide. - -### 6.3 Mapping (v1 → v2 ergonomics) - -| CLI v1 | CLI v2 (illustrative) | -| --------------------------------------------------------------- | ------------------------------------------------------------- | -| `inspector --cli … --method tools/list` | `mcp tools list` (ephemeral) **or** `mcp @s tools list` (session) | -| `inspector --cli … --method tools/call --tool-name X --tool-arg k=v` | `mcp tools call X k=v` | -| `… --config mcp.json --server myserver --method resources/list` | `mcp connect myserver` then `mcp @myserver resources list` | - -Session mode is **opt-in**; v1 one-liners remain valid without `connect`. - ---- - -## 7. Building on core (same stack as TUI / web) - -CLI v2 does **not** require new MCP capabilities in **`@inspector/core`** / `core/`. Web and TUI already cover the full client feature set through **`InspectorClient`** and optional **state managers** (`core/mcp/state/` + [v2_cli_tui_launcher.md](v2_cli_tui_launcher.md)). CLI v2 adds **subcommands**, **session lifecycle**, **output formatting**, and optionally a **session daemon**—composition work in **`clients/cli`**, not protocol gaps in core. - -### 7.1 Patterns to reuse (not reimplement) - -| CLI need | How v1 / TUI / web do it today | -| -------- | ------------------------------ | -| **Call tool by name** | **`ManagedToolsState`** (or `listTools`) → find by `name` → **`callTool(tool, args, …)`**. v1 already does this in `clients/cli/src/cli.ts`; TUI passes the selected **`Tool`** from the managed list. Scripts pass a name; the CLI resolves it from the cached list—the same pattern as picking a tool in the UI. | -| **List tools / resources / prompts** | **`Managed*State`** for full lists (auto-refresh on `*ListChanged`), or **`Paged*State`** / cursor loops on **`listTools(cursor)`** etc. v1 uses managed states for a one-shot refresh per invocation. | -| **Logging tail** | **`MessageLogState`** subscribes to protocol **`message`** events (includes server logging via **`notifications/message`**). Stream or filter entries for `logging tail`. | -| **Tasks** | **`ManagedRequestorTasksState`** / **`PagedRequestorTasksState`**; streaming tool calls via **`callToolStream`**. | -| **Roots** | **`getRoots()`** / **`setRoots()`** on **`InspectorClient`**. | -| **Subscriptions, completions, OAuth** | Direct **`InspectorClient`** methods and events; Node credential storage per [v2_storage.md](v2_storage.md) / [v2_auth.md](v2_auth.md)—same as TUI. | - -### 7.2 Where CLI v2 work actually lives - -All implementation belongs in **`clients/cli`**—one **`npm`** package (**`@modelcontextprotocol/inspector-cli`**), same publish story as today. No separate **`cli-daemon`** workspace. - -**Layout** - -| Area | Location | -| ---- | -------- | -| CLI front-end (Commander, subcommands, v1 compat) | `clients/cli/src/` (existing + new command modules) | -| Session daemon (IPC server, session registry, auto-spawn) | **`clients/cli/src/daemon/`** | -| Shared command handlers | Callable from both ephemeral CLI and daemon IPC paths | - -The daemon subdirectory owns long-lived process management; the CLI front-end stays short-lived subprocess invocations talking to it over IPC (§5.3). **`mcp`** and the daemon share types and handlers in-process within the same package—no cross-workspace imports. - -**Responsibilities** - -- Subcommand tree and argv parsing (Commander) -- Ephemeral vs session-daemon process model and IPC -- JSON / human-readable output and exit codes -- v1 **`--method`** compatibility routing to shared handlers -- Per-session wiring of **`InspectorClient`**, **`createTransportNode`**, **`resolveServerConfigs`**, and the state managers above - -If a workflow feels awkward, check how TUI/web solve it with existing core APIs before proposing core changes. - ---- - -## 8. Phased delivery - -**Note:** The one-shot CLI/TUI/launcher from v1.5 are already on `v2/main` (see [v2_cli_tui_launcher.md](v2_cli_tui_launcher.md)). Session-oriented CLI v2 builds on that baseline. - -Suggested phases (adjust in planning): - -| Phase | Deliverable | -| -------------------------- | ---------------------------------------------------------------------------------------------- | -| **0 — Design** | This doc; command naming RFC; daemon IPC sketch | -| **1 — Compat + structure** | Commander subcommand tree; v1 `--method` routed through shared handlers; **`mcp` bin** (§5.2); tests ported/extended | -| **2 — Session daemon** | Implicit auto-spawn; `connect` / `disconnect` / `sessions list`; IPC; `@session` on subcommands | -| **3 — Breadth** | Tools/resources/prompts/logging subcommands via managed/paged state managers; pagination, structured content | -| **4 — Advanced** | Tasks, subscriptions, roots, completions, OAuth UX—same **`InspectorClient`** + managers as TUI | -| **5 — Polish** | Docs, launcher help text, ergonomics polish | - -Each phase should ship test coverage (Vitest + test-servers harness, same as v1). - ---- - -## 9. Testing and documentation - -- **Unit / integration:** Extend `clients/cli/__tests__/`; use `@modelcontextprotocol/inspector-test-server` composable fixtures where possible. -- **Session daemon:** Separate test suite with mock IPC or in-process daemon for CI. -- **Docs:** Update [clients/cli/README.md](../clients/cli/README.md) with v2 command reference; keep a **“Legacy `--method` interface”** section until removal. -- **Cross-links:** [v2_catalog_launch_config.md](v2_catalog_launch_config.md) unchanged except examples showing session commands. - +## To-do + +| Item | Notes | +| --- | --- | +| **Mid-session auth over IPC** | Challenge + step-up UX on the invoking `mcp` 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 `mcp` 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`. | +| **`mcp daemon run`** | Optional foreground debug (not a Commander subcommand; `build/daemon.js` works today). | +| **Launcher help polish** | Make `mcp` 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 / `mcp attach`** | One consumer per stream invocation today. | +| **Sampling / elicitation CLI** | Still TUI/web. | +| **Ephemeral no-`connect` shortcuts on `mcp`** | Out of scope (keep two mental models). | +| **`MCP_SESSION` env** | Superseded by require-explicit-on-non-TTY + `MCP_ALLOW_DEFAULT_SESSION=1`. | +| **Human `--full` schema dumps** | Optional formatter polish. | From 5fbd3b9ebfdc1cd0ba511066cd00588efe6c6e27 Mon Sep 17 00:00:00 2001 From: Bob Dickinson Date: Sun, 19 Jul 2026 19:30:59 -0700 Subject: [PATCH 04/11] =?UTF-8?q?fix(cli):=20address=20review=20=E2=80=94?= =?UTF-8?q?=20idle=20reap,=20metadata,=20mcpi=20rename?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Arm the session daemon idle timer on start (and after failed connect) so session-less spawns self-reap. JSON.stringify structured --metadata values. Rename the session bin from mcp to mcpi to avoid PATH collisions, and clarify --relogin as ignore-stored-OAuth (no-op for stdio). Co-authored-by: Cursor --- AGENTS.md | 2 +- README.md | 4 +- clients/cli/README.md | 54 ++++++++--------- clients/cli/__tests__/README.md | 2 +- clients/cli/__tests__/daemon-coverage.test.ts | 33 ++++------- clients/cli/__tests__/daemon-private.test.ts | 2 +- clients/cli/__tests__/helpers/mcp-runner.ts | 4 +- clients/cli/__tests__/hoist-session.test.ts | 6 +- .../cli/__tests__/mcp-auth-coverage.test.ts | 10 ++-- clients/cli/__tests__/mcp-coverage.test.ts | 7 ++- clients/cli/__tests__/metadata.test.ts | 37 ++++++++++++ clients/cli/__tests__/servers-list.test.ts | 2 +- clients/cli/package-lock.json | 2 +- clients/cli/package.json | 2 +- clients/cli/src/cli.ts | 12 +++- clients/cli/src/daemon/ipc-glue.ts | 2 +- clients/cli/src/daemon/run.ts | 4 +- clients/cli/src/daemon/server.ts | 4 ++ clients/cli/src/daemon/sessions.ts | 16 ++++- clients/cli/src/handlers/servers-list.ts | 4 +- clients/cli/src/session/dispatch.ts | 2 +- clients/cli/src/session/mcp.ts | 20 ++++--- clients/cli/src/session/private-env.ts | 2 +- clients/cli/src/session/stored-auth.ts | 3 +- mcp | 4 -- package.json | 2 +- specification/v2_cli_tui_launcher.md | 4 +- specification/v2_cli_v2.md | 59 ++++++++++--------- 28 files changed, 182 insertions(+), 123 deletions(-) delete mode 100755 mcp diff --git a/AGENTS.md b/AGENTS.md index ac5654172..1b1554691 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -20,7 +20,7 @@ inspector/ │ │ └── static/ # sandbox_proxy.html (served by sandbox-controller for MCP Apps tab) │ ├── cli/ # CLI client (tsup bundle, @inspector/core alias) │ │ ├── src/cli.ts # One-shot entry (`mcp-inspector --cli`) -│ │ ├── src/session/ # Session front-end (`mcp` → mcp-bin.ts) +│ │ ├── src/session/ # Session front-end (`mcpi` → mcp-bin.ts) │ │ ├── src/daemon/ # Implicit Unix-socket session daemon │ │ └── src/handlers/ # Shared runMethod + method types (one-shot + daemon) │ ├── tui/ # TUI client (Ink + React, tsup bundle) diff --git a/README.md b/README.md index 4c8813eee..f22dbaf2a 100644 --- a/README.md +++ b/README.md @@ -6,13 +6,13 @@ A developer tool for inspecting [Model Context Protocol](https://modelcontextpro - **CLI** — a scriptable command-line client for automation, CI, and fast agent feedback loops. - **TUI** — an interactive terminal UI built with [Ink](https://github.com/vadimdemedes/ink). -All three run through one global `mcp-inspector` binary. The package also ships a session-oriented `mcp` binary (connect once, many commands): +All three run through one global `mcp-inspector` binary. The package also ships a session-oriented `mcpi` binary (connect once, many commands): ```bash npx @modelcontextprotocol/inspector # web UI (default) npx @modelcontextprotocol/inspector --cli # one-shot CLI npx @modelcontextprotocol/inspector --tui # TUI -npx --package=@modelcontextprotocol/inspector mcp servers/list # session CLI +npx --package=@modelcontextprotocol/inspector mcpi servers/list # session CLI ``` > **Repo status.** This is the **v2** line of the Inspector (branch `v2/main`). The `main` branch is the legacy v1 implementation (bug fixes only). v2 will eventually replace `main`. See [`AGENTS.md`](./AGENTS.md) for branch/board conventions. diff --git a/clients/cli/README.md b/clients/cli/README.md index c6fd6b64a..62e189bcc 100644 --- a/clients/cli/README.md +++ b/clients/cli/README.md @@ -3,40 +3,40 @@ The CLI package provides two entrypoints: - **One-shot** — `mcp-inspector --cli` (frozen contract): connect → one `--method` → disconnect. -- **Session** — `mcp` (new): connect once, then run many commands against a named session. +- **Session** — `mcpi` (MCP Inspector session CLI): connect once, then run many commands against a named session. -## Session CLI (`mcp`) +## Session CLI (`mcpi`) ```bash # From a built tree / published package: -mcp servers/list --config path/to/mcp.json # marks entries with a live session (@name / MRU) -mcp servers/show test-stdio --config path/to/mcp.json -mcp connect test-stdio --config path/to/mcp.json -mcp connect test-stdio --config path/to/mcp.json --relogin # ignore stored OAuth; fresh login if required -mcp auth/list -mcp auth/clear https://example.com/mcp -mcp auth/clear --all --yes -mcp tools/list -mcp tools/call echo message:=hi -mcp tools/call echo '{"message":"hi"}' # or --tool-arg / --tool-args-json -mcp @test-stdio resources/list # explicit session via @name -mcp initialize --session test-stdio -mcp logging/setLevel info -mcp logging/tail # long-lived; Ctrl-C to stop -mcp sessions/list -mcp disconnect --session test-stdio -mcp daemon status -mcp daemon stop +mcpi servers/list --config path/to/mcp.json # marks entries with a live session (@name / MRU) +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"}' # or --tool-arg / --tool-args-json +mcpi @test-stdio resources/list # explicit session via @name +mcpi initialize --session test-stdio +mcpi logging/setLevel info +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 (ssh-agent style) -eval "$(mcp private)" -mcp connect test-stdio --config path/to/mcp.json -mcp tools/list +eval "$(mcpi private)" +mcpi connect test-stdio --config path/to/mcp.json +mcpi tools/list ``` -Slash methods match one-shot `--method` names (`tools/*`, `resources/*`, `prompts/*`, `logging/*`, `tasks/*`, `roots/*`, `initialize`). Streams (`logging/tail`, `resources/subscribe`) stay attached until Ctrl-C and honour `--format` (human lines or pretty JSON per event). Auth uses the shared `oauth.json` store. On connect, the daemon reuses stored tokens when it can; if auth is still required (or stored credentials cannot complete a silent refresh), `mcp` runs interactive OAuth then retries (pass `--stored-auth-only` to refuse interactive login — also available on one-shot). Use `connect --relogin` (or one-shot `--relogin`) to ignore stored credentials for that connect and run a fresh interactive login when the server requires auth (new tokens are saved). Manage the store with `auth/list` and `auth/clear ` / `auth/clear --all` (`--yes` required for `--all` when non-interactive). On a TTY, the CLI prints a clickable authorization URL (OSC 8) and opens the default browser; non-TTY / CI only prints the plain URL. On the session CLI, authentication is handled at **connect** time only; mid-session re-login / step-up during a later command is available on one-shot `--cli`, not on `mcp`. +Slash methods match one-shot `--method` names (`tools/*`, `resources/*`, `prompts/*`, `logging/*`, `tasks/*`, `roots/*`, `initialize`). Streams (`logging/tail`, `resources/subscribe`) stay attached until Ctrl-C and honour `--format` (human lines or pretty JSON per event). Auth uses the shared `oauth.json` store. On connect, the daemon reuses stored tokens when it can; if auth is still required (or stored credentials cannot complete a silent refresh), `mcpi` runs interactive OAuth then retries (pass `--stored-auth-only` to refuse interactive login — also available on one-shot). Use `connect --relogin` (or one-shot `--relogin`) to ignore stored OAuth for that connect: it clears any `oauth.json` entry for the server URL, then connects; interactive login runs only if the server still requires auth (new tokens are saved). For stdio (or any target with no URL-keyed store entry) the flag is a no-op — there is nothing to clear and OAuth does not apply. Manage the store with `auth/list` and `auth/clear ` / `auth/clear --all` (`--yes` required for `--all` when non-interactive). On a TTY, the CLI prints a clickable authorization URL (OSC 8) and opens the default browser; non-TTY / CI only prints the plain URL. On the session CLI, authentication is handled at **connect** time only; mid-session re-login / step-up during a later command is available on one-shot `--cli`, not on `mcpi`. -Sessions are kept by a background process under `~/.mcp-inspector/` by default (shared across terminals). Set `MCP_STORAGE_DIR` to isolate parallel runs (daemon + OAuth store). For a daemon that only this shell can use, run `eval "$(mcp private)"` once — later `mcp` commands in that shell inherit the private binding. +Sessions are kept by a background process under `~/.mcp-inspector/` by default (shared across terminals). Set `MCP_STORAGE_DIR` to isolate parallel runs (daemon + OAuth store). For a daemon that only this shell can use, run `eval "$(mcpi private)"` once — later `mcpi` commands in that shell inherit the private binding. **Output (session only — different from one-shot):** @@ -46,7 +46,7 @@ Sessions are kept by a background process under `~/.mcp-inspector/` by default ( | `--format json` | Pretty-printed JSON of the payload (no `{ "result" }` envelope; never ANSI) | | `--plain` | Disable ANSI styling in human text (also honours `NO_COLOR`) | -Put global flags before the subcommand: `mcp --format json tools/list`, `mcp --plain tools/list`. Isolate parallel runs with `MCP_STORAGE_DIR` (scopes the daemon socket + OAuth store). One-shot `--cli` keeps its own format contract (`text` = pretty JSON, `json` = `{ result }` envelope). +Put global flags before the subcommand: `mcpi --format json tools/list`, `mcpi --plain tools/list`. Isolate parallel runs with `MCP_STORAGE_DIR` (scopes the daemon socket + OAuth store). One-shot `--cli` keeps its own format contract (`text` = pretty JSON, `json` = `{ result }` envelope). ## One-shot CLI (`mcp-inspector --cli`) @@ -196,7 +196,7 @@ The CLI runs the same loopback callback server as the TUI (`http://127.0.0.1:627 **Step-up (standard OAuth, one-shot only):** when an RPC needs extra scopes, the CLI prompts on stderr: `Proceed with step-up authorization? [y/N]`. **y** continues; **N** exits with an error. EMA step-up re-mints silently (no prompt). -**Session (`mcp`):** if connect requires auth, interactive OAuth runs and connect is retried. Mid-session step-up during a later command (for example `tools/call`) is not available on the session CLI — use one-shot `--cli` when you need that, or disconnect and connect again after refreshing tokens. +**Session (`mcpi`):** if connect requires auth, interactive OAuth runs and connect is retried. Mid-session step-up during a later command (for example `tools/call`) is not available on the session CLI — use one-shot `--cli` when you need that, or disconnect and connect again after refreshing tokens. **Shared OAuth storage:** the CLI **reuses** tokens from `~/.mcp-inspector/storage/oauth.json` when they already exist (same file as other Inspector clients). That is passive file sharing, not launching another app. diff --git a/clients/cli/__tests__/README.md b/clients/cli/__tests__/README.md index a9e08d441..9c2d9c58e 100644 --- a/clients/cli/__tests__/README.md +++ b/clients/cli/__tests__/README.md @@ -48,7 +48,7 @@ root provides a further end-to-end check of the binary.) - `oauth-interactive.test.ts` - **Integration** smoke parity for CLI interactive OAuth: connect-time callback server + step-up **y/N** against composable `TestServerHttp` (auto-completes authorize URL programmatically; not a subprocess binary e2e) - `e2e.test.ts` - Out-of-process spawn of the built binary (exit codes + boot; no OAuth) - `mcp-session.test.ts` / `mcp-coverage.test.ts` / `mcp-auth-coverage.test.ts` - Session CLI (`runMcp`) against the daemon -- `daemon-*.test.ts` / `daemon-private.test.ts` / `dispatch.test.ts` / `format-session.test.ts` / `parse-tool-args.test.ts` / `style.test.ts` - Session daemon (incl. `mcp private` IPC token), dispatch, and formatting +- `daemon-*.test.ts` / `daemon-private.test.ts` / `dispatch.test.ts` / `format-session.test.ts` / `parse-tool-args.test.ts` / `style.test.ts` - Session daemon (incl. `mcpi private` IPC token), dispatch, and formatting ## Helpers diff --git a/clients/cli/__tests__/daemon-coverage.test.ts b/clients/cli/__tests__/daemon-coverage.test.ts index 928025ea1..c00db5164 100644 --- a/clients/cli/__tests__/daemon-coverage.test.ts +++ b/clients/cli/__tests__/daemon-coverage.test.ts @@ -470,39 +470,30 @@ describe("daemon coverage", () => { } }); - it("idle timeout invokes onShutdown", async () => { + it("session-less start arms idle and self-reaps", async () => { const d = freshDir(); let shut = false; server = new DaemonServer({ dir: d, - idleMs: 30, + idleMs: 40, onShutdown: () => { shut = true; }, }); await server.start(); - const { command, args } = getTestMcpServerCommand(); - await callDaemon( - "connect", - { - name: "s", - serverConfig: { type: "stdio", command, args }, - serverIdentity: "s", - }, - { socketPath: server.socketPath, timeoutMs: 15000 }, - ); - await callDaemon( - "disconnect", - { name: "s" }, - { socketPath: server.socketPath }, - ); - await new Promise((r) => setTimeout(r, 80)); + // 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", async () => { - const registry = new SessionRegistry(0); + 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", @@ -514,6 +505,8 @@ describe("daemon coverage", () => { serverIdentity: "dead", }), ).rejects.toThrow(); + expect(registry.idleRemainingMs()).not.toBeNull(); + expect(idle).toBe(false); }); it("sessions/use via handle and blank IPC lines", async () => { diff --git a/clients/cli/__tests__/daemon-private.test.ts b/clients/cli/__tests__/daemon-private.test.ts index c9ea43afb..f4e536207 100644 --- a/clients/cli/__tests__/daemon-private.test.ts +++ b/clients/cli/__tests__/daemon-private.test.ts @@ -39,7 +39,7 @@ describe("daemon IPC token", () => { }); }); -describe("mcp private", () => { +describe("mcpi private", () => { let home: string | undefined; let prevHome: string | undefined; diff --git a/clients/cli/__tests__/helpers/mcp-runner.ts b/clients/cli/__tests__/helpers/mcp-runner.ts index c08a3a1df..9939e0d09 100644 --- a/clients/cli/__tests__/helpers/mcp-runner.ts +++ b/clients/cli/__tests__/helpers/mcp-runner.ts @@ -57,12 +57,12 @@ export async function runMcp( stderr += text; }) as typeof process.stderr.write; - const argv = ["node", "mcp", ...args]; + const argv = ["node", "mcpi", ...args]; const timeoutMs = options.timeout ?? 15000; let timer: ReturnType | undefined; const timeout = new Promise((_, reject) => { timer = setTimeout( - () => reject(new Error(`mcp command timed out after ${timeoutMs}ms`)), + () => reject(new Error(`mcpi command timed out after ${timeoutMs}ms`)), timeoutMs, ); }); diff --git a/clients/cli/__tests__/hoist-session.test.ts b/clients/cli/__tests__/hoist-session.test.ts index 86eabcd0b..19b9b13d1 100644 --- a/clients/cli/__tests__/hoist-session.test.ts +++ b/clients/cli/__tests__/hoist-session.test.ts @@ -5,18 +5,18 @@ describe("hoistAtSession", () => { it("lifts a leading @name into sessionFromAt", () => { const { argv, sessionFromAt } = hoistAtSession([ "node", - "mcp", + "mcpi", "@alpha", "tools/list", "--format", "json", ]); expect(sessionFromAt).toBe("alpha"); - expect(argv).toEqual(["node", "mcp", "tools/list", "--format", "json"]); + expect(argv).toEqual(["node", "mcpi", "tools/list", "--format", "json"]); }); it("leaves argv unchanged when there is no @name", () => { - const input = ["node", "mcp", "tools/list"]; + const input = ["node", "mcpi", "tools/list"]; expect(hoistAtSession(input)).toEqual({ argv: input }); }); }); diff --git a/clients/cli/__tests__/mcp-auth-coverage.test.ts b/clients/cli/__tests__/mcp-auth-coverage.test.ts index 230c701c9..c3d7363e4 100644 --- a/clients/cli/__tests__/mcp-auth-coverage.test.ts +++ b/clients/cli/__tests__/mcp-auth-coverage.test.ts @@ -78,7 +78,7 @@ describe("mcp.ts auth / daemon error paths", () => { const { runMcp } = await import("../src/session/mcp.js"); await runMcp([ "node", - "mcp", + "mcpi", "connect", "test-stdio", "--config", @@ -98,7 +98,7 @@ describe("mcp.ts auth / daemon error paths", () => { await expect( runMcp([ "node", - "mcp", + "mcpi", "--stored-auth-only", "connect", "test-stdio", @@ -144,7 +144,7 @@ describe("mcp.ts auth / daemon error paths", () => { const { runMcp } = await import("../src/session/mcp.js"); await runMcp([ "node", - "mcp", + "mcpi", "connect", "--session", "relogin-http", @@ -181,7 +181,7 @@ describe("mcp.ts auth / daemon error paths", () => { await expect( runMcp([ "node", - "mcp", + "mcpi", "connect", "test-stdio", "--config", @@ -204,7 +204,7 @@ describe("mcp.ts auth / daemon error paths", () => { const { runMcp } = await import("../src/session/mcp.js"); await expect( - runMcp(["node", "mcp", "daemon", "stop", "--format", "json"]), + runMcp(["node", "mcpi", "daemon", "stop", "--format", "json"]), ).rejects.toMatchObject({ exitCode: EXIT_CODES.USAGE, envelope: { code: "usage" }, diff --git a/clients/cli/__tests__/mcp-coverage.test.ts b/clients/cli/__tests__/mcp-coverage.test.ts index d53da6c29..cc759495e 100644 --- a/clients/cli/__tests__/mcp-coverage.test.ts +++ b/clients/cli/__tests__/mcp-coverage.test.ts @@ -69,6 +69,9 @@ describe("mcp.ts coverage", () => { "client=session-cov", "--metadata", "count=1", + // Object value must JSON.stringify (not String → "[object Object]"). + "--metadata", + 'nested={"a":1}', "--plain", "--format", "json", @@ -308,7 +311,7 @@ describe("mcp.ts coverage", () => { await runMcp(["daemon", "stop", "--format", "json"], { env: e }); }); - it("bare mcp / --help print usage without an ErrorEnvelope", async () => { + 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); @@ -330,7 +333,7 @@ describe("mcp.ts coverage", () => { const originalArgv = process.argv; process.argv = [ "node", - "mcp", + "mcpi", "servers/list", "--config", configPath, diff --git a/clients/cli/__tests__/metadata.test.ts b/clients/cli/__tests__/metadata.test.ts index ac2876f6c..b5867d50e 100644 --- a/clients/cli/__tests__/metadata.test.ts +++ b/clients/cli/__tests__/metadata.test.ts @@ -428,6 +428,43 @@ describe("Metadata Tests", () => { } }); + it("JSON.stringifies object/array metadata (not String → [object Object])", async () => { + const server = createTestServerHttp({ + serverInfo: createTestServerInfo(), + tools: [createEchoTool()], + }); + + try { + await server.start(); + const result = await runCli([ + server.url, + "--cli", + "--method", + "tools/list", + "--metadata", + 'nested={"key":"value"}', + "list=[1,2,3]", + "flag=true", + "--transport", + "http", + ]); + + expectCliSuccess(result); + + const recordedRequests = server.getRecordedRequests(); + const toolsListRequest = recordedRequests.find( + (r) => r.method === "tools/list", + ); + expect(toolsListRequest?.metadata).toEqual({ + nested: '{"key":"value"}', + list: "[1,2,3]", + flag: "true", + }); + } finally { + await server.stop(); + } + }); + it("should handle special characters", async () => { const server = createTestServerHttp({ serverInfo: createTestServerInfo(), diff --git a/clients/cli/__tests__/servers-list.test.ts b/clients/cli/__tests__/servers-list.test.ts index 6745de76e..b0c0be431 100644 --- a/clients/cli/__tests__/servers-list.test.ts +++ b/clients/cli/__tests__/servers-list.test.ts @@ -188,7 +188,7 @@ describe("showServerEntry / servers/show", () => { expect(entry.config.env).toEqual({ HELLO: "[redacted]" }); }); - it("works via session mcp servers/show", async () => { + it("works via session mcpi servers/show", async () => { configPath = createSampleTestConfig(); const result = await runMcp([ "servers/show", diff --git a/clients/cli/package-lock.json b/clients/cli/package-lock.json index c0f6740cf..e9a5543fd 100644 --- a/clients/cli/package-lock.json +++ b/clients/cli/package-lock.json @@ -22,7 +22,7 @@ "zod": "^4.3.6" }, "bin": { - "mcp": "build/mcp-bin.js", + "mcpi": "build/mcp-bin.js", "mcp-inspector-cli": "build/index.js" }, "devDependencies": { diff --git a/clients/cli/package.json b/clients/cli/package.json index 555bd2ea7..de6170206 100644 --- a/clients/cli/package.json +++ b/clients/cli/package.json @@ -10,7 +10,7 @@ }, "bin": { "mcp-inspector-cli": "./build/index.js", - "mcp": "./build/mcp-bin.js" + "mcpi": "./build/mcp-bin.js" }, "files": [ "build" diff --git a/clients/cli/src/cli.ts b/clients/cli/src/cli.ts index 7861be010..68109b9d7 100644 --- a/clients/cli/src/cli.ts +++ b/clients/cli/src/cli.ts @@ -452,6 +452,12 @@ function parseKeyValuePair( return { ...previous, [key as string]: parsedValue }; } +/** Preserve structured metadata (objects/arrays) instead of String → "[object Object]". */ +function metaValueToString(value: JsonValue): string { + if (typeof value === "string") return value; + return JSON.stringify(value); +} + type ParseResult = | { shortCircuit?: undefined; @@ -653,7 +659,7 @@ async function parseArgs(argv?: string[]): Promise { ) .option( "--relogin", - "Ignore stored OAuth credentials for this run; run interactive login if the server requires auth, then save new tokens", + "Ignore stored OAuth for this run (HTTP/SSE URL keys only); interactive login runs only if the server requires auth. No-op for stdio / servers with no stored entry", ) .option( "--wait-for-auth ", @@ -938,7 +944,7 @@ async function parseArgs(argv?: string[]): Promise { ? Object.fromEntries( Object.entries(options.metadata).map(([key, value]) => [ key, - String(value), + metaValueToString(value), ]), ) : undefined, @@ -946,7 +952,7 @@ async function parseArgs(argv?: string[]): Promise { ? Object.fromEntries( Object.entries(options.toolMetadata).map(([key, value]) => [ key, - String(value), + metaValueToString(value), ]), ) : undefined, diff --git a/clients/cli/src/daemon/ipc-glue.ts b/clients/cli/src/daemon/ipc-glue.ts index 0187d0a58..64a90c429 100644 --- a/clients/cli/src/daemon/ipc-glue.ts +++ b/clients/cli/src/daemon/ipc-glue.ts @@ -96,7 +96,7 @@ export async function removeStaleDaemonSocket( const live = await canConnect(socketPath); if (live) { throw new Error( - `Daemon already running at ${socketPath}. Use mcp daemon stop first.`, + `Daemon already running at ${socketPath}. Use mcpi daemon stop first.`, ); } try { diff --git a/clients/cli/src/daemon/run.ts b/clients/cli/src/daemon/run.ts index d80752ffc..4b28cd1b2 100644 --- a/clients/cli/src/daemon/run.ts +++ b/clients/cli/src/daemon/run.ts @@ -1,7 +1,7 @@ #!/usr/bin/env node /** * Session daemon entrypoint. Spawned detached by {@link ensureDaemon}. - * Optional foreground `mcp daemon run` is not shipped yet (see v2_cli_v2.md). + * Optional foreground `mcpi daemon run` is not shipped yet (see v2_cli_v2.md). */ import { DaemonServer } from "./server.js"; @@ -24,6 +24,6 @@ async function main(): Promise { main().catch((error: unknown) => { const message = error instanceof Error ? error.message : String(error); - process.stderr.write(`mcp daemon: ${message}\n`); + process.stderr.write(`mcpi daemon: ${message}\n`); process.exit(1); }); diff --git a/clients/cli/src/daemon/server.ts b/clients/cli/src/daemon/server.ts index c4bf6ffac..62df0f910 100644 --- a/clients/cli/src/daemon/server.ts +++ b/clients/cli/src/daemon/server.ts @@ -92,6 +92,10 @@ export class DaemonServer { } 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 { diff --git a/clients/cli/src/daemon/sessions.ts b/clients/cli/src/daemon/sessions.ts index eb9399345..7e4647dc3 100644 --- a/clients/cli/src/daemon/sessions.ts +++ b/clients/cli/src/daemon/sessions.ts @@ -64,6 +64,17 @@ export class SessionRegistry { 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) => ({ @@ -103,7 +114,7 @@ export class SessionRegistry { if (!this.mruName) { throw new CliExitCodeError( EXIT_CODES.USAGE, - "No open sessions. Connect first (e.g. mcp servers/list, mcp connect ).", + "No open sessions. Connect first (e.g. mcpi servers/list, mcpi connect ).", { code: "no_session" }, ); } @@ -113,7 +124,7 @@ export class SessionRegistry { if (!session) { throw new CliExitCodeError( EXIT_CODES.USAGE, - `Session '${name}' not found. Use mcp sessions/list.`, + `Session '${name}' not found. Use mcpi sessions/list.`, { code: "session_not_found" }, ); } @@ -178,6 +189,7 @@ export class SessionRegistry { await client.connect(); } catch (error) { await safeDisconnect(client); + this.armIdleTimerIfEmpty(); if (isSessionAuthRequiredError(error)) { throw new CliExitCodeError( EXIT_CODES.AUTH_REQUIRED, diff --git a/clients/cli/src/handlers/servers-list.ts b/clients/cli/src/handlers/servers-list.ts index 70bda0b28..e96998256 100644 --- a/clients/cli/src/handlers/servers-list.ts +++ b/clients/cli/src/handlers/servers-list.ts @@ -31,7 +31,7 @@ export type SessionListRef = { /** * Mark catalog entries that have a live session with the same name - * (`mcp connect ` default). Does not mutate `entries`. + * (`mcpi connect ` default). Does not mutate `entries`. */ export function annotateServerEntriesWithSessions( entries: ServerListEntry[], @@ -78,7 +78,7 @@ export function summarizeServerConfig(config: MCPServerConfig): { /** * Load catalog/config entries and return a sorted name + summary list. - * Shared by one-shot `--method servers/list` and `mcp servers/list`. + * Shared by one-shot `--method servers/list` and `mcpi servers/list`. */ export async function listServerEntries( serverOptions: ServerLoadOptions = {}, diff --git a/clients/cli/src/session/dispatch.ts b/clients/cli/src/session/dispatch.ts index bb0b4b124..2c932d44e 100644 --- a/clients/cli/src/session/dispatch.ts +++ b/clients/cli/src/session/dispatch.ts @@ -95,7 +95,7 @@ export function requireExplicitSession(): boolean { } /** - * Hoist a leading `@name` from argv so `mcp @alpha tools/list` works. + * Hoist a leading `@name` from argv so `mcpi @alpha tools/list` works. */ export function hoistAtSession(argv: string[]): { argv: string[]; diff --git a/clients/cli/src/session/mcp.ts b/clients/cli/src/session/mcp.ts index d2f115928..26b5abd67 100644 --- a/clients/cli/src/session/mcp.ts +++ b/clients/cli/src/session/mcp.ts @@ -84,7 +84,7 @@ function outOpts(opts: GlobalOpts) { const validLogLevels: LoggingLevel[] = Object.values(LoggingLevelSchema.enum); /** - * Session-first CLI entry (`mcp`). Talks to the implicit session daemon over + * 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). */ @@ -96,14 +96,14 @@ export async function runMcp(argv?: string[]): Promise { 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 `mcp` uses code `commander.help` with exitCode 1 + // 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("mcp") + .name("mcpi") .description( "MCP Inspector session CLI — connect once, run many commands against a named session.", ) @@ -254,7 +254,7 @@ function registerConnect(program: CommandType): void { ) .option( "--relogin", - "Ignore stored OAuth credentials for this connect; run interactive login if the server requires auth, then save new tokens", + "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", ) .action(async (target: string[], cmdOpts) => { const opts = program.opts(); @@ -576,8 +576,8 @@ function registerPrivateCommand(program: CommandType): void { program .command("private") .description( - 'Print shell exports for a private daemon (eval "$(mcp private)"). ' + - "Later mcp commands in that shell use an isolated, token-gated daemon.", + '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(); @@ -801,10 +801,16 @@ function stringifyMeta( ): Record | undefined { if (!meta || Object.keys(meta).length === 0) return undefined; return Object.fromEntries( - Object.entries(meta).map(([k, v]) => [k, String(v)]), + Object.entries(meta).map(([k, v]) => [k, metaValueToString(v)]), ); } +/** Preserve structured metadata (objects/arrays) instead of String → "[object Object]". */ +function metaValueToString(value: JsonValue): string { + if (typeof value === "string") return value; + return JSON.stringify(value); +} + function looksLikeUrl(value: string): boolean { return /^https?:\/\//i.test(value); } diff --git a/clients/cli/src/session/private-env.ts b/clients/cli/src/session/private-env.ts index 236278f85..f4b62e26e 100644 --- a/clients/cli/src/session/private-env.ts +++ b/clients/cli/src/session/private-env.ts @@ -21,7 +21,7 @@ export function createPrivateBinding(): PrivateEnvBinding { } /** - * Shell exports for `eval "$(mcp private)"` (POSIX sh / bash / zsh). + * Shell exports for `eval "$(mcpi private)"` (POSIX sh / bash / zsh). */ export function formatPrivateEnvExports(binding: PrivateEnvBinding): string { return [ diff --git a/clients/cli/src/session/stored-auth.ts b/clients/cli/src/session/stored-auth.ts index 894cc3d0a..0ffb75f55 100644 --- a/clients/cli/src/session/stored-auth.ts +++ b/clients/cli/src/session/stored-auth.ts @@ -137,7 +137,8 @@ export async function clearAllStoredAuth(): Promise<{ cleared: number }> { /** * 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. + * 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, diff --git a/mcp b/mcp deleted file mode 100755 index de2a87608..000000000 --- a/mcp +++ /dev/null @@ -1,4 +0,0 @@ -#!/bin/sh -ROOT="$(CDPATH= cd -- "$(dirname "$0")" && pwd)" -exec node "$ROOT/clients/cli/build/mcp-bin.js" \ - "$@" diff --git a/package.json b/package.json index e74f0fd4a..d35ce3d14 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,7 @@ "type": "module", "bin": { "mcp-inspector": "./clients/launcher/build/index.js", - "mcp": "./clients/cli/build/mcp-bin.js" + "mcpi": "./clients/cli/build/mcp-bin.js" }, "files": [ "clients/launcher/build", diff --git a/specification/v2_cli_tui_launcher.md b/specification/v2_cli_tui_launcher.md index 94f46fd47..4e673b7e1 100644 --- a/specification/v2_cli_tui_launcher.md +++ b/specification/v2_cli_tui_launcher.md @@ -20,7 +20,7 @@ This document describes how those clients are built, wired, and tested today, an ## Non-goals -- **CLI v2 sessions** (connect once, many subcommands) — as-built in [v2_cli_v2.md](v2_cli_v2.md) (`mcp` bin session-first; `mcp-inspector --cli` stays one-shot); tracked by [#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). @@ -94,7 +94,7 @@ All three clients import from `@inspector/core/...` (mapped to `../../core/` sou ## CLI -**Model:** one-shot — each invocation connects, runs a single `--method`, prints a result to stdout, disconnects, exits. Same surface as v1.5. Session-oriented CLI v2 (`mcp`) is documented as-built in [v2_cli_v2.md](v2_cli_v2.md) ([#1432](https://github.com/modelcontextprotocol/inspector/issues/1432)). +**Model:** one-shot — each invocation connects, runs a single `--method`, prints a result to stdout, disconnects, exits. Same surface as v1.5. 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 index 26fb3c1fe..9ec5599ad 100644 --- a/specification/v2_cli_v2.md +++ b/specification/v2_cli_v2.md @@ -4,7 +4,7 @@ #### [CLI, TUI, Launcher](v2_cli_tui_launcher.md) | CLI v2 | [Catalog / launch config](v2_catalog_launch_config.md) -Documentation of the session-oriented Inspector CLI (`mcp`) and how it relates to the frozen one-shot path (`mcp-inspector --cli`). Tracked by [#1432](https://github.com/modelcontextprotocol/inspector/issues/1432). +Documentation of the 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). **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/cli/README.md`](../clients/cli/README.md) (end-user reference). @@ -14,29 +14,29 @@ Documentation of the session-oriented Inspector CLI (`mcp`) and how it relates t | | **One-shot** | **Session** | | --- | --- | --- | -| Entrypoint | `mcp-inspector --cli` | `mcp` | +| 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` (shared with session) | Same; root `bin.mcp` → `clients/cli/build/mcp-bin.js` | +| Package | `clients/cli` (shared with session) | Same; root `bin.mcpi` → `clients/cli/build/mcp-bin.js` | -Both use `@inspector/core` `InspectorClient` and shared `handlers/run-method.ts`. One-shot never starts the daemon. `mcp` does not accept `--method`. +Both use `@inspector/core` `InspectorClient` and shared `handlers/run-method.ts`. One-shot never starts the daemon. `mcpi` does not accept `--method`. ```bash -mcp servers/list --config mcp.json -mcp servers/show my-server --config mcp.json -mcp connect myserver --config mcp.json -mcp tools/list -mcp tools/call search query:=hello -mcp @other resources/list -mcp disconnect +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 "$(mcp private)" -mcp connect myserver --config mcp.json -mcp tools/list +eval "$(mcpi private)" +mcpi connect myserver --config mcp.json +mcpi tools/list ``` --- @@ -53,7 +53,7 @@ mcp tools/list | Shared handlers | `clients/cli/src/handlers/` (`run-method.ts`, `method-types.ts`, `servers-list.ts`, `emit-result.ts`, …) | ``` -mcp-inspector --cli … mcp … +mcp-inspector --cli … mcpi … │ │ ▼ ▼ cli.ts session/mcp.ts @@ -77,7 +77,7 @@ Frozen automation contract. Each invocation: resolve server → connect → `run **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 (`mcp`) +### Session CLI (`mcpi`) #### Commands @@ -114,7 +114,7 @@ Frozen automation contract. Each invocation: resolve server → connect → `run **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; `daemon stop` tears down immediately. +- 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). @@ -123,17 +123,18 @@ Frozen automation contract. Each invocation: resolve server → connect → `run | 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 `mcp 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 "$(mcp 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. | +| **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. @@ -141,10 +142,10 @@ Frozen automation contract. Each invocation: resolve server → connect → `run | One-shot | Session | | --- | --- | -| `… --catalog mcp.json --server s --method tools/list` | `mcp connect --catalog mcp.json s` then `mcp tools/list` | -| `… --method tools/call --tool-name X --tool-args-json '…'` | `mcp tools/call X key:=val` / `'{"…"}'` | -| `… --method servers/list` | `mcp servers/list` | -| `… --method servers/show --server ` | `mcp servers/show ` | +| `… --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 @@ -158,21 +159,21 @@ Frozen automation contract. Each invocation: resolve server → connect → `run | Item | Notes | | --- | --- | -| **Mid-session auth over IPC** | Challenge + step-up UX on the invoking `mcp` during `rpc`/`stream`. Connect-time only today. | +| **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 `mcp` processes against one session can interleave on one `InspectorClient`. | +| **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`. | -| **`mcp daemon run`** | Optional foreground debug (not a Commander subcommand; `build/daemon.js` works today). | -| **Launcher help polish** | Make `mcp` vs `--cli` unmistakable in launcher `--help` / docs. | +| **`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 / `mcp attach`** | One consumer per stream invocation today. | +| **Stream fan-out / `mcpi attach`** | One consumer per stream invocation today. | | **Sampling / elicitation CLI** | Still TUI/web. | -| **Ephemeral no-`connect` shortcuts on `mcp`** | Out of scope (keep two mental models). | +| **Ephemeral no-`connect` shortcuts on `mcpi`** | Out of scope (keep two mental models). | | **`MCP_SESSION` env** | Superseded by require-explicit-on-non-TTY + `MCP_ALLOW_DEFAULT_SESSION=1`. | | **Human `--full` schema dumps** | Optional formatter polish. | From e570dda5eeae80be0e374a69b2d9ff787b3b6831 Mon Sep 17 00:00:00 2001 From: Bob Dickinson Date: Sat, 25 Jul 2026 10:44:23 -0700 Subject: [PATCH 05/11] Renames for refactor --- clients/cli/src/{session => }/style.ts | 0 clients/{cli => mcpi}/__tests__/authorize.test.ts | 0 clients/{cli => mcpi}/__tests__/daemon-coverage.test.ts | 0 clients/{cli => mcpi}/__tests__/daemon-paths.test.ts | 0 clients/{cli => mcpi}/__tests__/daemon-private.test.ts | 0 clients/{cli => mcpi}/__tests__/daemon-sessions.test.ts | 0 clients/{cli => mcpi}/__tests__/daemon-stream.test.ts | 0 clients/{cli => mcpi}/__tests__/dispatch.test.ts | 0 clients/{cli => mcpi}/__tests__/format-session.test.ts | 0 clients/{cli => mcpi}/__tests__/helpers/mcp-runner.ts | 0 clients/{cli => mcpi}/__tests__/hoist-session.test.ts | 0 clients/{cli => mcpi}/__tests__/mcp-auth-coverage.test.ts | 0 clients/{cli => mcpi}/__tests__/mcp-coverage.test.ts | 0 clients/{cli => mcpi}/__tests__/mcp-session.test.ts | 0 clients/{cli => mcpi}/__tests__/parse-tool-args.test.ts | 0 clients/{cli => mcpi}/__tests__/session-stored-auth.test.ts | 0 clients/{cli => mcpi}/src/daemon/auth.ts | 0 clients/{cli => mcpi}/src/daemon/client.ts | 0 clients/{cli => mcpi}/src/daemon/ensure.ts | 0 clients/{cli => mcpi}/src/daemon/framing.ts | 0 clients/{cli => mcpi}/src/daemon/index.ts | 0 clients/{cli => mcpi}/src/daemon/ipc-glue.ts | 0 clients/{cli => mcpi}/src/daemon/paths.ts | 0 clients/{cli => mcpi}/src/daemon/protocol.ts | 0 clients/{cli => mcpi}/src/daemon/run.ts | 0 clients/{cli => mcpi}/src/daemon/server.ts | 0 clients/{cli => mcpi}/src/daemon/sessions.ts | 0 clients/{cli => mcpi}/src/daemon/stream-client.ts | 0 clients/{cli => mcpi}/src/mcp-bin.ts | 0 clients/{cli => mcpi}/src/session/authorize.ts | 0 clients/{cli => mcpi}/src/session/dispatch.ts | 0 clients/{cli => mcpi}/src/session/format-human.ts | 0 clients/{cli => mcpi}/src/session/format-session.ts | 0 clients/{cli => mcpi}/src/session/mcp.ts | 0 clients/{cli => mcpi}/src/session/parse-tool-args.ts | 0 clients/{cli => mcpi}/src/session/private-env.ts | 0 clients/{cli => mcpi}/src/session/stored-auth.ts | 0 37 files changed, 0 insertions(+), 0 deletions(-) rename clients/cli/src/{session => }/style.ts (100%) rename clients/{cli => mcpi}/__tests__/authorize.test.ts (100%) rename clients/{cli => mcpi}/__tests__/daemon-coverage.test.ts (100%) rename clients/{cli => mcpi}/__tests__/daemon-paths.test.ts (100%) rename clients/{cli => mcpi}/__tests__/daemon-private.test.ts (100%) rename clients/{cli => mcpi}/__tests__/daemon-sessions.test.ts (100%) rename clients/{cli => mcpi}/__tests__/daemon-stream.test.ts (100%) rename clients/{cli => mcpi}/__tests__/dispatch.test.ts (100%) rename clients/{cli => mcpi}/__tests__/format-session.test.ts (100%) rename clients/{cli => mcpi}/__tests__/helpers/mcp-runner.ts (100%) rename clients/{cli => mcpi}/__tests__/hoist-session.test.ts (100%) rename clients/{cli => mcpi}/__tests__/mcp-auth-coverage.test.ts (100%) rename clients/{cli => mcpi}/__tests__/mcp-coverage.test.ts (100%) rename clients/{cli => mcpi}/__tests__/mcp-session.test.ts (100%) rename clients/{cli => mcpi}/__tests__/parse-tool-args.test.ts (100%) rename clients/{cli => mcpi}/__tests__/session-stored-auth.test.ts (100%) rename clients/{cli => mcpi}/src/daemon/auth.ts (100%) rename clients/{cli => mcpi}/src/daemon/client.ts (100%) rename clients/{cli => mcpi}/src/daemon/ensure.ts (100%) rename clients/{cli => mcpi}/src/daemon/framing.ts (100%) rename clients/{cli => mcpi}/src/daemon/index.ts (100%) rename clients/{cli => mcpi}/src/daemon/ipc-glue.ts (100%) rename clients/{cli => mcpi}/src/daemon/paths.ts (100%) rename clients/{cli => mcpi}/src/daemon/protocol.ts (100%) rename clients/{cli => mcpi}/src/daemon/run.ts (100%) rename clients/{cli => mcpi}/src/daemon/server.ts (100%) rename clients/{cli => mcpi}/src/daemon/sessions.ts (100%) rename clients/{cli => mcpi}/src/daemon/stream-client.ts (100%) rename clients/{cli => mcpi}/src/mcp-bin.ts (100%) rename clients/{cli => mcpi}/src/session/authorize.ts (100%) rename clients/{cli => mcpi}/src/session/dispatch.ts (100%) rename clients/{cli => mcpi}/src/session/format-human.ts (100%) rename clients/{cli => mcpi}/src/session/format-session.ts (100%) rename clients/{cli => mcpi}/src/session/mcp.ts (100%) rename clients/{cli => mcpi}/src/session/parse-tool-args.ts (100%) rename clients/{cli => mcpi}/src/session/private-env.ts (100%) rename clients/{cli => mcpi}/src/session/stored-auth.ts (100%) diff --git a/clients/cli/src/session/style.ts b/clients/cli/src/style.ts similarity index 100% rename from clients/cli/src/session/style.ts rename to clients/cli/src/style.ts diff --git a/clients/cli/__tests__/authorize.test.ts b/clients/mcpi/__tests__/authorize.test.ts similarity index 100% rename from clients/cli/__tests__/authorize.test.ts rename to clients/mcpi/__tests__/authorize.test.ts diff --git a/clients/cli/__tests__/daemon-coverage.test.ts b/clients/mcpi/__tests__/daemon-coverage.test.ts similarity index 100% rename from clients/cli/__tests__/daemon-coverage.test.ts rename to clients/mcpi/__tests__/daemon-coverage.test.ts diff --git a/clients/cli/__tests__/daemon-paths.test.ts b/clients/mcpi/__tests__/daemon-paths.test.ts similarity index 100% rename from clients/cli/__tests__/daemon-paths.test.ts rename to clients/mcpi/__tests__/daemon-paths.test.ts diff --git a/clients/cli/__tests__/daemon-private.test.ts b/clients/mcpi/__tests__/daemon-private.test.ts similarity index 100% rename from clients/cli/__tests__/daemon-private.test.ts rename to clients/mcpi/__tests__/daemon-private.test.ts diff --git a/clients/cli/__tests__/daemon-sessions.test.ts b/clients/mcpi/__tests__/daemon-sessions.test.ts similarity index 100% rename from clients/cli/__tests__/daemon-sessions.test.ts rename to clients/mcpi/__tests__/daemon-sessions.test.ts diff --git a/clients/cli/__tests__/daemon-stream.test.ts b/clients/mcpi/__tests__/daemon-stream.test.ts similarity index 100% rename from clients/cli/__tests__/daemon-stream.test.ts rename to clients/mcpi/__tests__/daemon-stream.test.ts diff --git a/clients/cli/__tests__/dispatch.test.ts b/clients/mcpi/__tests__/dispatch.test.ts similarity index 100% rename from clients/cli/__tests__/dispatch.test.ts rename to clients/mcpi/__tests__/dispatch.test.ts diff --git a/clients/cli/__tests__/format-session.test.ts b/clients/mcpi/__tests__/format-session.test.ts similarity index 100% rename from clients/cli/__tests__/format-session.test.ts rename to clients/mcpi/__tests__/format-session.test.ts diff --git a/clients/cli/__tests__/helpers/mcp-runner.ts b/clients/mcpi/__tests__/helpers/mcp-runner.ts similarity index 100% rename from clients/cli/__tests__/helpers/mcp-runner.ts rename to clients/mcpi/__tests__/helpers/mcp-runner.ts diff --git a/clients/cli/__tests__/hoist-session.test.ts b/clients/mcpi/__tests__/hoist-session.test.ts similarity index 100% rename from clients/cli/__tests__/hoist-session.test.ts rename to clients/mcpi/__tests__/hoist-session.test.ts diff --git a/clients/cli/__tests__/mcp-auth-coverage.test.ts b/clients/mcpi/__tests__/mcp-auth-coverage.test.ts similarity index 100% rename from clients/cli/__tests__/mcp-auth-coverage.test.ts rename to clients/mcpi/__tests__/mcp-auth-coverage.test.ts diff --git a/clients/cli/__tests__/mcp-coverage.test.ts b/clients/mcpi/__tests__/mcp-coverage.test.ts similarity index 100% rename from clients/cli/__tests__/mcp-coverage.test.ts rename to clients/mcpi/__tests__/mcp-coverage.test.ts diff --git a/clients/cli/__tests__/mcp-session.test.ts b/clients/mcpi/__tests__/mcp-session.test.ts similarity index 100% rename from clients/cli/__tests__/mcp-session.test.ts rename to clients/mcpi/__tests__/mcp-session.test.ts diff --git a/clients/cli/__tests__/parse-tool-args.test.ts b/clients/mcpi/__tests__/parse-tool-args.test.ts similarity index 100% rename from clients/cli/__tests__/parse-tool-args.test.ts rename to clients/mcpi/__tests__/parse-tool-args.test.ts diff --git a/clients/cli/__tests__/session-stored-auth.test.ts b/clients/mcpi/__tests__/session-stored-auth.test.ts similarity index 100% rename from clients/cli/__tests__/session-stored-auth.test.ts rename to clients/mcpi/__tests__/session-stored-auth.test.ts diff --git a/clients/cli/src/daemon/auth.ts b/clients/mcpi/src/daemon/auth.ts similarity index 100% rename from clients/cli/src/daemon/auth.ts rename to clients/mcpi/src/daemon/auth.ts diff --git a/clients/cli/src/daemon/client.ts b/clients/mcpi/src/daemon/client.ts similarity index 100% rename from clients/cli/src/daemon/client.ts rename to clients/mcpi/src/daemon/client.ts diff --git a/clients/cli/src/daemon/ensure.ts b/clients/mcpi/src/daemon/ensure.ts similarity index 100% rename from clients/cli/src/daemon/ensure.ts rename to clients/mcpi/src/daemon/ensure.ts diff --git a/clients/cli/src/daemon/framing.ts b/clients/mcpi/src/daemon/framing.ts similarity index 100% rename from clients/cli/src/daemon/framing.ts rename to clients/mcpi/src/daemon/framing.ts diff --git a/clients/cli/src/daemon/index.ts b/clients/mcpi/src/daemon/index.ts similarity index 100% rename from clients/cli/src/daemon/index.ts rename to clients/mcpi/src/daemon/index.ts diff --git a/clients/cli/src/daemon/ipc-glue.ts b/clients/mcpi/src/daemon/ipc-glue.ts similarity index 100% rename from clients/cli/src/daemon/ipc-glue.ts rename to clients/mcpi/src/daemon/ipc-glue.ts diff --git a/clients/cli/src/daemon/paths.ts b/clients/mcpi/src/daemon/paths.ts similarity index 100% rename from clients/cli/src/daemon/paths.ts rename to clients/mcpi/src/daemon/paths.ts diff --git a/clients/cli/src/daemon/protocol.ts b/clients/mcpi/src/daemon/protocol.ts similarity index 100% rename from clients/cli/src/daemon/protocol.ts rename to clients/mcpi/src/daemon/protocol.ts diff --git a/clients/cli/src/daemon/run.ts b/clients/mcpi/src/daemon/run.ts similarity index 100% rename from clients/cli/src/daemon/run.ts rename to clients/mcpi/src/daemon/run.ts diff --git a/clients/cli/src/daemon/server.ts b/clients/mcpi/src/daemon/server.ts similarity index 100% rename from clients/cli/src/daemon/server.ts rename to clients/mcpi/src/daemon/server.ts diff --git a/clients/cli/src/daemon/sessions.ts b/clients/mcpi/src/daemon/sessions.ts similarity index 100% rename from clients/cli/src/daemon/sessions.ts rename to clients/mcpi/src/daemon/sessions.ts diff --git a/clients/cli/src/daemon/stream-client.ts b/clients/mcpi/src/daemon/stream-client.ts similarity index 100% rename from clients/cli/src/daemon/stream-client.ts rename to clients/mcpi/src/daemon/stream-client.ts diff --git a/clients/cli/src/mcp-bin.ts b/clients/mcpi/src/mcp-bin.ts similarity index 100% rename from clients/cli/src/mcp-bin.ts rename to clients/mcpi/src/mcp-bin.ts diff --git a/clients/cli/src/session/authorize.ts b/clients/mcpi/src/session/authorize.ts similarity index 100% rename from clients/cli/src/session/authorize.ts rename to clients/mcpi/src/session/authorize.ts diff --git a/clients/cli/src/session/dispatch.ts b/clients/mcpi/src/session/dispatch.ts similarity index 100% rename from clients/cli/src/session/dispatch.ts rename to clients/mcpi/src/session/dispatch.ts diff --git a/clients/cli/src/session/format-human.ts b/clients/mcpi/src/session/format-human.ts similarity index 100% rename from clients/cli/src/session/format-human.ts rename to clients/mcpi/src/session/format-human.ts diff --git a/clients/cli/src/session/format-session.ts b/clients/mcpi/src/session/format-session.ts similarity index 100% rename from clients/cli/src/session/format-session.ts rename to clients/mcpi/src/session/format-session.ts diff --git a/clients/cli/src/session/mcp.ts b/clients/mcpi/src/session/mcp.ts similarity index 100% rename from clients/cli/src/session/mcp.ts rename to clients/mcpi/src/session/mcp.ts diff --git a/clients/cli/src/session/parse-tool-args.ts b/clients/mcpi/src/session/parse-tool-args.ts similarity index 100% rename from clients/cli/src/session/parse-tool-args.ts rename to clients/mcpi/src/session/parse-tool-args.ts diff --git a/clients/cli/src/session/private-env.ts b/clients/mcpi/src/session/private-env.ts similarity index 100% rename from clients/cli/src/session/private-env.ts rename to clients/mcpi/src/session/private-env.ts diff --git a/clients/cli/src/session/stored-auth.ts b/clients/mcpi/src/session/stored-auth.ts similarity index 100% rename from clients/cli/src/session/stored-auth.ts rename to clients/mcpi/src/session/stored-auth.ts From 6d5087ccfef421b709a6b615e8f9f2f0f2a4961b Mon Sep 17 00:00:00 2001 From: Bob Dickinson Date: Sat, 25 Jul 2026 10:45:07 -0700 Subject: [PATCH 06/11] Refactor to move mcpi into it's own client (still sharing some code with cli). --- AGENTS.md | 23 +- README.md | 7 +- clients/cli/README.md | 52 +- clients/cli/__tests__/README.md | 12 +- .../clear-stored-auth-for-relogin.test.ts | 68 + clients/cli/__tests__/format-output.test.ts | 50 + clients/cli/__tests__/method-types.test.ts | 12 + clients/cli/__tests__/servers-list.test.ts | 20 - clients/cli/__tests__/style.test.ts | 38 +- clients/cli/package-lock.json | 1 - clients/cli/package.json | 3 +- .../cli/src/clear-stored-auth-for-relogin.ts | 30 + clients/cli/src/cli-oauth-navigation.ts | 2 +- clients/cli/src/cli.ts | 2 +- clients/cli/src/style.ts | 2 +- clients/cli/tsup.config.ts | 2 - clients/cli/vitest.config.ts | 11 - clients/launcher/package-lock.json | 30 - clients/mcpi/README.md | 58 + clients/mcpi/__tests__/authorize.test.ts | 2 +- .../mcpi/__tests__/daemon-coverage.test.ts | 9 +- clients/mcpi/__tests__/daemon-paths.test.ts | 5 +- clients/mcpi/__tests__/daemon-private.test.ts | 42 +- .../mcpi/__tests__/daemon-sessions.test.ts | 2 +- clients/mcpi/__tests__/daemon-stream.test.ts | 2 +- clients/mcpi/__tests__/dispatch.test.ts | 3 +- clients/mcpi/__tests__/format-session.test.ts | 40 +- clients/mcpi/__tests__/helpers/mcp-runner.ts | 2 +- .../mcpi/__tests__/mcp-auth-coverage.test.ts | 10 +- clients/mcpi/__tests__/mcp-coverage.test.ts | 7 +- clients/mcpi/__tests__/mcp-session.test.ts | 6 +- .../__tests__/session-stored-auth.test.ts | 7 +- clients/mcpi/eslint.config.js | 17 + clients/mcpi/package-lock.json | 5891 +++++++++++++++++ clients/mcpi/package.json | 53 + clients/mcpi/src/daemon/auth.ts | 2 +- clients/mcpi/src/daemon/client.ts | 2 +- clients/mcpi/src/daemon/ensure.ts | 4 +- clients/mcpi/src/daemon/protocol.ts | 13 +- clients/mcpi/src/daemon/server.ts | 6 +- clients/mcpi/src/daemon/sessions.ts | 2 +- clients/mcpi/src/daemon/stream-client.ts | 2 +- clients/mcpi/src/mcp-bin.ts | 2 +- clients/mcpi/src/session/authorize.ts | 4 +- clients/mcpi/src/session/dispatch.ts | 9 +- clients/mcpi/src/session/format-human.ts | 2 +- clients/mcpi/src/session/format-session.ts | 10 +- clients/mcpi/src/session/mcp.ts | 16 +- clients/mcpi/src/session/stored-auth.ts | 2 +- clients/mcpi/tsconfig.json | 12 + clients/mcpi/tsup.config.ts | 39 + clients/mcpi/vitest.config.ts | 44 + package-lock.json | 3 +- package.json | 12 +- scripts/install-clients.mjs | 2 +- specification/v2_cli_tui_launcher.md | 3 +- specification/v2_cli_v2.md | 13 +- 57 files changed, 6471 insertions(+), 254 deletions(-) create mode 100644 clients/cli/__tests__/clear-stored-auth-for-relogin.test.ts create mode 100644 clients/cli/__tests__/format-output.test.ts create mode 100644 clients/cli/__tests__/method-types.test.ts create mode 100644 clients/cli/src/clear-stored-auth-for-relogin.ts create mode 100644 clients/mcpi/README.md create mode 100644 clients/mcpi/eslint.config.js create mode 100644 clients/mcpi/package-lock.json create mode 100644 clients/mcpi/package.json create mode 100644 clients/mcpi/tsconfig.json create mode 100644 clients/mcpi/tsup.config.ts create mode 100644 clients/mcpi/vitest.config.ts diff --git a/AGENTS.md b/AGENTS.md index 1b1554691..c9398d0a3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,6 @@ # Inspector V2 -This is an application for inspecting MCP servers. Has three incarnations, Web, TUI, and CLI. +This is an application for inspecting MCP servers. Has four client packages: Web, TUI, one-shot CLI, and session CLI (`mcpi`). ## Project Structure @@ -18,11 +18,12 @@ inspector/ │ │ │ # inject-auth-token.ts (embeds the API token into served index.html), │ │ │ # vite-base-config.ts (shared optimizeDeps exclusions) │ │ └── static/ # sandbox_proxy.html (served by sandbox-controller for MCP Apps tab) -│ ├── cli/ # CLI client (tsup bundle, @inspector/core alias) +│ ├── cli/ # One-shot CLI (tsup bundle, @inspector/core alias) │ │ ├── src/cli.ts # One-shot entry (`mcp-inspector --cli`) -│ │ ├── src/session/ # Session front-end (`mcpi` → mcp-bin.ts) -│ │ ├── src/daemon/ # Implicit Unix-socket session daemon -│ │ └── src/handlers/ # Shared runMethod + method types (one-shot + daemon) +│ │ └── src/handlers/ # Shared runMethod + method types (one-shot + mcpi daemon) +│ ├── mcpi/ # Session CLI (`mcpi` bin — connect once, many commands) +│ │ ├── src/session/ # Front-end (`mcp.ts` → mcp-bin.ts) +│ │ └── src/daemon/ # Implicit Unix-socket session daemon │ ├── tui/ # TUI client (Ink + React, tsup bundle) │ ├── launcher/ # Shared launcher (relative imports into sibling build/ outputs) ├── core/ # Shared core code (no package.json — consumed via the `@inspector/core` vite alias) @@ -64,7 +65,7 @@ inspector/ ## Development setup -v2 is **not** an npm workspace — each client under `clients/*` keeps its own `package.json` and `node_modules` (see the rationale in [specification/v2_cli_tui_launcher.md](specification/v2_cli_tui_launcher.md)). A single `npm install` at the repo root is still all you need: the root `postinstall` (`scripts/install-clients.mjs`) cascades `npm install` into `clients/web`, `clients/cli`, `clients/tui`, and `clients/launcher`. +v2 is **not** an npm workspace — each client under `clients/*` keeps its own `package.json` and `node_modules` (see the rationale in [specification/v2_cli_tui_launcher.md](specification/v2_cli_tui_launcher.md)). A single `npm install` at the repo root is still all you need: the root `postinstall` (`scripts/install-clients.mjs`) cascades `npm install` into `clients/web`, `clients/cli`, `clients/mcpi`, `clients/tui`, and `clients/launcher`. - **Fresh clone / first-time setup:** run `npm install` at the repo root. - **After a pull that changes a client's dependencies:** re-run `npm install` at the root to re-sync every client (the `postinstall` cascade handles it). @@ -187,12 +188,14 @@ gh project item-edit --project-id PVT_kwDOCt2Azc4BJVxt --id "$ITEM_ID" --field-i - In unit tests that expect error output, suppress it from the console - Run unit tests with `npm run test` (or `npm run test:watch` during development) from `clients/web/` - Run CLI tests with `npm run test` from `clients/cli/` (builds test-servers + CLI bin first via `pretest`) +- Run session CLI (`mcpi`) tests with `npm run test` from `clients/mcpi/` (builds test-servers + mcpi bins first via `pretest`) - Run TUI tests with `npm run test` from `clients/tui/` - The repo root has no aggregate `test` script — each client self-validates, so run `npm run validate` from the root (all clients, fast) or `cd clients/ && npm run validate` (one client). Each client still exposes its own `test` / `test:coverage` for quick iteration. -- **`validate` is fast: it runs `test`, not `test:coverage`.** The coverage gate (slower — adds v8 instrumentation, and for web the integration project) is a **separate** top-level `npm run coverage` (and per-client `coverage:web` / `coverage:cli` / `coverage:tui` / `coverage:launcher`, each delegating to that client's `test:coverage`). Run `npm run coverage` when you want to reproduce the gate locally before pushing. **CI runs `coverage`** on every push (#1550): the per-file ≥90 gate is CI-enforced, so a PR that drops any file below 90 on lines/statements/functions/branches fails the job. CI runs `validate` (fast) for format/lint/build/unit tests, then `coverage` for the instrumented gate. Because web's `test:coverage` already runs the integration project, CI has no separate `test:integration` step — the integration paths are exercised inside the coverage gate. -- Each client's `test:coverage` enforces a **uniform per-file gate of ≥ 90 on all four dimensions** — lines, statements, functions, and branches — across `clients/web`, `clients/cli`, `clients/tui`, and `clients/launcher` (CI enforces this gate). This is the result of a codebase-wide audit: the branch floor was first lifted 50 → 70 for web (#1271), then the whole gate raised to 90 with real tests added for every outlier. Genuinely-unreachable branches are **not** waved through by lowering the gate — they are annotated at the source with a justified `/* v8 ignore … -- */` comment. Acceptable reasons are happy-dom-inherent paths (Mantine portal mount points, `useMediaQuery` fallbacks, `typeof window` SSR guards), React StrictMode effect-replay blocks, and provably-dead defensive guards (e.g. a `?? fallback` for a value the types guarantee non-null, or a `Select.onChange` receiving a value outside the allowed list). New code must clear 90 on every dimension; reach for a justified `v8 ignore` only when a branch is genuinely impossible to exercise. +- **`validate` is fast: it runs `test`, not `test:coverage`.** The coverage gate (slower — adds v8 instrumentation, and for web the integration project) is a **separate** top-level `npm run coverage` (and per-client `coverage:web` / `coverage:cli` / `coverage:mcpi` / `coverage:tui` / `coverage:launcher`, each delegating to that client's `test:coverage`). Run `npm run coverage` when you want to reproduce the gate locally before pushing. **CI runs `coverage`** on every push (#1550): the per-file ≥90 gate is CI-enforced, so a PR that drops any file below 90 on lines/statements/functions/branches fails the job. CI runs `validate` (fast) for format/lint/build/unit tests, then `coverage` for the instrumented gate. Because web's `test:coverage` already runs the integration project, CI has no separate `test:integration` step — the integration paths are exercised inside the coverage gate. +- Each client's `test:coverage` enforces a **uniform per-file gate of ≥ 90 on all four dimensions** — lines, statements, functions, and branches — across `clients/web`, `clients/cli`, `clients/mcpi`, `clients/tui`, and `clients/launcher` (CI enforces this gate). This is the result of a codebase-wide audit: the branch floor was first lifted 50 → 70 for web (#1271), then the whole gate raised to 90 with real tests added for every outlier. Genuinely-unreachable branches are **not** waved through by lowering the gate — they are annotated at the source with a justified `/* v8 ignore … -- */` comment. Acceptable reasons are happy-dom-inherent paths (Mantine portal mount points, `useMediaQuery` fallbacks, `typeof window` SSR guards), React StrictMode effect-replay blocks, and provably-dead defensive guards (e.g. a `?? fallback` for a value the types guarantee non-null, or a `Select.onChange` receiving a value outside the allowed list). New code must clear 90 on every dimension; reach for a justified `v8 ignore` only when a branch is genuinely impossible to exercise. - The **same per-file gate** is enforced for the CLI and TUI (#1484), not just web: - - **CLI** (`clients/cli`): tests run **in-process** by importing `runCli()` / `runMcp()` (see `__tests__/helpers/cli-runner.ts`, `mcp-runner.ts`) so `clients/cli/src` is measured under v8 instrumentation. A thin out-of-process layer (`__tests__/e2e.test.ts` + `scripts/smoke-cli.mjs`) still spawns the built binary for the shebang/`process.exit` paths. Coverage exclusions (bootstraps + hard-to-stabilize accept/stream races): `src/index.ts`, `src/mcp-bin.ts`, `src/daemon/run.ts`, `src/daemon/ipc-glue.ts`, `src/daemon/stream-client.ts` — see `clients/cli/vitest.config.ts` and [specification/v2_cli_v2.md](specification/v2_cli_v2.md) §8.2 for follow-ups. `commander` uses `.exitOverride()` so a parse error throws instead of tearing down the test worker. + - **CLI** (`clients/cli`): tests run **in-process** by importing `runCli()` (see `__tests__/helpers/cli-runner.ts`) so `clients/cli/src` is measured under v8 instrumentation. A thin out-of-process layer (`__tests__/e2e.test.ts` + `scripts/smoke-cli.mjs`) still spawns the built binary for the shebang/`process.exit` paths. Coverage exclusion: `src/index.ts` — see `clients/cli/vitest.config.ts`. `commander` uses `.exitOverride()` so a parse error throws instead of tearing down the test worker. + - **mcpi** (`clients/mcpi`): session front-end + daemon tests run **in-process** via `__tests__/helpers/mcp-runner.ts` (`runMcp()`). Coverage exclusions (bootstraps + hard-to-stabilize accept/stream races): `src/mcp-bin.ts`, `src/daemon/run.ts`, `src/daemon/ipc-glue.ts`, `src/daemon/stream-client.ts` — see `clients/mcpi/vitest.config.ts` and [specification/v2_cli_v2.md](specification/v2_cli_v2.md) §8.2 for follow-ups. Build-time `@inspector/cli` alias reaches into `clients/cli/src` for shared handlers / error-handler / OAuth helpers (temporary; not a published API). - **TUI** (`clients/tui`): the gate covers the **non-React logic** only — `logger.ts`, `components/tabsConfig.ts`, and `utils/*` (server resolution lives in `core/` and is measured by the web suite). The Ink components, `App.tsx`, and `hooks/` are an **interim exclusion** in `clients/tui/vitest.config.ts` pending the renderer-based follow-up (#1501). When adding new **non-React** logic under `clients/tui/src`, it falls under the gate automatically — add tests for it. - Run `npm run test:integration` (also from `clients/web/`) for the InspectorClient + transport + auth integration suite. It runs under a separate `integration` vitest project in node env (no happy-dom) with 30s timeouts. The script builds `test-servers/` first via `tsc -p ../../test-servers --noCheck` so the stdio MCP test server can be spawned as a real subprocess. CI does not run `test:integration` as its own step — the integration project is covered by the CI `coverage` gate, whose web `test:coverage` runs `--project=unit --project=integration --coverage`. - Test files live alongside the source as `.test.tsx` (or `.test.ts` for non-React modules). Integration tests live under `clients/web/src/test/integration/`, mirroring the `core/` source layout (`mcp/`, `mcp/node/`, `mcp/remote/`, `auth/`, `auth/node/`, `storage/`). Any test file under that folder is automatically picked up by the `integration` vitest project (node env, 30s timeouts) via the folder glob in `vite.config.ts` — placement is the manifest, there is no enumeration to keep in sync. Tests outside the folder run in the `unit` project (happy-dom). When adding a new test for, e.g., `core/mcp/remote/foo.ts`, put it at `src/test/integration/mcp/remote/foo.test.ts`. @@ -208,7 +211,7 @@ gh project item-edit --project-id PVT_kwDOCt2Azc4BJVxt --id "$ITEM_ID" --field-i - ALWAYS do `npm run format` before committing — it auto-fixes any Prettier issues. `validate` runs `format:check` (the non-fixing variant) and will fail in CI on any unformatted file, so always run the auto-fixer first rather than letting `format:check` catch it. - **`npm run ci` is the mandatory pre-push command** — it mirrors `.github/workflows/main.yml` (minus `npm install`): `validate` → `coverage` → `smoke` → Storybook play-function tests (installs Playwright chromium if needed). It now runs **`npm run coverage`**, the per-file ≥90 gate (lines/statements/functions/branches) that CI enforces — so `npm run ci` is a true superset of GitHub CI, and passing it locally means CI's gates will pass. Expect several minutes. **`npm run validate`** remains the fast inner-loop check during development (unit tests only — no coverage gate, no smoke, no Storybook), but it is **NOT** an acceptable substitute for `npm run ci` before pushing: `validate` runs `test`, not `test:coverage`, so it does **zero** coverage gating. Skipping the gate is how a push passes every fast local check and still fails CI (this exact gap broke PR #1601 on a function-coverage regression). - ALWAYS do `npm run format` before committing, then **`npm run ci`** before pushing. From the repo root, `validate` chains the four per-client validations (`validate:web` → `validate:cli` → `validate:tui` → `validate:launcher`); each delegates to that client's own `npm run validate` = `format:check` + `lint` + `build` + `test` in its own folder (no coverage — fast). Every client is self-validating and the top level just chains them, building each client's bundle along the way (no cross-client build dependencies). - - The one CLI nuance: `clients/cli`'s out-of-process `e2e.test.ts` spawns the built binary, so its `test` **builds first** via `pretest` (`test-servers:build && build`). To avoid building it twice, `clients/cli`'s `validate` folds that in — it is `format:check && lint && test` with **no** separate `build` step (the other clients, whose tests don't spawn their bundle, keep an explicit `build`). `validate:web`/`validate:tui`/`validate:launcher` are the uniform `format:check && lint && build && test`. + - The CLI / mcpi nuance: both clients' tests need their built bundles (`e2e` / daemon spawn), so `test` **builds first** via `pretest` (`test-servers:build && build`). To avoid building twice, `clients/cli` and `clients/mcpi` `validate` fold that in — `format:check && lint && test` with **no** separate `build` step (the other clients keep an explicit `build`). `validate:web`/`validate:tui`/`validate:launcher` are the uniform `format:check && lint && build && test`. - **`npm run coverage`** is the per-file ≥90 gate and is now part of `npm run ci` — never treat it as optional before a push. It supersedes the old standalone `test:integration` step: web's `test:coverage` runs the `unit` **and** `integration` projects under v8 instrumentation, so `coverage` both enforces the ≥90 gate and exercises the same web integration paths CI covers. - **`smoke` is NOT part of `validate`** — it is included in `npm run ci`. It runs `smoke:launcher` (`--help` dispatch) plus the prod `smoke:cli` / `smoke:tui` / `smoke:web`, and contains **no build commands** — it assumes the cli/tui/launcher bundles already exist (a full `validate` builds them; `smoke:web` builds `clients/web/dist` on demand). CI runs `validate`, then the `coverage` gate (which also covers the web integration project), then `smoke`. Storybook is the only CI step left out (see below). - `smoke:launcher` (`scripts/smoke-launcher.mjs`) runs the built launcher with `--help`, `--cli --help`, and `--tui --help`, asserting each exits 0 and prints that mode's usage banner (which also proves the launcher resolved and loaded the right client build). It's the cheap dispatch check before the heavier prod smokes below. diff --git a/README.md b/README.md index f22dbaf2a..23aa6d38b 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,8 @@ v2 is **not** an npm workspace. Each client under `clients/*` keeps its own `pac inspector/ ├── clients/ │ ├── web/ # Web client (Vite + React + Mantine). src/ = browser app; server/ = Node dev/prod backend -│ ├── cli/ # CLI client — one-shot (`cli.ts`) + session (`session/`, `daemon/`, `handlers/`) +│ ├── cli/ # One-shot CLI (`cli.ts`, shared `handlers/` used by mcpi at build time) +│ ├── mcpi/ # Session CLI (`mcpi` bin — `session/` + `daemon/`) │ ├── 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) @@ -43,7 +44,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). Task-oriented guides live under [`docs/`](./docs) — see [Reviewing an MCP App](./docs/mcp-app-review.md), the CLI-first → one-shot-web recipe for automated App-tool review: `--app-info` probe → deep-link navigate → rendered widget, plus OAuth handoff and proxy support. @@ -79,7 +80,7 @@ npm run web:dev # web launcher in --dev mode (Vite) ![Shared code architecture: the four clients over the @inspector/core shared package](specification/diagrams/shared-code-architecture.png) -`core/` holds the logic shared by all three clients so that web, CLI, and TUI behave identically. Its entry point is the **`InspectorClient`** class (`core/mcp/`), which owns the connection to an MCP server, the request/response lifecycle, and a set of state stores; `core/react/` exposes React hooks over those stores that both the web and TUI (Ink) React trees consume. OAuth (`core/auth/`) is factored into isomorphic logic plus browser/node/remote backends so the same flows work in the browser, in Node, and against a remote backend. +`core/` holds the logic shared by the clients so that web, CLI, mcpi, and TUI behave identically. Its entry point is the **`InspectorClient`** class (`core/mcp/`), which owns the connection to an MCP server, the request/response lifecycle, and a set of state stores; `core/react/` exposes React hooks over those stores that both the web and TUI (Ink) React trees consume. OAuth (`core/auth/`) is factored into isomorphic logic plus browser/node/remote backends so the same flows work in the browser, in Node, and against a remote backend. `core/` intentionally has **no `package.json`** — it is not published on its own. Each client bundles it in via a `@inspector/core` alias: diff --git a/clients/cli/README.md b/clients/cli/README.md index 62e189bcc..db938c05c 100644 --- a/clients/cli/README.md +++ b/clients/cli/README.md @@ -1,52 +1,8 @@ -# MCP Inspector CLI Client +# MCP Inspector CLI Client (one-shot) -The CLI package provides two entrypoints: +Frozen one-shot CLI: connect → one `--method` → disconnect. Invoked as `mcp-inspector --cli`. -- **One-shot** — `mcp-inspector --cli` (frozen contract): connect → one `--method` → disconnect. -- **Session** — `mcpi` (MCP Inspector session CLI): connect once, then run many commands against a named session. - -## Session CLI (`mcpi`) - -```bash -# From a built tree / published package: -mcpi servers/list --config path/to/mcp.json # marks entries with a live session (@name / MRU) -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"}' # or --tool-arg / --tool-args-json -mcpi @test-stdio resources/list # explicit session via @name -mcpi initialize --session test-stdio -mcpi logging/setLevel info -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 (ssh-agent style) -eval "$(mcpi private)" -mcpi connect test-stdio --config path/to/mcp.json -mcpi tools/list -``` - -Slash methods match one-shot `--method` names (`tools/*`, `resources/*`, `prompts/*`, `logging/*`, `tasks/*`, `roots/*`, `initialize`). Streams (`logging/tail`, `resources/subscribe`) stay attached until Ctrl-C and honour `--format` (human lines or pretty JSON per event). Auth uses the shared `oauth.json` store. On connect, the daemon reuses stored tokens when it can; if auth is still required (or stored credentials cannot complete a silent refresh), `mcpi` runs interactive OAuth then retries (pass `--stored-auth-only` to refuse interactive login — also available on one-shot). Use `connect --relogin` (or one-shot `--relogin`) to ignore stored OAuth for that connect: it clears any `oauth.json` entry for the server URL, then connects; interactive login runs only if the server still requires auth (new tokens are saved). For stdio (or any target with no URL-keyed store entry) the flag is a no-op — there is nothing to clear and OAuth does not apply. Manage the store with `auth/list` and `auth/clear ` / `auth/clear --all` (`--yes` required for `--all` when non-interactive). On a TTY, the CLI prints a clickable authorization URL (OSC 8) and opens the default browser; non-TTY / CI only prints the plain URL. On the session CLI, authentication is handled at **connect** time only; mid-session re-login / step-up during a later command is available on one-shot `--cli`, not on `mcpi`. - -Sessions are kept by a background process under `~/.mcp-inspector/` by default (shared across terminals). Set `MCP_STORAGE_DIR` to isolate parallel runs (daemon + OAuth store). For a daemon that only this shell can use, run `eval "$(mcpi private)"` once — later `mcpi` commands in that shell inherit the private binding. - -**Output (session only — different from one-shot):** - -| Flag | Behaviour | -| ---- | --------- | -| `--format text` (default) | Human-readable views (tool lists, call results, sessions, …). On a TTY, uses ANSI styling (color, bold/dim, OSC 8 links). | -| `--format json` | Pretty-printed JSON of the payload (no `{ "result" }` envelope; never ANSI) | -| `--plain` | Disable ANSI styling in human text (also honours `NO_COLOR`) | - -Put global flags before the subcommand: `mcpi --format json tools/list`, `mcpi --plain tools/list`. Isolate parallel runs with `MCP_STORAGE_DIR` (scopes the daemon socket + OAuth store). One-shot `--cli` keeps its own format contract (`text` = pretty JSON, `json` = `{ result }` envelope). +The **session-oriented** CLI (`mcpi`) lives in [`../mcpi/`](../mcpi/) — connect once, many commands, local daemon. See that package’s README and [`specification/v2_cli_v2.md`](../../specification/v2_cli_v2.md). ## One-shot CLI (`mcp-inspector --cli`) @@ -196,8 +152,6 @@ The CLI runs the same loopback callback server as the TUI (`http://127.0.0.1:627 **Step-up (standard OAuth, one-shot only):** when an RPC needs extra scopes, the CLI prompts on stderr: `Proceed with step-up authorization? [y/N]`. **y** continues; **N** exits with an error. EMA step-up re-mints silently (no prompt). -**Session (`mcpi`):** if connect requires auth, interactive OAuth runs and connect is retried. Mid-session step-up during a later command (for example `tools/call`) is not available on the session CLI — use one-shot `--cli` when you need that, or disconnect and connect again after refreshing tokens. - **Shared OAuth storage:** the CLI **reuses** tokens from `~/.mcp-inspector/storage/oauth.json` when they already exist (same file as other Inspector clients). That is passive file sharing, not launching another app. **Shared with TUI** (config only, not interactive login): diff --git a/clients/cli/__tests__/README.md b/clients/cli/__tests__/README.md index 9c2d9c58e..20fc334d0 100644 --- a/clients/cli/__tests__/README.md +++ b/clients/cli/__tests__/README.md @@ -1,4 +1,4 @@ -# CLI Tests +# CLI Tests (one-shot) ## Running Tests @@ -20,6 +20,8 @@ npm run test:cli-metadata # metadata.test.ts npx vitest run oauth-interactive.test.ts cliOAuth.test.ts # OAuth interactive smoke parity ``` +Session CLI (`mcpi`) tests live in [`../../mcpi/__tests__/`](../../mcpi/__tests__/). + ## How the CLI is exercised Most tests run the CLI **in-process**: `helpers/cli-runner.ts` imports `runCli()` @@ -47,15 +49,14 @@ root provides a further end-to-end check of the binary.) - `cliOAuth.test.ts` - Unit tests for `cliOAuth.ts` (step-up confirm, helper wiring, retry) - `oauth-interactive.test.ts` - **Integration** smoke parity for CLI interactive OAuth: connect-time callback server + step-up **y/N** against composable `TestServerHttp` (auto-completes authorize URL programmatically; not a subprocess binary e2e) - `e2e.test.ts` - Out-of-process spawn of the built binary (exit codes + boot; no OAuth) -- `mcp-session.test.ts` / `mcp-coverage.test.ts` / `mcp-auth-coverage.test.ts` - Session CLI (`runMcp`) against the daemon -- `daemon-*.test.ts` / `daemon-private.test.ts` / `dispatch.test.ts` / `format-session.test.ts` / `parse-tool-args.test.ts` / `style.test.ts` - Session daemon (incl. `mcpi private` IPC token), dispatch, and formatting +- `style.test.ts` - ANSI / OSC 8 helpers shared with mcpi OAuth navigation and human output +- `stored-auth.test.ts` / `programmatic-ergonomics.test.ts` - Stored-auth / handoff / flag conflicts ## Helpers The `helpers/` directory contains shared utilities: - `cli-runner.ts` - Invokes `runCli()` in-process and captures stdout/stderr + exit code -- `mcp-runner.ts` - Invokes `runMcp()` in-process for the session CLI - `assertions.ts` - Custom assertion helpers for CLI output validation - `fixtures.ts` - Test config/catalog file generators and temporary directory management @@ -71,6 +72,5 @@ and the bundled stdio server is launched via `getTestMcpServerCommand()`. - Config files use `crypto.randomUUID()` for uniqueness - HTTP/SSE servers use dynamic port allocation to avoid conflicts - Coverage is enforced per-file at ≥90 on lines, statements, functions, and - branches. Exclusions: `src/index.ts`, `src/mcp-bin.ts`, `src/daemon/run.ts`, - `src/daemon/ipc-glue.ts`, `src/daemon/stream-client.ts` (see `vitest.config.ts`) + branches. Exclusion: `src/index.ts` (see `vitest.config.ts`) - All tests use the built-in MCP test servers — no external/registry dependencies diff --git a/clients/cli/__tests__/clear-stored-auth-for-relogin.test.ts b/clients/cli/__tests__/clear-stored-auth-for-relogin.test.ts new file mode 100644 index 000000000..0e4ecee93 --- /dev/null +++ b/clients/cli/__tests__/clear-stored-auth-for-relogin.test.ts @@ -0,0 +1,68 @@ +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 { + getStateFilePath, + resetNodeOAuthStorageCache, +} from "@inspector/core/auth/node/storage-node.js"; +import { clearStoredAuthForRelogin } from "../src/clear-stored-auth-for-relogin.js"; + +describe("clearStoredAuthForRelogin", () => { + 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; + } + }); + + it("no-ops when serverUrl is missing or blank", async () => { + await clearStoredAuthForRelogin(undefined); + await clearStoredAuthForRelogin(" "); + }); + + it("clears a URL-keyed entry and tolerates non-URL keys", async () => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), "cli-relogin-")); + const file = path.join(dir, "oauth.json"); + fs.writeFileSync( + file, + JSON.stringify({ + servers: { + "https://example.com/mcp": { + tokens: { access_token: "a", token_type: "Bearer" }, + }, + "not a url": { + tokens: { access_token: "b", token_type: "Bearer" }, + }, + }, + idpSessions: {}, + }), + "utf8", + ); + prevPath = process.env.MCP_INSPECTOR_OAUTH_STATE_PATH; + process.env.MCP_INSPECTOR_OAUTH_STATE_PATH = file; + resetNodeOAuthStorageCache(); + expect(getStateFilePath()).toBe(file); + + await clearStoredAuthForRelogin("https://example.com/mcp"); + let blob = JSON.parse(fs.readFileSync(file, "utf8")) as { + servers: Record; + }; + expect(blob.servers["https://example.com/mcp"]).toBeUndefined(); + expect(blob.servers["not a url"]).toBeDefined(); + + // normalizeServerUrl catch path — clears under the raw key + await clearStoredAuthForRelogin("not a url"); + blob = JSON.parse(fs.readFileSync(file, "utf8")) as { + servers: Record; + }; + expect(blob.servers["not a url"]).toBeUndefined(); + }); +}); diff --git a/clients/cli/__tests__/format-output.test.ts b/clients/cli/__tests__/format-output.test.ts new file mode 100644 index 000000000..14d633207 --- /dev/null +++ b/clients/cli/__tests__/format-output.test.ts @@ -0,0 +1,50 @@ +import { describe, it, expect, afterEach } from "vitest"; +import { writeFormattedResult } from "../src/handlers/format-output.js"; + +describe("writeFormattedResult", () => { + let originalWrite: typeof process.stdout.write; + + afterEach(() => { + if (originalWrite) process.stdout.write = originalWrite; + }); + + function captureStdout(): { get: () => string; restore: () => void } { + let out = ""; + originalWrite = 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; + return { + get: () => out, + restore: () => { + process.stdout.write = originalWrite; + }, + }; + } + + it("defaults to pretty text and supports the json envelope", async () => { + const cap = captureStdout(); + try { + await writeFormattedResult({ ok: 1 }); + expect(cap.get()).toContain('"ok": 1'); + expect(cap.get()).not.toContain('"result"'); + + // reset buffer by re-capturing + cap.restore(); + const cap2 = captureStdout(); + try { + await writeFormattedResult({ ok: 2 }, "json"); + expect(JSON.parse(cap2.get())).toEqual({ result: { ok: 2 } }); + } finally { + cap2.restore(); + } + } finally { + cap.restore(); + } + }); +}); diff --git a/clients/cli/__tests__/method-types.test.ts b/clients/cli/__tests__/method-types.test.ts new file mode 100644 index 000000000..2ac68c91c --- /dev/null +++ b/clients/cli/__tests__/method-types.test.ts @@ -0,0 +1,12 @@ +import { describe, it, expect } from "vitest"; +import { SESSION_RPC_METHODS } from "../src/handlers/method-types.js"; + +describe("SESSION_RPC_METHODS", () => { + it("lists the RPC methods the daemon / session CLI share", () => { + expect(SESSION_RPC_METHODS).toContain("tools/list"); + expect(SESSION_RPC_METHODS).toContain("tools/call"); + expect(SESSION_RPC_METHODS).toContain("logging/tail"); + expect(SESSION_RPC_METHODS).toContain("roots/set"); + expect(new Set(SESSION_RPC_METHODS).size).toBe(SESSION_RPC_METHODS.length); + }); +}); diff --git a/clients/cli/__tests__/servers-list.test.ts b/clients/cli/__tests__/servers-list.test.ts index b0c0be431..64ff58367 100644 --- a/clients/cli/__tests__/servers-list.test.ts +++ b/clients/cli/__tests__/servers-list.test.ts @@ -5,7 +5,6 @@ import { deleteConfigFile, } from "./helpers/fixtures.js"; import { expectCliSuccess } from "./helpers/assertions.js"; -import { runMcp } from "./helpers/mcp-runner.js"; import { annotateServerEntriesWithSessions, listServerEntries, @@ -188,25 +187,6 @@ describe("showServerEntry / servers/show", () => { expect(entry.config.env).toEqual({ HELLO: "[redacted]" }); }); - it("works via session mcpi servers/show", async () => { - configPath = createSampleTestConfig(); - const result = await runMcp([ - "servers/show", - "test-stdio", - "--config", - configPath, - "--format", - "json", - ]); - expectCliSuccess(result); - const body = JSON.parse(result.stdout) as { - name: string; - config: { env?: Record }; - }; - expect(body.name).toBe("test-stdio"); - expect(body.config.env?.HELLO).toBe("[redacted]"); - }); - it("works via one-shot --method servers/show --server", async () => { configPath = createSampleTestConfig(); const result = await runCli([ diff --git a/clients/cli/__tests__/style.test.ts b/clients/cli/__tests__/style.test.ts index 9f2c58ce5..f9b8cfeff 100644 --- a/clients/cli/__tests__/style.test.ts +++ b/clients/cli/__tests__/style.test.ts @@ -4,11 +4,7 @@ import { resolveAnsiEnabled, styleFromOpts, PLAIN, -} from "../src/session/style.js"; -import { - formatToolsHuman, - formatStreamEventHuman, -} from "../src/session/format-human.js"; +} from "../src/style.js"; describe("resolveAnsiEnabled", () => { it("is off for --plain, json, NO_COLOR, and non-TTY", () => { @@ -41,36 +37,4 @@ describe("createStyle", () => { expect(s.link("https://example.com", "ex")).toContain("ex"); expect(s.link("", "ex")).toBe("ex"); }); - - 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/cli/package-lock.json b/clients/cli/package-lock.json index e9a5543fd..d773449c3 100644 --- a/clients/cli/package-lock.json +++ b/clients/cli/package-lock.json @@ -22,7 +22,6 @@ "zod": "^4.3.6" }, "bin": { - "mcpi": "build/mcp-bin.js", "mcp-inspector-cli": "build/index.js" }, "devDependencies": { diff --git a/clients/cli/package.json b/clients/cli/package.json index de6170206..42e59d81e 100644 --- a/clients/cli/package.json +++ b/clients/cli/package.json @@ -9,8 +9,7 @@ ".": "./build/index.js" }, "bin": { - "mcp-inspector-cli": "./build/index.js", - "mcpi": "./build/mcp-bin.js" + "mcp-inspector-cli": "./build/index.js" }, "files": [ "build" diff --git a/clients/cli/src/clear-stored-auth-for-relogin.ts b/clients/cli/src/clear-stored-auth-for-relogin.ts new file mode 100644 index 000000000..c177579bf --- /dev/null +++ b/clients/cli/src/clear-stored-auth-for-relogin.ts @@ -0,0 +1,30 @@ +import { + NodeOAuthStorage, + resetNodeOAuthStorageCache, +} from "@inspector/core/auth/node/storage-node.js"; + +/** Same canonicalisation as one-shot `normalizeServerUrl` (avoid cycles). */ +function normalizeServerUrl(serverUrl: string): string { + try { + return new URL(serverUrl).href; + } catch { + return serverUrl; + } +} + +/** + * 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. + * + * Kept in the one-shot CLI package so `--relogin` does not depend on the mcpi + * client (which owns the fuller `auth/list` / `auth/clear` surface). + */ +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/cli/src/cli-oauth-navigation.ts b/clients/cli/src/cli-oauth-navigation.ts index 429127793..03352a697 100644 --- a/clients/cli/src/cli-oauth-navigation.ts +++ b/clients/cli/src/cli-oauth-navigation.ts @@ -1,6 +1,6 @@ import { CallbackNavigation } from "@inspector/core/auth/index.js"; import { openUrl } from "./open-url.js"; -import { createStyle, resolveAnsiEnabled } from "./session/style.js"; +import { createStyle, resolveAnsiEnabled } from "./style.js"; export type CliOAuthNavigationOptions = { /** Override TTY detection (tests). Defaults to stderr.isTTY. */ diff --git a/clients/cli/src/cli.ts b/clients/cli/src/cli.ts index 68109b9d7..4c3aee33a 100644 --- a/clients/cli/src/cli.ts +++ b/clients/cli/src/cli.ts @@ -13,7 +13,7 @@ import { } from "./handlers/connect-timeout.js"; import { listServerEntries, showServerEntry } from "./handlers/servers-list.js"; import { writeFormattedResult } from "./handlers/format-output.js"; -import { clearStoredAuthForRelogin } from "./session/stored-auth.js"; +import { clearStoredAuthForRelogin } from "./clear-stored-auth-for-relogin.js"; import { InspectorClient } from "@inspector/core/mcp/index.js"; import { createTransportNode, diff --git a/clients/cli/src/style.ts b/clients/cli/src/style.ts index 856b3c1b5..315edb381 100644 --- a/clients/cli/src/style.ts +++ b/clients/cli/src/style.ts @@ -1,4 +1,4 @@ -import type { OutputFormat } from "../handlers/format-output.js"; +import type { OutputFormat } from "./handlers/format-output.js"; export type Style = { /** Whether ANSI styling is enabled. */ diff --git a/clients/cli/tsup.config.ts b/clients/cli/tsup.config.ts index de637fe10..5a58c1ab8 100644 --- a/clients/cli/tsup.config.ts +++ b/clients/cli/tsup.config.ts @@ -8,8 +8,6 @@ const repoRoot = path.resolve(dirname, '../..'); export default defineConfig({ entry: { index: 'src/index.ts', - 'mcp-bin': 'src/mcp-bin.ts', - daemon: 'src/daemon/run.ts', }, format: ['esm'], outDir: 'build', diff --git a/clients/cli/vitest.config.ts b/clients/cli/vitest.config.ts index e68cd1600..4480e1bd3 100644 --- a/clients/cli/vitest.config.ts +++ b/clients/cli/vitest.config.ts @@ -31,17 +31,6 @@ export default defineConfig({ // which spawns build/index.js and so can't be measured under in-process // coverage. Mirrors web's `**/index.{ts,tsx}` exclusion. 'src/index.ts', - // Session CLI + daemon process bootstraps (same rationale as index.ts): - // shebang / isMain / process.exit / signal wiring only runs out-of-process. - 'src/mcp-bin.ts', - 'src/daemon/run.ts', - // Unix-socket accept / stream client: behavioral coverage lives in - // __tests__/daemon-stream.test.ts (ok/data/end, errors, timeout, abort, - // stale socket, startStream). Remaining branch matrix is connect/error/ - // half-close races that are not stably ≥90 under v8 — keep out of the - // per-file gate rather than papering over with broad ignores. - 'src/daemon/ipc-glue.ts', - 'src/daemon/stream-client.ts', ], thresholds: { perFile: true, diff --git a/clients/launcher/package-lock.json b/clients/launcher/package-lock.json index 55f0543cf..eba139e8f 100644 --- a/clients/launcher/package-lock.json +++ b/clients/launcher/package-lock.json @@ -440,9 +440,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -460,9 +457,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -480,9 +474,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -500,9 +491,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -520,9 +508,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -540,9 +525,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1825,9 +1807,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -1849,9 +1828,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -1873,9 +1849,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -1897,9 +1870,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ diff --git a/clients/mcpi/README.md b/clients/mcpi/README.md new file mode 100644 index 000000000..b3adccf62 --- /dev/null +++ b/clients/mcpi/README.md @@ -0,0 +1,58 @@ +# MCP Inspector session CLI (`mcpi`) + +Session-oriented CLI: connect once, then run many MCP commands against a named session. Uses an implicit local daemon (ssh-agent style). + +> **Layout note:** Source lives in `clients/mcpi/`. At build time it bundles some modules from `clients/cli/src` (`handlers/`, `error-handler`, OAuth helpers) via the `@inspector/cli` alias. That reach-in is intentional and temporary — not a published library API — until a cleaner shared package exists. + +## Install / run (from this repo) + +```bash +cd clients/mcpi && npm install && npm run build +node build/mcp-bin.js --help +# or, after linking / publishing: +mcpi --help +``` + +## 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. + +## 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__/authorize.test.ts b/clients/mcpi/__tests__/authorize.test.ts index 97272cd1a..a96f5b3a0 100644 --- a/clients/mcpi/__tests__/authorize.test.ts +++ b/clients/mcpi/__tests__/authorize.test.ts @@ -4,7 +4,7 @@ import type { MCPServerConfig } from "@inspector/core/mcp/types.js"; const connectSpy = vi.fn(); const disconnectSpy = vi.fn().mockResolvedValue(undefined); -vi.mock("../src/cliOAuth.js", () => ({ +vi.mock("@inspector/cli/cliOAuth.js", () => ({ connectInspectorWithOAuth: (...args: unknown[]) => connectSpy(...args), })); diff --git a/clients/mcpi/__tests__/daemon-coverage.test.ts b/clients/mcpi/__tests__/daemon-coverage.test.ts index c00db5164..e25168cef 100644 --- a/clients/mcpi/__tests__/daemon-coverage.test.ts +++ b/clients/mcpi/__tests__/daemon-coverage.test.ts @@ -8,13 +8,16 @@ 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 "../src/error-handler.js"; +import { CliExitCodeError } from "@inspector/cli/error-handler.js"; import { runMcp } from "./helpers/mcp-runner.js"; import { createSampleTestConfig, deleteConfigFile, -} from "./helpers/fixtures.js"; -import { expectCliSuccess, expectCliFailure } from "./helpers/assertions.js"; +} from "../../cli/__tests__/helpers/fixtures.js"; +import { + expectCliSuccess, + expectCliFailure, +} from "../../cli/__tests__/helpers/assertions.js"; describe("daemon coverage", () => { let server: DaemonServer | undefined; diff --git a/clients/mcpi/__tests__/daemon-paths.test.ts b/clients/mcpi/__tests__/daemon-paths.test.ts index fc3b934c1..cffcb086d 100644 --- a/clients/mcpi/__tests__/daemon-paths.test.ts +++ b/clients/mcpi/__tests__/daemon-paths.test.ts @@ -10,7 +10,7 @@ import { getDaemonSocketPath, getInspectorHome, } from "../src/daemon/paths.js"; -import { writeFormattedResult } from "../src/handlers/format-output.js"; +import { writeFormattedResult } from "@inspector/cli/handlers/format-output.js"; describe("daemon paths", () => { const backup: Record = {}; @@ -81,8 +81,7 @@ describe("writeFormattedResult", () => { process.stdout.write = ((chunk: unknown, ...rest: unknown[]) => { out += String(chunk); const cb = rest.find((x) => typeof x === "function") as - | (() => void) - | undefined; + (() => void) | undefined; cb?.(); return true; }) as typeof process.stdout.write; diff --git a/clients/mcpi/__tests__/daemon-private.test.ts b/clients/mcpi/__tests__/daemon-private.test.ts index f4e536207..d36f9cc35 100644 --- a/clients/mcpi/__tests__/daemon-private.test.ts +++ b/clients/mcpi/__tests__/daemon-private.test.ts @@ -12,9 +12,16 @@ import { DAEMON_TOKEN_ENV, } from "../src/daemon/paths.js"; import { DaemonServer } from "../src/daemon/server.js"; -import { CliExitCodeError } from "../src/error-handler.js"; +import { CliExitCodeError } from "@inspector/cli/error-handler.js"; import { runMcp } from "./helpers/mcp-runner.js"; -import { expectCliSuccess } from "./helpers/assertions.js"; +import { + expectCliSuccess, + expectCliFailure, +} from "../../cli/__tests__/helpers/assertions.js"; +import { + createSampleTestConfig, + deleteConfigFile, +} from "../../cli/__tests__/helpers/fixtures.js"; import { createPrivateBinding, formatPrivateEnvExports, @@ -133,6 +140,37 @@ describe("private daemon end-to-end", () => { 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; diff --git a/clients/mcpi/__tests__/daemon-sessions.test.ts b/clients/mcpi/__tests__/daemon-sessions.test.ts index 114b5eacc..b91d25b64 100644 --- a/clients/mcpi/__tests__/daemon-sessions.test.ts +++ b/clients/mcpi/__tests__/daemon-sessions.test.ts @@ -11,7 +11,7 @@ import { isSessionAuthRequiredError, SessionRegistry, } from "../src/daemon/sessions.js"; -import { CliExitCodeError } from "../src/error-handler.js"; +import { CliExitCodeError } from "@inspector/cli/error-handler.js"; import { AuthRecoveryRequiredError } from "@inspector/core/auth/challenge.js"; describe("daemon framing", () => { diff --git a/clients/mcpi/__tests__/daemon-stream.test.ts b/clients/mcpi/__tests__/daemon-stream.test.ts index 591e52581..8e2969237 100644 --- a/clients/mcpi/__tests__/daemon-stream.test.ts +++ b/clients/mcpi/__tests__/daemon-stream.test.ts @@ -8,7 +8,7 @@ import { acceptDaemonConnection, removeStaleDaemonSocket, } from "../src/daemon/ipc-glue.js"; -import { CliExitCodeError, EXIT_CODES } from "../src/error-handler.js"; +import { CliExitCodeError, EXIT_CODES } from "@inspector/cli/error-handler.js"; describe("streamDaemon + ipc-glue", () => { let dir: string | undefined; diff --git a/clients/mcpi/__tests__/dispatch.test.ts b/clients/mcpi/__tests__/dispatch.test.ts index 3aa44e2cf..f4798464a 100644 --- a/clients/mcpi/__tests__/dispatch.test.ts +++ b/clients/mcpi/__tests__/dispatch.test.ts @@ -20,8 +20,7 @@ describe("dispatchSessionRpc", () => { 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; + (() => void) | undefined; cb?.(); return true; }) as typeof process.stdout.write; diff --git a/clients/mcpi/__tests__/format-session.test.ts b/clients/mcpi/__tests__/format-session.test.ts index ebd6007fb..500405716 100644 --- a/clients/mcpi/__tests__/format-session.test.ts +++ b/clients/mcpi/__tests__/format-session.test.ts @@ -23,7 +23,8 @@ import { formatRpcResultHuman, } from "../src/session/format-human.js"; import { writeSessionOutput } from "../src/session/format-session.js"; -import { CliExitCodeError, EXIT_CODES } from "../src/error-handler.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", () => { @@ -464,8 +465,7 @@ describe("writeSessionOutput", () => { 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; + (() => void) | undefined; cb?.(); return true; }) as typeof process.stdout.write; @@ -663,3 +663,37 @@ describe("writeSessionOutput", () => { 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 index 9939e0d09..341dd37cd 100644 --- a/clients/mcpi/__tests__/helpers/mcp-runner.ts +++ b/clients/mcpi/__tests__/helpers/mcp-runner.ts @@ -1,5 +1,5 @@ import { runMcp as invokeMcp } from "../../src/session/mcp.js"; -import { formatErrorOutput } from "../../src/error-handler.js"; +import { formatErrorOutput } from "@inspector/cli/error-handler.js"; export interface McpResult { exitCode: number | null; diff --git a/clients/mcpi/__tests__/mcp-auth-coverage.test.ts b/clients/mcpi/__tests__/mcp-auth-coverage.test.ts index c3d7363e4..104e6b59e 100644 --- a/clients/mcpi/__tests__/mcp-auth-coverage.test.ts +++ b/clients/mcpi/__tests__/mcp-auth-coverage.test.ts @@ -2,8 +2,8 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { createSampleTestConfig, deleteConfigFile, -} from "./helpers/fixtures.js"; -import { CliExitCodeError, EXIT_CODES } from "../src/error-handler.js"; +} from "../../cli/__tests__/helpers/fixtures.js"; +import { CliExitCodeError, EXIT_CODES } from "@inspector/cli/error-handler.js"; const callDaemon = vi.fn(); const ensureDaemon = vi.fn(); @@ -32,15 +32,13 @@ describe("mcp.ts auth / daemon error paths", () => { 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; + (() => 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; + (() => void) | undefined; cb?.(); return true; }) as typeof process.stderr.write; diff --git a/clients/mcpi/__tests__/mcp-coverage.test.ts b/clients/mcpi/__tests__/mcp-coverage.test.ts index cc759495e..d6ed517dc 100644 --- a/clients/mcpi/__tests__/mcp-coverage.test.ts +++ b/clients/mcpi/__tests__/mcp-coverage.test.ts @@ -7,8 +7,11 @@ import { runMcp } from "./helpers/mcp-runner.js"; import { createSampleTestConfig, deleteConfigFile, -} from "./helpers/fixtures.js"; -import { expectCliSuccess, expectCliFailure } from "./helpers/assertions.js"; +} 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"; diff --git a/clients/mcpi/__tests__/mcp-session.test.ts b/clients/mcpi/__tests__/mcp-session.test.ts index a292fdc66..8d38e1cf1 100644 --- a/clients/mcpi/__tests__/mcp-session.test.ts +++ b/clients/mcpi/__tests__/mcp-session.test.ts @@ -3,12 +3,12 @@ import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; import { runMcp } from "./helpers/mcp-runner.js"; -import { runCli } from "./helpers/cli-runner.js"; +import { runCli } from "../../cli/__tests__/helpers/cli-runner.js"; import { createSampleTestConfig, deleteConfigFile, -} from "./helpers/fixtures.js"; -import { expectCliSuccess } from "./helpers/assertions.js"; +} 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"; diff --git a/clients/mcpi/__tests__/session-stored-auth.test.ts b/clients/mcpi/__tests__/session-stored-auth.test.ts index 4396687f0..11c918d10 100644 --- a/clients/mcpi/__tests__/session-stored-auth.test.ts +++ b/clients/mcpi/__tests__/session-stored-auth.test.ts @@ -10,9 +10,12 @@ import { listStoredAuth, resolveStoredAuthKey, } from "../src/session/stored-auth.js"; -import { CliExitCodeError } from "../src/error-handler.js"; +import { CliExitCodeError } from "@inspector/cli/error-handler.js"; import { runMcp } from "./helpers/mcp-runner.js"; -import { expectCliSuccess, expectCliFailure } from "./helpers/assertions.js"; +import { + expectCliSuccess, + expectCliFailure, +} from "../../cli/__tests__/helpers/assertions.js"; function writeOAuthFixture(dir: string): string { const file = path.join(dir, "oauth.json"); diff --git a/clients/mcpi/eslint.config.js b/clients/mcpi/eslint.config.js new file mode 100644 index 000000000..1c43ee8fd --- /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 000000000..90c0d8dbc --- /dev/null +++ b/clients/mcpi/package-lock.json @@ -0,0 +1,5891 @@ +{ + "name": "@modelcontextprotocol/mcpi", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@modelcontextprotocol/mcpi", + "license": "MIT", + "dependencies": { + "@modelcontextprotocol/client": "2.0.0-beta.4", + "@modelcontextprotocol/core": "2.0.0-beta.4", + "@modelcontextprotocol/server": "2.0.0-beta.4", + "@modelcontextprotocol/server-legacy": "2.0.0-beta.4", + "@napi-rs/keyring": "^1.3.0", + "ajv": "^8.17.1", + "atomically": "^2.1.1", + "commander": "^13.1.0", + "open": "^10.2.0", + "pino": "^9.14.0", + "undici": "^8.5.0", + "zod": "^4.3.6" + }, + "bin": { + "mcpi": "build/mcp-bin.js" + }, + "devDependencies": { + "@eslint/js": "^9.39.4", + "@types/node": "^24.12.4", + "@vitest/coverage-v8": "^4.1.0", + "eslint": "^9.39.4", + "globals": "^17.4.0", + "prettier": "^3.8.1", + "tsup": "^8.5.0", + "typescript": "~5.9.3", + "typescript-eslint": "^8.56.1", + "vitest": "^4.1.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bcoe/v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@emnapi/core": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.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/@eslint-community/eslint-utils": { + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz", + "integrity": "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.2", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", + "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.5" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.6.tgz", + "integrity": "sha512-l2Ul9PrHsPCKcEY/ac7VgFj9D80C7S68sOKc618SyHDPK36s1XcFebXY0iTzUVn4Yq+YbwvSnDmCz9yxjX+QrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.14.0", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.3.0", + "minimatch": "^3.1.5", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@eslint/eslintrc/node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@eslint/eslintrc/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@eslint/js": { + "version": "9.39.5", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.5.tgz", + "integrity": "sha512-QywQuszQh77pIXCsq998c8hbhSTI/azTty1Z6N53dmAudKHhy573j3yvRLsX2BSp8YpLtoCEG8E9DJe+8zUh4A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "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.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "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-beta.4", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/client/-/client-2.0.0-beta.4.tgz", + "integrity": "sha512-VNHA/UXDk7mCpVl+jOg5B4WMRRD2OEl+it360Lhi6HiCbrIB2f6pZ0cqSDKXQVUtZvqnhdQHzZAM9h8EKWhq/A==", + "license": "MIT", + "dependencies": { + "@modelcontextprotocol/core": "2.0.0-beta.4", + "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-beta.4", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/core/-/core-2.0.0-beta.4.tgz", + "integrity": "sha512-nsMXd4wQBKzmph6r+WOhum+mXjDYljTAqwY/XUg3hLtvNOQ8+JVqBSJOVCMJvx9lXhTpTOPrGZ3BuNiaNPjSvg==", + "license": "MIT", + "dependencies": { + "zod": "^4.2.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@modelcontextprotocol/server": { + "version": "2.0.0-beta.4", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/server/-/server-2.0.0-beta.4.tgz", + "integrity": "sha512-pjMZcNEt1dOq0aJCcY3b7w1Ayh+qmQN2xlfTELcGV9yiAjEGIczBPBLWWW0k0zzo5T8kiv15jtKPsWeHGxLvLg==", + "license": "MIT", + "dependencies": { + "@modelcontextprotocol/core": "2.0.0-beta.4", + "zod": "^4.2.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@modelcontextprotocol/server-legacy": { + "version": "2.0.0-beta.4", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/server-legacy/-/server-legacy-2.0.0-beta.4.tgz", + "integrity": "sha512-GaEa7mfjTqevGuz2taFj2fqb3FthxFvWOmoI/wbLmiK1XRnxdYsAaaJG/lgSlA+W6XWJ1+u+6oZ7610TowkjMA==", + "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-beta.4", + "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/wasm-runtime": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", + "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.3" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.139.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.139.0.tgz", + "integrity": "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "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/@rolldown/binding-android-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz", + "integrity": "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.5.tgz", + "integrity": "sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.5.tgz", + "integrity": "sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.5.tgz", + "integrity": "sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.5.tgz", + "integrity": "sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.5.tgz", + "integrity": "sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.5.tgz", + "integrity": "sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.5.tgz", + "integrity": "sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.5.tgz", + "integrity": "sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.5.tgz", + "integrity": "sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.5.tgz", + "integrity": "sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.5.tgz", + "integrity": "sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.5.tgz", + "integrity": "sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.11.1", + "@emnapi/runtime": "1.11.1", + "@napi-rs/wasm-runtime": "^1.1.6" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.5.tgz", + "integrity": "sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.5.tgz", + "integrity": "sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz", + "integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz", + "integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz", + "integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz", + "integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz", + "integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz", + "integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz", + "integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz", + "integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz", + "integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz", + "integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz", + "integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz", + "integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz", + "integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz", + "integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz", + "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz", + "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz", + "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz", + "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz", + "integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz", + "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz", + "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz", + "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz", + "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz", + "integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz", + "integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "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/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "24.13.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz", + "integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.65.0.tgz", + "integrity": "sha512-IEgob78X12rHpUmtcwFsXhZdVGJtwTVP8FiCLZkR6GlYVrl2PcuB+KhCE5BlVC/eQpQnu8WXRtkHZuPar+gCRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.65.0", + "@typescript-eslint/type-utils": "8.65.0", + "@typescript-eslint/utils": "8.65.0", + "@typescript-eslint/visitor-keys": "8.65.0", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.65.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.6.tgz", + "integrity": "sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.65.0.tgz", + "integrity": "sha512-CZ4nMxWwgu1HEEFNkeaCptra9QCtkmKdgf3sWh1rl1trIhmxLilgTV4cwcbQ4wemnT4sWQN8CaKOmdYx+g2gMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.65.0", + "@typescript-eslint/types": "8.65.0", + "@typescript-eslint/typescript-estree": "8.65.0", + "@typescript-eslint/visitor-keys": "8.65.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.65.0.tgz", + "integrity": "sha512-SxnPhbTsGahizDgbu7oqFH/xVtzIqMd/s+WtnSxNxJZJpLbdT5IPdzg8EZxO3+PoKahXmwJLeNQOpKJb3/bi7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.65.0", + "@typescript-eslint/types": "^8.65.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.65.0.tgz", + "integrity": "sha512-Esbl8OSYiVxBokYgWPf7VVWg/BE798wXhimnn9ML9Pt5qoDf8bfQlgjlKXR/k98+AcNzlLKYrpCcrcuZ9DZLgg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.65.0", + "@typescript-eslint/visitor-keys": "8.65.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.65.0.tgz", + "integrity": "sha512-j6GzGqCiRdA7Qhur2VVmKZAkBLfnHFQfx4TaJGL9RMveZqCo48jSHHO0DTgizEnGhtWnqmbtCUSrqSkdiY/0Hg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.65.0.tgz", + "integrity": "sha512-YjaZ7PRI5qY7ax2L3PbvX0rRyGtipAReCWs0mhhDBHjH/vl0g0BonaGXrKdKpMbIIsMIwDgbk/xzkBTyAltS5g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.65.0", + "@typescript-eslint/typescript-estree": "8.65.0", + "@typescript-eslint/utils": "8.65.0", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.65.0.tgz", + "integrity": "sha512-JSSwWNy+H0E/01jJEM+hrX6N0OFDzFzeIhHFSAS01tlVaevpG8cFyYRPhS5yjGOvBUx3sqQHVMjCL1CAZZMxBg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.65.0.tgz", + "integrity": "sha512-JboAE2swaYt4tb1fHhHTABE2K+OLy09XfcTbhnk4Pw96f9dd2e9iYsJ28gBggHlo5z5x1rkyWvcPoTuNTd4oGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.65.0", + "@typescript-eslint/tsconfig-utils": "8.65.0", + "@typescript-eslint/types": "8.65.0", + "@typescript-eslint/visitor-keys": "8.65.0", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.8.tgz", + "integrity": "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.65.0.tgz", + "integrity": "sha512-gXiwIHsYreboxeJucHKPvgwl7dXt50mF8s1/c00cP/WoVTyWKFdtfhRWwZiXYFU5H2O8vVoSLNrexFZjYS/SGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.65.0", + "@typescript-eslint/types": "8.65.0", + "@typescript-eslint/typescript-estree": "8.65.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.65.0.tgz", + "integrity": "sha512-8C71BQkGjiMmXtop7pHVJu1l2NNShFdkCyD6a2ezzs5vU/L3LRtb69EtcteFwz0mYMPzIgOw0n6OV4VBUWZd7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.65.0", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@vitest/coverage-v8": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.10.tgz", + "integrity": "sha512-IM49HmthevbgAO4anp1hwtoT9wYe59w0LR00gr+eagHE+ZJ5lK4sLPeO0ubgoJcwLk6dehU3R24N+FbEEKDc8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bcoe/v8-coverage": "^1.0.2", + "@vitest/utils": "4.1.10", + "ast-v8-to-istanbul": "^1.0.0", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-reports": "^3.2.0", + "magicast": "^0.5.2", + "obug": "^2.1.1", + "std-env": "^4.0.0-rc.1", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "4.1.10", + "vitest": "4.1.10" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, + "node_modules/@vitest/expect": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", + "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz", + "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.10", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", + "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz", + "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.10", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz", + "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "@vitest/utils": "4.1.10", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz", + "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz", + "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "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.17.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", + "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "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/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "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/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/ast-v8-to-istanbul": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.5.tgz", + "integrity": "sha512-UPAgKJFSEGMWSDr3LX4tqnAb4f7KGT8O40Tyx8wbYmmZ/yn58lNCm8h3svs3eXgiGd5AXxz8NDOvXWvicq+rJA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.31", + "estree-walker": "^3.0.3", + "js-tokens": "^10.0.0" + } + }, + "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/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "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.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", + "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "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/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "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/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "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/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "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/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "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/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/default-browser": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.5.0.tgz", + "integrity": "sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw==", + "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/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=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-module-lexer": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.1.tgz", + "integrity": "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==", + "dev": true, + "license": "MIT" + }, + "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/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.39.5", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.5.tgz", + "integrity": "sha512-DgZS62aPLXKlnxILS/AYCoRvHaZeXceIzlXPkkGGzJWSow1aEk0lbTlxUSlyjC8jcaKxAdOnTDz+o1JFSBsyjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.2", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.6", + "@eslint/js": "9.39.5", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/eslint/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "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.0", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.0.tgz", + "integrity": "sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.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.6.0", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.6.0.tgz", + "integrity": "sha512-XKJXDsASUOo0LLtFwW5hCcQGH0N4WQc/Rn8/Pvoia+TJFOkkFPvrtW9lZOeeNcxQJspvOIERMwiRLsVFlhHEkA==", + "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-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.4.tgz", + "integrity": "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==", + "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/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "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/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "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/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.3.tgz", + "integrity": "sha512-/zipXxyO6rGvuNGDiULY9MvEGSkb2gaG4GGH4ygMi0ZZzyMHdUZBmntJmx5x1G2VuPytCwGN4xsJP6cw+sK+vQ==", + "dev": true, + "license": "ISC" + }, + "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/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "17.7.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.7.0.tgz", + "integrity": "sha512-Czmyns5dUsq4seFBR/Kdydhmo8y9kC79hiSkPn0YcGtNnYWnrgt0vjrSjx9tspoDGWm2CMarffRuLjM4xUz8xg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "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-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "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/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, + "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/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "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.3.1", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.3.1.tgz", + "integrity": "sha512-1e9d3kb97NHJTIJDZW9rKqW2h6+dFa50Dy0fpPSMQp2ADje5gvKsXmdiK6dwY5t76TaTt5+P5N1Y/LoToIxP6g==", + "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-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "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/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/jose": { + "version": "6.2.4", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.4.tgz", + "integrity": "sha512-N8acGzVsQy6M/fjFcxtysNc4Q379TcM5dM/qKkNtsHFji88yANnXTr7BLeP75iPnFwBfQzM/jg2BZ9+HZrHCZA==", + "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/js-tokens": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", + "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", + "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "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/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "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/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "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/magicast": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.3.tgz", + "integrity": "sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.3", + "@babel/types": "^7.29.0", + "source-map-js": "^1.2.1" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "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/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "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/nanoid": { + "version": "3.3.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.6" + } + }, + "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/obug": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz", + "integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "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/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "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-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=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.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "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": { + "version": "8.5.23", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.23.tgz", + "integrity": "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.16", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "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/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prettier": { + "version": "3.9.6", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.6.tgz", + "integrity": "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/process-warning": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz", + "integrity": "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==", + "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/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/qs": { + "version": "6.15.3", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", + "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", + "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": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/rolldown": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz", + "integrity": "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.139.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.1.5", + "@rolldown/binding-darwin-arm64": "1.1.5", + "@rolldown/binding-darwin-x64": "1.1.5", + "@rolldown/binding-freebsd-x64": "1.1.5", + "@rolldown/binding-linux-arm-gnueabihf": "1.1.5", + "@rolldown/binding-linux-arm64-gnu": "1.1.5", + "@rolldown/binding-linux-arm64-musl": "1.1.5", + "@rolldown/binding-linux-ppc64-gnu": "1.1.5", + "@rolldown/binding-linux-s390x-gnu": "1.1.5", + "@rolldown/binding-linux-x64-gnu": "1.1.5", + "@rolldown/binding-linux-x64-musl": "1.1.5", + "@rolldown/binding-openharmony-arm64": "1.1.5", + "@rolldown/binding-wasm32-wasi": "1.1.5", + "@rolldown/binding-win32-arm64-msvc": "1.1.5", + "@rolldown/binding-win32-x64-msvc": "1.1.5" + } + }, + "node_modules/rollup": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", + "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==", + "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": { + "@rollup/rollup-android-arm-eabi": "4.62.2", + "@rollup/rollup-android-arm64": "4.62.2", + "@rollup/rollup-darwin-arm64": "4.62.2", + "@rollup/rollup-darwin-x64": "4.62.2", + "@rollup/rollup-freebsd-arm64": "4.62.2", + "@rollup/rollup-freebsd-x64": "4.62.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", + "@rollup/rollup-linux-arm-musleabihf": "4.62.2", + "@rollup/rollup-linux-arm64-gnu": "4.62.2", + "@rollup/rollup-linux-arm64-musl": "4.62.2", + "@rollup/rollup-linux-loong64-gnu": "4.62.2", + "@rollup/rollup-linux-loong64-musl": "4.62.2", + "@rollup/rollup-linux-ppc64-gnu": "4.62.2", + "@rollup/rollup-linux-ppc64-musl": "4.62.2", + "@rollup/rollup-linux-riscv64-gnu": "4.62.2", + "@rollup/rollup-linux-riscv64-musl": "4.62.2", + "@rollup/rollup-linux-s390x-gnu": "4.62.2", + "@rollup/rollup-linux-x64-gnu": "4.62.2", + "@rollup/rollup-linux-x64-musl": "4.62.2", + "@rollup/rollup-openbsd-x64": "4.62.2", + "@rollup/rollup-openharmony-arm64": "4.62.2", + "@rollup/rollup-win32-arm64-msvc": "4.62.2", + "@rollup/rollup-win32-ia32-msvc": "4.62.2", + "@rollup/rollup-win32-x64-gnu": "4.62.2", + "@rollup/rollup-win32-x64-msvc": "4.62.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/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "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/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "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/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "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/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "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/std-env": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", + "dev": true, + "license": "MIT" + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "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/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "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/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "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/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "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-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "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/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "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/tsup/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/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "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.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.65.0.tgz", + "integrity": "sha512-/ggrHAwyjENDusvyxbuqxAC2dTnZg/Z8F+fgQtYIz+L6n/9HfSlEZcFGV/NsMNa6CkGk0xUjUAFwC0vHOflvIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.65.0", + "@typescript-eslint/parser": "8.65.0", + "@typescript-eslint/typescript-estree": "8.65.0", + "@typescript-eslint/utils": "8.65.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "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/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/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/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "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/vite": { + "version": "8.1.5", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.5.tgz", + "integrity": "sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.17", + "rolldown": "~1.1.5", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.3.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz", + "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.10", + "@vitest/mocker": "4.1.10", + "@vitest/pretty-format": "4.1.10", + "@vitest/runner": "4.1.10", + "@vitest/snapshot": "4.1.10", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.10", + "@vitest/browser-preview": "4.1.10", + "@vitest/browser-webdriverio": "4.1.10", + "@vitest/coverage-istanbul": "4.1.10", + "@vitest/coverage-v8": "4.1.10", + "@vitest/ui": "4.1.10", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/vitest/node_modules/tinyexec": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "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/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "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/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "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 000000000..f614ec196 --- /dev/null +++ b/clients/mcpi/package.json @@ -0,0 +1,53 @@ +{ + "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", + "validate": "npm run format:check && npm run lint && 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__", + "format:check": "prettier --check src __tests__" + }, + "dependencies": { + "@modelcontextprotocol/client": "2.0.0-beta.4", + "@modelcontextprotocol/core": "2.0.0-beta.4", + "@modelcontextprotocol/server": "2.0.0-beta.4", + "@modelcontextprotocol/server-legacy": "2.0.0-beta.4", + "@napi-rs/keyring": "^1.3.0", + "ajv": "^8.17.1", + "atomically": "^2.1.1", + "commander": "^13.1.0", + "open": "^10.2.0", + "pino": "^9.14.0", + "undici": "^8.5.0", + "zod": "^4.3.6" + }, + "devDependencies": { + "@eslint/js": "^9.39.4", + "@types/node": "^24.12.4", + "@vitest/coverage-v8": "^4.1.0", + "eslint": "^9.39.4", + "globals": "^17.4.0", + "prettier": "^3.8.1", + "tsup": "^8.5.0", + "typescript": "~5.9.3", + "typescript-eslint": "^8.56.1", + "vitest": "^4.1.0" + } +} diff --git a/clients/mcpi/src/daemon/auth.ts b/clients/mcpi/src/daemon/auth.ts index daae12be5..68996ef55 100644 --- a/clients/mcpi/src/daemon/auth.ts +++ b/clients/mcpi/src/daemon/auth.ts @@ -1,5 +1,5 @@ import { timingSafeEqual } from "node:crypto"; -import { CliExitCodeError, EXIT_CODES } from "../error-handler.js"; +import { CliExitCodeError, EXIT_CODES } from "@inspector/cli/error-handler.js"; import { DAEMON_TOKEN_ENV } from "./paths.js"; /** diff --git a/clients/mcpi/src/daemon/client.ts b/clients/mcpi/src/daemon/client.ts index e6a37c92d..69d6e57c3 100644 --- a/clients/mcpi/src/daemon/client.ts +++ b/clients/mcpi/src/daemon/client.ts @@ -1,6 +1,6 @@ import { randomUUID } from "node:crypto"; import * as net from "node:net"; -import { CliExitCodeError, EXIT_CODES } from "../error-handler.js"; +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"; diff --git a/clients/mcpi/src/daemon/ensure.ts b/clients/mcpi/src/daemon/ensure.ts index dc2ba68b3..69f6b435e 100644 --- a/clients/mcpi/src/daemon/ensure.ts +++ b/clients/mcpi/src/daemon/ensure.ts @@ -3,7 +3,7 @@ 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 "../error-handler.js"; +import { CliExitCodeError, EXIT_CODES } from "@inspector/cli/error-handler.js"; import { getDaemonTokenFromEnv } from "./auth.js"; import { callDaemon } from "./client.js"; import { @@ -40,7 +40,7 @@ export function resolveDaemonScriptPath(): string { builds, and fs.existsSync cannot be spied in this ESM package under vitest. */ throw new CliExitCodeError( EXIT_CODES.USAGE, - `Session daemon bundle not found (looked for daemon.js near ${here}). Run npm run build in clients/cli.`, + `Session daemon bundle not found (looked for daemon.js near ${here}). Run npm run build in clients/mcpi.`, { code: "daemon_not_built" }, ); } diff --git a/clients/mcpi/src/daemon/protocol.ts b/clients/mcpi/src/daemon/protocol.ts index a26ab2b45..2d1241cd4 100644 --- a/clients/mcpi/src/daemon/protocol.ts +++ b/clients/mcpi/src/daemon/protocol.ts @@ -2,7 +2,10 @@ import type { InspectorServerSettings, MCPServerConfig, } from "@inspector/core/mcp/types.js"; -import type { CliAppInfo, MethodArgs } from "../handlers/method-types.js"; +import type { + CliAppInfo, + MethodArgs, +} from "@inspector/cli/handlers/method-types.js"; /** Operations the session daemon accepts over IPC. */ export type DaemonOp = @@ -50,10 +53,7 @@ export type DaemonRequest = { */ token?: string; params?: - | ConnectParams - | SessionNameParams - | RpcParams - | Record; + ConnectParams | SessionNameParams | RpcParams | Record; }; export type DaemonErrorBody = { @@ -69,8 +69,7 @@ export type DaemonResponse = /** Frames after the initial ok response on a `stream` connection. */ export type DaemonStreamFrame = - | { id: string; stream: "data"; data: unknown } - | { id: string; stream: "end" }; + { id: string; stream: "data"; data: unknown } | { id: string; stream: "end" }; export type SessionInfo = { name: string; diff --git a/clients/mcpi/src/daemon/server.ts b/clients/mcpi/src/daemon/server.ts index 62df0f910..a1a6578b3 100644 --- a/clients/mcpi/src/daemon/server.ts +++ b/clients/mcpi/src/daemon/server.ts @@ -4,9 +4,9 @@ import { classifyError, CliExitCodeError, EXIT_CODES, -} from "../error-handler.js"; -import { runMethod } from "../handlers/run-method.js"; -import type { MethodArgs } from "../handlers/method-types.js"; +} 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, diff --git a/clients/mcpi/src/daemon/sessions.ts b/clients/mcpi/src/daemon/sessions.ts index 7e4647dc3..a45b69cb3 100644 --- a/clients/mcpi/src/daemon/sessions.ts +++ b/clients/mcpi/src/daemon/sessions.ts @@ -27,7 +27,7 @@ import { AuthRecoveryRequiredError, isUnauthorizedError, } from "@inspector/core/auth/index.js"; -import { CliExitCodeError, EXIT_CODES } from "../error-handler.js"; +import { CliExitCodeError, EXIT_CODES } from "@inspector/cli/error-handler.js"; import type { SessionInfo } from "./protocol.js"; const SESSION_CLIENT_NAME = "inspector-cli"; diff --git a/clients/mcpi/src/daemon/stream-client.ts b/clients/mcpi/src/daemon/stream-client.ts index 62effd20a..1b51a41ac 100644 --- a/clients/mcpi/src/daemon/stream-client.ts +++ b/clients/mcpi/src/daemon/stream-client.ts @@ -6,7 +6,7 @@ */ import { randomUUID } from "node:crypto"; import * as net from "node:net"; -import { CliExitCodeError, EXIT_CODES } from "../error-handler.js"; +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"; diff --git a/clients/mcpi/src/mcp-bin.ts b/clients/mcpi/src/mcp-bin.ts index 9f58b9f3d..6d2cd9332 100644 --- a/clients/mcpi/src/mcp-bin.ts +++ b/clients/mcpi/src/mcp-bin.ts @@ -2,7 +2,7 @@ import { resolve } from "path"; import { fileURLToPath } from "url"; -import { handleError } from "./error-handler.js"; +import { handleError } from "@inspector/cli/error-handler.js"; import { runMcp } from "./session/mcp.js"; export { runMcp }; diff --git a/clients/mcpi/src/session/authorize.ts b/clients/mcpi/src/session/authorize.ts index 3bd2eb2e8..774d7a31c 100644 --- a/clients/mcpi/src/session/authorize.ts +++ b/clients/mcpi/src/session/authorize.ts @@ -19,8 +19,8 @@ import { type MCPServerConfig, } from "@inspector/core/mcp/types.js"; import { readInspectorVersion } from "@inspector/core/node/version.js"; -import { createCliOAuthNavigation } from "../cli-oauth-navigation.js"; -import { connectInspectorWithOAuth } from "../cliOAuth.js"; +import { createCliOAuthNavigation } from "@inspector/cli/cli-oauth-navigation.js"; +import { connectInspectorWithOAuth } from "@inspector/cli/cliOAuth.js"; /** * Run interactive (or stored-auth-only) OAuth in the front-end process so tokens diff --git a/clients/mcpi/src/session/dispatch.ts b/clients/mcpi/src/session/dispatch.ts index 2c932d44e..47a048c07 100644 --- a/clients/mcpi/src/session/dispatch.ts +++ b/clients/mcpi/src/session/dispatch.ts @@ -1,9 +1,12 @@ import { callDaemon, ensureDaemon, streamDaemon } from "../daemon/index.js"; import type { RpcParams, RpcResult } from "../daemon/protocol.js"; -import type { CliAppInfo, MethodArgs } from "../handlers/method-types.js"; -import type { OutputFormat } from "../handlers/format-output.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 "./style.js"; +import { styleFromOpts } from "@inspector/cli/style.js"; const STREAM_METHODS = new Set(["logging/tail", "resources/subscribe"]); diff --git a/clients/mcpi/src/session/format-human.ts b/clients/mcpi/src/session/format-human.ts index 40f146a4e..13f8902e9 100644 --- a/clients/mcpi/src/session/format-human.ts +++ b/clients/mcpi/src/session/format-human.ts @@ -3,7 +3,7 @@ * Styling (color / bold / dim / OSC 8 links) is parameterized via {@link Style}. */ -import { PLAIN, type Style } from "./style.js"; +import { PLAIN, type Style } from "@inspector/cli/style.js"; type JsonObject = Record; diff --git a/clients/mcpi/src/session/format-session.ts b/clients/mcpi/src/session/format-session.ts index 3f6fcf3f3..db634e44f 100644 --- a/clients/mcpi/src/session/format-session.ts +++ b/clients/mcpi/src/session/format-session.ts @@ -1,8 +1,8 @@ -import { awaitableLog } from "../utils/awaitable-log.js"; +import { awaitableLog } from "@inspector/cli/utils/awaitable-log.js"; import type { SessionInfo } from "../daemon/protocol.js"; -import { CliExitCodeError, EXIT_CODES } from "../error-handler.js"; -import type { OutputFormat } from "../handlers/format-output.js"; -import type { CliAppInfo } from "../handlers/method-types.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, @@ -14,7 +14,7 @@ import { formatSessionsListHuman, formatStreamEventHuman, } from "./format-human.js"; -import { PLAIN, type Style } from "./style.js"; +import { PLAIN, type Style } from "@inspector/cli/style.js"; type JsonObject = Record; diff --git a/clients/mcpi/src/session/mcp.ts b/clients/mcpi/src/session/mcp.ts index 26b5abd67..3cd3e839b 100644 --- a/clients/mcpi/src/session/mcp.ts +++ b/clients/mcpi/src/session/mcp.ts @@ -8,7 +8,7 @@ import { } from "@inspector/core/mcp/node/index.js"; import { type LoggingLevel } from "@modelcontextprotocol/client"; import { LoggingLevelSchema } from "@modelcontextprotocol/core"; -import { CliExitCodeError, EXIT_CODES } from "../error-handler.js"; +import { CliExitCodeError, EXIT_CODES } from "@inspector/cli/error-handler.js"; import { callDaemon, ensureDaemon } from "../daemon/index.js"; import type { SessionInfo } from "../daemon/protocol.js"; import { @@ -16,14 +16,14 @@ import { listServerEntries, showServerEntry, summarizeServerConfig, -} from "../handlers/servers-list.js"; -import { type OutputFormat } from "../handlers/format-output.js"; +} from "@inspector/cli/handlers/servers-list.js"; +import { type OutputFormat } from "@inspector/cli/handlers/format-output.js"; import { DEFAULT_CONNECT_TIMEOUT_MS, withConnectTimeout, -} from "../handlers/connect-timeout.js"; -import type { MethodArgs } from "../handlers/method-types.js"; -import { SESSION_RPC_METHODS } from "../handlers/method-types.js"; +} from "@inspector/cli/handlers/connect-timeout.js"; +import type { MethodArgs } from "@inspector/cli/handlers/method-types.js"; +import { SESSION_RPC_METHODS } from "@inspector/cli/handlers/method-types.js"; import { authorizeInFrontend } from "./authorize.js"; import { resolveToolCallArgs } from "./parse-tool-args.js"; import { @@ -43,8 +43,8 @@ import { clearStoredAuthForRelogin, listStoredAuth, } from "./stored-auth.js"; -import { styleFromOpts } from "./style.js"; -import { awaitableLog } from "../utils/awaitable-log.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 { diff --git a/clients/mcpi/src/session/stored-auth.ts b/clients/mcpi/src/session/stored-auth.ts index 0ffb75f55..804621275 100644 --- a/clients/mcpi/src/session/stored-auth.ts +++ b/clients/mcpi/src/session/stored-auth.ts @@ -5,7 +5,7 @@ import { NodeOAuthStorage, resetNodeOAuthStorageCache, } from "@inspector/core/auth/node/storage-node.js"; -import { CliExitCodeError, EXIT_CODES } from "../error-handler.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 { diff --git a/clients/mcpi/tsconfig.json b/clients/mcpi/tsconfig.json new file mode 100644 index 000000000..c897207af --- /dev/null +++ b/clients/mcpi/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "noEmit": true, + "paths": { + "@inspector/core/*": ["../../core/*"], + "@inspector/cli/*": ["../cli/src/*"] + } + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "**/*.test.ts", "build"] +} diff --git a/clients/mcpi/tsup.config.ts b/clients/mcpi/tsup.config.ts new file mode 100644 index 000000000..df2a373f2 --- /dev/null +++ b/clients/mcpi/tsup.config.ts @@ -0,0 +1,39 @@ +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", + "commander", + "pino", + "open", + ], + 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 000000000..2663e899f --- /dev/null +++ b/clients/mcpi/vitest.config.ts @@ -0,0 +1,44 @@ +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { defineConfig } from "vitest/config"; +import { 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"], + testTimeout: 15000, + pool: "forks", + coverage: { + provider: "v8", + reporter: ["text", "html", "json-summary"], + include: ["src/**/*.ts"], + exclude: [ + "src/mcp-bin.ts", + "src/daemon/run.ts", + "src/daemon/ipc-glue.ts", + "src/daemon/stream-client.ts", + ], + thresholds: { + perFile: true, + lines: 90, + statements: 90, + functions: 90, + branches: 90, + }, + }, + }, +}); diff --git a/package-lock.json b/package-lock.json index 83c5a9bb0..e316e4f07 100644 --- a/package-lock.json +++ b/package-lock.json @@ -35,7 +35,8 @@ "zod": "^4.3.6" }, "bin": { - "mcp-inspector": "clients/launcher/build/index.js" + "mcp-inspector": "clients/launcher/build/index.js", + "mcpi": "clients/mcpi/build/mcp-bin.js" }, "engines": { "node": ">=22.19.0" diff --git a/package.json b/package.json index d35ce3d14..a8f3dec93 100644 --- a/package.json +++ b/package.json @@ -19,13 +19,14 @@ "type": "module", "bin": { "mcp-inspector": "./clients/launcher/build/index.js", - "mcpi": "./clients/cli/build/mcp-bin.js" + "mcpi": "./clients/mcpi/build/mcp-bin.js" }, "files": [ "clients/launcher/build", "clients/web/build", "clients/web/dist", "clients/cli/build", + "clients/mcpi/build", "clients/tui/build", "scripts/install-clients.mjs" ], @@ -33,20 +34,23 @@ "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:tui": "cd clients/tui && npm run build", "build:web": "cd clients/web && npm run build", "build:launcher": "cd clients/launcher && npm run build", "ci": "npm run validate && npm run coverage && npm run smoke && npm run ci:storybook", "ci:storybook": "cd clients/web && npx playwright install chromium && npm run test:storybook", - "validate": "npm run validate:web && npm run validate:cli && npm run validate:tui && npm run validate:launcher", + "validate": "npm run validate:web && npm run validate:cli && npm run validate:mcpi && npm run validate:tui && npm run validate:launcher", "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 94867b0a4..b7cf34f7b 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/specification/v2_cli_tui_launcher.md b/specification/v2_cli_tui_launcher.md index 4e673b7e1..967682a25 100644 --- a/specification/v2_cli_tui_launcher.md +++ b/specification/v2_cli_tui_launcher.md @@ -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` (root `bin.mcpi`; session CLI) | | 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) | diff --git a/specification/v2_cli_v2.md b/specification/v2_cli_v2.md index 9ec5599ad..a055dfd5a 100644 --- a/specification/v2_cli_v2.md +++ b/specification/v2_cli_v2.md @@ -6,7 +6,7 @@ Documentation of the 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). -**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/cli/README.md`](../clients/cli/README.md) (end-user reference). +**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) (session end-user reference), [`clients/cli/README.md`](../clients/cli/README.md) (one-shot). --- @@ -17,9 +17,9 @@ Documentation of the session-oriented Inspector CLI (`mcpi`) and how it relates | 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` (shared with session) | Same; root `bin.mcpi` → `clients/cli/build/mcp-bin.js` | +| Package | `clients/cli` | `clients/mcpi`; root `bin.mcpi` → `clients/mcpi/build/mcp-bin.js` | -Both use `@inspector/core` `InspectorClient` and shared `handlers/run-method.ts`. One-shot never starts the daemon. `mcpi` does not accept `--method`. +Both use `@inspector/core` `InspectorClient` and shared `clients/cli/src/handlers/run-method.ts` (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 @@ -48,20 +48,21 @@ mcpi tools/list | Piece | Location | | --- | --- | | One-shot | `clients/cli/src/cli.ts`, `cliOAuth.ts`, `index.ts` | -| Session front-end | `clients/cli/src/session/` (`mcp.ts`, `dispatch.ts`, `authorize.ts`, `format-*.ts`, `private-env.ts`, `mcp-bin.ts`) | -| Daemon | `clients/cli/src/daemon/` | +| 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) └──────────┬─────────────┘ ▼ - handlers/run-method.ts → InspectorClient + clients/cli handlers/run-method.ts → InspectorClient ``` ### One-shot (`mcp-inspector --cli`) From 2241d978153a56f14f0c990e24fc31bbeeba48ba Mon Sep 17 00:00:00 2001 From: Bob Dickinson Date: Sat, 25 Jul 2026 11:01:05 -0700 Subject: [PATCH 07/11] Excised mcpi references from top level, including docs --- README.md | 10 ++++------ clients/cli/README.md | 6 ++---- clients/cli/__tests__/README.md | 4 +--- clients/launcher/package-lock.json | 30 ++++++++++++++++++++++++++++ clients/mcpi/README.md | 2 +- package-lock.json | 3 +-- package.json | 4 +--- specification/v2_cli_tui_launcher.md | 2 +- specification/v2_cli_v2.md | 6 +++--- 9 files changed, 44 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index 23aa6d38b..e1ce42541 100644 --- a/README.md +++ b/README.md @@ -6,13 +6,12 @@ A developer tool for inspecting [Model Context Protocol](https://modelcontextpro - **CLI** — a scriptable command-line client for automation, CI, and fast agent feedback loops. - **TUI** — an interactive terminal UI built with [Ink](https://github.com/vadimdemedes/ink). -All three run through one global `mcp-inspector` binary. The package also ships a session-oriented `mcpi` binary (connect once, many commands): +All three run through one global `mcp-inspector` binary: ```bash npx @modelcontextprotocol/inspector # web UI (default) npx @modelcontextprotocol/inspector --cli # one-shot CLI npx @modelcontextprotocol/inspector --tui # TUI -npx --package=@modelcontextprotocol/inspector mcpi servers/list # session CLI ``` > **Repo status.** This is the **v2** line of the Inspector (branch `v2/main`). The `main` branch is the legacy v1 implementation (bug fixes only). v2 will eventually replace `main`. See [`AGENTS.md`](./AGENTS.md) for branch/board conventions. @@ -25,8 +24,7 @@ v2 is **not** an npm workspace. Each client under `clients/*` keeps its own `pac inspector/ ├── clients/ │ ├── web/ # Web client (Vite + React + Mantine). src/ = browser app; server/ = Node dev/prod backend -│ ├── cli/ # One-shot CLI (`cli.ts`, shared `handlers/` used by mcpi at build time) -│ ├── mcpi/ # Session CLI (`mcpi` bin — `session/` + `daemon/`) +│ ├── cli/ # CLI client (tsup bundle) — one-shot `mcp-inspector --cli` │ ├── 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) @@ -44,7 +42,7 @@ inspector/ ``` Each client has its own README with client-specific detail: -[web](./clients/web/README.md) · [cli](./clients/cli/README.md) · [mcpi](./clients/mcpi/README.md) · [tui](./clients/tui/README.md) · [launcher](./clients/launcher/README.md). +[web](./clients/web/README.md) · [cli](./clients/cli/README.md) · [tui](./clients/tui/README.md) · [launcher](./clients/launcher/README.md). Task-oriented guides live under [`docs/`](./docs) — see [Reviewing an MCP App](./docs/mcp-app-review.md), the CLI-first → one-shot-web recipe for automated App-tool review: `--app-info` probe → deep-link navigate → rendered widget, plus OAuth handoff and proxy support. @@ -80,7 +78,7 @@ npm run web:dev # web launcher in --dev mode (Vite) ![Shared code architecture: the four clients over the @inspector/core shared package](specification/diagrams/shared-code-architecture.png) -`core/` holds the logic shared by the clients so that web, CLI, mcpi, and TUI behave identically. Its entry point is the **`InspectorClient`** class (`core/mcp/`), which owns the connection to an MCP server, the request/response lifecycle, and a set of state stores; `core/react/` exposes React hooks over those stores that both the web and TUI (Ink) React trees consume. OAuth (`core/auth/`) is factored into isomorphic logic plus browser/node/remote backends so the same flows work in the browser, in Node, and against a remote backend. +`core/` holds the logic shared by all three clients so that web, CLI, and TUI behave identically. Its entry point is the **`InspectorClient`** class (`core/mcp/`), which owns the connection to an MCP server, the request/response lifecycle, and a set of state stores; `core/react/` exposes React hooks over those stores that both the web and TUI (Ink) React trees consume. OAuth (`core/auth/`) is factored into isomorphic logic plus browser/node/remote backends so the same flows work in the browser, in Node, and against a remote backend. `core/` intentionally has **no `package.json`** — it is not published on its own. Each client bundles it in via a `@inspector/core` alias: diff --git a/clients/cli/README.md b/clients/cli/README.md index db938c05c..ab79bbf9f 100644 --- a/clients/cli/README.md +++ b/clients/cli/README.md @@ -1,8 +1,6 @@ -# MCP Inspector CLI Client (one-shot) +# MCP Inspector CLI Client -Frozen one-shot CLI: connect → one `--method` → disconnect. Invoked as `mcp-inspector --cli`. - -The **session-oriented** CLI (`mcpi`) lives in [`../mcpi/`](../mcpi/) — connect once, many commands, local daemon. See that package’s README and [`specification/v2_cli_v2.md`](../../specification/v2_cli_v2.md). +One-shot CLI: connect → one `--method` → disconnect. Invoked as `mcp-inspector --cli`. ## One-shot CLI (`mcp-inspector --cli`) diff --git a/clients/cli/__tests__/README.md b/clients/cli/__tests__/README.md index 20fc334d0..a51413da3 100644 --- a/clients/cli/__tests__/README.md +++ b/clients/cli/__tests__/README.md @@ -20,8 +20,6 @@ npm run test:cli-metadata # metadata.test.ts npx vitest run oauth-interactive.test.ts cliOAuth.test.ts # OAuth interactive smoke parity ``` -Session CLI (`mcpi`) tests live in [`../../mcpi/__tests__/`](../../mcpi/__tests__/). - ## How the CLI is exercised Most tests run the CLI **in-process**: `helpers/cli-runner.ts` imports `runCli()` @@ -49,7 +47,7 @@ root provides a further end-to-end check of the binary.) - `cliOAuth.test.ts` - Unit tests for `cliOAuth.ts` (step-up confirm, helper wiring, retry) - `oauth-interactive.test.ts` - **Integration** smoke parity for CLI interactive OAuth: connect-time callback server + step-up **y/N** against composable `TestServerHttp` (auto-completes authorize URL programmatically; not a subprocess binary e2e) - `e2e.test.ts` - Out-of-process spawn of the built binary (exit codes + boot; no OAuth) -- `style.test.ts` - ANSI / OSC 8 helpers shared with mcpi OAuth navigation and human output +- `style.test.ts` - ANSI / OSC 8 helpers used by OAuth navigation prompts - `stored-auth.test.ts` / `programmatic-ergonomics.test.ts` - Stored-auth / handoff / flag conflicts ## Helpers diff --git a/clients/launcher/package-lock.json b/clients/launcher/package-lock.json index eba139e8f..55f0543cf 100644 --- a/clients/launcher/package-lock.json +++ b/clients/launcher/package-lock.json @@ -440,6 +440,9 @@ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -457,6 +460,9 @@ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -474,6 +480,9 @@ "ppc64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -491,6 +500,9 @@ "s390x" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -508,6 +520,9 @@ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -525,6 +540,9 @@ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -1807,6 +1825,9 @@ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -1828,6 +1849,9 @@ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -1849,6 +1873,9 @@ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -1870,6 +1897,9 @@ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MPL-2.0", "optional": true, "os": [ diff --git a/clients/mcpi/README.md b/clients/mcpi/README.md index b3adccf62..403a8464c 100644 --- a/clients/mcpi/README.md +++ b/clients/mcpi/README.md @@ -1,6 +1,6 @@ # MCP Inspector session CLI (`mcpi`) -Session-oriented CLI: connect once, then run many MCP commands against a named session. Uses an implicit local daemon (ssh-agent style). +**Experimental** separate client — not part of the published `@modelcontextprotocol/inspector` package. Connect once, then run many MCP commands against a named session via an implicit local daemon (ssh-agent style). > **Layout note:** Source lives in `clients/mcpi/`. At build time it bundles some modules from `clients/cli/src` (`handlers/`, `error-handler`, OAuth helpers) via the `@inspector/cli` alias. That reach-in is intentional and temporary — not a published library API — until a cleaner shared package exists. diff --git a/package-lock.json b/package-lock.json index e316e4f07..83c5a9bb0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -35,8 +35,7 @@ "zod": "^4.3.6" }, "bin": { - "mcp-inspector": "clients/launcher/build/index.js", - "mcpi": "clients/mcpi/build/mcp-bin.js" + "mcp-inspector": "clients/launcher/build/index.js" }, "engines": { "node": ">=22.19.0" diff --git a/package.json b/package.json index a8f3dec93..06696095c 100644 --- a/package.json +++ b/package.json @@ -18,15 +18,13 @@ "author": "The MCP Maintainers and Community", "type": "module", "bin": { - "mcp-inspector": "./clients/launcher/build/index.js", - "mcpi": "./clients/mcpi/build/mcp-bin.js" + "mcp-inspector": "./clients/launcher/build/index.js" }, "files": [ "clients/launcher/build", "clients/web/build", "clients/web/dist", "clients/cli/build", - "clients/mcpi/build", "clients/tui/build", "scripts/install-clients.mjs" ], diff --git a/specification/v2_cli_tui_launcher.md b/specification/v2_cli_tui_launcher.md index 967682a25..54f837443 100644 --- a/specification/v2_cli_tui_launcher.md +++ b/specification/v2_cli_tui_launcher.md @@ -35,7 +35,7 @@ This document describes how those clients are built, wired, and tested today, an | ---------- | ------------------------------- | ------------------------------------------------------ | -------------------------------------------------------- | | Launcher | `clients/launcher/` | `tsc` → `build/index.js` | Root `mcp-inspector` → `clients/launcher/build/index.js` | | CLI | `clients/cli/` | `tsup` → `build/index.js` | `mcp-inspector-cli` (client package only; one-shot) | -| mcpi | `clients/mcpi/` | `tsup` → `build/mcp-bin.js` + `build/daemon.js` | `mcpi` (root `bin.mcpi`; session CLI) | +| 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) | diff --git a/specification/v2_cli_v2.md b/specification/v2_cli_v2.md index a055dfd5a..9fad86c63 100644 --- a/specification/v2_cli_v2.md +++ b/specification/v2_cli_v2.md @@ -4,9 +4,9 @@ #### [CLI, TUI, Launcher](v2_cli_tui_launcher.md) | CLI v2 | [Catalog / launch config](v2_catalog_launch_config.md) -Documentation of the 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). +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) (session end-user reference), [`clients/cli/README.md`](../clients/cli/README.md) (one-shot). +**Related:** [CLI, TUI, and Launcher](v2_cli_tui_launcher.md), [Catalog and Launch Configuration](v2_catalog_launch_config.md), [Storage](v2_storage.md), [Auth](v2_auth.md), [`clients/mcpi/README.md`](../clients/mcpi/README.md), [`clients/cli/README.md`](../clients/cli/README.md) (one-shot). --- @@ -17,7 +17,7 @@ Documentation of the session-oriented Inspector CLI (`mcpi`) and how it relates | 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` | `clients/mcpi`; root `bin.mcpi` → `clients/mcpi/build/mcp-bin.js` | +| 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`. From 0b39ba30bc6e1a691b0f3ea4de1f7f2429d10f69 Mon Sep 17 00:00:00 2001 From: Bob Dickinson Date: Sat, 25 Jul 2026 11:15:36 -0700 Subject: [PATCH 08/11] Final tweaks to npm link mcpi --- clients/mcpi/README.md | 26 +++++++++++++++++++++++--- clients/mcpi/src/mcp-bin.ts | 17 +++++++++++++---- 2 files changed, 36 insertions(+), 7 deletions(-) diff --git a/clients/mcpi/README.md b/clients/mcpi/README.md index 403a8464c..13724be94 100644 --- a/clients/mcpi/README.md +++ b/clients/mcpi/README.md @@ -6,13 +6,33 @@ ## Install / run (from this repo) +Build, then put `mcpi` on your PATH with `npm link` (points at this package’s `build/mcp-bin.js`): + ```bash -cd clients/mcpi && npm install && npm run build -node build/mcp-bin.js --help -# or, after linking / publishing: +# 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. + +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 diff --git a/clients/mcpi/src/mcp-bin.ts b/clients/mcpi/src/mcp-bin.ts index 6d2cd9332..fcda90325 100644 --- a/clients/mcpi/src/mcp-bin.ts +++ b/clients/mcpi/src/mcp-bin.ts @@ -1,5 +1,6 @@ #!/usr/bin/env node +import { realpathSync } from "fs"; import { resolve } from "path"; import { fileURLToPath } from "url"; import { handleError } from "@inspector/cli/error-handler.js"; @@ -8,11 +9,19 @@ import { runMcp } from "./session/mcp.js"; export { runMcp }; const __filename = fileURLToPath(import.meta.url); -const isMain = - process.argv[1] !== undefined && - resolve(process.argv[1]) === resolve(__filename); -if (isMain) { +/** 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); From a7b3cc38a444212b3aa1659a013f10000614a8b2 Mon Sep 17 00:00:00 2001 From: Bob Dickinson Date: Sat, 25 Jul 2026 11:57:44 -0700 Subject: [PATCH 09/11] Addressed review comments --- clients/cli/README.md | 12 +-- clients/cli/__tests__/method-types.test.ts | 17 +++- clients/cli/__tests__/methods.test.ts | 23 +++-- clients/cli/__tests__/servers-list.test.ts | 44 +++++++++- clients/cli/src/cli.ts | 12 ++- clients/cli/src/handlers/method-types.ts | 24 ++++++ clients/cli/src/handlers/servers-list.ts | 28 +++++- .../mcpi/__tests__/daemon-coverage.test.ts | 48 +++++++++++ clients/mcpi/__tests__/dispatch.test.ts | 32 +++++-- clients/mcpi/src/daemon/client.ts | 14 +++ clients/mcpi/src/daemon/sessions.ts | 85 ++++++++++--------- clients/mcpi/src/session/dispatch.ts | 8 +- 12 files changed, 281 insertions(+), 66 deletions(-) diff --git a/clients/cli/README.md b/clients/cli/README.md index ab79bbf9f..c1199b11d 100644 --- a/clients/cli/README.md +++ b/clients/cli/README.md @@ -98,7 +98,7 @@ Options that specify the MCP server (catalog/config file, ad-hoc command/URL, en | Option | Description | | ----------------------------- | ----------------------------------------------------------------------------------------- | -| `--method ` | MCP method to invoke. Supports `initialize` (connect-only probe → `{serverInfo, protocolVersion, capabilities, instructions}`), `tools/list`, `tools/call`, `resources/list`, `resources/read`, `resources/templates/list`, `prompts/list`, `prompts/get`, `logging/setLevel`. | +| `--method ` | MCP method to invoke. Supports `initialize` (connect-only probe → `{serverInfo, protocolVersion, capabilities, instructions}`), `tools/list`, `tools/call`, `resources/list`, `resources/read`, `resources/templates/list`, `prompts/list`, `prompts/get`, `logging/setLevel`, plus catalog-only `servers/list` / `servers/show` (no MCP connect). Stream / session-only methods (e.g. `logging/tail`) are rejected. | | `--tool-name ` | Tool name (for `tools/call`). | | `--tool-arg ` | Tool argument; repeat for multiple. Use `key='{"json":true}'` for JSON. Values are coerced (JSON-parsed, so `count=1` becomes a number). | | `--tool-args-json ` | Tool arguments as a single JSON object (e.g. `'{"zip":"10001"}'`). Passed verbatim — no `key=value` coercion, so `"012"` stays a string. Mutually exclusive with `--tool-arg`. | @@ -111,6 +111,8 @@ Options that specify the MCP server (catalog/config file, ad-hoc command/URL, en | `--connect-timeout ` | Connection timeout in ms. Defaults to `15000` for ad-hoc `--server-url`/target runs (so a black-holed host fails fast) and to the file-level timeout for `--catalog`/`--config` runs. `0` disables the timeout. | | `--app-info` | Probe a tool's MCP App UI metadata without invoking it. With `--method tools/call --tool-name `: prints one JSON line (`hasApp`, `resourceUri`, `csp`, `permissions`, `domain`, …) and exits `0` if the tool has an app or `2` (`no_app`) if not. With `--method tools/list`: emits NDJSON — one app-info line per tool over a single connection. | | `--format ` | Output format. `text` (default) pretty-prints the result. `json` emits a single JSON object on stdout (`{ "result": … }`, plus `{ "appInfo": … }` as a sibling key for App tools) with no banners, so the whole output pipes cleanly into `jq`. | +| `--relogin` | Ignore any stored OAuth for this run’s server URL before connect; interactive login still only runs if the server requires auth. No-op for stdio (no URL-keyed store entry). Conflicts with `--stored-auth-only` / `--use-stored-auth` / `--wait-for-auth`. | +| `--stored-auth-only` | Never start interactive OAuth / step-up; use the shared store if present, otherwise fail with `auth_required`. | #### App probing (`--app-info`) and machine-readable output (`--format json`) @@ -145,7 +147,7 @@ The CLI runs the same loopback callback server as the TUI (`http://127.0.0.1:627 **One-shot (`mcp-inspector --cli`):** on connect **401** or mid-session interactive auth (re-login / step-up), it: 1. Starts the callback listener on `--callback-url` (or `MCP_OAUTH_CALLBACK_URL`) -2. Prints the authorization URL to the console (`ConsoleNavigation`) +2. Prints the authorization URL to stderr (OSC 8 hyperlink when stderr is a TTY) and **opens the default browser** on a TTY; non-TTY / CI prints a plain URL only and never launches a browser 3. Waits for the browser redirect, exchanges the code, and retries connect or the failed RPC **Step-up (standard OAuth, one-shot only):** when an RPC needs extra scopes, the CLI prompts on stderr: `Proceed with step-up authorization? [y/N]`. **y** continues; **N** exits with an error. EMA step-up re-mints silently (no prompt). @@ -269,6 +271,6 @@ separate `build` step (`pretest` builds). Repo-root `validate:cli` delegates here; the coverage gate is `npm run coverage` / `coverage:cli` (also in `npm run ci`), matching AGENTS.md. -Tests run the CLI **in-process** (importing `runCli()` / `runMcp()`) so `src/` -is measured under coverage, with a thin out-of-process spawn layer for the real -binary. See [`__tests__/README.md`](./__tests__/README.md) for details. +Tests run the CLI **in-process** (importing `runCli()`) so `src/` is measured +under coverage, with a thin out-of-process spawn layer for the real binary. See +[`__tests__/README.md`](./__tests__/README.md) for details. diff --git a/clients/cli/__tests__/method-types.test.ts b/clients/cli/__tests__/method-types.test.ts index 2ac68c91c..1d9e87afd 100644 --- a/clients/cli/__tests__/method-types.test.ts +++ b/clients/cli/__tests__/method-types.test.ts @@ -1,5 +1,9 @@ import { describe, it, expect } from "vitest"; -import { SESSION_RPC_METHODS } from "../src/handlers/method-types.js"; +import { + isOneShotMethod, + ONE_SHOT_METHODS, + SESSION_RPC_METHODS, +} from "../src/handlers/method-types.js"; describe("SESSION_RPC_METHODS", () => { it("lists the RPC methods the daemon / session CLI share", () => { @@ -10,3 +14,14 @@ describe("SESSION_RPC_METHODS", () => { expect(new Set(SESSION_RPC_METHODS).size).toBe(SESSION_RPC_METHODS.length); }); }); + +describe("ONE_SHOT_METHODS", () => { + it("excludes stream and session-only methods", () => { + expect(isOneShotMethod("tools/list")).toBe(true); + expect(isOneShotMethod("logging/setLevel")).toBe(true); + expect(isOneShotMethod("logging/tail")).toBe(false); + expect(isOneShotMethod("resources/subscribe")).toBe(false); + expect(isOneShotMethod("tasks/list")).toBe(false); + expect(ONE_SHOT_METHODS).not.toContain("logging/tail"); + }); +}); diff --git a/clients/cli/__tests__/methods.test.ts b/clients/cli/__tests__/methods.test.ts index 0fb720b36..f4d2b8d7c 100644 --- a/clients/cli/__tests__/methods.test.ts +++ b/clients/cli/__tests__/methods.test.ts @@ -59,20 +59,29 @@ describe("CLI method coverage", () => { expect(Array.isArray(json.tools)).toBe(true); }); - it("throws for an unsupported method after connecting", async () => { - // Reaches the final `else` in callMethod: the connection succeeds, none of - // the known method branches match, so it throws "Unsupported method". - const result = await runCli([ + it("rejects unsupported and stream methods before connecting", async () => { + const unknown = await runCli([ command, ...args, "--cli", "--method", "definitely/not-a-real-method", ]); + expectCliFailure(unknown); + expectOutputContains(unknown, "Unsupported method"); + expectOutputContains(unknown, "definitely/not-a-real-method"); - expectCliFailure(result); - expectOutputContains(result, "Unsupported method"); - expectOutputContains(result, "definitely/not-a-real-method"); + // Stream methods used to hang via consumeMethodOutcome; now rejected. + const tail = await runCli([ + command, + ...args, + "--cli", + "--method", + "logging/tail", + ]); + expectCliFailure(tail); + expectOutputContains(tail, "Unsupported method"); + expectOutputContains(tail, "logging/tail"); }); it("rejects an invalid --transport value before connecting", async () => { diff --git a/clients/cli/__tests__/servers-list.test.ts b/clients/cli/__tests__/servers-list.test.ts index 64ff58367..86a3d26d2 100644 --- a/clients/cli/__tests__/servers-list.test.ts +++ b/clients/cli/__tests__/servers-list.test.ts @@ -137,7 +137,7 @@ describe("showServerEntry / servers/show", () => { } }); - it("redacts env values and sensitive settings fields", () => { + it("redacts env values, init headers, and sensitive settings fields", () => { expect( sanitizeServerConfig({ type: "stdio", @@ -150,13 +150,48 @@ describe("showServerEntry / servers/show", () => { env: { SECRET: "[redacted]", HELLO: "[redacted]" }, }); + expect( + sanitizeServerConfig({ + type: "streamable-http", + url: "https://example.com/mcp", + requestInit: { + headers: { + Authorization: "Bearer secret", + "X-Custom": "ok", + }, + }, + eventSourceInit: { + headers: { + "X-Api-Key": "k", + Accept: "text/event-stream", + }, + }, + } as MCPServerConfig), + ).toMatchObject({ + requestInit: { + headers: { + Authorization: "[redacted]", + "X-Custom": "ok", + }, + }, + eventSourceInit: { + headers: { + "X-Api-Key": "[redacted]", + Accept: "text/event-stream", + }, + }, + }); + const settings: InspectorServerSettings = { headers: [ { key: "Authorization", value: "Bearer x" }, { key: "X-Custom", value: "ok" }, ], metadata: [], - env: [{ key: "TOKEN", value: "secret" }], + env: [ + { key: "TOKEN", value: "secret" }, + { key: "", value: "still-secret" }, + ], connectionTimeout: 0, requestTimeout: 0, taskTtl: 60_000, @@ -172,7 +207,10 @@ describe("showServerEntry / servers/show", () => { { key: "Authorization", value: "[redacted]" }, { key: "X-Custom", value: "ok" }, ]); - expect(sanitized.env).toEqual([{ key: "TOKEN", value: "[redacted]" }]); + expect(sanitized.env).toEqual([ + { key: "TOKEN", value: "[redacted]" }, + { key: "", value: "[redacted]" }, + ]); }); it("shows one resolved entry without connecting", async () => { diff --git a/clients/cli/src/cli.ts b/clients/cli/src/cli.ts index 4c3aee33a..6d7551df5 100644 --- a/clients/cli/src/cli.ts +++ b/clients/cli/src/cli.ts @@ -26,7 +26,11 @@ import type { JsonValue } from "@inspector/core/mcp/index.js"; import { getStateFilePath } from "@inspector/core/auth/node/storage-node.js"; import { consumeMethodOutcome } from "./handlers/consume-outcome.js"; import { runMethod } from "./handlers/run-method.js"; -import type { MethodArgs } from "./handlers/method-types.js"; +import { + isOneShotMethod, + ONE_SHOT_METHODS, + type MethodArgs, +} from "./handlers/method-types.js"; export type { CliAppInfo } from "./handlers/method-types.js"; export { emitResult } from "./handlers/emit-result.js"; export { collectAppInfo } from "./handlers/collect-app-info.js"; @@ -877,6 +881,12 @@ async function parseArgs(argv?: string[]): Promise { return { shortCircuit: true }; } + if (!isOneShotMethod(options.method)) { + throw new Error( + `Unsupported method: ${options.method}. One-shot --cli supports: ${ONE_SHOT_METHODS.join(", ")}, servers/list, servers/show.`, + ); + } + // Shared with the TUI: resolves the catalog/config source (or ad-hoc target), // enforces the conflict matrix, and lifts disk headers/timeouts/OAuth into // per-server settings. `--server` selects one when the file has several. diff --git a/clients/cli/src/handlers/method-types.ts b/clients/cli/src/handlers/method-types.ts index 42c7670e3..69ab1b793 100644 --- a/clients/cli/src/handlers/method-types.ts +++ b/clients/cli/src/handlers/method-types.ts @@ -80,3 +80,27 @@ export const SESSION_RPC_METHODS = [ ] as const; export type SessionRpcMethod = (typeof SESSION_RPC_METHODS)[number]; + +/** + * Methods accepted by one-shot `mcp-inspector --cli` (plus catalog-only + * `servers/list` / `servers/show`, handled before connect). Stream methods and + * session-only RPCs (`logging/tail`, `tasks/*`, …) are rejected so one-shot + * never hangs waiting for SIGINT. + */ +export const ONE_SHOT_METHODS = [ + "initialize", + "tools/list", + "tools/call", + "resources/list", + "resources/read", + "resources/templates/list", + "prompts/list", + "prompts/get", + "logging/setLevel", +] as const; + +export type OneShotMethod = (typeof ONE_SHOT_METHODS)[number]; + +export function isOneShotMethod(method: string): method is OneShotMethod { + return (ONE_SHOT_METHODS as readonly string[]).includes(method); +} diff --git a/clients/cli/src/handlers/servers-list.ts b/clients/cli/src/handlers/servers-list.ts index e96998256..129cfe93c 100644 --- a/clients/cli/src/handlers/servers-list.ts +++ b/clients/cli/src/handlers/servers-list.ts @@ -128,6 +128,12 @@ export function sanitizeServerConfig( if ("env" in config && config.env) { out.env = redactStringRecord(config.env); } + if ("requestInit" in config && isPlainObject(config.requestInit)) { + out.requestInit = sanitizeInitRecord(config.requestInit); + } + if ("eventSourceInit" in config && isPlainObject(config.eventSourceInit)) { + out.eventSourceInit = sanitizeInitRecord(config.eventSourceInit); + } return out; } @@ -143,7 +149,7 @@ export function sanitizeServerSettings( })), env: settings.env.map((e) => ({ key: e.key, - value: e.key ? REDACTED : e.value, + value: REDACTED, })), }; if (settings.oauthClientSecret !== undefined) { @@ -158,6 +164,26 @@ function redactStringRecord( return Object.fromEntries(Object.keys(record).map((key) => [key, REDACTED])); } +function isPlainObject(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +/** Redact sensitive header values inside requestInit / eventSourceInit. */ +function sanitizeInitRecord( + init: Record, +): Record { + const out: Record = { ...init }; + if (isPlainObject(init.headers)) { + out.headers = Object.fromEntries( + Object.entries(init.headers).map(([key, value]) => [ + key, + isSensitiveHeader(key) ? REDACTED : value, + ]), + ); + } + return out; +} + function isSensitiveHeader(key: string): boolean { const k = key.toLowerCase(); return ( diff --git a/clients/mcpi/__tests__/daemon-coverage.test.ts b/clients/mcpi/__tests__/daemon-coverage.test.ts index e25168cef..41d5c82a4 100644 --- a/clients/mcpi/__tests__/daemon-coverage.test.ts +++ b/clients/mcpi/__tests__/daemon-coverage.test.ts @@ -512,6 +512,54 @@ describe("daemon coverage", () => { 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 }); diff --git a/clients/mcpi/__tests__/dispatch.test.ts b/clients/mcpi/__tests__/dispatch.test.ts index f4798464a..1221d171b 100644 --- a/clients/mcpi/__tests__/dispatch.test.ts +++ b/clients/mcpi/__tests__/dispatch.test.ts @@ -114,20 +114,38 @@ describe("hoistAtSession / stripAt / requireExplicitSession", () => { else process.env.MCP_ALLOW_DEFAULT_SESSION = prev; }); - it("requireExplicitSession is false on a TTY when allow-default is unset", async () => { + 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 desc = Object.getOwnPropertyDescriptor(process.stdout, "isTTY"); - Object.defineProperty(process.stdout, "isTTY", { - configurable: true, - value: true, - }); + 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 (desc) Object.defineProperty(process.stdout, "isTTY", desc); + 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, diff --git a/clients/mcpi/src/daemon/client.ts b/clients/mcpi/src/daemon/client.ts index 69d6e57c3..2932370fa 100644 --- a/clients/mcpi/src/daemon/client.ts +++ b/clients/mcpi/src/daemon/client.ts @@ -98,6 +98,20 @@ export async function callDaemon( ); }); + // 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( diff --git a/clients/mcpi/src/daemon/sessions.ts b/clients/mcpi/src/daemon/sessions.ts index a45b69cb3..21eebb00e 100644 --- a/clients/mcpi/src/daemon/sessions.ts +++ b/clients/mcpi/src/daemon/sessions.ts @@ -171,52 +171,59 @@ export class SessionRegistry { }): Promise { this.clearIdleTimer(); - if (this.sessions.has(params.name)) { - // Reconnect: tear down the previous client first. - await this.disconnect(params.name, false); - } + 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(); + // 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, - ); + const client = await createSessionClient( + params.serverConfig, + params.serverSettings, + ); - try { - await client.connect(); + 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(); + this.sessions.set(params.name, { + name: params.name, + serverIdentity: params.serverIdentity, + connectedAt: now, + lastAccessedAt: now, + client, + }); + this.mruName = params.name; + + return { + name: params.name, + serverIdentity: params.serverIdentity, + connectedAt: now, + lastAccessedAt: now, + isMru: true, + }; } catch (error) { - await safeDisconnect(client); + // Any failure after clearIdleTimer (createSessionClient, reconnect + // disconnect, client.connect, …) must re-arm so a session-less daemon + // still self-reaps. this.armIdleTimerIfEmpty(); - 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(); - this.sessions.set(params.name, { - name: params.name, - serverIdentity: params.serverIdentity, - connectedAt: now, - lastAccessedAt: now, - client, - }); - this.mruName = params.name; - - return { - name: params.name, - serverIdentity: params.serverIdentity, - connectedAt: now, - lastAccessedAt: now, - isMru: true, - }; } async disconnect( diff --git a/clients/mcpi/src/session/dispatch.ts b/clients/mcpi/src/session/dispatch.ts index 47a048c07..8cf160000 100644 --- a/clients/mcpi/src/session/dispatch.ts +++ b/clients/mcpi/src/session/dispatch.ts @@ -91,10 +91,14 @@ export function stripAt(name: string | undefined): string | undefined { return name.startsWith("@") ? name.slice(1) : name; } -/** Non-TTY (CI/pipelines) must pass an explicit session for MRU-targeting ops. */ +/** + * Non-interactive runs must pass an explicit session for MRU-targeting ops. + * Key off stdin (not stdout) so piping output (`mcpi tools/list | jq`) still + * uses MRU when a human is at the keyboard. + */ export function requireExplicitSession(): boolean { if (process.env.MCP_ALLOW_DEFAULT_SESSION === "1") return false; - return !process.stdout.isTTY; + return process.stdin.isTTY !== true; } /** From ab2220892295c8b2797b2b5a550a76c05f8108b6 Mon Sep 17 00:00:00 2001 From: Bob Dickinson Date: Sat, 25 Jul 2026 13:41:05 -0700 Subject: [PATCH 10/11] Addressed most recent pr comments. --- clients/cli/__tests__/method-types.test.ts | 9 ++++++ clients/cli/__tests__/methods.test.ts | 14 ++++++++ clients/cli/__tests__/servers-list.test.ts | 20 ++++++++++++ clients/cli/src/cli.ts | 34 +++++++++----------- clients/cli/src/handlers/method-types.ts | 10 ++++++ clients/cli/src/handlers/servers-list.ts | 14 ++++++++ clients/mcpi/__tests__/daemon-stream.test.ts | 18 +++++++++++ clients/mcpi/src/daemon/stream-client.ts | 15 ++++++++- clients/mcpi/src/session/mcp.ts | 13 +++----- specification/v2_cli_v2.md | 13 +++++--- 10 files changed, 129 insertions(+), 31 deletions(-) diff --git a/clients/cli/__tests__/method-types.test.ts b/clients/cli/__tests__/method-types.test.ts index 1d9e87afd..927e4c717 100644 --- a/clients/cli/__tests__/method-types.test.ts +++ b/clients/cli/__tests__/method-types.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect } from "vitest"; import { isOneShotMethod, + metaValueToString, ONE_SHOT_METHODS, SESSION_RPC_METHODS, } from "../src/handlers/method-types.js"; @@ -25,3 +26,11 @@ describe("ONE_SHOT_METHODS", () => { expect(ONE_SHOT_METHODS).not.toContain("logging/tail"); }); }); + +describe("metaValueToString", () => { + it("passes strings through and JSON-encodes structured values", () => { + expect(metaValueToString("plain")).toBe("plain"); + expect(metaValueToString({ a: 1 })).toBe('{"a":1}'); + expect(metaValueToString([1, 2])).toBe("[1,2]"); + }); +}); diff --git a/clients/cli/__tests__/methods.test.ts b/clients/cli/__tests__/methods.test.ts index f4d2b8d7c..a41459586 100644 --- a/clients/cli/__tests__/methods.test.ts +++ b/clients/cli/__tests__/methods.test.ts @@ -84,6 +84,20 @@ describe("CLI method coverage", () => { expectOutputContains(tail, "logging/tail"); }); + it("rejects bad methods before --use-stored-auth network work", async () => { + // No oauth.json / refresh — must fail on allowlist, not no_stored_token. + const result = await runCli([ + "--method", + "logging/tail", + "--use-stored-auth", + "--server-url", + "https://example.com/mcp", + ]); + expectCliFailure(result); + expectOutputContains(result, "Unsupported method"); + expect(result.stderr).not.toMatch(/No stored OAuth token/); + }); + it("rejects an invalid --transport value before connecting", async () => { const result = await runCli([ command, diff --git a/clients/cli/__tests__/servers-list.test.ts b/clients/cli/__tests__/servers-list.test.ts index 86a3d26d2..703bd97ba 100644 --- a/clients/cli/__tests__/servers-list.test.ts +++ b/clients/cli/__tests__/servers-list.test.ts @@ -182,6 +182,26 @@ describe("showServerEntry / servers/show", () => { }, }); + expect( + sanitizeServerConfig({ + type: "streamable-http", + url: "https://example.com/mcp", + requestInit: { + headers: [ + ["Authorization", "Bearer secret"], + ["X-Custom", "ok"], + ], + }, + } as MCPServerConfig), + ).toMatchObject({ + requestInit: { + headers: [ + ["Authorization", "[redacted]"], + ["X-Custom", "ok"], + ], + }, + }); + const settings: InspectorServerSettings = { headers: [ { key: "Authorization", value: "Bearer x" }, diff --git a/clients/cli/src/cli.ts b/clients/cli/src/cli.ts index 6d7551df5..1d476feb8 100644 --- a/clients/cli/src/cli.ts +++ b/clients/cli/src/cli.ts @@ -28,6 +28,7 @@ import { consumeMethodOutcome } from "./handlers/consume-outcome.js"; import { runMethod } from "./handlers/run-method.js"; import { isOneShotMethod, + metaValueToString, ONE_SHOT_METHODS, type MethodArgs, } from "./handlers/method-types.js"; @@ -456,12 +457,6 @@ function parseKeyValuePair( return { ...previous, [key as string]: parsedValue }; } -/** Preserve structured metadata (objects/arrays) instead of String → "[object Object]". */ -function metaValueToString(value: JsonValue): string { - if (typeof value === "string") return value; - return JSON.stringify(value); -} - type ParseResult = | { shortCircuit?: undefined; @@ -763,6 +758,21 @@ async function parseArgs(argv?: string[]): Promise { return { shortCircuit: true }; } + // Validate --method before stored-auth network work (refresh / wait) so a + // typo or stream method fails locally without burning a token round-trip. + if (!options.method) { + throw new Error( + "Method is required. Use --method to specify the method to invoke.", + ); + } + const isCatalogMethod = + options.method === "servers/list" || options.method === "servers/show"; + if (!isCatalogMethod && !isOneShotMethod(options.method)) { + throw new Error( + `Unsupported method: ${options.method}. One-shot --cli supports: ${ONE_SHOT_METHODS.join(", ")}, servers/list, servers/show.`, + ); + } + // Honour MCP_CATALOG_PATH only when no ad-hoc target is given. Applying it // unconditionally meant a homespace that exports the env var could never run // `--server-url …` (serverSourceConflict rejects catalog + ad-hoc). @@ -852,12 +862,6 @@ async function parseArgs(argv?: string[]): Promise { }; } - if (!options.method) { - throw new Error( - "Method is required. Use --method to specify the method to invoke.", - ); - } - // Catalog list / show — no MCP connection. if (options.method === "servers/list") { const servers = await listServerEntries(serverOptions); @@ -881,12 +885,6 @@ async function parseArgs(argv?: string[]): Promise { return { shortCircuit: true }; } - if (!isOneShotMethod(options.method)) { - throw new Error( - `Unsupported method: ${options.method}. One-shot --cli supports: ${ONE_SHOT_METHODS.join(", ")}, servers/list, servers/show.`, - ); - } - // Shared with the TUI: resolves the catalog/config source (or ad-hoc target), // enforces the conflict matrix, and lifts disk headers/timeouts/OAuth into // per-server settings. `--server` selects one when the file has several. diff --git a/clients/cli/src/handlers/method-types.ts b/clients/cli/src/handlers/method-types.ts index 69ab1b793..1af2a536a 100644 --- a/clients/cli/src/handlers/method-types.ts +++ b/clients/cli/src/handlers/method-types.ts @@ -104,3 +104,13 @@ export type OneShotMethod = (typeof ONE_SHOT_METHODS)[number]; export function isOneShotMethod(method: string): method is OneShotMethod { return (ONE_SHOT_METHODS as readonly string[]).includes(method); } + +/** + * Encode a metadata / tool-metadata value for MethodArgs (string map). + * Strings pass through; objects/arrays use JSON.stringify so structure is kept + * (avoids String(obj) → "[object Object]"). + */ +export function metaValueToString(value: JsonValue): string { + if (typeof value === "string") return value; + return JSON.stringify(value); +} diff --git a/clients/cli/src/handlers/servers-list.ts b/clients/cli/src/handlers/servers-list.ts index 129cfe93c..80de8e0d8 100644 --- a/clients/cli/src/handlers/servers-list.ts +++ b/clients/cli/src/handlers/servers-list.ts @@ -180,6 +180,20 @@ function sanitizeInitRecord( isSensitiveHeader(key) ? REDACTED : value, ]), ); + } else if (Array.isArray(init.headers)) { + // HeadersInit pair form: [["Authorization", "Bearer …"], …] + out.headers = init.headers.map((entry) => { + if ( + Array.isArray(entry) && + entry.length >= 2 && + typeof entry[0] === "string" + ) { + const key = entry[0]; + const value = entry[1]; + return [key, isSensitiveHeader(key) ? REDACTED : value]; + } + return entry; + }); } return out; } diff --git a/clients/mcpi/__tests__/daemon-stream.test.ts b/clients/mcpi/__tests__/daemon-stream.test.ts index 8e2969237..ddf3e9e75 100644 --- a/clients/mcpi/__tests__/daemon-stream.test.ts +++ b/clients/mcpi/__tests__/daemon-stream.test.ts @@ -188,6 +188,24 @@ describe("streamDaemon + ipc-glue", () => { ).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) => { diff --git a/clients/mcpi/src/daemon/stream-client.ts b/clients/mcpi/src/daemon/stream-client.ts index 1b51a41ac..7a2419cd3 100644 --- a/clients/mcpi/src/daemon/stream-client.ts +++ b/clients/mcpi/src/daemon/stream-client.ts @@ -136,7 +136,20 @@ export async function streamDaemon( }); socket.on("close", () => { - if (!settled) succeed(); + 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(() => { diff --git a/clients/mcpi/src/session/mcp.ts b/clients/mcpi/src/session/mcp.ts index 3cd3e839b..db1fc310b 100644 --- a/clients/mcpi/src/session/mcp.ts +++ b/clients/mcpi/src/session/mcp.ts @@ -22,8 +22,11 @@ import { DEFAULT_CONNECT_TIMEOUT_MS, withConnectTimeout, } from "@inspector/cli/handlers/connect-timeout.js"; -import type { MethodArgs } from "@inspector/cli/handlers/method-types.js"; -import { SESSION_RPC_METHODS } from "@inspector/cli/handlers/method-types.js"; +import { + metaValueToString, + SESSION_RPC_METHODS, + type MethodArgs, +} from "@inspector/cli/handlers/method-types.js"; import { authorizeInFrontend } from "./authorize.js"; import { resolveToolCallArgs } from "./parse-tool-args.js"; import { @@ -805,12 +808,6 @@ function stringifyMeta( ); } -/** Preserve structured metadata (objects/arrays) instead of String → "[object Object]". */ -function metaValueToString(value: JsonValue): string { - if (typeof value === "string") return value; - return JSON.stringify(value); -} - function looksLikeUrl(value: string): boolean { return /^https?:\/\//i.test(value); } diff --git a/specification/v2_cli_v2.md b/specification/v2_cli_v2.md index 9fad86c63..68bac9211 100644 --- a/specification/v2_cli_v2.md +++ b/specification/v2_cli_v2.md @@ -71,9 +71,11 @@ Frozen automation contract. Each invocation: resolve server → connect → `run | `--method` | Notes | | --- | --- | -| `initialize`, `tools/list`, `tools/call`, `resources/*`, `prompts/list`, `prompts/get`, `logging/setLevel` | Core one-shot surface | +| `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). @@ -150,9 +152,12 @@ Frozen automation contract. Each invocation: resolve server → connect → `run ### Testing -- In-process `runCli()` / `runMcp()`; daemon IPC + stream + private-token tests; thin binary e2e. -- Per-file ≥90 coverage on measured `clients/cli/src`. -- Exclusions: `index.ts`, `mcp-bin.ts`, `daemon/run.ts`, `ipc-glue.ts`, `stream-client.ts`. `session/mcp.ts` is gated. +| 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`. --- From 0ff8f71c6012d8a6b31e74dcc196348ddec7b475 Mon Sep 17 00:00:00 2001 From: Bob Dickinson Date: Sat, 25 Jul 2026 13:45:26 -0700 Subject: [PATCH 11/11] Fix servers/list type narrowing for CLI typecheck Narrow on sse/streamable-http first so optional stdio `type` does not block access to `url` under the new cli typecheck gate from v2/main. Co-authored-by: Cursor --- clients/cli/src/handlers/servers-list.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/clients/cli/src/handlers/servers-list.ts b/clients/cli/src/handlers/servers-list.ts index cba1e895f..e7bcc5691 100644 --- a/clients/cli/src/handlers/servers-list.ts +++ b/clients/cli/src/handlers/servers-list.ts @@ -72,7 +72,7 @@ export function summarizeServerConfig(config: MCPServerConfig): { // an else-after-`=== "stdio"` check would still leave `StdioServerConfig` in // the residual union (see `StdioServerConfig` in core/mcp/types.ts). if (config.type === "sse" || config.type === "streamable-http") { - return { type: config.type, detail: config.url }; + return { type: config.type, detail: config.url ?? "" }; } const args = config.args?.length ? ` ${config.args.join(" ")}` : ""; return { type: "stdio", detail: `${config.command}${args}` };