From 2dcfaf09a06fddbfc84b0b1b5b1e007e2a4f05ef Mon Sep 17 00:00:00 2001 From: Bob Dickinson Date: Sun, 19 Jul 2026 10:09:06 -0700 Subject: [PATCH 1/4] 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 2/4] 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 3/4] 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 4/4] =?UTF-8?q?fix(cli):=20address=20review=20=E2=80=94=20?= =?UTF-8?q?idle=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. |