diff --git a/.claude/skills/local-dev/SKILL.md b/.claude/skills/local-dev/SKILL.md
index 5236cb2a05..c2ce589317 100644
--- a/.claude/skills/local-dev/SKILL.md
+++ b/.claude/skills/local-dev/SKILL.md
@@ -21,8 +21,8 @@ npm install # at the REPO ROOT
v2 is **not** an npm workspace — each client under `clients/*` keeps its own
`package.json` and `node_modules`. A single root `npm install` is still all you
need: the root `postinstall` (`scripts/install-clients.mjs`) cascades
-`npm install` into `clients/web`, `clients/cli`, `clients/tui`, and
-`clients/launcher`.
+`npm install` into `clients/web`, `clients/cli`, `clients/daemon-cli`,
+`clients/tui`, and `clients/launcher`.
- **Fresh clone:** `npm install` at the root.
- **After a pull that changes a client's dependencies:** re-run `npm install` at
@@ -52,21 +52,24 @@ The launcher-driven scripts run the **built** launcher, so `npm run build`
first:
```sh
-npm run build # web → cli → tui → launcher
+npm run build # web → cli → daemon-cli → tui → launcher
npm run web # prod web launcher against clients/web/dist
npm run web:dev # web launcher in --dev mode (Vite)
```
-Individual builds: `build:web`, `build:cli`, `build:tui`, `build:launcher`. The
+Individual builds: `build:web`, `build:cli`, `build:daemon-cli`, `build:tui`,
+`build:launcher`. The
web build produces both the browser SPA (`clients/web/dist`, Vite) and the Node
prod-server runner (`clients/web/build`, tsup).
To run the CLI or TUI: `node clients/launcher/build/index.js --cli …` /
-`--tui …`.
+`--tui …`. The connection CLI (`mcpdo`) has its own bin:
+`node clients/daemon-cli/build/mcp-bin.js …` (or `npm link` from
+`clients/daemon-cli` for a global `mcpdo`).
## The `@inspector/core` alias
-`core/` holds the logic shared by all three clients and intentionally has **no
+`core/` holds the logic shared by all five clients and intentionally has **no
`package.json`** — it is not published on its own. Each client bundles it via a
build-time alias:
@@ -82,7 +85,7 @@ build-time alias:
## Where a dependency goes
**The rules are in [`AGENTS.md`](../../../AGENTS.md) → Dependency placement, and
-they are not restated here.** Read them there and come back for the *why* — what
+they are not restated here.** Read them there and come back for the _why_ — what
each rule is defending against, what it looked like when it was violated, and how
to tell you have hit one.
@@ -107,8 +110,8 @@ Keep two distinctions straight, because AGENTS.md's rules split on them:
- **Root-declared is not the same as `core/`-imported.** `commander`, `open` and
`@hono/node-server` are root `dependencies` too, but they are reached only
- from client code. Only the `core/` set has to appear in *all three* bundler
- `external` lists.
+ from client code. Only the `core/` set has to appear in _every_ client's
+ bundler `external` list.
- **Root-declared is not the same as aliased.** The `vitest.shared.mts` pins and
the `clients/web/tsconfig.*.json` `paths` cover the packages whose resolution
is genuinely ambiguous, which is two different situations: the importer is
@@ -163,25 +166,25 @@ do not need to be: `npm run` prepends **every ancestor** `node_modules/.bin` to
all still resolves the root's copy. `clients/launcher` declares no
`devDependencies` whatsoever and its `validate` is unchanged.
-What a per-client declaration *does* buy is a second copy free to drift, and it
+What a per-client declaration _does_ buy is a second copy free to drift, and it
had (#2196): `globals` sat at `^17.7.0` at the root against `^17.4.0` in all four
clients, and `typescript-eslint` at `^8.65.0` against `^8.56.1`. Nothing failed —
which is the point. A lint or format tool that differs per client makes the gate's
-verdict a function of *where you ran it*, and the exact `prettier` pin (#1790)
+verdict a function of _where you ran it_, and the exact `prettier` pin (#1790)
only means something when there is one of it.
⚠️ The line is **used by every client**, not "used by one" and not "is it
toolchain". `tsx`, `playwright`, `storybook`, `happy-dom`, `ink-testing-library`,
`vite-node` and each client's own `@types/*` are toolchain too and stay where
-they are — hoisting them would make every client install the union of all four.
+they are — hoisting them would make every client install the union of all five.
So do the ones **more than one** client declares without all of them doing so:
`tsup` sits in web, cli and tui, and `vite` in web and tui on top of the root
-*runtime* `dependency` that `--web --dev` needs. Neither is in scope here;
+_runtime_ `dependency` that `--web --dev` needs. Neither is in scope here;
whether to consolidate them is a separate call with a separate rationale (`vite`
especially, since its root declaration is a `dependency`, not a
`devDependency`).
-#### What the walk-up does *not* buy you
+#### What the walk-up does _not_ buy you
⚠️ **Deleting a client's declaration does not always delete the copy** — and
where a copy survives, it is the one that wins. Two mechanisms put one back,
@@ -195,7 +198,7 @@ neither of which the manifest mentions:
- **A hoisted transitive.** `@types/express` brings `@types/node` into web and
cli's trees on its own.
-Those copies sit *nearer* than the root's, so `clients/web/node_modules/.bin`
+Those copies sit _nearer_ than the root's, so `clients/web/node_modules/.bin`
precedes the root bin directory on `PATH` and TypeScript resolves the nearest
`node_modules/@types`. Verify with `npm exec -- which eslint` from the client
rather than assuming — the assumption is what made the first cut of #2196 claim
@@ -217,10 +220,10 @@ on disk. The two mechanisms are **not** equally safe, and neither is a guarantee
#2226.
✅ **`verify:dep-lockstep` gates both of those since #2226.** Its second tier
-compares every package **any** install *declares* — `dependencies`,
+compares every package **any** install _declares_ — `dependencies`,
`devDependencies` and `optionalDependencies`, unioned across the root and all
-four clients — against every **top-level** copy in every install, independent of
-what a `tsc` program resolves. So a tool *binary* that no program loads
+five clients — against every **top-level** copy in every install, independent of
+what a `tsc` program resolves. So a tool _binary_ that no program loads
(`eslint`, `typescript`, `vitest`) and a transitive copy that no single program
meets (the cli `@types/node` above) are both in scope now, as is a skew between
two **clients** with no root copy involved (`@types/react`, web against tui).
@@ -270,7 +273,7 @@ Two live examples worth knowing:
tsup and Vite externalise what the **client's** `package.json` declares, and a
root-only dependency is in none of them — so it is **bundled**, silently. For a
CJS package inlined into an ESM bundle that is fatal: esbuild leaves a
-`Dynamic require of "path" is not supported` shim that throws at *import* time,
+`Dynamic require of "path" is not supported` shim that throws at _import_ time,
so the binary dies before it parses a flag (`proper-lockfile`, #2082).
`undici` (#2067) is the worse variant, because it is `import()`ed lazily: the
@@ -293,7 +296,7 @@ file.
### Why React-rendering packages are the exception
An externalised package resolves its own `react` from wherever npm placed
-**it** — beside a React satisfying *that package's* peer range, which is looser
+**it** — beside a React satisfying _that package's_ peer range, which is looser
than ours in every case here. `ink-form` and `ink-scroll-view` declare `">=18"`,
so a consumer's React 18 satisfies them and hoists them while our React 19 nests
underneath: the bundle renders through one React, those packages call hooks on
@@ -303,8 +306,8 @@ another, and the TUI crashes on the first hook (#1952).
a `createRequire` banner). ⚠️ **Never justify that exemption by a peer range** —
it briefly read "its `">=19"` peer keeps npm honest", which is false: a consumer
pinning React 19.0 satisfies `">=19"` while a narrower range of ours nests
-underneath. What makes it safe is the *root `react` range staying open to the
-whole major*, so npm can dedupe. `clients/tui/__tests__/tsupConfig.test.ts`
+underneath. What makes it safe is the _root `react` range staying open to the
+whole major_, so npm can dedupe. `clients/tui/__tests__/tsupConfig.test.ts`
enforces the whole split, the exemption included.
### Why a version skew is worth aligning rather than working around
@@ -332,7 +335,7 @@ an `overrides` entry in that install (see the next section).
### Why `overrides` beats `npm audit fix`
`tsup@8.5.1` declares `esbuild: ^0.27.0`, and the advisory covers
-`0.27.3 - 0.28.0` with `0.27.7` the last 0.27.x — so there is no *upward* escape
+`0.27.3 - 0.28.0` with `0.27.7` the last 0.27.x — so there is no _upward_ escape
inside that range, and `npm audit fix` "resolves" it by silently **downgrading**
to `0.27.2` across three installs (~700 lines of lockfile churn for a low-severity
dev-only advisory; tried and reverted in #2058). The override forces one deduped
diff --git a/.claude/skills/project-structure/SKILL.md b/.claude/skills/project-structure/SKILL.md
index c7e0e54bce..1bc21e10ff 100644
--- a/.claude/skills/project-structure/SKILL.md
+++ b/.claude/skills/project-structure/SKILL.md
@@ -20,6 +20,7 @@ inspector/
│ │ ├── server/ Node-only dev/prod backend wiring (see below)
│ │ └── static/ sandbox_proxy.html — served for the MCP Apps tab
│ ├── cli/ Scriptable CLI (tsup bundle, @inspector/core alias)
+│ ├── daemon-cli/ The `mcpdo` connection CLI bin; daemon + client over local IPC (Unix socket / Windows named pipe; tsup bundle, @inspector/core alias)
│ ├── tui/ Ink + React terminal UI (tsup bundle)
│ └── launcher/ The `mcp-inspector` bin; dispatches to web/cli/tui in-process
├── core/ Shared code, consumed via the `@inspector/core` alias (no package.json)
@@ -35,20 +36,20 @@ inspector/
Its entry point is the **`InspectorClient`** class, which owns the connection to
an MCP server, the request/response lifecycle, and a set of state stores.
-| Directory | Owns |
-| --- | --- |
-| `core/mcp/` | `InspectorClient`, transports, state stores, config import, URI templates, task/subscription/App-elicitation protocol helpers |
-| `core/mcp/node/` | Node stdio transport factory; `proxyFetch.ts` (the shared HTTPS_PROXY/NO_PROXY fetch) |
-| `core/mcp/remote/` | Browser HTTP/SSE transport + remote logger/fetch, and (under `node/`) the Hono backend it talks to |
-| `core/mcp/state/` | The stores `core/react/` hooks read |
-| `core/auth/` | OAuth end to end — providers, discovery, storage, endpoint overrides, scopes, revocation, mid-session recovery — split into isomorphic logic plus `browser/`, `node/` and `remote/` backends |
-| `core/auth/node/` | Node OAuth storage + loopback callback server, **and** the `SecretStore` backends (keychain / file / memory) and their selection policy |
-| `core/client/` | Install-level client config (`client.json`): browser-safe parse plus Node load/save, remote backend, secrets, runner |
-| `core/json/` | JSON + parameter/argument conversion; the schema normalizations all three form builders share (nullable unions, root composition) and the tool-schema portability lint |
-| `core/react/` | React hooks over the state stores — consumed by both the web and TUI React trees. Every subscription reads its snapshot **during render** via `useSyncExternalStore` (#1955); `useStoreSnapshot.ts` caches the fresh-value-per-read getters |
-| `core/node/` | Node-only helpers: version reader, host normalization/detection |
-| `core/storage/` | File I/O helpers used by the OAuth persist backends |
-| `core/logging/` | Silent pino logger singleton |
+| Directory | Owns |
+| ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `core/mcp/` | `InspectorClient`, transports, state stores, config import, URI templates, task/subscription/App-elicitation protocol helpers |
+| `core/mcp/node/` | Node stdio transport factory; `proxyFetch.ts` (the shared HTTPS_PROXY/NO_PROXY fetch) |
+| `core/mcp/remote/` | Browser HTTP/SSE transport + remote logger/fetch, and (under `node/`) the Hono backend it talks to |
+| `core/mcp/state/` | The stores `core/react/` hooks read |
+| `core/auth/` | OAuth end to end — providers, discovery, storage, endpoint overrides, scopes, revocation, mid-session recovery — split into isomorphic logic plus `browser/`, `node/` and `remote/` backends |
+| `core/auth/node/` | Node OAuth storage + loopback callback server, **and** the `SecretStore` backends (keychain / file / memory) and their selection policy |
+| `core/client/` | Install-level client config (`client.json`): browser-safe parse plus Node load/save, remote backend, secrets, runner |
+| `core/json/` | JSON + parameter/argument conversion; the schema normalizations all three form builders share (nullable unions, root composition) and the tool-schema portability lint |
+| `core/react/` | React hooks over the state stores — consumed by both the web and TUI React trees. Every subscription reads its snapshot **during render** via `useSyncExternalStore` (#1955); `useStoreSnapshot.ts` caches the fresh-value-per-read getters |
+| `core/node/` | Node-only helpers: version reader, host normalization/detection |
+| `core/storage/` | File I/O helpers used by the OAuth persist backends |
+| `core/logging/` | Silent pino logger singleton |
`core/` is isomorphic (browser + Node) and has **no `package.json`** — it is not
published on its own. Its tests live in `clients/web/src/test/core/`, and its
@@ -56,20 +57,20 @@ browser-consumed runtime is inside the web coverage gate.
## `clients/web/server/` — the Node backend
-| File | Role |
-| --- | --- |
-| `vite-hono-plugin.ts` | Hono middleware on the Vite dev server |
-| `server.ts` | Standalone Hono prod server |
-| `start-vite-dev-server.ts` | In-process Vite starter for the launcher |
-| `web-server-config.ts` | Env parsing, initial-config payload, startup banner |
-| `sandbox-controller.ts` | The MCP Apps sandbox HTTP server |
-| `app-origin-controller.ts` | The dedicated app origin for `_meta.ui.domain` |
-| `inject-auth-token.ts` | Embeds the API token into served `index.html` |
-| `resolve-bind-host.ts` | Bind-host policy (defaults to `127.0.0.1`; refuses a wildcard bind without the opt-in) |
-| `browser-externalized-builtin-gate.ts` | Fails `vite build` on a browser-externalized Node built-in |
-| `ensure-web-build.ts` | Builds `clients/web/dist` on demand for prod `--web` |
-
-Each of these files carries a header comment explaining the *why*; read the
+| File | Role |
+| -------------------------------------- | -------------------------------------------------------------------------------------- |
+| `vite-hono-plugin.ts` | Hono middleware on the Vite dev server |
+| `server.ts` | Standalone Hono prod server |
+| `start-vite-dev-server.ts` | In-process Vite starter for the launcher |
+| `web-server-config.ts` | Env parsing, initial-config payload, startup banner |
+| `sandbox-controller.ts` | The MCP Apps sandbox HTTP server |
+| `app-origin-controller.ts` | The dedicated app origin for `_meta.ui.domain` |
+| `inject-auth-token.ts` | Embeds the API token into served `index.html` |
+| `resolve-bind-host.ts` | Bind-host policy (defaults to `127.0.0.1`; refuses a wildcard bind without the opt-in) |
+| `browser-externalized-builtin-gate.ts` | Fails `vite build` on a browser-externalized Node built-in |
+| `ensure-web-build.ts` | Builds `clients/web/dist` on demand for prod `--web` |
+
+Each of these files carries a header comment explaining the _why_; read the
source rather than looking for a second copy of it here.
## Web source layout: `src/lib` vs `src/utils`
@@ -93,14 +94,14 @@ in near the top of the tree. Element components live in
## Where to put a new file
-| It is… | It goes in |
-| --- | --- |
-| Logic two or more clients need | `core//` |
-| Browser-only React or DOM code | `clients/web/src/` |
-| A pure transform used by web | `clients/web/src/utils/` |
-| A stateful adapter / subsystem wrapper used by web | `clients/web/src/lib/` |
-| Node-only web backend wiring | `clients/web/server/` |
-| A build/verify script | `scripts/` (with a sibling `*.test.mjs` if it has pure logic) |
-| A test fixture MCP server | `test-servers/src/` + a config in `test-servers/configs/` |
+| It is… | It goes in |
+| -------------------------------------------------- | ------------------------------------------------------------- |
+| Logic two or more clients need | `core//` |
+| Browser-only React or DOM code | `clients/web/src/` |
+| A pure transform used by web | `clients/web/src/utils/` |
+| A stateful adapter / subsystem wrapper used by web | `clients/web/src/lib/` |
+| Node-only web backend wiring | `clients/web/server/` |
+| A build/verify script | `scripts/` (with a sibling `*.test.mjs` if it has pure logic) |
+| A test fixture MCP server | `test-servers/src/` + a config in `test-servers/configs/` |
Test placement is a separate question with its own rules — see `/testing`.
diff --git a/.claude/skills/testing/SKILL.md b/.claude/skills/testing/SKILL.md
index 9f6fd256bf..0887a42f7e 100644
--- a/.claude/skills/testing/SKILL.md
+++ b/.claude/skills/testing/SKILL.md
@@ -1,6 +1,6 @@
---
name: testing
-description: Write, run, place and fix tests in this repo. Use when adding a test, or end-to-end or integration coverage of an MCP operation (listing, paginating or calling tools); when choosing which npm command runs a given suite (web unit, web integration, Storybook, cli, tui, launcher, scripts); when deciding where a new test file belongs — beside its source, under src/test/, or in a client's __tests__/; when a per-file coverage check fails or a v8 ignore is in question; when asking which test tier spawns the built binary rather than importing it; or when rendering, mounting or asserting on Mantine components and their transitions in a test.
+description: Write, run, place and fix tests in this repo. Use when adding a test, or end-to-end or integration coverage of an MCP operation (listing, paginating or calling tools); when choosing which npm command runs a given suite (web unit, web integration, Storybook, cli, daemon-cli, tui, launcher, scripts); when deciding where a new test file belongs — beside its source, under src/test/, or in a client's __tests__/; when a per-file coverage check fails or a v8 ignore is in question; when asking which test tier spawns the built binary rather than importing it; or when rendering, mounting or asserting on Mantine components and their transitions in a test.
disable-model-invocation: false
---
@@ -20,7 +20,7 @@ choosing a location or writing a line.**
end-to-end or integration coverage of an MCP operation — listing tools,
paginating a list, calling a tool, reading a resource — almost always stands a
fixture up, so treat that phrasing as the answer to the question above and load
-`test-servers` *first*. Grepping for an existing test to copy is not a
+`test-servers` _first_. Grepping for an existing test to copy is not a
substitute: the fixture you find that way (a config under
`test-servers/configs/`) does not tell you which of the three shapes below
drives it, or that it can be stale. If the skill then shows the case needs no
@@ -45,7 +45,7 @@ ways to depend on one, and they need different halves of that skill:
config and no era table apply.**
- **An integration or CLI test where stdio is the point → spawned stdio.**
`getTestMcpServerCommand()` handed to a stdio transport or to the built CLI,
- which spawns it. A subprocess *is* started, but it runs the stdio fixture's
+ which spawns it. A subprocess _is_ started, but it runs the stdio fixture's
**default** config, so there is still nothing to pick — and nothing to
override, so if the case needs a specific tool set it is an in-process HTTP
test instead.
@@ -64,31 +64,32 @@ ways to depend on one, and they need different halves of that skill:
What applies to all three is that section's build warning.
⚠️ **Connecting is a strong hint, not the rule.** A few integration tests
deliberately hand-roll a JSON-RPC server because the composable fixture
- *cannot* produce what they assert on — `inspectorClient-malformed-list.test.ts`
+ _cannot_ produce what they assert on — `inspectorClient-malformed-list.test.ts`
and `listSalvage-era.test.ts` need wire shapes the SDK's own server refuses to
emit. Real transport, real client, no `test-servers/` dependency. Check
whether a fixture can express the case before reaching for one.
+
- **It names or runs the built fixture without connecting.** `smoke:tui` boots
- the TUI against a catalog whose stdio command *is* the built fixture, then
+ the TUI against a catalog whose stdio command _is_ the built fixture, then
asserts it survives. No transport is driven and no protocol era applies, but
the **build and staleness** half lands on it in full.
⚠️ **"A build ran" is not the dependency — using the artefact is.**
-`clients/web`'s `pretest` runs `test-servers:build` before *every* unit run, so
+`clients/web`'s `pretest` runs `test-servers:build` before _every_ unit run, so
the fixture is on disk for tests that never reference it. What counts is whether
the test **starts, spawns, configures, or hands a built entry to the subject
under test**. That last clause is what covers `smoke:tui`, which drives no
transport at all and still depends on the fixture — see the build-only bullet
above.
-⚠️ **And *importing* the package is not the dependency either.** The barrel
+⚠️ **And _importing_ the package is not the dependency either.** The barrel
exports plain functions as well as server factories, so a test can import from
it and never stand a server up — `src/test/core/mcp/test-server-scope.test.ts`
imports `createScopeCheckMiddleware` and friends to unit-test the scope
middleware as a pure function, with no `start()` anywhere in the file. None of
the procedure applies to it — no config, no era, no lifecycle — it is an
ordinary unit test that happens to import its subject from that package. Ask
-whether a *server* runs, not whether the import line is present.
+whether a _server_ runs, not whether the import line is present.
So the condition does **not** hold when the test renders a component from
fixture props, exercises a pure function or a parser, or is a smoke that touches
@@ -100,7 +101,7 @@ holds `storage/store-id.test.ts`, which validates a string, and `mcp/import/*`,
which parses config files, right beside the tests that drive a live connection.
They sit there for the node env and the 30s timeout, not because they connect —
placement is the project manifest, so it cannot also be the fixture trigger.
-Ask what the test *does*, not where it lives.
+Ask what the test _does_, not where it lives.
**In the connecting case**, the test drives a **real server over a real
transport, never a mock**, and picking the fixture, building it, and connecting
@@ -122,7 +123,7 @@ the Node clients are different.**
Components, hooks, `lib/`, `utils/`. This is the overwhelming majority; a
web-owned test living under `src/test/` instead is a bug.
-`clients/web/src/test/` is for the three things that *cannot* be co-located:
+`clients/web/src/test/` is for the three things that _cannot_ be co-located:
1. **Tests of the repo-root `core/` package** → `src/test/core/…`, mirroring the
`core/` folder layout. `core/` physically lives outside `clients/web/`, is
@@ -132,7 +133,7 @@ web-owned test living under `src/test/` instead is a bug.
`core/` source layout (`mcp/`, `mcp/node/`, `mcp/remote/`, `auth/`,
`auth/node/`, `storage/`). **Placement is the manifest** — any file under that
folder is picked up by the integration project (node env, 30s timeouts) via a
- folder glob; there is no enumeration to keep in sync. ⚠️ Placement is *not*
+ folder glob; there is no enumeration to keep in sync. ⚠️ Placement is _not_
the fixture trigger, though — this folder holds pure parser and storage tests
alongside the connecting ones. If the test you are adding here **needs a
fixture from `test-servers/`, load that skill first**; the fixture is half of
@@ -141,7 +142,7 @@ web-owned test living under `src/test/` instead is a bug.
3. **Shared test infrastructure** — `renderWithMantine.tsx`, `setup.ts`,
`fixtures/`, `scrollAreaStoryAssertions.ts`.
-### `clients/cli`, `clients/tui`, `clients/launcher` — a top-level `__tests__/`
+### `clients/cli`, `clients/daemon-cli`, `clients/tui`, `clients/launcher` — a top-level `__tests__/`
**All** their tests, not beside their source. Their `tsconfig.json` excludes
`**/*.test.*` and their `tsconfig.test.json` includes `__tests__/**/*`, so a
@@ -157,17 +158,18 @@ file its glob misses and still exits 0.
## Running them
-| Scope | From | Command |
-| --- | --- | --- |
-| Web unit | `clients/web` | `npm run test` (`test:watch` while iterating) |
-| Web integration | `clients/web` | `npm run test:integration` |
-| Web Storybook play fns | `clients/web` | `npm run test:storybook` |
-| CLI | `clients/cli` | `npm run test` (`pretest` builds test-servers + the bin) |
-| TUI | `clients/tui` | `npm run test` |
-| Launcher | `clients/launcher` | `npm run test` |
-| Root tooling | repo root | `npm run test:scripts` |
-| Everything, fast | repo root | `npm run validate` |
-| The coverage gate | repo root | `npm run coverage` |
+| Scope | From | Command |
+| ---------------------- | -------------------- | -------------------------------------------------------- |
+| Web unit | `clients/web` | `npm run test` (`test:watch` while iterating) |
+| Web integration | `clients/web` | `npm run test:integration` |
+| Web Storybook play fns | `clients/web` | `npm run test:storybook` |
+| CLI | `clients/cli` | `npm run test` (`pretest` builds test-servers + the bin) |
+| Connection CLI (mcpdo) | `clients/daemon-cli` | `npm run test` (`pretest` builds test-servers + the bin) |
+| TUI | `clients/tui` | `npm run test` |
+| Launcher | `clients/launcher` | `npm run test` |
+| Root tooling | repo root | `npm run test:scripts` |
+| Everything, fast | repo root | `npm run validate` |
+| The coverage gate | repo root | `npm run coverage` |
There is **no aggregate root `test` script** — each client self-validates.
@@ -201,8 +203,8 @@ inside the `coverage` gate. CI therefore has no separate `test:integration` step
## The coverage gate
-**Per-file ≥90 on all four dimensions**, CI-enforced, across web, cli, tui and
-launcher. New code must clear 90 on every dimension.
+**Per-file ≥90 on all four dimensions**, CI-enforced, across web, cli,
+daemon-cli, tui and launcher. New code must clear 90 on every dimension.
Scope notes:
@@ -220,19 +222,14 @@ Scope notes:
only exclusion. `commander` uses `.exitOverride()` so a parse error throws
instead of tearing down the test worker.
- **TUI** covers **all of `src/**`, React surface included**. Components mount
- through `__tests__/helpers/renderTui.tsx` — `ink-testing-library`'s `render`
- with every frame ANSI-stripped — alongside the passthrough doubles in the same
- directory; keypresses are driven through stdin. The only exclusion is
- `src/tui-servers.ts` (a pure re-export, excluded so it doesn't surface as a
- misleading 0/0 row).
- ⚠️ **Import `render` from that helper, not from `ink-testing-library`.** Ink
- writes styling *inside* the styled run, so `Info`
- reaches the frame buffer with escapes between `I` and `nfo` and a plain
- `toContain("Info")` fails against a component that is rendering correctly. It
- only shows up where chalk emits color — a developer whose shell exports
- `FORCE_COLOR` — so CI, which has no TTY, stays green on a suite that is red
- for them (#2207). If a frame assertion fails on a string you can plainly see
- in the printed diff, that is the tell. Reach `stdout.lastFrame()` on the
+through `**tests**/helpers/renderTui.tsx`—`ink-testing-library`'s `render`with every frame ANSI-stripped — alongside the passthrough doubles in the same
+directory; keypresses are driven through stdin. The only exclusion is`src/tui-servers.ts`(a pure re-export, excluded so it doesn't surface as a
+misleading 0/0 row).
+⚠️ **Import`render`from that helper, not from`ink-testing-library`.** Ink
+writes styling *inside* the styled run, so `Info`reaches the frame buffer with escapes between`I`and`nfo`and a plain`toContain("Info")`fails against a component that is rendering correctly. It
+only shows up where chalk emits color — a developer whose shell exports`FORCE_COLOR`— so CI, which has no TTY, stays green on a suite that is red
+for them (#2207). If a frame assertion fails on a string you can plainly see
+in the printed diff, that is the tell. Reach`stdout.lastFrame()` on the
returned instance for the raw bytes.
### When a `v8 ignore` is justified
@@ -295,7 +292,7 @@ the skill and use all of it**: which showcase config covers the feature, which
protocol era to connect with, how to add a combination that does not exist yet,
and why a fixture can keep serving stale code after an edit.
-**A test that only *names* the built fixture needs that skill too, for a
+**A test that only _names_ the built fixture needs that skill too, for a
narrower reason.** `smoke:tui` boots the TUI against a catalog whose stdio
command is the build output and asserts it survives — it opens no transport, so
config choice and protocol era do not apply to it, but **building the fixture
diff --git a/AGENTS.md b/AGENTS.md
index 4e8e03be84..3b5e63ac35 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -1,7 +1,8 @@
# Inspector V2
-This is an application for inspecting MCP servers. It has three incarnations —
-Web, TUI, and CLI — over a shared `core/`.
+This is an application for inspecting MCP servers. It has four client
+surfaces — Web, TUI, one-shot CLI, and the experimental connection CLI (`mcpdo`) —
+over a shared `core/`.
**This file holds the _rules_: the conventions a reviewer cites against a diff.**
It is loaded in full on every turn, so it stays resident and must stay complete
@@ -41,6 +42,9 @@ inspector/
│ │ ├── server/ Node-only dev/prod backend wiring
│ │ └── static/ sandbox_proxy.html — served for the MCP Apps tab
│ ├── cli/ Scriptable CLI (tsup bundle, @inspector/core alias)
+│ ├── daemon-cli/ Experimental connection CLI (`mcpdo` bin — connect once, many
+│ │ commands; implicit Unix-socket connection daemon). Bundled
+│ │ into the published package — see clients/daemon-cli/README.md
│ ├── tui/ Ink + React terminal UI (tsup bundle)
│ └── launcher/ The `mcp-inspector` bin; dispatches to web/cli/tui in-process
├── core/ Shared code, consumed via the `@inspector/core` alias (no package.json)
@@ -57,6 +61,10 @@ inspector/
│ plus repo automation run from CI (the dependency, alert + SDK sweeps)
├── docs/ Task-oriented guides
├── specification/ Design/build specifications
+├── skills/ End-user agent skills (skills/mcpdo teaches an agent to drive
+│ the `mcpdo` CLI; shipped in the published tarball). Distinct
+│ from .claude/skills/ (repo procedures): not indexed above
+│ and not checked by verify:skills
└── .claude/skills/ The procedures (see the index above)
```
@@ -89,14 +97,14 @@ The reasoning behind each of these, and what breaks when it is ignored, is the
`local-dev` skill. The rules themselves:
- **Every runtime dependency `core/` imports is declared in the repo-root `package.json` and nowhere else.** That is the MCP SDK packages (`@modelcontextprotocol/client`, `core`, `server`, `ext-apps`) and, since #2195, the rest of what `core/` reaches: `ajv`, `atomically`, `chokidar`, `hono`, `@napi-rs/keyring`, `pino`, `proper-lockfile`, `react`, `undici`, `zod`. So is anything reached only through root-owned code with no manifest of its own (`test-servers/src`, `core/`). `@modelcontextprotocol/server-legacy` is that case: only `test-servers/src` imports it (the legacy SSE transport), so it is a root **`devDependency`** — declared as a runtime one, every `npx` install fetched it and printed its npm deprecation warning (#2519). The v1 SDK (`@modelcontextprotocol/sdk`) is **not** a dependency of this repo and must not become one.
-- **A root declaration is not by itself a claim that `core/` imports it.** `commander`, `open`, `@hono/node-server`, `vite` and `@vitejs/plugin-react` are root `dependencies` reached only from _client_ code, for the runtime-consumption reason below: a published install resolves every externalized import from the root manifest, so a client's runtime import has to be declared there whether or not `core/` also reaches it. Those need naming only in the `external` list of the client that actually imports them, not in all three.
-- **A client declares only what that client alone consumes** — its own UI stack, its bundler-inlined packages, its dev tooling. `clients/cli` and `clients/launcher` therefore declare **no** runtime dependencies at all, and that is the expected steady state, not an omission: everything they run on is root-declared and resolves by walk-up from the client directory. Re-adding a root-declared package to a client manifest re-creates the second copy this rule exists to make impossible (#1896), so a missing module at runtime is a signal to check the **root** manifest and the client's `external` list, never to add it back.
+- **A root declaration is not by itself a claim that `core/` imports it.** `commander`, `open`, `@hono/node-server`, `vite` and `@vitejs/plugin-react` are root `dependencies` reached only from _client_ code, for the runtime-consumption reason below: a published install resolves every externalized import from the root manifest, so a client's runtime import has to be declared there whether or not `core/` also reaches it. Those need naming only in the `external` list of the client that actually imports them, not in all four.
+- **A client declares only what that client alone consumes** — its own UI stack, its bundler-inlined packages, its dev tooling. `clients/cli`, `clients/daemon-cli` and `clients/launcher` therefore declare **no** runtime dependencies at all, and that is the expected steady state, not an omission: everything they run on is root-declared and resolves by walk-up from the client directory. Re-adding a root-declared package to a client manifest re-creates the second copy this rule exists to make impossible (#1896), so a missing module at runtime is a signal to check the **root** manifest and the client's `external` list, never to add it back.
- **A package that moves to the root moves its `vitest.shared.mts` pin with it.** Left pointing at `/node_modules` a pin resolves to a directory that no longer exists — or, where a transitive copy happens to sit there (`chokidar` under `vite`, `react` as a peer of `react-dom` and `ink`), to the very duplicate the pin list exists to prevent. **`react` and `react-dom` are the deliberate exception** and stay pinned per client, so a client's renderer and the React it calls into come from one install; every other root-owned pin resolves from the repo root.
- **`dependencies` vs `devDependencies` follows from who consumes it at runtime**, not from where it is declared. Anything `core/` imports at runtime must be a root **`dependency`** — the client builds externalize npm packages and a published install resolves them from the root manifest, where devDependencies are absent.
- **The shared toolchain is declared once, at the repo root, and in no client manifest.** `eslint`, `@eslint/js`, `typescript-eslint`, `globals`, `prettier`, `typescript`, `vitest`, `@vitest/coverage-v8` and `@types/node` are used by every client's own scripts, and a client that declares none of them still resolves the root copy by walk-up — `npm run` puts each ancestor `node_modules/.bin` on `PATH`, and Node and TypeScript walk parent `node_modules` / `node_modules/@types` the same way. `clients/launcher` declares no `devDependencies` at all and its `validate` is unchanged. A client-side declaration buys nothing and installs a second copy free to drift, as `globals` (`^17.7.0` root / `^17.4.0` clients) and `typescript-eslint` (`^8.65.0` / `^8.56.1`) had before #2196. These stay **`devDependencies`** — none is consumed at runtime and the tarball ships only each client's `build/`. The boundary is **used by every client**, not "used by one": anything narrower stays where it is, whether one client declares it (`tsx`, `playwright`, `storybook`, `happy-dom`, `ink-testing-library`, `vite-node`, each client's own `@types/*`) or several do — `tsup` is declared in web, cli and tui, and `vite` in web and tui on top of the root **runtime** `dependency` that `--web --dev` needs. Those are out of scope here; consolidating them is a different call with a different rationale.
- ⚠️ **Deleting the declaration does not always delete the copy, and the local copy still wins.** npm auto-installs an unmet **peer** into the install that needs it, and it has no visibility into the root's tree — so a client-only ESLint plugin drags a client-local `eslint` in (`eslint-plugin-react-refresh`/`-storybook` in web, `eslint-plugin-react-hooks` in tui), and web's Storybook/Vitest stack drags in a local `typescript` and `vitest`. A hoisted transitive does the same: `@types/express` puts an `@types/node` in web and cli. Those copies sit _nearer_ than the root's and take precedence. The consolidation is therefore about **one declaration and one place to bump**, not about a single copy on disk. ⚠️ **Nothing keeps the surviving copies aligned automatically — but since #2226 the guard rejects the drift.** A **peer** copy is at least constrained by its holder's peer range — tightly for `vitest` (an exact peer, hence the pin below), loosely for `eslint` (`^9 || ^10`), where the copies agree only because npm resolves the same latest in both installs. A **transitive** copy is constrained by nothing of ours at all, and cli's `@types/node` (`24.13.1` against the root's `24.13.3`) diverged on exactly that. **That is detection, not alignment: `verify:dep-lockstep` fails on this class since #2226, and you still do the bump by hand.** Its second tier compares every package any install _declares_ (`dependencies`, `devDependencies`, `optionalDependencies`; not peers) against every top-level copy across all five installs, independent of what a `tsc` program loads, so a transitive drift and a peer shadow (`eslint`, `typescript`, `vitest`) are both in scope now. Two limits remain: the tier reads lockfiles, so a tool binary you installed by hand and never committed is still invisible; and it only compares names some manifest declares, so a purely transitive package no manifest names is out of scope in both tiers unless a `tsc` program loads both copies. Aligning a stale install is `npm update ` there; a transitive copy that will not move takes an `overrides` entry in that install (`clients/cli` pins `@types/node` this way).
- ⚠️ **`vitest`, `@vitest/coverage-v8` and web's `@vitest/browser-playwright` are pinned exactly, and move together.** `@vitest/browser-playwright` declares an **exact** peer on `vitest`, so it — not the root range — decides which `vitest` web installs. Left to float, the root resolves a newer patch and web's tests then run on one `vitest` while loading a coverage provider built against another. Bumping means editing all three in one change, the same discipline the exact `prettier` pin (#1790) exists for. ⚠️ **Editing the three is necessary but not sufficient — `clients/web` also carries a `vitest` `overrides` entry that has to move with them.** Web does not declare `vitest`, so its copy is the peer shadow above; its lockfile pins that copy at the old patch, and the exact peer plus the lockfile form a knot `npm install` resolves by refusing outright (`Conflicting peer dependency: vitest@`), while `npm update` will not move it either. Deleting web's lockfile clears the error and re-resolves every caret range in the tree at once — an uncontrolled dependency update wearing a security patch's clothes. The `overrides` entry is the controlled alternative, the same mechanism `clients/cli` uses for `@types/node`: it moves the shadowed copy and nothing else, keeping the churn inside the vitest constellation. So a vitest bump is **four** edits, and the override's version is an exact pin like the other three (#2301).
-- **A root-declared package that `core/` imports at runtime must also be named in all three bundler `external` lists** (`clients/{cli,tui}/tsup.config.ts`, `clients/web/tsup.runner.config.ts`), since which client reaches it is a function of what `core/` imports rather than of what the client's own code names. `npm run verify:bundle-externals` enforces this against the **built output**.
+- **A root-declared package that `core/` imports at runtime must also be named in all four bundler `external` lists** (`clients/{cli,daemon-cli,tui}/tsup.config.ts`, `clients/web/tsup.runner.config.ts`), since which client reaches it is a function of what `core/` imports rather than of what the client's own code names. `npm run verify:bundle-externals` enforces this against the **built output**.
- **A dependency that renders React components must be bundled** into the client that uses it (`noExternal`) and declared only there — an externalized one resolves its own `react` and splits the tree. `ink` is the single exemption, on cost, and it is only safe while the root `react` range stays open to the whole major (`^19.0.0`).
- **One version per install-crossing dependency.** When bumping a dependency the shared sources pull in, bump it in every install that declares it. Consolidating to the root is what makes most of these unbumpable in two places at once, but it does not retire the rule — a client's `devDependencies`, and any package that arrives transitively into a client install, can still skew against the root. Never raise the tsc heap to work around one. `npm run verify:dep-lockstep` enforces this in two tiers: packages that reach one `tsc` **program** from two installs (the #1896 heap-exhaustion class), and — since #2226 — every package any install **declares** that more than one install holds a top-level copy of, whether or not a program ever sees both.
- **Pin a transitive dependency with an `overrides` entry**, not with `npm audit fix` — which "resolves" an advisory with no upward escape by silently downgrading.
@@ -398,12 +406,12 @@ When asked to respond to a code review of a PR:
The _procedure_ — where a given test file goes, which command runs it, how to
diagnose a failing gate — is the `testing` skill. These are the rules.
-- **Ensure all code has corresponding tests.** New code must clear **≥ 90 on all four dimensions** — lines, statements, functions, and branches — per file. This gate is enforced by each client's `test:coverage` across `clients/web`, `clients/cli`, `clients/tui` and `clients/launcher`, and **CI enforces it**: a PR that drops any file below 90 on any dimension fails.
+- **Ensure all code has corresponding tests.** New code must clear **≥ 90 on all four dimensions** — lines, statements, functions, and branches — per file. This gate is enforced by each client's `test:coverage` across `clients/web`, `clients/cli`, `clients/tui`, `clients/launcher`, and (experimentally) `clients/daemon-cli`, and **CI enforces it**: a PR that drops any file below 90 on any dimension fails. **mcpdo** excludes only true bootstraps from the gate (`src/mcp-bin.ts`, `src/daemon/run.ts` — see `clients/daemon-cli/vitest.config.ts`); its build-time `@inspector/cli` alias reaches into `clients/cli/src` for shared handlers/error-handler/OAuth helpers (temporary, not a published API — #2461 tracks promoting that surface to a shared area).
- **A genuinely-unreachable branch is annotated at the source, never waved through by lowering the gate.** Use a justified `/* v8 ignore … -- */`. Acceptable reasons: happy-dom-inherent paths (Mantine portal mount points, `useMediaQuery` fallbacks, `typeof window` SSR guards); React StrictMode effect-replay blocks; and provably-dead defensive guards (a `?? fallback` for a value the types guarantee non-null, a `Select.onChange` receiving a value outside the allowed list). Reach for it only when the branch is genuinely impossible to exercise.
- **In unit tests that expect error output, suppress it from the console.**
- **Test placement — side-by-side by default, `src/test/` only for what can't be co-located, and the Node clients are different.**
- **`clients/web`**: `.test.tsx` **next to the source** — components, hooks, `lib/`, `utils/`. A web-owned test living under `src/test/` instead is a bug. `src/test/` is for the three things that cannot be co-located: tests of the repo-root **`core/`** package (`src/test/core/…`, mirroring the `core/` layout — it lives outside `clients/web/` and has no harness of its own); the **`integration`** project (`src/test/integration/…` — _placement is the manifest_, picked up by a folder glob, with no enumeration to keep in sync); and **shared test infrastructure** (`renderWithMantine.tsx`, `setup.ts`, `fixtures/`).
- - **`clients/cli`, `clients/tui`, `clients/launcher`**: **all** tests in a top-level **`__tests__/`**, not beside their source. Their `tsconfig.json` excludes `**/*.test.*`, so a co-located test lands in **no** tsconfig project and fails `npm run verify:typecheck-coverage`.
+ - **`clients/cli`, `clients/daemon-cli`, `clients/tui`, `clients/launcher`**: **all** tests in a top-level **`__tests__/`**, not beside their source. Their `tsconfig.json` excludes `**/*.test.*`, so a co-located test lands in **no** tsconfig project and fails `npm run verify:typecheck-coverage`.
- **Root tooling**: a `scripts/*.mjs` helper with pure logic gets a sibling `*.test.mjs`. Keep that exact filename — `node --test` silently _skips_ a file its glob misses and still exits 0.
- **Render Ink components through the TUI's own `render`** (`clients/tui/__tests__/helpers/renderTui.tsx`), never `ink-testing-library`'s directly. It is the same function with every frame ANSI-stripped, which is what keeps an assertion on styled text from depending on the ambient environment: Ink writes styling *inside* the styled run, so `Info` reaches the frame buffer with escapes between `I` and `nfo` and `toContain("Info")` fails. It only bites where chalk emits color — a developer whose shell exports `FORCE_COLOR` — so CI is green on a suite that is broken for them (#2207). A test that genuinely needs the raw bytes reads `stdout.lastFrame()` off the returned instance.
- **Render React components through `renderWithMantine`** (`src/test/renderWithMantine.tsx`); do not hand-roll a bare `MantineProvider`, which skips the project theme and the helper's options and drifts from every other test. Pass the `colorScheme` option to exercise a forced scheme rather than hand-rolling `defaultColorScheme`. Use `renderWithMantineTransitions` **only** when a test must assert mid-flight transition state, and read the long comment on the helper before changing anything about it.
diff --git a/README.md b/README.md
index 3b78e4558b..e76d4f5a82 100644
--- a/README.md
+++ b/README.md
@@ -52,6 +52,8 @@ inspector/
├── clients/
│ ├── web/ Web client (Vite + React + Mantine). src/ = browser app; server/ = Node backend
│ ├── cli/ CLI client (tsup bundle, @inspector/core alias)
+│ ├── daemon-cli/ Experimental connection CLI (`mcpdo` bin) — bundled into the
+│ │ published package; see clients/daemon-cli/README.md
│ ├── tui/ TUI client (Ink + React, tsup bundle)
│ └── launcher/ Shared launcher — provides the `mcp-inspector` bin, dispatches to web/cli/tui
├── core/ Shared code consumed via the `@inspector/core` alias (no package.json)
@@ -61,13 +63,16 @@ inspector/
│ and the Docker image's HEALTHCHECK probe
├── docs/ Task-oriented guides — see below
├── specification/ Design/build specifications
+├── skills/ End-user agent skills (e.g. skills/mcpdo teaches an agent to
+│ drive the `mcpdo` CLI) — distinct from .claude/skills/,
+│ which holds this repo's own procedures
├── .claude/skills/ Agent skills: the repo's procedures, invokable by name
├── AGENTS.md Contribution rules for agents AND humans
└── README.md You are here
```
Each client has its own README with client-specific detail:
-[web](./clients/web/README.md) · [cli](./clients/cli/README.md) · [tui](./clients/tui/README.md) · [launcher](./clients/launcher/README.md).
+[web](./clients/web/README.md) · [cli](./clients/cli/README.md) · [mcpdo](./clients/daemon-cli/README.md) · [tui](./clients/tui/README.md) · [launcher](./clients/launcher/README.md).
## Documentation
diff --git a/clients/cli/__tests__/method-types.test.ts b/clients/cli/__tests__/method-types.test.ts
index 85230043d8..76edddaf24 100644
--- a/clients/cli/__tests__/method-types.test.ts
+++ b/clients/cli/__tests__/method-types.test.ts
@@ -2,16 +2,18 @@ import { describe, it, expect } from "vitest";
import {
isOneShotMethod,
ONE_SHOT_METHODS,
- SESSION_RPC_METHODS,
+ CONNECTION_RPC_METHODS,
} from "../src/handlers/method-types.js";
-describe("SESSION_RPC_METHODS", () => {
+describe("CONNECTION_RPC_METHODS", () => {
it("lists the full RPC method set supported by runMethod", () => {
- expect(SESSION_RPC_METHODS).toContain("tools/list");
- expect(SESSION_RPC_METHODS).toContain("tools/call");
- expect(SESSION_RPC_METHODS).toContain("logging/tail");
- expect(SESSION_RPC_METHODS).toContain("roots/set");
- expect(new Set(SESSION_RPC_METHODS).size).toBe(SESSION_RPC_METHODS.length);
+ expect(CONNECTION_RPC_METHODS).toContain("tools/list");
+ expect(CONNECTION_RPC_METHODS).toContain("tools/call");
+ expect(CONNECTION_RPC_METHODS).toContain("logging/tail");
+ expect(CONNECTION_RPC_METHODS).toContain("roots/set");
+ expect(new Set(CONNECTION_RPC_METHODS).size).toBe(
+ CONNECTION_RPC_METHODS.length,
+ );
});
});
diff --git a/clients/cli/__tests__/run-method-mocks.test.ts b/clients/cli/__tests__/run-method-mocks.test.ts
index 4fda0c5197..437f9515a5 100644
--- a/clients/cli/__tests__/run-method-mocks.test.ts
+++ b/clients/cli/__tests__/run-method-mocks.test.ts
@@ -12,6 +12,7 @@ function mockClient(overrides: Partial = {}): InspectorClient {
getRequestorTask: vi.fn().mockResolvedValue({ taskId: "t1" }),
cancelRequestorTask: vi.fn().mockResolvedValue(undefined),
getRequestorTaskResult: vi.fn().mockResolvedValue({ content: [] }),
+ updateRequestorTask: vi.fn().mockResolvedValue(undefined),
getRoots: vi.fn().mockReturnValue([]),
setRoots: vi.fn().mockResolvedValue(undefined),
setLoggingLevel: vi.fn().mockResolvedValue(undefined),
@@ -65,6 +66,192 @@ vi.mock("@inspector/core/mcp/state/index.js", async (importOriginal) => {
});
describe("runMethod (mocked client)", () => {
+ it("reference-counts same-URI subscribe streams", async () => {
+ const client = mockClient();
+ const s1 = await runMethod(client, {
+ method: "resources/subscribe",
+ uri: "test://x",
+ });
+ const s2 = await runMethod(client, {
+ method: "resources/subscribe",
+ uri: "test://x",
+ });
+ // Both streams share one core subscription.
+ expect(client.subscribeToResource).toHaveBeenCalledTimes(1);
+ expect(s1.kind).toBe("stream");
+ expect(s2.kind).toBe("stream");
+ if (s1.kind === "stream" && s2.kind === "stream") {
+ const stop1 = s1.start(() => {});
+ const stop2 = s2.start(() => {});
+ stop1();
+ // The survivor keeps the subscription alive.
+ expect(client.unsubscribeFromResource).not.toHaveBeenCalled();
+ stop2();
+ expect(client.unsubscribeFromResource).toHaveBeenCalledTimes(1);
+ }
+
+ // A rejected unsubscribe (e.g. after daemon disconnectAll) is caught at
+ // the source instead of surfacing as an unhandled rejection.
+ const failing = mockClient({
+ unsubscribeFromResource: vi
+ .fn()
+ .mockRejectedValue(new Error("client closed")),
+ } as Partial);
+ const s3 = await runMethod(failing, {
+ method: "resources/subscribe",
+ uri: "test://y",
+ });
+ if (s3.kind === "stream") {
+ s3.start(() => {})();
+ }
+ await new Promise((resolve) => setImmediate(resolve));
+ expect(failing.unsubscribeFromResource).toHaveBeenCalledTimes(1);
+ });
+
+ it("buffers updates that land between subscribe and start", async () => {
+ const listeners = new Set<(ev: Event) => void>();
+ const client = mockClient({
+ addEventListener: vi.fn((_type: string, fn: (ev: Event) => void) =>
+ listeners.add(fn),
+ ),
+ removeEventListener: vi.fn((_type: string, fn: (ev: Event) => void) =>
+ listeners.delete(fn),
+ ),
+ } as unknown as Partial);
+ const outcome = await runMethod(client, {
+ method: "resources/subscribe",
+ uri: "test://early",
+ });
+ // The listener is live before any consumer starts the stream...
+ expect(listeners.size).toBe(1);
+ // ...so an update in the subscribe→start window is captured, not lost.
+ const dispatch = (uri: string) => {
+ for (const fn of listeners)
+ fn(new CustomEvent("resourceUpdated", { detail: { uri } }));
+ };
+ dispatch("test://early");
+ dispatch("test://other"); // different URI: filtered out
+ const lines: unknown[] = [];
+ expect(outcome.kind).toBe("stream");
+ if (outcome.kind !== "stream") return;
+ const stop = outcome.start((obj) => lines.push(obj));
+ expect(lines).toEqual([
+ { type: "subscribed", uri: "test://early" },
+ { type: "resources/updated", uri: "test://early" },
+ ]);
+ // Post-start events flow straight through.
+ dispatch("test://early");
+ expect(lines).toHaveLength(3);
+ stop();
+ expect(listeners.size).toBe(0);
+ });
+
+ it("detaches the early listener when the subscribe fails", async () => {
+ const listeners = new Set<(ev: Event) => void>();
+ const client = mockClient({
+ addEventListener: vi.fn((_type: string, fn: (ev: Event) => void) =>
+ listeners.add(fn),
+ ),
+ removeEventListener: vi.fn((_type: string, fn: (ev: Event) => void) =>
+ listeners.delete(fn),
+ ),
+ subscribeToResource: vi.fn().mockRejectedValue(new Error("nope")),
+ } as unknown as Partial);
+ await expect(
+ runMethod(client, { method: "resources/subscribe", uri: "test://f" }),
+ ).rejects.toThrow("nope");
+ expect(listeners.size).toBe(0);
+ });
+
+ it("concurrent same-URI subscribes share one in-flight subscription", async () => {
+ let release!: () => void;
+ const gate = new Promise((resolve) => (release = resolve));
+ const client = mockClient({
+ subscribeToResource: vi.fn().mockImplementation(() => gate),
+ } as Partial);
+ // Both setups race before the subscribe resolves; the reservation is
+ // synchronous, so they must join one in-flight subscribe rather than
+ // each subscribing and writing a count of 1.
+ const p1 = runMethod(client, {
+ method: "resources/subscribe",
+ uri: "test://race",
+ });
+ const p2 = runMethod(client, {
+ method: "resources/subscribe",
+ uri: "test://race",
+ });
+ release();
+ const [s1, s2] = await Promise.all([p1, p2]);
+ expect(client.subscribeToResource).toHaveBeenCalledTimes(1);
+ const stop1 = s1.kind === "stream" ? s1.start(() => {}) : () => {};
+ const stop2 = s2.kind === "stream" ? s2.start(() => {}) : () => {};
+ stop1();
+ stop1(); // double-stop must not corrupt the shared count
+ expect(client.unsubscribeFromResource).not.toHaveBeenCalled();
+ stop2();
+ expect(client.unsubscribeFromResource).toHaveBeenCalledTimes(1);
+ });
+
+ it("rolls back reservations when the shared subscribe fails, allowing retry", async () => {
+ const subscribe = vi
+ .fn()
+ .mockRejectedValueOnce(new Error("subscribe boom"))
+ .mockResolvedValue(undefined);
+ const client = mockClient({
+ subscribeToResource: subscribe,
+ } as Partial);
+ const p1 = runMethod(client, {
+ method: "resources/subscribe",
+ uri: "test://fail",
+ });
+ const p2 = runMethod(client, {
+ method: "resources/subscribe",
+ uri: "test://fail",
+ });
+ await expect(p1).rejects.toThrow("subscribe boom");
+ await expect(p2).rejects.toThrow("subscribe boom");
+ // Both joined the same failed attempt…
+ expect(subscribe).toHaveBeenCalledTimes(1);
+ // …and both rolled back, so a retry issues a fresh subscribe.
+ const s3 = await runMethod(client, {
+ method: "resources/subscribe",
+ uri: "test://fail",
+ });
+ expect(subscribe).toHaveBeenCalledTimes(2);
+ expect(s3.kind).toBe("stream");
+ });
+
+ it("rejects explicit unsubscribe while subscribe streams share the URI", async () => {
+ const client = mockClient();
+ const sub = await runMethod(client, {
+ method: "resources/subscribe",
+ uri: "test://shared",
+ });
+ expect(sub.kind).toBe("stream");
+ const stop = sub.kind === "stream" ? sub.start(() => {}) : () => {};
+
+ // Tearing down the shared subscription out from under the open stream
+ // (and double-unsubscribing later) is refused with guidance.
+ await expect(
+ runMethod(client, {
+ method: "resources/unsubscribe",
+ uri: "test://shared",
+ }),
+ ).rejects.toThrow(/active resources\/subscribe stream/);
+ expect(client.unsubscribeFromResource).not.toHaveBeenCalled();
+
+ // Once the last stream closes, its cleanup unsubscribes and an explicit
+ // unsubscribe is allowed again.
+ stop();
+ expect(client.unsubscribeFromResource).toHaveBeenCalledTimes(1);
+ const out = await runMethod(client, {
+ method: "resources/unsubscribe",
+ uri: "test://shared",
+ });
+ expect(out.kind).toBe("result");
+ expect(client.unsubscribeFromResource).toHaveBeenCalledTimes(2);
+ });
+
it("covers subscribe stream, tasks, complete, and app-info call", async () => {
const client = mockClient({
callTool: vi.fn().mockResolvedValue({
@@ -104,11 +291,14 @@ describe("runMethod (mocked client)", () => {
listener?.(
new CustomEvent("resourceUpdated", { detail: { uri: "test://x" } }),
);
- expect(
- lines.some(
- (l) => (l as { type?: string }).type === "resources/updated",
- ),
- ).toBe(true);
+ // Updates for other URIs on the same connection are filtered out.
+ listener?.(
+ new CustomEvent("resourceUpdated", { detail: { uri: "test://other" } }),
+ );
+ const updated = lines.filter(
+ (l) => (l as { type?: string }).type === "resources/updated",
+ );
+ expect(updated).toEqual([{ type: "resources/updated", uri: "test://x" }]);
stop();
}
@@ -133,6 +323,19 @@ describe("runMethod (mocked client)", () => {
});
expect(result.kind).toBe("result");
+ const updated = await runMethod(client, {
+ method: "tasks/update",
+ taskId: "t1",
+ inputResponsesJson: '{"confirm":{"approved":true}}',
+ });
+ expect(updated.kind).toBe("result");
+ if (updated.kind === "result") {
+ expect(updated.result).toMatchObject({ updated: true, taskId: "t1" });
+ }
+ expect(client.updateRequestorTask).toHaveBeenCalledWith("t1", {
+ confirm: { approved: true },
+ });
+
const complete = await runMethod(client, {
method: "prompts/complete",
completeRefType: "ref/prompt",
@@ -191,6 +394,36 @@ describe("runMethod (mocked client)", () => {
/tasks\/result/,
);
+ await expect(runMethod(client, { method: "tasks/update" })).rejects.toThrow(
+ /tasks\/update/,
+ );
+ await expect(
+ runMethod(client, { method: "tasks/update", taskId: "t1" }),
+ ).rejects.toThrow(/--input-responses/);
+ await expect(
+ runMethod(client, {
+ method: "tasks/update",
+ taskId: "t1",
+ inputResponsesJson: "not-json",
+ }),
+ ).rejects.toThrow(/--input-responses is invalid/);
+ await expect(
+ runMethod(client, {
+ method: "tasks/update",
+ taskId: "t1",
+ inputResponsesJson: "[1,2,3]",
+ }),
+ ).rejects.toThrow(/--input-responses is invalid/);
+ // 1e999 parses as Infinity, which serialization would silently send as
+ // null — reject it instead of answering with a different value.
+ await expect(
+ runMethod(client, {
+ method: "tasks/update",
+ taskId: "t1",
+ inputResponsesJson: '{"a":{"b":[1e999]}}',
+ }),
+ ).rejects.toThrow(/no JSON representation/);
+
await expect(
runMethod(client, {
method: "roots/set",
diff --git a/clients/cli/__tests__/servers-list.test.ts b/clients/cli/__tests__/servers-list.test.ts
index daa1c44418..b425536252 100644
--- a/clients/cli/__tests__/servers-list.test.ts
+++ b/clients/cli/__tests__/servers-list.test.ts
@@ -7,8 +7,9 @@ import {
} from "./helpers/fixtures.js";
import { expectCliSuccess } from "./helpers/assertions.js";
import {
- annotateServerEntriesWithSessions,
+ annotateServerEntriesWithConnections,
listServerEntries,
+ resolveServerListSource,
sanitizeServerConfig,
sanitizeServerSettings,
showServerEntry,
@@ -60,38 +61,71 @@ describe("summarizeServerConfig", () => {
});
});
-describe("annotateServerEntriesWithSessions", () => {
+describe("annotateServerEntriesWithConnections", () => {
const entries = [
{ name: "a", type: "stdio", detail: "node a" },
{ name: "b", type: "stdio", detail: "node b" },
];
- it("returns entries unchanged when there are no sessions", () => {
- expect(annotateServerEntriesWithSessions(entries, [])).toBe(entries);
+ it("returns entries unchanged when there are no connections", () => {
+ expect(annotateServerEntriesWithConnections(entries, [])).toBe(entries);
});
it("marks matching entry names and MRU", () => {
expect(
- annotateServerEntriesWithSessions(entries, [
+ annotateServerEntriesWithConnections(entries, [
{ name: "b", isMru: true },
{ name: "other" },
]),
).toEqual([
{ name: "a", type: "stdio", detail: "node a" },
- { name: "b", type: "stdio", detail: "node b", session: "b", isMru: true },
+ {
+ name: "b",
+ type: "stdio",
+ detail: "node b",
+ connection: "b",
+ isMru: true,
+ },
]);
});
- it("omits isMru when the session is not MRU", () => {
+ it("omits isMru when the connection is not MRU", () => {
expect(
- annotateServerEntriesWithSessions(entries, [{ name: "a", isMru: false }]),
+ annotateServerEntriesWithConnections(entries, [
+ { name: "a", isMru: false },
+ ]),
).toEqual([
- { name: "a", type: "stdio", detail: "node a", session: "a" },
+ { name: "a", type: "stdio", detail: "node a", connection: "a" },
{ name: "b", type: "stdio", detail: "node b" },
]);
});
});
+describe("resolveServerListSource", () => {
+ it("reports catalog (writable) vs config (read-only) with the resolved path", () => {
+ expect(resolveServerListSource({ catalogPath: "/tmp/cat.json" })).toEqual({
+ kind: "catalog",
+ path: "/tmp/cat.json",
+ });
+ expect(resolveServerListSource({ configPath: "/tmp/conf.json" })).toEqual({
+ kind: "config",
+ path: "/tmp/conf.json",
+ });
+ });
+
+ it("falls back to the default writable catalog when no source is given", () => {
+ const source = resolveServerListSource({});
+ expect(source?.kind).toBe("catalog");
+ expect(source?.path).toMatch(/mcp\.json$/);
+ });
+
+ it("is null for ad-hoc targets (no list source)", () => {
+ expect(
+ resolveServerListSource({ target: ["https://example.com/mcp"] }),
+ ).toBeNull();
+ });
+});
+
describe("listServerEntries / --method servers/list", () => {
let configPath: string | undefined;
diff --git a/clients/cli/src/cli-oauth-navigation.ts b/clients/cli/src/cli-oauth-navigation.ts
index f805c08278..5cd5f1efa0 100644
--- a/clients/cli/src/cli-oauth-navigation.ts
+++ b/clients/cli/src/cli-oauth-navigation.ts
@@ -50,6 +50,15 @@ export type CliOAuthNavigationOptions = {
* (`MCP_AUTO_OPEN_ENABLED=true`).
*/
forceAutoOpen?: boolean;
+ /**
+ * Build the printed prompt line for a given authorize URL. Receives the
+ * (possibly OSC-8-linked) display string and whether stderr is a TTY.
+ * Defaults to the CLI's own "Please navigate to: " framing. Override
+ * when a different caller needs different wording — e.g. mcpdo, addressed to
+ * whatever is running it (which may be an agent that must relay the link to
+ * a human) rather than to a human reading the terminal directly.
+ */
+ promptMessage?: (hrefDisplay: string, tty: boolean) => string;
};
/**
@@ -108,7 +117,10 @@ export function createCliOAuthNavigation(
);
const write =
options.write ?? ((line: string) => process.stderr.write(line));
- write(`Please navigate to: ${style.link(href)}\n`);
+ const promptMessage =
+ options.promptMessage ??
+ ((hrefDisplay: string) => `Please navigate to: ${hrefDisplay}`);
+ write(`${promptMessage(style.link(href), tty)}\n`);
const envAllows =
options.autoOpenEnabled !== undefined
diff --git a/clients/cli/src/cliOAuth.ts b/clients/cli/src/cliOAuth.ts
index 665c2d0a79..698f429400 100644
--- a/clients/cli/src/cliOAuth.ts
+++ b/clients/cli/src/cliOAuth.ts
@@ -215,6 +215,8 @@ export async function runCliInteractiveOAuth(
createCallbackServer: createOAuthCallbackServer,
authorizationUrl: options?.authorizationUrl,
authChallenge: options?.authChallenge,
+ // The CLI has no other Ctrl-C owner; cancel the wait cleanly.
+ handleSignals: true,
}),
);
diff --git a/clients/cli/src/handlers/method-types.ts b/clients/cli/src/handlers/method-types.ts
index 5bef12a05e..ce5a00f557 100644
--- a/clients/cli/src/handlers/method-types.ts
+++ b/clients/cli/src/handlers/method-types.ts
@@ -37,7 +37,7 @@ export type MethodArgs = {
*/
strict?: boolean;
format?: OutputFormat;
- /** Task id for tasks/get, tasks/cancel, tasks/result. */
+ /** Task id for tasks/get, tasks/cancel, tasks/result, tasks/update. */
taskId?: string;
/** When true, tools/call uses callToolStream (task-augmented). */
task?: boolean;
@@ -61,6 +61,12 @@ export type MethodArgs = {
cursor?: string;
/** roots/set payload (JSON array of {uri, name?}). */
rootsJson?: string;
+ /**
+ * tasks/update payload (JSON object keyed by the server's `inputRequests`
+ * ids). Resumes a modern (SEP-2663) task paused on `input_required` —
+ * modern-only, symmetric with `roots/set`'s JSON-blob convention.
+ */
+ inputResponsesJson?: string;
/** prompts/complete: argument name / value / ref. */
completeRefType?: "ref/prompt" | "ref/resource";
completeRef?: string;
@@ -103,10 +109,16 @@ export type MethodOutcome =
*
* TODO(#1432): several of these (subscribe, tasks, roots, logging/tail, …) are
* not exposed by `mcp-inspector --cli` today; they exist for the experimental
- * session CLI (`mcpi`) and other Node runners that share this dispatcher.
+ * connection CLI (`mcpdo`) and other Node runners that share this dispatcher.
+ *
+ * Deliberately excludes `"initialize"` — that's still a valid {@link
+ * ONE_SHOT_METHODS} entry (scripting parity with the literal wire method
+ * name), but for `mcpdo` it read as "send another initialize", which it never
+ * did (it only replays cached connect-time state). `mcpdo connections/show`
+ * covers the same data (server info, capabilities, negotiated era) alongside
+ * daemon session bookkeeping instead.
*/
-export const SESSION_RPC_METHODS = [
- "initialize",
+export const CONNECTION_RPC_METHODS = [
"tools/list",
"tools/call",
"resources/list",
@@ -124,13 +136,14 @@ export const SESSION_RPC_METHODS = [
"tasks/get",
"tasks/cancel",
"tasks/result",
+ "tasks/update",
"roots/list",
"roots/set",
"skills/list",
"skills/get",
] as const;
-export type SessionRpcMethod = (typeof SESSION_RPC_METHODS)[number];
+export type ConnectionRpcMethod = (typeof CONNECTION_RPC_METHODS)[number];
/**
* Methods accepted by `mcp-inspector --cli` (plus catalog-only
diff --git a/clients/cli/src/handlers/run-method.ts b/clients/cli/src/handlers/run-method.ts
index 880c2b2572..7333adddaf 100644
--- a/clients/cli/src/handlers/run-method.ts
+++ b/clients/cli/src/handlers/run-method.ts
@@ -33,6 +33,25 @@ import type {
* `resources/directory/read`, whose stricter `directoryRead` gate lives in
* `InspectorClient` itself.
*/
+/**
+ * `JSON.parse` accepts numeric literals JSON cannot represent (`1e999` →
+ * `Infinity`); serializing the request for IPC/MCP would then silently send
+ * `null` instead of the value the user supplied. Reject anything that cannot
+ * round-trip.
+ */
+function assertJsonRoundTrips(value: unknown): void {
+ if (typeof value === "number" && !Number.isFinite(value)) {
+ throw new Error(
+ `${value} has no JSON representation (it would silently be sent as null)`,
+ );
+ }
+ if (Array.isArray(value)) {
+ for (const item of value) assertJsonRoundTrips(item);
+ } else if (value !== null && typeof value === "object") {
+ for (const item of Object.values(value)) assertJsonRoundTrips(item);
+ }
+}
+
function assertSkillsSupported(
inspectorClient: InspectorClient,
method: string,
@@ -46,6 +65,32 @@ function assertSkillsSupported(
}
}
+/**
+ * Live `resources/subscribe` stream consumers per client and URI. Streams
+ * for the same URI on one connection share a single core subscription
+ * (`subscribeToResource` is a no-op filter update when already subscribed),
+ * so the unsubscribe must be reference-counted: tearing it down when the
+ * first stream closes would leave the survivors open but silent.
+ */
+const resourceStreamRefs = new WeakMap<
+ InspectorClient,
+ Map
+>();
+
+/**
+ * One server-side subscription shared by every open subscribe stream for a
+ * given client + URI. The entry is the synchronization point for concurrent
+ * setups: it is reserved synchronously (before any await), so racing streams
+ * all join the same in-flight `ready` promise instead of each subscribing
+ * and corrupting the count. Consumers are counted from reservation; on
+ * subscribe failure each waiter rolls back its own reservation and the last
+ * one out removes the entry so a later subscribe can retry cleanly.
+ */
+type SharedResourceSubscription = {
+ count: number;
+ ready: Promise;
+};
+
/**
* Run one MCP method against a connected {@link InspectorClient}.
* Core method dispatch used by the CLI (and other Inspector Node runners).
@@ -190,23 +235,76 @@ export async function runMethod(
"URI is required for resources/subscribe. Use --uri to specify the resource URI.",
);
}
- await inspectorClient.subscribeToResource(args.uri);
+ let refs = resourceStreamRefs.get(inspectorClient);
+ if (!refs) {
+ refs = new Map();
+ resourceStreamRefs.set(inspectorClient, refs);
+ }
+ const uri = args.uri;
+ // Reserve before awaiting (see SharedResourceSubscription): the first
+ // arrival creates the entry with the in-flight subscribe, and every
+ // concurrent arrival joins it. The stream is only exposed once the
+ // shared subscribe has succeeded.
+ let shared = refs.get(uri);
+ if (!shared) {
+ shared = { count: 0, ready: inspectorClient.subscribeToResource(uri) };
+ refs.set(uri, shared);
+ }
+ const entry = shared;
+ entry.count++;
+ // Attached BEFORE the subscribe handshake completes: a server may
+ // notify immediately after (or with) its subscribe response, and the
+ // stream's consumer only calls start() after this outcome crosses
+ // back through dispatch. Updates landing in that window are buffered
+ // and flushed to the first writeLine; ipc-glue guarantees every
+ // stream outcome is started (inert-started on a vanished caller), so
+ // stop() below always detaches this listener.
+ const buffered: Array<{ type: string; uri: string }> = [];
+ let sink: ((obj: unknown) => void) | undefined;
+ const onUpdate = (ev: Event) => {
+ const detail = (ev as CustomEvent<{ uri: string }>).detail;
+ // Multiple subscribe streams can share one connection; only
+ // forward updates for this stream's URI. Events without a uri
+ // (spec-noncompliant server) still pass through as before.
+ if (detail?.uri !== undefined && detail.uri !== uri) return;
+ const line = { type: "resources/updated", uri: detail?.uri ?? uri };
+ if (sink) sink(line);
+ else buffered.push(line);
+ };
+ inspectorClient.addEventListener("resourceUpdated", onUpdate);
+ try {
+ await entry.ready;
+ } catch (error) {
+ inspectorClient.removeEventListener("resourceUpdated", onUpdate);
+ entry.count--;
+ if (entry.count === 0 && refs.get(uri) === entry) refs.delete(uri);
+ throw error;
+ }
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);
+ for (const line of buffered) writeLine(line);
+ buffered.length = 0;
+ sink = writeLine;
+ let closed = false;
return () => {
+ // A second stop from any caller must not double-decrement the
+ // shared count.
+ if (closed) return;
+ closed = true;
inspectorClient.removeEventListener("resourceUpdated", onUpdate);
- void inspectorClient.unsubscribeFromResource(args.uri!);
+ entry.count--;
+ if (entry.count > 0) return;
+ // Guard against deleting a successor generation: only remove
+ // the mapping if it is still this stream's entry.
+ if (refs.get(uri) === entry) refs.delete(uri);
+ // Catch the rejection here: this stop can run during daemon
+ // shutdown after disconnectAll has closed the client, where the
+ // unsubscribe rejects; a bare `void` would surface that as an
+ // unhandled rejection outside any caller's try/catch.
+ void inspectorClient.unsubscribeFromResource(uri).catch(() => {});
};
},
};
@@ -216,6 +314,17 @@ export async function runMethod(
"URI is required for resources/unsubscribe. Use --uri to specify the resource URI.",
);
}
+ // Subscribe streams share one server-side subscription per URI (see
+ // resourceStreamRefs above). An explicit unsubscribe here would tear
+ // that shared subscription down while the counted streams stay open
+ // and silent — and the last stream's cleanup would unsubscribe again.
+ const activeStreams =
+ resourceStreamRefs.get(inspectorClient)?.get(args.uri)?.count ?? 0;
+ if (activeStreams > 0) {
+ throw new Error(
+ `Cannot unsubscribe: ${activeStreams} active resources/subscribe stream(s) share this URI's subscription. Close those streams (Ctrl-C) instead; the subscription ends when the last one closes.`,
+ );
+ }
await inspectorClient.unsubscribeFromResource(args.uri);
result = { unsubscribed: true, uri: args.uri };
} else if (args.method === "prompts/list") {
@@ -313,6 +422,39 @@ export async function runMethod(
result = (await inspectorClient.getRequestorTaskResult(
args.taskId,
)) as McpResponse;
+ } else if (args.method === "tasks/update") {
+ if (!args.taskId) {
+ throw new Error("Task id is required for tasks/update. Use --task-id.");
+ }
+ if (!args.inputResponsesJson) {
+ throw new Error(
+ "tasks/update requires --input-responses ''.",
+ );
+ }
+ let inputResponses: Record;
+ try {
+ const parsed: unknown = JSON.parse(args.inputResponsesJson);
+ if (
+ typeof parsed !== "object" ||
+ parsed === null ||
+ Array.isArray(parsed)
+ ) {
+ throw new Error("must be a JSON object");
+ }
+ assertJsonRoundTrips(parsed);
+ inputResponses = parsed as Record;
+ } catch (e) {
+ throw new Error(
+ `--input-responses is invalid: ${e instanceof Error ? e.message : String(e)}`,
+ { cause: e },
+ );
+ }
+ await inspectorClient.updateRequestorTask(args.taskId, inputResponses);
+ // The server acks with an empty result and the task's status advances
+ // only on a subsequent tasks/get poll (updateRequestorTask says so) —
+ // so echo back what was actually sent rather than imply a fresher
+ // status is available here.
+ result = { updated: true, taskId: args.taskId };
} else if (args.method === "skills/list") {
// The store's cursor walk is reused rather than re-implemented — it
// carries the repeated-cursor and page-cap guards, and a second copy of
diff --git a/clients/cli/src/handlers/servers-list.ts b/clients/cli/src/handlers/servers-list.ts
index d580f47c07..76a02603c5 100644
--- a/clients/cli/src/handlers/servers-list.ts
+++ b/clients/cli/src/handlers/servers-list.ts
@@ -5,7 +5,9 @@ import type {
import { InMemorySecretStore } from "@inspector/core/auth/node/secret-store.js";
import {
loadServerEntries,
+ resolveServerSource,
selectServerEntry,
+ withDefaultCatalogPath,
type ServerLoadOptions,
} from "@inspector/core/mcp/node/index.js";
@@ -16,40 +18,61 @@ export type ServerListEntry = {
/** Command line, URL, or other short identity for display. */
detail: string;
/**
- * Optional live-session name when a caller annotates catalog entries
- * with connected sessions (omitted for plain catalog listing).
+ * Optional live-connection name when a caller annotates catalog entries
+ * with live connections (omitted for plain catalog listing).
*/
- session?: string;
- /** True when that session is the most-recently-used connected session. */
+ connection?: string;
+ /** True when that connection is the most-recently-used connection. */
isMru?: boolean;
};
-/** Minimal session shape needed to annotate catalog entries. */
-export type SessionListRef = {
+/** Minimal connection shape needed to annotate catalog entries. */
+export type ConnectionListRef = {
name: string;
isMru?: boolean;
};
/**
- * Mark catalog entries that have a live session with the same name.
+ * Where a server list came from: the writable catalog (default
+ * `~/.mcp-inspector/mcp.json`, or `--catalog` / `MCP_CATALOG_PATH`) or a
+ * read-only `--config` file. Surfaced by `servers/list` so users working
+ * across shells with different catalog env vars can see which file produced
+ * the entries. `null` for ad-hoc targets (no list source).
+ */
+export type ServerListSource = { kind: "catalog" | "config"; path: string };
+
+/**
+ * Resolve the source `listServerEntries` would read for these options,
+ * applying the same default-catalog fallback.
+ */
+export function resolveServerListSource(
+ serverOptions: ServerLoadOptions = {},
+): ServerListSource | null {
+ const source = resolveServerSource(withDefaultCatalogPath(serverOptions));
+ if (!source) return null;
+ return { kind: source.writable ? "catalog" : "config", path: source.path };
+}
+
+/**
+ * Mark catalog entries that have a live connection with the same name.
* Does not mutate `entries`.
*
- * TODO(#1432): consumed by the experimental session CLI (`mcpi`); kept here so
+ * TODO(#1432): consumed by the experimental connection CLI (`mcpdo`); kept here so
* that client can reuse catalog listing without duplicating this helper.
*/
-export function annotateServerEntriesWithSessions(
+export function annotateServerEntriesWithConnections(
entries: ServerListEntry[],
- sessions: SessionListRef[],
+ connections: ConnectionListRef[],
): ServerListEntry[] {
- if (sessions.length === 0) return entries;
- const byName = new Map(sessions.map((s) => [s.name, s] as const));
+ if (connections.length === 0) return entries;
+ const byName = new Map(connections.map((s) => [s.name, s] as const));
return entries.map((entry) => {
- const session = byName.get(entry.name);
- if (!session) return entry;
+ const connection = byName.get(entry.name);
+ if (!connection) return entry;
return {
...entry,
- session: session.name,
- ...(session.isMru === true ? { isMru: true } : {}),
+ connection: connection.name,
+ ...(connection.isMru === true ? { isMru: true } : {}),
};
});
}
diff --git a/clients/cli/src/style.ts b/clients/cli/src/style.ts
index dc2ed15c53..748dc5a91b 100644
--- a/clients/cli/src/style.ts
+++ b/clients/cli/src/style.ts
@@ -5,7 +5,7 @@ import type { OutputFormat } from "./handlers/format-output.js";
*
* TODO(#1432): the CLI OAuth path only needs {@link Style.link} today; bold /
* color helpers and {@link styleFromOpts} are used by the experimental session
- * CLI (`mcpi`) human formatter.
+ * CLI (`mcpdo`) human formatter.
*/
export type Style = {
/** Whether ANSI styling is enabled. */
diff --git a/clients/daemon-cli/README.md b/clients/daemon-cli/README.md
new file mode 100644
index 0000000000..f8969e775e
--- /dev/null
+++ b/clients/daemon-cli/README.md
@@ -0,0 +1,218 @@
+# MCP Inspector connection CLI (`mcpdo`)
+
+**Experimental** separate client — **bundled into the published `@modelcontextprotocol/inspector` package** as the `mcpdo` bin. Connect once, then run many MCP commands against a named connection via an implicit local daemon (ssh-agent style).
+
+> **Layout note:** Source lives in `clients/daemon-cli/`. At build time it bundles some modules from `clients/cli/src` (`handlers/`, `error-handler`, OAuth helpers) via the `@inspector/cli` alias. That reach-in is intentional and temporary — not a published library API — until a cleaner shared package exists (tracked by [#2461](https://github.com/modelcontextprotocol/inspector/issues/2461)).
+
+## Install
+
+`mcpdo` ships with the published package:
+
+```bash
+npm install -g @modelcontextprotocol/inspector
+mcpdo --help
+```
+
+## Install / run (from this repo)
+
+Build, then put `mcpdo` on your PATH with `npm link` (points at this package’s `build/mcp-bin.js`):
+
+```bash
+# from the repo root — install deps once if needed
+npm install
+
+cd clients/daemon-cli
+npm run build
+npm link
+
+mcpdo --help
+```
+
+Rebuild after pulling source changes (`npm run build` in `clients/daemon-cli`). You usually do **not** need to re-link unless the package `bin` entry changes.
+
+### Development loop
+
+`mcpdo` itself is a short-lived process re-executed on every invocation, so a
+plain rebuild is enough for its changes to take effect on the next command.
+The **connection daemon** (`build/daemon.js`) is different: `ensureDaemon` (see
+`src/daemon/ensure.ts`) reuses an already-running daemon without checking its
+code version, so a daemon started before your rebuild keeps running stale
+code indefinitely.
+
+Use `npm run build:dev` instead of `npm run build` while iterating: it runs
+`mcpdo daemon stop` first (harmless/no-op if no daemon is running — it treats
+"daemon not running" as success) and then `tsup`, so the next daemon-backed
+command (`connect`, `tools/list`, …) spawns a fresh daemon from the code you
+just built. Commands that never touch the daemon (`servers/list`,
+`servers/show`, `--help`) don't need this — a plain `npm run build` is enough
+for those.
+
+Without linking, run the built file directly:
+
+```bash
+node clients/daemon-cli/build/mcp-bin.js --help
+```
+
+Remove the link when you’re done:
+
+```bash
+npm unlink -g @modelcontextprotocol/daemon-cli
+```
+
+## Usage
+
+```bash
+mcpdo servers/list --config path/to/mcp.json
+mcpdo servers/show test-stdio --config path/to/mcp.json
+mcpdo connect test-stdio --config path/to/mcp.json
+mcpdo connect my-http --config path/to/mcp.json --relogin # ignore stored OAuth; login only if auth required
+mcpdo auth/list
+mcpdo auth/clear https://example.com/mcp
+mcpdo auth/clear --all --yes
+mcpdo tools/list
+mcpdo tools/call echo message:=hi
+mcpdo tools/call echo '{"message":"hi"}'
+mcpdo @test-stdio resources/list
+mcpdo logging/tail # long-lived; Ctrl-C to stop
+mcpdo connections/list
+mcpdo disconnect --connection test-stdio
+mcpdo daemon status
+mcpdo daemon stop
+
+# Optional: private daemon for this shell only
+eval "$(mcpdo private)"
+mcpdo connect test-stdio --config path/to/mcp.json
+mcpdo tools/list
+```
+
+**Private mode:** `eval "$(mcpdo private)"` gives the shell its own daemon and
+bearer token, separating its connections and daemon state from other mcpdo
+daemons. It is not a security boundary against other processes running as your
+user: anything with the same UID that learns the daemon directory can read the
+token. For a hard boundary, use OS-level isolation (separate user, container).
+
+**Globals (before subcommand):** `--format text|json`, `--plain`, `--connection ` (shorthand: `--conn`), `--catalog` / `--config`, `--stored-auth-only`.
+
+**Output:** `--format text` (default) is human-readable (TTY ANSI unless `--plain` / `NO_COLOR`). `--format json` is pretty-printed payload with **no** `{ result }` envelope.
+
+**Auth:** shared `oauth.json` with other Inspector clients. Connect-time OAuth only on this CLI; mid-connection step-up remains on one-shot `mcp-inspector --cli`. `--relogin` clears any URL-keyed store entry before connect (no-op for stdio). Non-TTY `connect` exits 0 with `pendingAuth: true` and an `authUrl` to relay; after the user signs in, any real command completes the connection, `connections/show` completes it too, and `connections/list` marks the entry `pendingAuthSignedIn` ("signed in — completing on next use") without dialing.
+
+See [`specification/v2_cli_v2.md`](../../specification/v2_cli_v2.md) for the as-built design and to-do list.
+
+## Isolating untrusted stdio servers
+
+The daemon's token controls **who can command the daemon**, not **what a
+spawned server can do**: a stdio MCP server runs with your full user
+privileges, like in any MCP host. To isolate a server you don't fully trust,
+wrap the stdio command in a container — this works today with no mcpdo
+support:
+
+```bash
+mcpdo connect -- docker run -i --rm --network none -v "$PWD:/work:ro"
+```
+
+Tighten or loosen the flags per server (drop `--network none` if it needs
+egress; adjust the mount to what it should see). HTTP/SSE targets run no
+local code, so they need no process isolation.
+
+## Protocol era support
+
+mcpdo shares `core`'s `InspectorClient`, so it negotiates whichever era
+(`legacy` 2025-03-26-style vs. `modern`/2026-era, e.g. task-augmented calls,
+`server/discover`) the target actually speaks — no extra flags needed for
+that to work. Two things are mcpdo-specific:
+
+- **`--era ` on `connect`**: `legacy` (default), `auto` (probe via
+ `server/discover` before connecting), or `modern`. Overrides whatever a
+ catalog/config entry's `protocolEra` says, and is the only way to set it
+ for an ad-hoc target (no config entry to read one from).
+
+ ```bash
+ mcpdo connect my-modern-server --config path/to/mcp.json --era modern
+ mcpdo connect https://example.com/mcp --era auto
+ ```
+
+- **Era visibility in connection output**: `connections/list`, `connections/use`, and
+ `connect` all show the negotiated era inline (`@name (MRU) — server
+[modern]`). `connections/show ` gives the full picture — era, negotiated
+ protocol version, server info, capabilities, and (when the connect probed
+ `server/discover`) the server's supported-versions list:
+
+ ```
+ $ mcpdo connections/show my-modern-server
+ Connection: my-modern-server
+ Server: https://example.com/mcp
+ Era: modern (2026-06-18)
+ Supported versions: 2025-03-26, 2026-06-18
+ ...
+ ```
+
+A paused modern (SEP-2663) task — one whose `tasks/get` shows
+`status: "input_required"` — can be resumed with `tasks/update`:
+
+```bash
+mcpdo tasks/update --input-responses '{"":{"approved":true}}'
+```
+
+## Elicitation support
+
+mcpdo can prompt interactively for both elicitation delivery mechanisms —
+legacy server→client `elicitation/create` requests and modern non-task MRTR
+(multi-round tool response) rounds — and both modes a server may ask for:
+
+- **URL mode**: mcpdo prints the URL and waits for you to confirm you've
+ finished out-of-band (there's no "decline", only accept-that-you-finished
+ or cancel — the actual completion can't be observed locally).
+- **Form mode**: mcpdo renders one prompt per field from the schema, with a
+ review step (edit any field again, or submit) before answering.
+
+Interactive callers (`--format text` on a TTY) get these prompts inline.
+Non-interactive callers — `--format json`, or no TTY at all — don't get a
+prompt: the daemon **parks** the elicitation and the RPC returns an
+`elicitationPending` payload naming the pending id. Answer it (from any
+shell) with `elicitation/respond ` — form answers as `key:=value` pairs
+or JSON, `--done` for URL mode, or `--decline` / `--cancel` — after which the
+original call completes. An unanswered parked elicitation is auto-cancelled
+after 10 minutes.
+
+> **Decision — who answers a prompt.** Non-interactive callers never get an
+> automatic decline: the elicitation is parked so whoever drives mcpdo (a
+> script, an agent relaying to a human) can answer deliberately via
+> `elicitation/respond`, on its own schedule. That is deliberate for an
+> inspector tool. URL-mode is different: there is never an auto-accept —
+> completion is only ever confirmed by an explicit answer, because the
+> out-of-band action (typically an auth or consent step in a browser) is the
+> user's to perform. Use `--elicit off` on `connect` to keep any elicitation
+> from being asked at all.
+
+By default mcpdo advertises **both** modes to the server (`elicit: {url,
+form}`), matching pre-#1783 behavior. Override this per connection with
+`--elicit ` on `connect`:
+
+- `off` — advertise no elicitation capability at all. Useful when whatever is
+ driving mcpdo (a script, an agent) can't handle an interactive prompt itself
+ — omitting the capability lets a well-behaved server fall back to its own
+ alternative (e.g. proceeding with defaults) instead of the request sitting
+ parked until someone answers it.
+- `url` — URL mode only.
+- `form` — form mode only.
+- `both` — the default; both modes.
+
+Like `--era`, this overrides whatever a catalog/config entry's
+`elicitCapability` says, and is the only way to set it for an ad-hoc target
+(no config entry to read one from):
+
+```bash
+mcpdo connect my-server --config path/to/mcp.json --elicit off
+mcpdo connect https://example.com/mcp --elicit url
+```
+
+## Relation to one-shot CLI
+
+| | One-shot | Connection (`mcpdo`) |
+| ------------- | ------------------------------------- | ------------------------------- |
+| Entrypoint | `mcp-inspector --cli` | `mcpdo` |
+| Package (dev) | `clients/cli` | `clients/daemon-cli` |
+| Lifecycle | Connect → one `--method` → disconnect | Connect once → many subcommands |
+
+One-shot docs: [`clients/cli/README.md`](../cli/README.md).
diff --git a/clients/daemon-cli/__tests__/agent-help.test.ts b/clients/daemon-cli/__tests__/agent-help.test.ts
new file mode 100644
index 0000000000..86aaf605ab
--- /dev/null
+++ b/clients/daemon-cli/__tests__/agent-help.test.ts
@@ -0,0 +1,47 @@
+import { describe, it, expect } from "vitest";
+import { existsSync } from "node:fs";
+import { runMcp } from "./helpers/mcp-runner.js";
+
+describe("mcpdo agent-help", () => {
+ it("prints the SKILL.md body with the frontmatter stripped", async () => {
+ const result = await runMcp(["agent-help"]);
+ expect(result.exitCode).toBe(0);
+ expect(result.stdout).toContain("mcpdo connect");
+ expect(result.stdout).not.toContain("name: mcpdo");
+ expect(result.stdout.startsWith("---")).toBe(false);
+ });
+
+ it("--skill explicitly selects the default guide output", async () => {
+ const [bare, explicit] = [
+ await runMcp(["agent-help"]),
+ await runMcp(["agent-help", "--skill"]),
+ ];
+ expect(explicit.exitCode).toBe(0);
+ expect(explicit.stdout).toBe(bare.stdout);
+ });
+
+ it("--skill-path prints the resolved SKILL.md file path", async () => {
+ const result = await runMcp(["agent-help", "--skill-path"]);
+ expect(result.exitCode).toBe(0);
+ const printedPath = result.stdout.trim();
+ expect(printedPath.endsWith("skills/mcpdo/SKILL.md")).toBe(true);
+ expect(existsSync(printedPath)).toBe(true);
+ });
+
+ it("--instructions prints the always-on CLAUDE.md/AGENTS.md snippet", async () => {
+ const result = await runMcp(["agent-help", "--instructions"]);
+ expect(result.exitCode).toBe(0);
+ expect(result.stdout).toContain("part of your available toolset");
+ expect(result.stdout).toContain("mcpdo agent-help");
+ });
+
+ it("rejects --instructions combined with --skill-path", async () => {
+ const result = await runMcp([
+ "agent-help",
+ "--instructions",
+ "--skill-path",
+ ]);
+ expect(result.exitCode).not.toBe(0);
+ expect(result.stderr).toContain("mutually exclusive");
+ });
+});
diff --git a/clients/daemon-cli/__tests__/auth-helper.test.ts b/clients/daemon-cli/__tests__/auth-helper.test.ts
new file mode 100644
index 0000000000..e4368c5d0b
--- /dev/null
+++ b/clients/daemon-cli/__tests__/auth-helper.test.ts
@@ -0,0 +1,592 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+import * as fs from "node:fs";
+import * as os from "node:os";
+import * as path from "node:path";
+import { PassThrough } from "node:stream";
+import type { CallbackNavigation } from "@inspector/core/auth/index.js";
+
+const authorizeInFrontend = vi.fn();
+
+vi.mock("../src/connection/authorize.js", () => ({
+ authorizeInFrontend: (...args: unknown[]) => authorizeInFrontend(...args),
+}));
+
+import {
+ AUTH_HELPER_COMMAND,
+ obtainPendingAuthUrl,
+ pendingAuthMarkerPath,
+ readLivePendingAuthMarker,
+ removeOwnPendingAuthMarker,
+ runAuthHelper,
+ type PendingAuthMarker,
+} from "../src/connection/auth-helper.js";
+
+const SERVER_URL = "https://mcp.example.com/mcp";
+
+describe("auth-helper", () => {
+ let dir: string;
+ let prevDaemonDir: string | undefined;
+
+ beforeEach(() => {
+ dir = fs.mkdtempSync(path.join(os.tmpdir(), "mcp-auth-helper-"));
+ prevDaemonDir = process.env.MCP_INSPECTOR_DAEMON_DIR;
+ process.env.MCP_INSPECTOR_DAEMON_DIR = dir;
+ authorizeInFrontend.mockReset();
+ });
+
+ afterEach(() => {
+ if (prevDaemonDir === undefined)
+ delete process.env.MCP_INSPECTOR_DAEMON_DIR;
+ else process.env.MCP_INSPECTOR_DAEMON_DIR = prevDaemonDir;
+ fs.rmSync(dir, { recursive: true, force: true });
+ });
+
+ function writeMarker(marker: PendingAuthMarker): string {
+ const markerPath = pendingAuthMarkerPath(SERVER_URL);
+ fs.writeFileSync(markerPath, JSON.stringify(marker), { mode: 0o600 });
+ return markerPath;
+ }
+
+ describe("readLivePendingAuthMarker", () => {
+ it("returns undefined when no marker exists", () => {
+ expect(readLivePendingAuthMarker(SERVER_URL)).toBeUndefined();
+ });
+
+ it("returns a live marker (unexpired, helper pid running)", () => {
+ writeMarker({
+ url: "https://as.example/authorize?state=s1",
+ pid: process.pid,
+ expiresAt: Date.now() + 60_000,
+ });
+ expect(readLivePendingAuthMarker(SERVER_URL)).toMatchObject({
+ url: "https://as.example/authorize?state=s1",
+ });
+ });
+
+ it("ignores an expired marker without deleting it (writers replace it)", () => {
+ const markerPath = writeMarker({
+ url: "https://as.example/authorize",
+ pid: process.pid,
+ expiresAt: Date.now() - 1,
+ });
+ expect(readLivePendingAuthMarker(SERVER_URL)).toBeUndefined();
+ // Read-time deletion would race a just-spawned helper's fresh marker.
+ expect(fs.existsSync(markerPath)).toBe(true);
+ });
+
+ it("cleanup removes only this process's own marker", () => {
+ const markerPath = writeMarker({
+ url: "https://as.example/authorize",
+ pid: process.pid,
+ expiresAt: Date.now() + 60_000,
+ });
+ removeOwnPendingAuthMarker(markerPath);
+ expect(fs.existsSync(markerPath)).toBe(false);
+ // A replacement flow's marker (different pid) must survive the old
+ // helper's exit cleanup.
+ writeMarker({
+ url: "https://as.example/authorize-2",
+ pid: process.pid + 1,
+ expiresAt: Date.now() + 60_000,
+ });
+ removeOwnPendingAuthMarker(markerPath);
+ expect(fs.existsSync(markerPath)).toBe(true);
+ // Missing or malformed markers are a no-op, not an error.
+ fs.writeFileSync(markerPath, "not json\n");
+ expect(() => removeOwnPendingAuthMarker(markerPath)).not.toThrow();
+ fs.rmSync(markerPath, { force: true });
+ expect(() => removeOwnPendingAuthMarker(markerPath)).not.toThrow();
+ });
+
+ it("ignores a marker whose helper process is gone", () => {
+ writeMarker({
+ url: "https://as.example/authorize",
+ // Out-of-range / nonexistent pid: process.kill(pid, 0) throws.
+ pid: 0x7fffffff,
+ expiresAt: Date.now() + 60_000,
+ });
+ expect(readLivePendingAuthMarker(SERVER_URL)).toBeUndefined();
+ });
+
+ it("ignores malformed marker files", () => {
+ fs.writeFileSync(pendingAuthMarkerPath(SERVER_URL), "not-json");
+ expect(readLivePendingAuthMarker(SERVER_URL)).toBeUndefined();
+ fs.writeFileSync(pendingAuthMarkerPath(SERVER_URL), '{"url":42}');
+ expect(readLivePendingAuthMarker(SERVER_URL)).toBeUndefined();
+ });
+ });
+
+ describe("obtainPendingAuthUrl", () => {
+ function writeHelperScript(body: string): string {
+ const script = path.join(dir, "fake-helper.mjs");
+ fs.writeFileSync(script, body);
+ return script;
+ }
+
+ it("reuses a live marker's URL without spawning a second helper", async () => {
+ writeMarker({
+ url: "https://as.example/authorize?state=reuse",
+ pid: process.pid,
+ expiresAt: Date.now() + 60_000,
+ });
+ const url = await obtainPendingAuthUrl(
+ { type: "streamable-http", url: SERVER_URL },
+ undefined,
+ // Would fail loudly if a spawn were attempted.
+ { helperArgv1: path.join(dir, "does-not-exist.mjs") },
+ );
+ expect(url).toBe("https://as.example/authorize?state=reuse");
+ });
+
+ it("spawns the helper, passes params over stdin, and returns the reported URL", async () => {
+ const script = writeHelperScript(`
+ let body = "";
+ process.stdin.on("data", (c) => (body += c));
+ process.stdin.on("end", () => {
+ const params = JSON.parse(body);
+ if (process.argv[2] !== ${JSON.stringify(AUTH_HELPER_COMMAND)}) {
+ process.exit(9);
+ }
+ process.stdout.write(
+ JSON.stringify({
+ event: "auth_url",
+ url: "https://as.example/authorize?server=" +
+ encodeURIComponent(params.serverConfig.url),
+ }) + "\\n",
+ );
+ });
+ `);
+ const url = await obtainPendingAuthUrl(
+ { type: "streamable-http", url: SERVER_URL },
+ undefined,
+ { helperArgv1: script },
+ );
+ expect(url).toBe(
+ `https://as.example/authorize?server=${encodeURIComponent(SERVER_URL)}`,
+ );
+ });
+
+ it("maps a helper error event to auth_required", async () => {
+ const script = writeHelperScript(`
+ process.stdin.resume();
+ process.stdin.on("end", () => {
+ process.stdout.write(
+ JSON.stringify({ event: "error", message: "no AS metadata" }) + "\\n",
+ );
+ });
+ `);
+ await expect(
+ obtainPendingAuthUrl(
+ { type: "streamable-http", url: SERVER_URL },
+ undefined,
+ { helperArgv1: script },
+ ),
+ ).rejects.toMatchObject({
+ envelope: { code: "auth_required" },
+ message: expect.stringContaining("no AS metadata"),
+ });
+ });
+
+ it("skips marker reuse for stdio configs and still spawns the helper", async () => {
+ const script = writeHelperScript(`
+ process.stdin.resume();
+ process.stdin.on("end", () => {
+ process.stdout.write(
+ JSON.stringify({ event: "auth_url", url: "https://as.example/stdio" }) + "\\n",
+ );
+ });
+ `);
+ const url = await obtainPendingAuthUrl(
+ { type: "stdio", command: "srv" },
+ undefined,
+ { helperArgv1: script },
+ );
+ expect(url).toBe("https://as.example/stdio");
+ });
+
+ it("skips blank, malformed, and unknown-event lines before the URL", async () => {
+ const script = writeHelperScript(`
+ process.stdin.resume();
+ process.stdin.on("end", () => {
+ process.stdout.write(
+ "\\n" +
+ "not json\\n" +
+ JSON.stringify({ event: "progress" }) + "\\n" +
+ JSON.stringify({ event: "auth_url", url: "https://as.example/after-noise" }) + "\\n",
+ );
+ });
+ `);
+ const url = await obtainPendingAuthUrl(
+ { type: "streamable-http", url: SERVER_URL },
+ undefined,
+ { helperArgv1: script },
+ );
+ expect(url).toBe("https://as.example/after-noise");
+ });
+
+ it("waits for a concurrent reserver's marker instead of spawning", async () => {
+ const lockPath = `${pendingAuthMarkerPath(SERVER_URL)}.lock`;
+ fs.writeFileSync(lockPath, "1234\n", { mode: 0o600 });
+ // Publish the marker shortly after, as the winner's helper would.
+ const t = setTimeout(() => {
+ writeMarker({
+ url: "https://as.example/authorize?state=winner",
+ pid: process.pid,
+ expiresAt: Date.now() + 60_000,
+ });
+ }, 50);
+ try {
+ const url = await obtainPendingAuthUrl(
+ { type: "streamable-http", url: SERVER_URL },
+ undefined,
+ // Would fail loudly if a spawn were attempted.
+ {
+ helperArgv1: path.join(dir, "does-not-exist.mjs"),
+ waitMs: 2_000,
+ pollMs: 10,
+ },
+ );
+ expect(url).toBe("https://as.example/authorize?state=winner");
+ } finally {
+ clearTimeout(t);
+ }
+ });
+
+ it("times out with auth_required when the reserved flow never publishes", async () => {
+ const lockPath = `${pendingAuthMarkerPath(SERVER_URL)}.lock`;
+ fs.writeFileSync(lockPath, "1234\n", { mode: 0o600 });
+ await expect(
+ obtainPendingAuthUrl(
+ { type: "streamable-http", url: SERVER_URL },
+ undefined,
+ {
+ helperArgv1: path.join(dir, "does-not-exist.mjs"),
+ waitMs: 60,
+ pollMs: 10,
+ },
+ ),
+ ).rejects.toMatchObject({
+ envelope: { code: "auth_required" },
+ message: expect.stringContaining("in-progress sign-in"),
+ });
+ });
+
+ it("steals a stale reservation and releases its own after the flow", async () => {
+ const lockPath = `${pendingAuthMarkerPath(SERVER_URL)}.lock`;
+ fs.writeFileSync(lockPath, "1234\n", { mode: 0o600 });
+ // Backdate past the steal threshold (wait window + slack).
+ const old = new Date(Date.now() - 120_000);
+ fs.utimesSync(lockPath, old, old);
+ const script = writeHelperScript(`
+ process.stdin.resume();
+ process.stdin.on("end", () => {
+ process.stdout.write(
+ JSON.stringify({ event: "auth_url", url: "https://as.example/stolen" }) + "\\n",
+ );
+ });
+ `);
+ const url = await obtainPendingAuthUrl(
+ { type: "streamable-http", url: SERVER_URL },
+ undefined,
+ { helperArgv1: script },
+ );
+ expect(url).toBe("https://as.example/stolen");
+ // The reservation is released once the URL is obtained.
+ expect(fs.existsSync(lockPath)).toBe(false);
+ });
+
+ it("backs off when another stealer claims the stale lock first", async () => {
+ const lockPath = `${pendingAuthMarkerPath(SERVER_URL)}.lock`;
+ fs.writeFileSync(lockPath, "1234\n", { mode: 0o600 });
+ const old = new Date(Date.now() - 120_000);
+ fs.utimesSync(lockPath, old, old);
+ // Occupying this process's claim path makes the rename throw — the
+ // same observable outcome as losing the claim race: reserve fails,
+ // the caller waits, and (with no marker forthcoming) times out.
+ fs.mkdirSync(`${lockPath}.claim-${process.pid}`);
+ await expect(
+ obtainPendingAuthUrl(
+ { type: "streamable-http", url: SERVER_URL },
+ undefined,
+ {
+ helperArgv1: path.join(dir, "does-not-exist.mjs"),
+ waitMs: 60,
+ pollMs: 10,
+ },
+ ),
+ ).rejects.toMatchObject({ envelope: { code: "auth_required" } });
+ // The stale lock was claimed away by the rename attempt or left in
+ // place — either way this process never spawned a helper.
+ });
+
+ it("maps a helper spawn failure to auth_required", async () => {
+ const originalExecPath = process.execPath;
+ // A nonexistent interpreter makes spawn emit `error` instead of `exit`.
+ process.execPath = path.join(dir, "no-such-node");
+ try {
+ await expect(
+ obtainPendingAuthUrl(
+ { type: "streamable-http", url: SERVER_URL },
+ undefined,
+ { helperArgv1: path.join(dir, "unused.mjs") },
+ ),
+ ).rejects.toMatchObject({
+ envelope: { code: "auth_required" },
+ message: expect.stringContaining("Failed to spawn"),
+ });
+ } finally {
+ process.execPath = originalExecPath;
+ }
+ });
+
+ it("fails when the helper exits before producing a URL", async () => {
+ const script = writeHelperScript(`process.exit(2);`);
+ await expect(
+ obtainPendingAuthUrl(
+ { type: "streamable-http", url: SERVER_URL },
+ undefined,
+ { helperArgv1: script },
+ ),
+ ).rejects.toMatchObject({
+ envelope: { code: "auth_required" },
+ message: expect.stringContaining("exited"),
+ });
+ });
+ });
+
+ describe("runAuthHelper", () => {
+ function stubStdin(body: string): () => void {
+ const stream = new PassThrough();
+ const descriptor = Object.getOwnPropertyDescriptor(process, "stdin");
+ Object.defineProperty(process, "stdin", {
+ value: stream,
+ configurable: true,
+ });
+ stream.end(body);
+ return () => {
+ if (descriptor) Object.defineProperty(process, "stdin", descriptor);
+ };
+ }
+
+ function captureStdout(): { lines: () => string[]; restore: () => void } {
+ let out = "";
+ const original = process.stdout.write;
+ process.stdout.write = ((chunk: unknown) => {
+ out += typeof chunk === "string" ? chunk : String(chunk);
+ return true;
+ }) as typeof process.stdout.write;
+ return {
+ lines: () =>
+ out
+ .split("\n")
+ .filter((l) => l.trim())
+ .map((l) => l),
+ restore: () => {
+ process.stdout.write = original;
+ },
+ };
+ }
+
+ it("writes the marker while the flow runs, emits auth_url and done, and removes the marker on exit", async () => {
+ let markerDuringFlow: PendingAuthMarker | undefined;
+ authorizeInFrontend.mockImplementation(
+ async (
+ _config: unknown,
+ _settings: unknown,
+ options: {
+ makeNavigation: (control: { armed: boolean }) => CallbackNavigation;
+ },
+ ) => {
+ const navigation = options.makeNavigation({ armed: true });
+ navigation.navigateToAuthorization(
+ new URL("https://as.example/authorize?state=s2"),
+ );
+ markerDuringFlow = readLivePendingAuthMarker(SERVER_URL);
+ },
+ );
+ const restoreStdin = stubStdin(
+ JSON.stringify({
+ serverConfig: { type: "streamable-http", url: SERVER_URL },
+ }),
+ );
+ const stdout = captureStdout();
+ try {
+ await runAuthHelper();
+ } finally {
+ stdout.restore();
+ restoreStdin();
+ }
+ expect(markerDuringFlow).toMatchObject({
+ url: "https://as.example/authorize?state=s2",
+ pid: process.pid,
+ });
+ const events = stdout
+ .lines()
+ .map((l) => JSON.parse(l) as { event: string });
+ expect(events.map((e) => e.event)).toEqual(["auth_url", "done"]);
+ // Marker removed once the flow completed.
+ expect(fs.existsSync(pendingAuthMarkerPath(SERVER_URL))).toBe(false);
+ });
+
+ it("stays silent while the navigation is disarmed (SDK auth during plain connect)", async () => {
+ authorizeInFrontend.mockImplementation(
+ async (
+ _config: unknown,
+ _settings: unknown,
+ options: {
+ makeNavigation: (control: { armed: boolean }) => CallbackNavigation;
+ },
+ ) => {
+ const navigation = options.makeNavigation({ armed: false });
+ navigation.navigateToAuthorization(
+ new URL("https://as.example/authorize?leaked=1"),
+ );
+ },
+ );
+ const restoreStdin = stubStdin(
+ JSON.stringify({
+ serverConfig: { type: "streamable-http", url: SERVER_URL },
+ }),
+ );
+ const stdout = captureStdout();
+ try {
+ await runAuthHelper();
+ } finally {
+ stdout.restore();
+ restoreStdin();
+ }
+ const events = stdout
+ .lines()
+ .map((l) => JSON.parse(l) as { event: string });
+ expect(events.map((e) => e.event)).toEqual(["done"]);
+ expect(fs.existsSync(pendingAuthMarkerPath(SERVER_URL))).toBe(false);
+ });
+
+ it("emits an error event and rethrows when the flow fails", async () => {
+ authorizeInFrontend.mockRejectedValueOnce(new Error("flow exploded"));
+ const restoreStdin = stubStdin(
+ JSON.stringify({
+ serverConfig: { type: "streamable-http", url: SERVER_URL },
+ }),
+ );
+ const stdout = captureStdout();
+ try {
+ await expect(runAuthHelper()).rejects.toThrow("flow exploded");
+ } finally {
+ stdout.restore();
+ restoreStdin();
+ }
+ const events = stdout
+ .lines()
+ .map((l) => JSON.parse(l) as { event: string; message?: string });
+ expect(events).toEqual([{ event: "error", message: "flow exploded" }]);
+ });
+
+ it("emits the URL without writing a marker for stdio configs", async () => {
+ authorizeInFrontend.mockImplementation(
+ async (
+ _config: unknown,
+ _settings: unknown,
+ options: {
+ makeNavigation: (control: { armed: boolean }) => CallbackNavigation;
+ },
+ ) => {
+ const navigation = options.makeNavigation({ armed: true });
+ navigation.navigateToAuthorization(
+ new URL("https://as.example/authorize?stdio=1"),
+ );
+ },
+ );
+ const restoreStdin = stubStdin(
+ JSON.stringify({ serverConfig: { type: "stdio", command: "srv" } }),
+ );
+ const stdout = captureStdout();
+ try {
+ await runAuthHelper();
+ // The EPIPE guard on stdout must swallow late write errors.
+ process.stdout.emit("error", new Error("EPIPE"));
+ } finally {
+ stdout.restore();
+ restoreStdin();
+ }
+ const events = stdout
+ .lines()
+ .map((l) => JSON.parse(l) as { event: string });
+ expect(events.map((e) => e.event)).toEqual(["auth_url", "done"]);
+ // No url on the config → no marker file anywhere in the daemon dir.
+ expect(fs.readdirSync(dir).filter((f) => f.includes("auth"))).toEqual([]);
+ });
+
+ it("stringifies a non-Error flow failure in the error event", async () => {
+ authorizeInFrontend.mockRejectedValueOnce("string boom");
+ const restoreStdin = stubStdin(
+ JSON.stringify({
+ serverConfig: { type: "streamable-http", url: SERVER_URL },
+ }),
+ );
+ const stdout = captureStdout();
+ try {
+ await expect(runAuthHelper()).rejects.toBe("string boom");
+ } finally {
+ stdout.restore();
+ restoreStdin();
+ }
+ const events = stdout
+ .lines()
+ .map((l) => JSON.parse(l) as { event: string; message?: string });
+ expect(events).toEqual([{ event: "error", message: "string boom" }]);
+ });
+
+ it("rejects when stdin errors before EOF", async () => {
+ const stream = new PassThrough();
+ const descriptor = Object.getOwnPropertyDescriptor(process, "stdin");
+ Object.defineProperty(process, "stdin", {
+ value: stream,
+ configurable: true,
+ });
+ const stdout = captureStdout();
+ try {
+ const pending = runAuthHelper();
+ stream.emit("error", new Error("broken pipe"));
+ await expect(pending).rejects.toThrow("broken pipe");
+ } finally {
+ stdout.restore();
+ if (descriptor) Object.defineProperty(process, "stdin", descriptor);
+ }
+ });
+
+ it("times out when the parent never sends params", async () => {
+ vi.useFakeTimers();
+ const stream = new PassThrough();
+ const descriptor = Object.getOwnPropertyDescriptor(process, "stdin");
+ Object.defineProperty(process, "stdin", {
+ value: stream,
+ configurable: true,
+ });
+ const stdout = captureStdout();
+ try {
+ const pending = runAuthHelper();
+ const expectation = expect(pending).rejects.toThrow(
+ /timed out waiting for params/,
+ );
+ await vi.advanceTimersByTimeAsync(30_000);
+ await expectation;
+ } finally {
+ vi.useRealTimers();
+ stdout.restore();
+ if (descriptor) Object.defineProperty(process, "stdin", descriptor);
+ }
+ });
+
+ it("rejects params without a serverConfig", async () => {
+ const restoreStdin = stubStdin(JSON.stringify({}));
+ const stdout = captureStdout();
+ try {
+ await expect(runAuthHelper()).rejects.toThrow(/serverConfig/);
+ } finally {
+ stdout.restore();
+ restoreStdin();
+ }
+ });
+ });
+});
diff --git a/clients/daemon-cli/__tests__/authorize.test.ts b/clients/daemon-cli/__tests__/authorize.test.ts
new file mode 100644
index 0000000000..420ccf50a8
--- /dev/null
+++ b/clients/daemon-cli/__tests__/authorize.test.ts
@@ -0,0 +1,152 @@
+import { describe, it, expect, vi, afterEach } from "vitest";
+import type { MCPServerConfig } from "@inspector/core/mcp/types.js";
+
+const connectSpy = vi.fn();
+const disconnectSpy = vi.fn().mockResolvedValue(undefined);
+const navigationSpy = vi.fn();
+
+vi.mock("@inspector/cli/cliOAuth.js", () => ({
+ connectInspectorWithOAuth: (...args: unknown[]) => connectSpy(...args),
+}));
+
+vi.mock("@inspector/cli/cli-oauth-navigation.js", () => ({
+ createCliOAuthNavigation: (...args: unknown[]) => {
+ navigationSpy(...args);
+ return { navigate: vi.fn() };
+ },
+}));
+
+vi.mock("@inspector/core/mcp/index.js", () => ({
+ InspectorClient: class {
+ connect = vi.fn();
+ disconnect = disconnectSpy;
+ },
+}));
+
+vi.mock("@inspector/core/client/runner.js", async (importOriginal) => {
+ const actual =
+ await importOriginal();
+ return {
+ ...actual,
+ loadRunnerClientConfig: vi.fn().mockResolvedValue({}),
+ buildRunnerClientAuthOptions: vi.fn().mockReturnValue({}),
+ };
+});
+
+describe("authorizeInFrontend", () => {
+ afterEach(() => {
+ connectSpy.mockReset();
+ disconnectSpy.mockClear();
+ navigationSpy.mockClear();
+ });
+
+ it("no-ops for non-OAuth-capable (stdio) configs", async () => {
+ const { authorizeInFrontend } =
+ await import("../src/connection/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/connection/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/connection/authorize.js");
+ await expect(
+ authorizeInFrontend(
+ { type: "streamable-http", url: "https://example.com/mcp" },
+ undefined,
+ ),
+ ).resolves.toBeUndefined();
+ });
+
+ it("always admits interactive OAuth (isTTY: true), regardless of the real TTY state", async () => {
+ connectSpy.mockResolvedValue(undefined);
+ const { authorizeInFrontend } =
+ await import("../src/connection/authorize.js");
+ await authorizeInFrontend(
+ { type: "streamable-http", url: "https://example.com/mcp" },
+ undefined,
+ );
+ const options = connectSpy.mock.calls[0]?.[5] as { isTTY?: boolean };
+ expect(options.isTTY).toBe(true);
+ });
+
+ it("addresses the printed authorization line to whoever must relay it — a human directly, or an agent on behalf of one", async () => {
+ connectSpy.mockResolvedValue(undefined);
+ const { authorizeInFrontend } =
+ await import("../src/connection/authorize.js");
+ await authorizeInFrontend(
+ { type: "streamable-http", url: "https://example.com/mcp" },
+ undefined,
+ );
+ const navOptions = navigationSpy.mock.calls[0]?.[0] as {
+ promptMessage: (hrefDisplay: string, tty: boolean) => string;
+ };
+ expect(navOptions.promptMessage("https://example.com/auth", true)).toBe(
+ "Please navigate to: https://example.com/auth",
+ );
+ expect(navOptions.promptMessage("https://example.com/auth", false)).toBe(
+ "The user needs to navigate to this link to authenticate: https://example.com/auth",
+ );
+ });
+
+ it("rethrows non-EMA connect failures unchanged", async () => {
+ connectSpy.mockRejectedValue(new Error("network down"));
+ const { authorizeInFrontend } =
+ await import("../src/connection/authorize.js");
+ await expect(
+ authorizeInFrontend(
+ { type: "streamable-http", url: "https://example.com/mcp" },
+ undefined,
+ ),
+ ).rejects.toThrow("network down");
+ expect(disconnectSpy).toHaveBeenCalled();
+ });
+
+ it("uses a caller-provided navigation instead of the CLI default", async () => {
+ connectSpy.mockResolvedValue(undefined);
+ const makeNavigation = vi.fn().mockReturnValue({ navigate: vi.fn() });
+ const { authorizeInFrontend } =
+ await import("../src/connection/authorize.js");
+ await authorizeInFrontend(
+ { type: "streamable-http", url: "https://example.com/mcp" },
+ undefined,
+ { makeNavigation },
+ );
+ expect(makeNavigation).toHaveBeenCalledTimes(1);
+ expect(navigationSpy).not.toHaveBeenCalled();
+ });
+
+ it("maps EmaClientNotConfiguredError to actionable mcpdo guidance", async () => {
+ const { EmaClientNotConfiguredError } =
+ await import("@inspector/core/auth/ema/clientConfigError.js");
+ connectSpy.mockRejectedValue(new EmaClientNotConfiguredError("disabled"));
+ const { authorizeInFrontend } =
+ await import("../src/connection/authorize.js");
+ await expect(
+ authorizeInFrontend(
+ { type: "streamable-http", url: "https://example.com/mcp" },
+ undefined,
+ ),
+ ).rejects.toThrow(/EMA.*disabled/i);
+ // Still tears the probe client down on the error path.
+ expect(disconnectSpy).toHaveBeenCalled();
+ });
+});
diff --git a/clients/daemon-cli/__tests__/connection-stored-auth.test.ts b/clients/daemon-cli/__tests__/connection-stored-auth.test.ts
new file mode 100644
index 0000000000..4855552f86
--- /dev/null
+++ b/clients/daemon-cli/__tests__/connection-stored-auth.test.ts
@@ -0,0 +1,329 @@
+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 { writeOAuthSections } from "@inspector/core/auth/node/oauth-persist-file.js";
+import {
+ clearAllStoredAuth,
+ clearStoredAuth,
+ clearStoredAuthForRelogin,
+ listStoredAuth,
+ resolveStoredAuthKey,
+} from "../src/connection/stored-auth.js";
+import { CliExitCodeError } from "@inspector/cli/error-handler.js";
+import { defaultSecretStore } from "@inspector/core/auth/node/secret-store-selection.js";
+import { oauthSecretServerId } from "@inspector/core/auth/node/oauth-secrets.js";
+import { runMcp } from "./helpers/mcp-runner.js";
+import {
+ expectCliSuccess,
+ expectCliFailure,
+} from "../../cli/__tests__/helpers/assertions.js";
+
+function writeOAuthFixture(dir: string): string {
+ const file = path.join(dir, "oauth.json");
+ fs.writeFileSync(
+ file,
+ JSON.stringify({
+ servers: {
+ "https://example.com/mcp": {
+ byIssuer: {
+ "https://as.example/": {
+ tokens: {
+ access_token: "a",
+ token_type: "Bearer",
+ refresh_token: "r",
+ },
+ },
+ },
+ activeIssuer: "https://as.example/",
+ },
+ "https://other.example/mcp": {
+ tokens: { access_token: "x", token_type: "Bearer" },
+ },
+ "https://empty.example/mcp": {
+ codeVerifier: "cv",
+ },
+ // Note: entries that are not objects (null, strings) now make the
+ // whole file unrecognized upstream (proto-safe parsing) — the file
+ // indexes secret-store entries, so core refuses rather than treats
+ // it as empty. Junk entries therefore no longer belong in a fixture.
+ "https://issuer-empty.example/mcp": {
+ byIssuer: {
+ "https://as.example/": {},
+ },
+ },
+ "https://multi.example/mcp": {
+ // First issuer: access only. Second: access + refresh. The summary
+ // must aggregate across slots, not stop at the first access token.
+ byIssuer: {
+ "https://as-a.example/": {
+ tokens: { access_token: "a1", token_type: "Bearer" },
+ },
+ "https://as-b.example/": {
+ tokens: {
+ access_token: "a2",
+ token_type: "Bearer",
+ refresh_token: "r2",
+ },
+ },
+ },
+ },
+ },
+ idpSessions: {},
+ }),
+ "utf8",
+ );
+ return file;
+}
+
+const FIXTURE_URLS = [
+ "https://example.com/mcp",
+ "https://other.example/mcp",
+ "https://empty.example/mcp",
+ "https://issuer-empty.example/mcp",
+ "https://multi.example/mcp",
+];
+
+/**
+ * The pinned in-memory secret store (vitest.config.ts) is process-wide and
+ * keyed by server URL, not by state-file path — reads migrate fixture
+ * plaintext into it and joined reads prefer it over the file, so one test's
+ * migrated tokens would leak into the next test's fresh fixture.
+ */
+async function purgeFixtureSecrets(): Promise {
+ const store = defaultSecretStore();
+ for (const url of FIXTURE_URLS) {
+ await store.deleteAllForServer(oauthSecretServerId(url));
+ }
+}
+
+describe("connection stored-auth helpers", () => {
+ let dir: string | undefined;
+ let prevPath: string | undefined;
+
+ afterEach(async () => {
+ if (prevPath === undefined)
+ delete process.env.MCP_INSPECTOR_OAUTH_STATE_PATH;
+ else process.env.MCP_INSPECTOR_OAUTH_STATE_PATH = prevPath;
+ resetNodeOAuthStorageCache();
+ await purgeFixtureSecrets();
+ if (dir) {
+ fs.rmSync(dir, { recursive: true, force: true });
+ dir = undefined;
+ }
+ });
+
+ 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://multi.example/mcp",
+ "https://other.example/mcp",
+ ]);
+ expect(
+ list.servers.find((s) => s.url.includes("issuer-empty")),
+ ).toMatchObject({ hasTokens: false, hasRefreshToken: false });
+ // Aggregated across issuer slots: the refresh token lives in the second
+ // slot, so first-match-wins would have reported hasRefreshToken: false.
+ expect(list.servers.find((s) => s.url.includes("multi"))).toMatchObject({
+ hasTokens: true,
+ hasRefreshToken: true,
+ });
+ expect(
+ list.servers.find((s) => s.url.includes("example.com")),
+ ).toMatchObject({ hasTokens: true, hasRefreshToken: true });
+ 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("still reports tokens after they are split into the secret store", async () => {
+ // Regression for the #2482 adaptation: writes split tokens out of
+ // oauth.json into the secret store, so a raw-file parse would report
+ // every entry as token-less. listStoredAuth must use the joined read.
+ // (Read-side plaintext migration is skipped for the non-durable memory
+ // store pinned in vitest.config.ts, so exercise the write-side split.)
+ dir = fs.mkdtempSync(path.join(os.tmpdir(), "mcp-stored-auth-"));
+ const file = path.join(dir, "oauth.json");
+ prevPath = process.env.MCP_INSPECTOR_OAUTH_STATE_PATH;
+ process.env.MCP_INSPECTOR_OAUTH_STATE_PATH = file;
+ resetNodeOAuthStorageCache();
+
+ await writeOAuthSections(
+ file,
+ {
+ servers: {
+ "https://example.com/mcp": {
+ tokens: {
+ access_token: "a",
+ token_type: "Bearer",
+ refresh_token: "r",
+ },
+ },
+ },
+ idpSessions: {},
+ },
+ { servers: ["https://example.com/mcp"] },
+ );
+
+ const raw = fs.readFileSync(file, "utf8");
+ expect(raw).not.toContain("access_token");
+
+ const list = await listStoredAuth();
+ expect(
+ list.servers.find((s) => s.url.includes("example.com")),
+ ).toMatchObject({ hasTokens: true, hasRefreshToken: true });
+ });
+
+ it("clears one key and all keys", async () => {
+ useFixture();
+ const cleared = await clearStoredAuth("https://example.com/mcp");
+ 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(4);
+ 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(async () => {
+ resetNodeOAuthStorageCache();
+ await purgeFixtureSecrets();
+ if (dir) {
+ fs.rmSync(dir, { recursive: true, force: true });
+ dir = undefined;
+ }
+ });
+
+ it("lists and clears via connection 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(5);
+
+ 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: 4 });
+ });
+
+ 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/daemon-cli/__tests__/daemon-connections.test.ts b/clients/daemon-cli/__tests__/daemon-connections.test.ts
new file mode 100644
index 0000000000..35857d16d5
--- /dev/null
+++ b/clients/daemon-cli/__tests__/daemon-connections.test.ts
@@ -0,0 +1,1617 @@
+import { describe, it, expect, afterEach, vi } from "vitest";
+import * as fs from "node:fs";
+import * as os from "node:os";
+import * as path from "node:path";
+import { getTestMcpServerCommand } from "@modelcontextprotocol/inspector-test-server";
+import { DaemonServer } from "../src/daemon/server.js";
+import { callDaemon } from "../src/daemon/client.js";
+import { parseRequestLine, encodeResponse } from "../src/daemon/framing.js";
+import {
+ DEFAULT_IDLE_MS,
+ elicitCapabilityToClientOption,
+ getLiveConnectionAuthInfo,
+ getConnectionAuthInfo,
+ isConnectionAuthRequiredError,
+ ConnectionRegistry,
+} from "../src/daemon/connections.js";
+import { CliExitCodeError } from "@inspector/cli/error-handler.js";
+import { AuthRecoveryRequiredError } from "@inspector/core/auth/challenge.js";
+
+describe("daemon framing", () => {
+ it("parses and rejects invalid request lines", () => {
+ expect(parseRequestLine("")).toBeNull();
+ expect(parseRequestLine(" ")).toBeNull();
+ expect(parseRequestLine('{"id":"1","op":"ping"}')).toEqual({
+ id: "1",
+ op: "ping",
+ });
+ expect(() => parseRequestLine("not-json")).toThrow();
+ expect(() => parseRequestLine('{"op":"ping"}')).toThrow(/Invalid daemon/);
+ expect(encodeResponse({ id: "1", ok: true, result: { pong: true } })).toBe(
+ '{"id":"1","ok":true,"result":{"pong":true}}\n',
+ );
+ });
+});
+
+describe("elicitCapabilityToClientOption", () => {
+ it("maps each elicitCapability mode to the InspectorClient elicit shape", () => {
+ expect(elicitCapabilityToClientOption("off")).toBe(false);
+ expect(elicitCapabilityToClientOption("url")).toEqual({ url: true });
+ expect(elicitCapabilityToClientOption("form")).toEqual({ form: true });
+ expect(elicitCapabilityToClientOption("both")).toEqual({
+ url: true,
+ form: true,
+ });
+ });
+
+ it("defaults to both (url+form) when unset, matching the pre-#1783 hardcoded default", () => {
+ expect(elicitCapabilityToClientOption(undefined)).toEqual({
+ url: true,
+ form: true,
+ });
+ });
+});
+
+describe("isConnectionAuthRequiredError", () => {
+ it("treats EMA client misconfiguration as auth_required (front-end maps it to guidance)", async () => {
+ const { EmaClientNotConfiguredError } =
+ await import("@inspector/core/auth/ema/clientConfigError.js");
+ expect(
+ isConnectionAuthRequiredError(
+ new EmaClientNotConfiguredError("not_configured"),
+ ),
+ ).toBe(true);
+ });
+
+ it("recognizes unauthorized, recovery, and SDK token-exchange failures", () => {
+ expect(isConnectionAuthRequiredError(new Error("nope"))).toBe(false);
+ expect(
+ isConnectionAuthRequiredError(
+ new AuthRecoveryRequiredError(new URL("https://as.example/a"), {
+ reason: "unauthorized",
+ }),
+ ),
+ ).toBe(true);
+ const unauthorized = Object.assign(new Error("boom"), { status: 401 });
+ expect(isConnectionAuthRequiredError(unauthorized)).toBe(true);
+ expect(
+ isConnectionAuthRequiredError(
+ new Error(
+ "Either provider.prepareTokenRequest() or authorizationCode is required",
+ ),
+ ),
+ ).toBe(true);
+ expect(
+ isConnectionAuthRequiredError(
+ new Error("redirectUrl is required for authorization_code flow"),
+ ),
+ ).toBe(true);
+ expect(
+ isConnectionAuthRequiredError(
+ new Error("No code verifier saved for connection"),
+ ),
+ ).toBe(true);
+ });
+});
+
+describe("getConnectionAuthInfo", () => {
+ const clientWith = (
+ getOAuthState: () => Promise,
+ ): Parameters[0] =>
+ ({ getOAuthState }) as unknown as Parameters<
+ typeof getConnectionAuthInfo
+ >[0];
+
+ it("is undefined for no-auth connections and when the state read fails", async () => {
+ expect(
+ await getConnectionAuthInfo(clientWith(async () => undefined)),
+ ).toBeUndefined();
+ expect(
+ await getConnectionAuthInfo(
+ clientWith(async () => {
+ throw new Error("storage unavailable");
+ }),
+ ),
+ ).toBeUndefined();
+ });
+
+ it("projects standard OAuth state (scope + clientId when present)", async () => {
+ expect(
+ await getConnectionAuthInfo(
+ clientWith(async () => ({
+ authorized: true,
+ protocol: "standard",
+ serverUrl: "https://mcp.example",
+ grantedScope: "mcp:tools",
+ client: { clientId: "client-123", hasClientSecret: false },
+ })),
+ ),
+ ).toEqual({
+ method: "oauth",
+ authorized: true,
+ scope: "mcp:tools",
+ clientId: "client-123",
+ });
+ });
+
+ it("projects EMA state with IdP session and omits absent optionals", async () => {
+ expect(
+ await getConnectionAuthInfo(
+ clientWith(async () => ({
+ authorized: false,
+ protocol: "ema",
+ serverUrl: "https://mcp.example",
+ ema: {
+ idpIssuer: "https://idp.example",
+ idpClientId: "idp-client",
+ idpSession: "logged_in",
+ },
+ })),
+ ),
+ ).toEqual({ method: "ema", authorized: false, idpSession: "logged_in" });
+ });
+});
+
+describe("getLiveConnectionAuthInfo", () => {
+ it("is undefined for stdio, malformed http configs, and unengaged OAuth", async () => {
+ const { resetNodeOAuthStorageCache } =
+ await import("@inspector/core/auth/node/storage-node.js");
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "mcp-conn-live-auth-"));
+ const saved = process.env.MCP_INSPECTOR_OAUTH_STATE_PATH;
+ const savedClient = process.env.MCP_CLIENT_CONFIG_PATH;
+ process.env.MCP_INSPECTOR_OAUTH_STATE_PATH = path.join(dir, "oauth.json");
+ process.env.MCP_CLIENT_CONFIG_PATH = path.join(dir, "client.json");
+ resetNodeOAuthStorageCache();
+ try {
+ expect(
+ await getLiveConnectionAuthInfo({
+ serverConfig: { type: "stdio", command: "x" },
+ }),
+ ).toBeUndefined();
+ // Defensive: OAuth-capable type without a usable url.
+ expect(
+ await getLiveConnectionAuthInfo({
+ serverConfig: { type: "streamable-http" } as never,
+ }),
+ ).toBeUndefined();
+ // http server, no oauth config anywhere, empty storage: no snapshot.
+ expect(
+ await getLiveConnectionAuthInfo({
+ serverConfig: {
+ type: "streamable-http",
+ url: "https://mcp.example.com/mcp",
+ },
+ }),
+ ).toBeUndefined();
+ // Corrupt oauth.json: the disk read fails, and the best-effort catch
+ // yields undefined rather than failing connections/show.
+ fs.writeFileSync(process.env.MCP_INSPECTOR_OAUTH_STATE_PATH!, "{nope");
+ resetNodeOAuthStorageCache();
+ expect(
+ await getLiveConnectionAuthInfo({
+ serverConfig: {
+ type: "streamable-http",
+ url: "https://mcp.example.com/mcp",
+ },
+ }),
+ ).toBeUndefined();
+ } finally {
+ if (saved === undefined)
+ delete process.env.MCP_INSPECTOR_OAUTH_STATE_PATH;
+ else process.env.MCP_INSPECTOR_OAUTH_STATE_PATH = saved;
+ if (savedClient === undefined) delete process.env.MCP_CLIENT_CONFIG_PATH;
+ else process.env.MCP_CLIENT_CONFIG_PATH = savedClient;
+ resetNodeOAuthStorageCache();
+ fs.rmSync(dir, { recursive: true, force: true });
+ }
+ });
+});
+
+describe("ConnectionRegistry", () => {
+ it("requires an explicit connection when asked", () => {
+ const registry = new ConnectionRegistry(0);
+ expect(() => registry.resolve(undefined, true)).toThrow(CliExitCodeError);
+ expect(() => registry.resolve(undefined, false)).toThrow(
+ /No open connections/,
+ );
+ });
+
+ it("tracks MRU across connect/disconnect", async () => {
+ const { command, args } = getTestMcpServerCommand();
+ const registry = new ConnectionRegistry(0);
+ const a = await registry.connect({
+ name: "a",
+ serverConfig: { type: "stdio", command, args },
+ serverIdentity: `${command} ${args.join(" ")}`,
+ });
+ expect(a.isMru).toBe(true);
+ // stdio transport: no OAuth, so no auth snapshot is reported.
+ expect(a.auth).toBeUndefined();
+ const b = await registry.connect({
+ name: "b",
+ serverConfig: { type: "stdio", command, args },
+ serverIdentity: `${command} ${args.join(" ")}`,
+ });
+ expect(b.isMru).toBe(true);
+ expect(registry.getMruName()).toBe("b");
+ registry.use("a");
+ expect(registry.getMruName()).toBe("a");
+ await registry.disconnect("b", false);
+ expect(registry.list().map((s) => s.name)).toEqual(["a"]);
+ await registry.disconnect(undefined, false);
+ expect(registry.connectionCount()).toBe(0);
+ expect(DEFAULT_IDLE_MS).toBe(60_000);
+ });
+
+ it("disconnectAll tears down the remaining connections when one disconnect fails", async () => {
+ const { InspectorClient } = await import("@inspector/core/mcp/index.js");
+ const connectSpy = vi
+ .spyOn(InspectorClient.prototype, "connect")
+ .mockResolvedValue(undefined);
+ const disconnectSpy = vi
+ .spyOn(InspectorClient.prototype, "disconnect")
+ .mockResolvedValue(undefined);
+ const authSpy = vi
+ .spyOn(InspectorClient.prototype, "getOAuthState")
+ .mockResolvedValue(undefined as never);
+ const registry = new ConnectionRegistry(0);
+ try {
+ const params = (name: string) =>
+ ({
+ name,
+ serverConfig: {
+ type: "streamable-http",
+ url: "https://mcp.example.com/mcp",
+ },
+ serverIdentity: "https://mcp.example.com/mcp",
+ }) as const;
+ await registry.connect(params("a"));
+ await registry.connect(params("b"));
+ // First teardown loses a race (connection_not_found); shutdown must
+ // still settle and tear down the rest instead of leaking "b".
+ const spy = vi.spyOn(registry, "disconnect");
+ spy.mockRejectedValueOnce(
+ new CliExitCodeError(1, "No connection named 'a'.", {
+ code: "connection_not_found",
+ }),
+ );
+ await expect(registry.disconnectAll()).resolves.toBeUndefined();
+ expect(spy).toHaveBeenCalledTimes(2);
+ // "b" was genuinely disconnected, not abandoned mid-loop.
+ expect(registry.list().map((c) => c.name)).toEqual(["a"]);
+ } finally {
+ connectSpy.mockRestore();
+ disconnectSpy.mockRestore();
+ authSpy.mockRestore();
+ }
+ });
+
+ it("serializes concurrent connects for the same name so the replaced client is torn down, not leaked", async () => {
+ const { InspectorClient } = await import("@inspector/core/mcp/index.js");
+ // Slow connect widens the check→set window that raced pre-lock.
+ const connectSpy = vi
+ .spyOn(InspectorClient.prototype, "connect")
+ .mockImplementation(
+ () => new Promise((resolve) => setTimeout(resolve, 25)),
+ );
+ const disconnectSpy = vi
+ .spyOn(InspectorClient.prototype, "disconnect")
+ .mockResolvedValue(undefined);
+ const authSpy = vi
+ .spyOn(InspectorClient.prototype, "getOAuthState")
+ .mockResolvedValue(undefined as never);
+ const registry = new ConnectionRegistry(0);
+ try {
+ const params = {
+ name: "dup",
+ serverConfig: {
+ type: "streamable-http",
+ url: "https://mcp.example.com/mcp",
+ },
+ serverIdentity: "https://mcp.example.com/mcp",
+ } as const;
+ const [a, b] = await Promise.all([
+ registry.connect(params),
+ registry.connect(params),
+ ]);
+ expect(a.name).toBe("dup");
+ expect(b.name).toBe("dup");
+ // Exactly one tracked connection; the loser of the race was
+ // disconnected by the serialized reconnect path, not orphaned.
+ expect(registry.connectionCount()).toBe(1);
+ expect(connectSpy).toHaveBeenCalledTimes(2);
+ expect(disconnectSpy).toHaveBeenCalledTimes(1);
+ await registry.disconnect("dup", false);
+ expect(disconnectSpy).toHaveBeenCalledTimes(2);
+ expect(registry.connectionCount()).toBe(0);
+ // A queued duplicate disconnect fails cleanly rather than tearing
+ // down a successor's connection.
+ await expect(registry.disconnect("dup", false)).rejects.toThrow(
+ /not found/,
+ );
+ } finally {
+ connectSpy.mockRestore();
+ disconnectSpy.mockRestore();
+ authSpy.mockRestore();
+ }
+ });
+
+ it("liveClientFor revives a connection whose transport settled into a terminal state", async () => {
+ const { InspectorClient } = await import("@inspector/core/mcp/index.js");
+ const connectSpy = vi
+ .spyOn(InspectorClient.prototype, "connect")
+ .mockResolvedValue(undefined);
+ const disconnectSpy = vi
+ .spyOn(InspectorClient.prototype, "disconnect")
+ .mockResolvedValue(undefined);
+ const authSpy = vi
+ .spyOn(InspectorClient.prototype, "getOAuthState")
+ .mockResolvedValue(undefined as never);
+ const registry = new ConnectionRegistry(0);
+ let deadClient: unknown;
+ const statusSpy = vi
+ .spyOn(InspectorClient.prototype, "getStatus")
+ .mockImplementation(function (this: unknown) {
+ // Only the original client is dead; the revived one is live.
+ return this === deadClient ? "error" : "connected";
+ });
+ try {
+ await registry.connect({
+ name: "r",
+ serverConfig: {
+ type: "streamable-http",
+ url: "https://mcp.example.com/mcp",
+ },
+ serverIdentity: "https://mcp.example.com/mcp",
+ });
+ const original = registry.clientFor("r", false);
+
+ // Live client: no re-dial.
+ expect(await registry.liveClientFor("r", false)).toBe(original);
+ expect(connectSpy).toHaveBeenCalledTimes(1);
+
+ // Simulate the overnight drop (server expired the session).
+ deadClient = original;
+ const revived = await registry.liveClientFor("r", false);
+ expect(revived).not.toBe(original);
+ expect(connectSpy).toHaveBeenCalledTimes(2);
+ // The dead client's resources were released.
+ expect(disconnectSpy).toHaveBeenCalledTimes(1);
+ // The registry entry was swapped in place — still one connection.
+ expect(registry.connectionCount()).toBe(1);
+ expect(registry.clientFor("r", false)).toBe(revived);
+ // Live now: no further re-dial.
+ expect(await registry.liveClientFor("r", false)).toBe(revived);
+ expect(connectSpy).toHaveBeenCalledTimes(2);
+ } finally {
+ connectSpy.mockRestore();
+ disconnectSpy.mockRestore();
+ authSpy.mockRestore();
+ statusSpy.mockRestore();
+ }
+ });
+
+ it("revive maps a credentials failure to auth_required and keeps the entry on any failure", async () => {
+ const { InspectorClient } = await import("@inspector/core/mcp/index.js");
+ const connectSpy = vi
+ .spyOn(InspectorClient.prototype, "connect")
+ .mockResolvedValueOnce(undefined) // initial connect
+ .mockRejectedValueOnce(
+ // Revive 1: SDK token-exchange failure meaning "needs full re-auth".
+ new Error("prepareTokenRequest() or authorizationCode is required"),
+ )
+ .mockRejectedValueOnce(new Error("connect ECONNREFUSED 127.0.0.1:443")) // revive 2: server down
+ .mockResolvedValueOnce(undefined); // revive 3: server back
+ const disconnectSpy = vi
+ .spyOn(InspectorClient.prototype, "disconnect")
+ .mockResolvedValue(undefined);
+ const authSpy = vi
+ .spyOn(InspectorClient.prototype, "getOAuthState")
+ .mockResolvedValue(undefined as never);
+ const registry = new ConnectionRegistry(0);
+ let deadClient: unknown;
+ const statusSpy = vi
+ .spyOn(InspectorClient.prototype, "getStatus")
+ .mockImplementation(function (this: unknown) {
+ return this === deadClient ? "error" : "connected";
+ });
+ try {
+ await registry.connect({
+ name: "r",
+ serverConfig: {
+ type: "streamable-http",
+ url: "https://mcp.example.com/mcp",
+ },
+ serverIdentity: "https://mcp.example.com/mcp",
+ });
+ deadClient = registry.clientFor("r", false);
+
+ // Refresh credentials are gone: the ONLY case that involves the user.
+ await expect(registry.liveClientFor("r", false)).rejects.toMatchObject({
+ envelope: { code: "auth_required" },
+ });
+ await expect(registry.liveClientFor("r", false)).rejects.toThrow(
+ /ECONNREFUSED/,
+ );
+ // Both failures kept the entry — the user's intent persists…
+ expect(registry.connectionCount()).toBe(1);
+ // …so a later op simply revives once the server is reachable again.
+ const revived = await registry.liveClientFor("r", false);
+ expect(revived).not.toBe(deadClient);
+ expect(registry.clientFor("r", false)).toBe(revived);
+ } finally {
+ connectSpy.mockRestore();
+ disconnectSpy.mockRestore();
+ authSpy.mockRestore();
+ statusSpy.mockRestore();
+ }
+ });
+
+ it("pendingOnAuthRequired registers a dormant intent entry that completes via revive on first use", async () => {
+ const { InspectorClient } = await import("@inspector/core/mcp/index.js");
+ const connectSpy = vi
+ .spyOn(InspectorClient.prototype, "connect")
+ // Dial: no stored tokens yet — auth_required.
+ .mockRejectedValueOnce(Object.assign(new Error("boom"), { status: 401 }))
+ // Revive after the helper stored tokens: succeeds.
+ .mockResolvedValueOnce(undefined);
+ const disconnectSpy = vi
+ .spyOn(InspectorClient.prototype, "disconnect")
+ .mockResolvedValue(undefined);
+ const authSpy = vi
+ .spyOn(InspectorClient.prototype, "getOAuthState")
+ .mockResolvedValue(undefined as never);
+ const registry = new ConnectionRegistry(0);
+ let pendingClient: unknown;
+ const statusSpy = vi
+ .spyOn(InspectorClient.prototype, "getStatus")
+ .mockImplementation(function (this: unknown) {
+ // The pending entry's client never connected (terminal status →
+ // revivable); the revived client is live.
+ return this === pendingClient ? "disconnected" : "connected";
+ });
+ try {
+ const info = await registry.connect({
+ name: "p",
+ serverConfig: {
+ type: "streamable-http",
+ url: "https://mcp.example.com/mcp",
+ },
+ serverIdentity: "https://mcp.example.com/mcp",
+ pendingOnAuthRequired: true,
+ });
+ // Registered as pending intent instead of throwing.
+ expect(info.pendingAuth).toBe(true);
+ expect(info.auth).toEqual({ method: "oauth", authorized: false });
+ expect(registry.connectionCount()).toBe(1);
+ expect(registry.list()[0]).toMatchObject({
+ name: "p",
+ pendingAuth: true,
+ });
+ pendingClient = registry.clientFor("p", false);
+
+ // First op after tokens land: revive dials and clears the flag.
+ const revived = await registry.liveClientFor("p", false);
+ expect(revived).not.toBe(pendingClient);
+ expect(registry.list()[0]?.pendingAuth).toBeUndefined();
+ } finally {
+ connectSpy.mockRestore();
+ disconnectSpy.mockRestore();
+ authSpy.mockRestore();
+ statusSpy.mockRestore();
+ }
+ });
+
+ it("pendingOnAuthRequired only swallows auth_required — other dial failures still throw with no entry", async () => {
+ const { InspectorClient } = await import("@inspector/core/mcp/index.js");
+ const connectSpy = vi
+ .spyOn(InspectorClient.prototype, "connect")
+ .mockRejectedValueOnce(new Error("connect ECONNREFUSED 127.0.0.1:443"));
+ const disconnectSpy = vi
+ .spyOn(InspectorClient.prototype, "disconnect")
+ .mockResolvedValue(undefined);
+ const registry = new ConnectionRegistry(0);
+ try {
+ await expect(
+ registry.connect({
+ name: "p",
+ serverConfig: {
+ type: "streamable-http",
+ url: "https://mcp.example.com/mcp",
+ },
+ serverIdentity: "https://mcp.example.com/mcp",
+ pendingOnAuthRequired: true,
+ }),
+ ).rejects.toThrow(/ECONNREFUSED/);
+ expect(registry.connectionCount()).toBe(0);
+ } finally {
+ connectSpy.mockRestore();
+ disconnectSpy.mockRestore();
+ }
+ });
+
+ it("without pendingOnAuthRequired, an auth_required dial still throws with no entry", async () => {
+ const { InspectorClient } = await import("@inspector/core/mcp/index.js");
+ const connectSpy = vi
+ .spyOn(InspectorClient.prototype, "connect")
+ .mockRejectedValueOnce(Object.assign(new Error("boom"), { status: 401 }));
+ const disconnectSpy = vi
+ .spyOn(InspectorClient.prototype, "disconnect")
+ .mockResolvedValue(undefined);
+ const registry = new ConnectionRegistry(0);
+ try {
+ await expect(
+ registry.connect({
+ name: "p",
+ serverConfig: {
+ type: "streamable-http",
+ url: "https://mcp.example.com/mcp",
+ },
+ serverIdentity: "https://mcp.example.com/mcp",
+ }),
+ ).rejects.toMatchObject({ envelope: { code: "auth_required" } });
+ expect(registry.connectionCount()).toBe(0);
+ } finally {
+ connectSpy.mockRestore();
+ disconnectSpy.mockRestore();
+ }
+ });
+
+ it("connections/show completes a pending-auth entry once signed-in tokens are on disk", async () => {
+ const { InspectorClient } = await import("@inspector/core/mcp/index.js");
+ const { NodeOAuthStorage, resetNodeOAuthStorageCache } =
+ await import("@inspector/core/auth/node/storage-node.js");
+ const serverUrl = "https://mcp.example.com/mcp";
+ // Isolated client.json + oauth.json so the show handler's disk reads are
+ // deterministic (same pattern as the show-recomputes-from-disk test).
+ const stateDir = fs.mkdtempSync(
+ path.join(os.tmpdir(), "mcp-show-pending-"),
+ );
+ const savedEnv = {
+ MCP_CLIENT_CONFIG_PATH: process.env.MCP_CLIENT_CONFIG_PATH,
+ MCP_INSPECTOR_OAUTH_STATE_PATH:
+ process.env.MCP_INSPECTOR_OAUTH_STATE_PATH,
+ };
+ process.env.MCP_CLIENT_CONFIG_PATH = path.join(stateDir, "client.json");
+ process.env.MCP_INSPECTOR_OAUTH_STATE_PATH = path.join(
+ stateDir,
+ "oauth.json",
+ );
+ resetNodeOAuthStorageCache();
+ const connectSpy = vi
+ .spyOn(InspectorClient.prototype, "connect")
+ // Dial at connect time: no stored tokens yet — auth_required.
+ .mockRejectedValueOnce(Object.assign(new Error("boom"), { status: 401 }))
+ // Revive triggered by connections/show after tokens land: succeeds.
+ .mockResolvedValueOnce(undefined);
+ const disconnectSpy = vi
+ .spyOn(InspectorClient.prototype, "disconnect")
+ .mockResolvedValue(undefined);
+ const authSpy = vi
+ .spyOn(InspectorClient.prototype, "getOAuthState")
+ .mockResolvedValue(undefined as never);
+ let pendingClient: unknown;
+ const statusSpy = vi
+ .spyOn(InspectorClient.prototype, "getStatus")
+ .mockImplementation(function (this: unknown) {
+ return this === pendingClient ? "disconnected" : "connected";
+ });
+ const server = new DaemonServer({
+ dir: fs.mkdtempSync(path.join(os.tmpdir(), "mcp-show-pending-daemon-")),
+ idleMs: 0,
+ });
+ try {
+ await server.registry.connect({
+ name: "p",
+ serverConfig: { type: "streamable-http", url: serverUrl },
+ serverIdentity: serverUrl,
+ pendingOnAuthRequired: true,
+ });
+ pendingClient = server.registry.clientFor("p", false);
+
+ // Before sign-in completes, show reports the pending snapshot (no
+ // usable tokens on disk → no revive attempt).
+ const before = await server.handle({
+ id: "s1",
+ op: "connections/show",
+ params: { name: "p" },
+ });
+ expect(before.ok).toBe(true);
+ if (!before.ok) throw new Error("unreachable");
+ expect(before.result).toMatchObject({
+ pendingAuth: true,
+ transport: "dormant",
+ });
+
+ // The detached helper finishes sign-in: usable tokens land on disk.
+ await new NodeOAuthStorage().saveTokens(serverUrl, {
+ access_token: "opaque-access-token",
+ token_type: "Bearer",
+ });
+ resetNodeOAuthStorageCache();
+
+ // Now show itself completes the connection via revive.
+ const after = await server.handle({
+ id: "s2",
+ op: "connections/show",
+ params: { name: "p" },
+ });
+ expect(after.ok).toBe(true);
+ if (!after.ok) throw new Error("unreachable");
+ expect(
+ (after.result as { pendingAuth?: boolean }).pendingAuth,
+ ).toBeUndefined();
+ expect(after.result).toMatchObject({
+ transport: "live",
+ auth: { method: "oauth", authorized: true },
+ });
+ expect(server.registry.clientFor("p", false)).not.toBe(pendingClient);
+ } finally {
+ connectSpy.mockRestore();
+ disconnectSpy.mockRestore();
+ authSpy.mockRestore();
+ statusSpy.mockRestore();
+ process.env.MCP_CLIENT_CONFIG_PATH = savedEnv.MCP_CLIENT_CONFIG_PATH;
+ process.env.MCP_INSPECTOR_OAUTH_STATE_PATH =
+ savedEnv.MCP_INSPECTOR_OAUTH_STATE_PATH;
+ resetNodeOAuthStorageCache();
+ await server.stop().catch(() => {});
+ fs.rmSync(stateDir, { recursive: true, force: true });
+ }
+ });
+
+ it("connections/show keeps the pending snapshot when the revive attempt fails", async () => {
+ const { InspectorClient } = await import("@inspector/core/mcp/index.js");
+ const { NodeOAuthStorage, resetNodeOAuthStorageCache } =
+ await import("@inspector/core/auth/node/storage-node.js");
+ const serverUrl = "https://mcp.example.com/mcp";
+ const stateDir = fs.mkdtempSync(
+ path.join(os.tmpdir(), "mcp-show-pending-fail-"),
+ );
+ const savedEnv = {
+ MCP_CLIENT_CONFIG_PATH: process.env.MCP_CLIENT_CONFIG_PATH,
+ MCP_INSPECTOR_OAUTH_STATE_PATH:
+ process.env.MCP_INSPECTOR_OAUTH_STATE_PATH,
+ };
+ process.env.MCP_CLIENT_CONFIG_PATH = path.join(stateDir, "client.json");
+ process.env.MCP_INSPECTOR_OAUTH_STATE_PATH = path.join(
+ stateDir,
+ "oauth.json",
+ );
+ resetNodeOAuthStorageCache();
+ const connectSpy = vi
+ .spyOn(InspectorClient.prototype, "connect")
+ // Both the original dial and the show-triggered revive fail.
+ .mockRejectedValue(Object.assign(new Error("boom"), { status: 401 }));
+ const disconnectSpy = vi
+ .spyOn(InspectorClient.prototype, "disconnect")
+ .mockResolvedValue(undefined);
+ const statusSpy = vi
+ .spyOn(InspectorClient.prototype, "getStatus")
+ .mockReturnValue("disconnected");
+ const server = new DaemonServer({
+ dir: fs.mkdtempSync(
+ path.join(os.tmpdir(), "mcp-show-pending-fail-daemon-"),
+ ),
+ idleMs: 0,
+ });
+ try {
+ await server.registry.connect({
+ name: "p",
+ serverConfig: { type: "streamable-http", url: serverUrl },
+ serverIdentity: serverUrl,
+ pendingOnAuthRequired: true,
+ });
+ await new NodeOAuthStorage().saveTokens(serverUrl, {
+ access_token: "opaque-access-token",
+ token_type: "Bearer",
+ });
+ resetNodeOAuthStorageCache();
+
+ // Revive fails (tokens rejected on dial): show still answers with the
+ // honest pending snapshot instead of erroring, and the entry survives
+ // so a later op retries.
+ const shown = await server.handle({
+ id: "s1",
+ op: "connections/show",
+ params: { name: "p" },
+ });
+ expect(shown.ok).toBe(true);
+ if (!shown.ok) throw new Error("unreachable");
+ expect(shown.result).toMatchObject({
+ pendingAuth: true,
+ transport: "dormant",
+ });
+ expect(server.registry.connectionCount()).toBe(1);
+
+ // The read-only echoes annotate instead of dialing: tokens are on
+ // disk, so list/use report signed-in progress while the entry stays
+ // pending.
+ const listed = await server.handle({
+ id: "l1",
+ op: "connections/list",
+ params: {},
+ });
+ expect(listed.ok).toBe(true);
+ if (!listed.ok) throw new Error("unreachable");
+ expect(
+ (listed.result as { connections: unknown[] }).connections[0],
+ ).toMatchObject({
+ pendingAuth: true,
+ pendingAuthSignedIn: true,
+ auth: { method: "oauth", authorized: true },
+ });
+ const used = await server.handle({
+ id: "u1",
+ op: "connections/use",
+ params: { name: "p" },
+ });
+ expect(used.ok).toBe(true);
+ if (!used.ok) throw new Error("unreachable");
+ expect(used.result).toMatchObject({
+ pendingAuth: true,
+ pendingAuthSignedIn: true,
+ });
+ } finally {
+ connectSpy.mockRestore();
+ disconnectSpy.mockRestore();
+ statusSpy.mockRestore();
+ process.env.MCP_CLIENT_CONFIG_PATH = savedEnv.MCP_CLIENT_CONFIG_PATH;
+ process.env.MCP_INSPECTOR_OAUTH_STATE_PATH =
+ savedEnv.MCP_INSPECTOR_OAUTH_STATE_PATH;
+ resetNodeOAuthStorageCache();
+ await server.stop().catch(() => {});
+ fs.rmSync(stateDir, { recursive: true, force: true });
+ }
+ });
+
+ it("a connect that outlives shutdown's quiesce grace tears its client down instead of leaking it", async () => {
+ const { InspectorClient } = await import("@inspector/core/mcp/index.js");
+ let releaseConnect!: () => void;
+ const gate = new Promise((resolve) => (releaseConnect = resolve));
+ const connectSpy = vi
+ .spyOn(InspectorClient.prototype, "connect")
+ .mockImplementation(() => gate);
+ const disconnectSpy = vi
+ .spyOn(InspectorClient.prototype, "disconnect")
+ .mockResolvedValue(undefined);
+ const authSpy = vi
+ .spyOn(InspectorClient.prototype, "getOAuthState")
+ .mockResolvedValue(undefined as never);
+ const registry = new ConnectionRegistry(0);
+ try {
+ const params = {
+ name: "late",
+ serverConfig: {
+ type: "streamable-http",
+ url: "https://mcp.example.com/mcp",
+ },
+ serverIdentity: "https://mcp.example.com/mcp",
+ } as const;
+ const pending = registry.connect(params);
+ // Ensure the client is actually dialing before the shutdown snapshot
+ // runs — the bounded-quiesce-expired case (otherwise the entry check
+ // rejects it before a client exists).
+ await vi.waitFor(() => expect(connectSpy).toHaveBeenCalled());
+ await registry.disconnectAll();
+ releaseConnect();
+ await expect(pending).rejects.toMatchObject({
+ envelope: { code: "daemon_stopping" },
+ });
+ // The freshly connected client was disconnected, not registered.
+ expect(disconnectSpy).toHaveBeenCalledTimes(1);
+ expect(registry.connectionCount()).toBe(0);
+ // And a connect arriving after close fails fast, before dialing.
+ await expect(registry.connect(params)).rejects.toMatchObject({
+ envelope: { code: "daemon_stopping" },
+ });
+ expect(connectSpy).toHaveBeenCalledTimes(1); // no second dial
+ } finally {
+ connectSpy.mockRestore();
+ disconnectSpy.mockRestore();
+ authSpy.mockRestore();
+ }
+ });
+
+ it("idle timer does not fire while another connect is still in flight", async () => {
+ const { InspectorClient } = await import("@inspector/core/mcp/index.js");
+ let releaseConnect!: () => void;
+ const gate = new Promise((resolve) => (releaseConnect = resolve));
+ let dials = 0;
+ const connectSpy = vi
+ .spyOn(InspectorClient.prototype, "connect")
+ .mockImplementation(() => {
+ dials++;
+ // First dial (the slow, valid connect) blocks on the gate; the
+ // second (a concurrent connect for a different name) fails.
+ return dials === 1 ? gate : Promise.reject(new Error("dial failed"));
+ });
+ const disconnectSpy = vi
+ .spyOn(InspectorClient.prototype, "disconnect")
+ .mockResolvedValue(undefined);
+ const authSpy = vi
+ .spyOn(InspectorClient.prototype, "getOAuthState")
+ .mockResolvedValue(undefined as never);
+ const registry = new ConnectionRegistry(25);
+ const onIdle = vi.fn();
+ registry.setIdleHandler(onIdle);
+ try {
+ const config = {
+ type: "streamable-http",
+ url: "https://mcp.example.com/mcp",
+ } as const;
+ const identity = "https://mcp.example.com/mcp";
+ const slow = registry.connect({
+ name: "slow",
+ serverConfig: config,
+ serverIdentity: identity,
+ });
+ await vi.waitFor(() => expect(connectSpy).toHaveBeenCalled());
+ await expect(
+ registry.connect({
+ name: "fail",
+ serverConfig: config,
+ serverIdentity: identity,
+ }),
+ ).rejects.toThrow("dial failed");
+ // The failed connect must not arm the idle timer while the valid
+ // connect is still in flight — the daemon would otherwise stop under
+ // it and fail it with daemon_stopping.
+ expect(registry.idleRemainingMs()).toBeNull();
+ await new Promise((resolve) => setTimeout(resolve, 60));
+ expect(onIdle).not.toHaveBeenCalled();
+ releaseConnect();
+ await expect(slow).resolves.toMatchObject({ name: "slow" });
+ expect(registry.connectionCount()).toBe(1);
+ // Self-reaping still works once the registry actually empties.
+ await registry.disconnect("slow", false);
+ await vi.waitFor(() => expect(onIdle).toHaveBeenCalled());
+ } finally {
+ connectSpy.mockRestore();
+ disconnectSpy.mockRestore();
+ authSpy.mockRestore();
+ }
+ });
+
+ it("rejects a connect whose caller is already gone (pre-aborted signal)", async () => {
+ const registry = new ConnectionRegistry(0);
+ const ac = new AbortController();
+ ac.abort();
+ const { command, args } = getTestMcpServerCommand();
+ await expect(
+ registry.connect(
+ {
+ name: "gone",
+ serverConfig: { type: "stdio", command, args },
+ serverIdentity: "test-stdio",
+ },
+ ac.signal,
+ ),
+ ).rejects.toThrow(/Connect cancelled/);
+ expect(registry.connectionCount()).toBe(0);
+ });
+
+ it("cancels an in-flight connect when the caller disconnects, tearing the client down", async () => {
+ const { InspectorClient } = await import("@inspector/core/mcp/index.js");
+ let releaseConnect!: () => void;
+ const gate = new Promise((resolve) => (releaseConnect = resolve));
+ const connectSpy = vi
+ .spyOn(InspectorClient.prototype, "connect")
+ .mockImplementation(() => gate);
+ const disconnectSpy = vi
+ .spyOn(InspectorClient.prototype, "disconnect")
+ .mockResolvedValue(undefined);
+ const registry = new ConnectionRegistry(0);
+ try {
+ const ac = new AbortController();
+ const pending = registry.connect(
+ {
+ name: "slow",
+ serverConfig: {
+ type: "streamable-http",
+ url: "https://mcp.example.com/mcp",
+ },
+ serverIdentity: "https://mcp.example.com/mcp",
+ },
+ ac.signal,
+ );
+ // Let the connect get in flight before hanging up.
+ const deadline = Date.now() + 3000;
+ while (connectSpy.mock.calls.length === 0 && Date.now() < deadline) {
+ await new Promise((resolve) => setImmediate(resolve));
+ }
+ expect(connectSpy).toHaveBeenCalledTimes(1);
+ ac.abort();
+ await expect(pending).rejects.toThrow(/Connect cancelled/);
+ // The abandoned client was torn down, not left dialing.
+ expect(disconnectSpy).toHaveBeenCalledTimes(1);
+ expect(registry.connectionCount()).toBe(0);
+ // The late settlement of the abandoned connect is observed by the
+ // cancellation race, so it never surfaces as an unhandled rejection.
+ releaseConnect();
+ await new Promise((resolve) => setTimeout(resolve, 5));
+ } finally {
+ connectSpy.mockRestore();
+ disconnectSpy.mockRestore();
+ }
+ });
+
+ it("does not start dialing when the abort lands during reconnect teardown", async () => {
+ const { InspectorClient } = await import("@inspector/core/mcp/index.js");
+ const ac = new AbortController();
+ const connectSpy = vi
+ .spyOn(InspectorClient.prototype, "connect")
+ .mockResolvedValue(undefined);
+ // Abort while the reconnect path is tearing down the previous client —
+ // after the entry abort check, before the dial. AbortSignal does not
+ // replay, so only withAbort's synchronous pre-start check catches this.
+ const disconnectSpy = vi
+ .spyOn(InspectorClient.prototype, "disconnect")
+ .mockImplementation(async () => {
+ ac.abort();
+ });
+ const authSpy = vi
+ .spyOn(InspectorClient.prototype, "getOAuthState")
+ .mockResolvedValue(undefined as never);
+ const registry = new ConnectionRegistry(0);
+ const params = {
+ name: "re",
+ serverConfig: {
+ type: "streamable-http",
+ url: "https://mcp.example.com/mcp",
+ },
+ serverIdentity: "https://mcp.example.com/mcp",
+ } as const;
+ try {
+ await registry.connect(params);
+ expect(connectSpy).toHaveBeenCalledTimes(1);
+ await expect(registry.connect(params, ac.signal)).rejects.toThrow(
+ /Connect cancelled/,
+ );
+ // The second dial never started: the signal was checked synchronously
+ // before invoking connect, with no listener-install gap to hang in.
+ expect(connectSpy).toHaveBeenCalledTimes(1);
+ // Reconnect teardown plus the cancelled attempt's cleanup.
+ expect(disconnectSpy).toHaveBeenCalledTimes(2);
+ expect(registry.connectionCount()).toBe(0);
+ } finally {
+ connectSpy.mockRestore();
+ disconnectSpy.mockRestore();
+ authSpy.mockRestore();
+ }
+ });
+
+ it("discards a connect that completes only after the caller hung up", async () => {
+ const { InspectorClient } = await import("@inspector/core/mcp/index.js");
+ const ac = new AbortController();
+ const connectSpy = vi
+ .spyOn(InspectorClient.prototype, "connect")
+ .mockResolvedValue(undefined);
+ const disconnectSpy = vi
+ .spyOn(InspectorClient.prototype, "disconnect")
+ .mockResolvedValue(undefined);
+ // Abort between connect settling and registration (the auth snapshot
+ // read sits exactly there), hitting the post-connect abort check.
+ const authSpy = vi
+ .spyOn(InspectorClient.prototype, "getOAuthState")
+ .mockImplementation(async () => {
+ ac.abort();
+ return undefined as never;
+ });
+ const registry = new ConnectionRegistry(0);
+ try {
+ await expect(
+ registry.connect(
+ {
+ name: "late",
+ serverConfig: {
+ type: "streamable-http",
+ url: "https://mcp.example.com/mcp",
+ },
+ serverIdentity: "https://mcp.example.com/mcp",
+ },
+ ac.signal,
+ ),
+ ).rejects.toThrow(/Connect cancelled/);
+ // Connected fine — but registering would leak a connection nobody
+ // asked to keep, so it was disconnected instead.
+ expect(disconnectSpy).toHaveBeenCalledTimes(1);
+ expect(registry.connectionCount()).toBe(0);
+ } finally {
+ connectSpy.mockRestore();
+ disconnectSpy.mockRestore();
+ authSpy.mockRestore();
+ }
+ });
+
+ it("reports the connect-time auth snapshot, and connections/show recomputes from disk", async () => {
+ const { InspectorClient } = await import("@inspector/core/mcp/index.js");
+ const { NodeOAuthStorage, resetNodeOAuthStorageCache } =
+ await import("@inspector/core/auth/node/storage-node.js");
+ // Isolated client.json (EMA IdP config) + oauth.json so the show
+ // handler's disk read is deterministic.
+ const stateDir = fs.mkdtempSync(
+ path.join(os.tmpdir(), "mcp-conn-auth-info-"),
+ );
+ const savedEnv = {
+ MCP_CLIENT_CONFIG_PATH: process.env.MCP_CLIENT_CONFIG_PATH,
+ MCP_INSPECTOR_OAUTH_STATE_PATH:
+ process.env.MCP_INSPECTOR_OAUTH_STATE_PATH,
+ };
+ process.env.MCP_CLIENT_CONFIG_PATH = path.join(stateDir, "client.json");
+ process.env.MCP_INSPECTOR_OAUTH_STATE_PATH = path.join(
+ stateDir,
+ "oauth.json",
+ );
+ const issuer = "https://idp.example.com";
+ fs.writeFileSync(
+ process.env.MCP_CLIENT_CONFIG_PATH,
+ JSON.stringify({
+ enterpriseManagedAuth: {
+ enabled: true,
+ idp: { issuer, clientId: "idp-client" },
+ },
+ }),
+ );
+ resetNodeOAuthStorageCache();
+ // Unexpired unsigned JWT so the seeded IdP session reads as logged_in.
+ const b64 = (obj: object) =>
+ Buffer.from(JSON.stringify(obj)).toString("base64url");
+ const idToken = `${b64({ alg: "none" })}.${b64({
+ exp: Math.floor(Date.now() / 1000) + 3600,
+ })}.sig`;
+
+ // Force an auth snapshot onto the connect result without a live OAuth
+ // server, so the auth-present reporting paths (connect result, list,
+ // use) are exercised.
+ const stateSpy = vi
+ .spyOn(InspectorClient.prototype, "getOAuthState")
+ .mockResolvedValue({
+ authorized: true,
+ protocol: "ema",
+ serverUrl: "https://mcp.example.com/mcp",
+ ema: {
+ idpIssuer: issuer,
+ idpClientId: "idp-client",
+ idpSession: "logged_in",
+ },
+ });
+ const connectSpy = vi
+ .spyOn(InspectorClient.prototype, "connect")
+ .mockResolvedValue(undefined);
+ const server = new DaemonServer({
+ dir: fs.mkdtempSync(path.join(os.tmpdir(), "mcp-conn-auth-daemon-")),
+ idleMs: 0,
+ });
+ const registry = server.registry;
+ try {
+ const info = await registry.connect({
+ name: "a",
+ serverConfig: {
+ type: "streamable-http",
+ url: "https://mcp.example.com/mcp",
+ },
+ serverSettings: {
+ headers: [],
+ metadata: {},
+ env: [],
+ connectionTimeout: 30_000,
+ requestTimeout: 0,
+ taskTtl: 0,
+ maxFetchRequests: 0,
+ autoRefreshOnListChanged: false,
+ paginatedLists: false,
+ roots: [],
+ enterpriseManaged: true,
+ },
+ serverIdentity: "https://mcp.example.com/mcp",
+ });
+ const expected = {
+ method: "ema",
+ authorized: true,
+ idpSession: "logged_in",
+ };
+ expect(info.auth).toEqual(expected);
+ expect(registry.list()[0]?.auth).toEqual(expected);
+ expect(registry.use("a").auth).toEqual(expected);
+
+ // connections/show reads *disk*, not the client's memory-cached storage:
+ // seed an IdP session on disk and expect logged_in (no tokens were
+ // persisted, so authorized is false — matching auth/ema-status).
+ await new NodeOAuthStorage().saveIdpSession(issuer, {
+ idToken,
+ idTokenExpiresAt: Date.now() + 3600_000,
+ });
+ const shown = await server.handle({
+ id: "show",
+ op: "connections/show",
+ params: { name: "a" },
+ });
+ expect(shown.ok).toBe(true);
+ if (!shown.ok) throw new Error("unreachable");
+ expect((shown.result as { auth?: unknown }).auth).toEqual({
+ method: "ema",
+ authorized: false,
+ idpSession: "logged_in",
+ });
+
+ // Simulate a cross-process logout (e.g. auth/ema-logout): clear the
+ // IdP session on disk. list keeps the connect-time value; show
+ // reflects the new disk state.
+ resetNodeOAuthStorageCache();
+ await new NodeOAuthStorage().clearIdpSession(issuer);
+ expect(registry.list()[0]?.auth).toEqual(expected);
+ const loggedOut = await server.handle({
+ id: "show2",
+ op: "connections/show",
+ params: { name: "a" },
+ });
+ expect(loggedOut.ok).toBe(true);
+ if (!loggedOut.ok) throw new Error("unreachable");
+ expect((loggedOut.result as { auth?: unknown }).auth).toEqual({
+ method: "ema",
+ authorized: false,
+ idpSession: "none",
+ });
+ } finally {
+ await registry.disconnectAll();
+ stateSpy.mockRestore();
+ connectSpy.mockRestore();
+ for (const [key, value] of Object.entries(savedEnv)) {
+ if (value === undefined) delete process.env[key];
+ else process.env[key] = value;
+ }
+ resetNodeOAuthStorageCache();
+ fs.rmSync(stateDir, { recursive: true, force: true });
+ }
+ });
+});
+
+describe("DaemonServer IPC", () => {
+ let server: DaemonServer | undefined;
+ let dir: string | undefined;
+
+ afterEach(async () => {
+ if (server) {
+ await server.stop("stop");
+ server = undefined;
+ }
+ if (dir) {
+ fs.rmSync(dir, { recursive: true, force: true });
+ dir = undefined;
+ }
+ });
+
+ it("serves ping / connect / connections/list / disconnect over the socket", async () => {
+ dir = fs.mkdtempSync(path.join(os.tmpdir(), "mcp-daemon-"));
+ server = new DaemonServer({ dir, idleMs: 0 });
+ await server.start();
+
+ 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<{ connections: { name: string }[] }>(
+ "connections/list",
+ {},
+ { socketPath: server.socketPath },
+ );
+ expect(listed.connections.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 connection", 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("rpc transparently revives a connection whose transport died (end-to-end)", async () => {
+ dir = fs.mkdtempSync(path.join(os.tmpdir(), "mcp-daemon-revive-"));
+ server = new DaemonServer({ dir, idleMs: 0 });
+ await server.start();
+
+ const { command, args } = getTestMcpServerCommand();
+ await callDaemon(
+ "connect",
+ {
+ name: "stdio",
+ serverConfig: { type: "stdio", command, args },
+ serverIdentity: "test-stdio",
+ },
+ { socketPath: server.socketPath, timeoutMs: 15000 },
+ );
+
+ // Kill the daemon-held client's transport out from under the registry —
+ // the in-process equivalent of the server expiring the session (or a
+ // stdio child dying) overnight.
+ const registry = (server as unknown as { registry: ConnectionRegistry })
+ .registry;
+ await registry.clientFor("stdio", false).disconnect();
+
+ // connections/show is passive for a non-pending entry: it reports the
+ // drop, no revive (out-of-band sign-in completion is the one case where
+ // show itself revives — covered separately).
+ const shown = await callDaemon<{ transport?: string }>(
+ "connections/show",
+ { name: "stdio" },
+ { socketPath: server.socketPath },
+ );
+ expect(shown.transport).toBe("dormant");
+
+ // An actual op self-heals: fresh dial, real result — never "Tools (0)".
+ const listed = await callDaemon<{
+ kind: string;
+ result: { tools: unknown[] };
+ }>(
+ "rpc",
+ { method: "tools/list", name: "stdio" },
+ { socketPath: server.socketPath, timeoutMs: 15000 },
+ );
+ expect(listed.kind).toBe("result");
+ expect(listed.result.tools.length).toBeGreaterThan(0);
+
+ const after = await callDaemon<{ transport?: string }>(
+ "connections/show",
+ { name: "stdio" },
+ { socketPath: server.socketPath },
+ );
+ expect(after.transport).toBe("live");
+ });
+
+ it("rejects stream methods on rpc and rpc methods on stream", async () => {
+ dir = fs.mkdtempSync(path.join(os.tmpdir(), "mcp-daemon-ops-"));
+ server = new DaemonServer({ dir, idleMs: 0 });
+ 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 },
+ );
+ });
+
+ it("ends an open stream when its connection disconnects", async () => {
+ dir = fs.mkdtempSync(path.join(os.tmpdir(), "mcp-daemon-streamlife-"));
+ server = new DaemonServer({ dir, idleMs: 0 });
+ const { command, args } = getTestMcpServerCommand();
+ await server.registry.connect({
+ name: "s",
+ serverConfig: { type: "stdio", command, args },
+ serverIdentity: "test-stdio",
+ });
+
+ const outcome = await server.handleOutcome({
+ id: "st",
+ op: "stream",
+ params: { method: "logging/tail", name: "s" } as never,
+ });
+ expect(outcome.response.ok).toBe(true);
+ expect(outcome.startStream).toBeDefined();
+ let ended = 0;
+ const stop = outcome.startStream!(
+ () => {},
+ () => {
+ ended += 1;
+ },
+ );
+
+ // Disconnecting the named connection must terminate its streams instead
+ // of leaving the caller attached to a stale client until Ctrl-C.
+ await server.handle({
+ id: "d",
+ op: "disconnect",
+ params: { name: "s" } as never,
+ });
+ const deadline = Date.now() + 3000;
+ while (ended === 0 && Date.now() < deadline) {
+ await new Promise((resolve) => setImmediate(resolve));
+ }
+ expect(ended).toBe(1);
+ stop();
+ });
+
+ it("ends a stream whose connection disconnected before startStream ran", async () => {
+ // Regression: the statusChange listener was only installed inside
+ // startStream, which ipc-glue invokes after the ok frame. A disconnect
+ // completing in the window after runMethod() returned but before the
+ // listener existed lost the terminal event, leaving the stream open
+ // against a dead client forever. Terminal status is persistent state,
+ // so startStream now checks the current status after installing the
+ // listener.
+ dir = fs.mkdtempSync(path.join(os.tmpdir(), "mcp-daemon-streamrace-"));
+ server = new DaemonServer({ dir, idleMs: 0 });
+ const { command, args } = getTestMcpServerCommand();
+ await server.registry.connect({
+ name: "s",
+ serverConfig: { type: "stdio", command, args },
+ serverIdentity: "test-stdio",
+ });
+
+ const outcome = await server.handleOutcome({
+ id: "st",
+ op: "stream",
+ params: { method: "logging/tail", name: "s" } as never,
+ });
+ expect(outcome.response.ok).toBe(true);
+
+ // Disconnect in the window between runMethod() and startStream.
+ await server.handle({
+ id: "d",
+ op: "disconnect",
+ params: { name: "s" } as never,
+ });
+
+ let ended = 0;
+ const stop = outcome.startStream!(
+ () => {},
+ () => {
+ ended += 1;
+ },
+ );
+ expect(ended).toBe(1);
+ stop();
+ });
+
+ it("serializes rpc ops per connection so elicitation routing is exact", async () => {
+ dir = fs.mkdtempSync(path.join(os.tmpdir(), "mcp-daemon-rpcqueue-"));
+ server = new DaemonServer({ dir, idleMs: 0 });
+ const { command, args } = getTestMcpServerCommand();
+ await server.registry.connect({
+ name: "s",
+ serverConfig: { type: "stdio", command, args },
+ serverIdentity: "test-stdio",
+ });
+
+ const { InspectorClient } = await import("@inspector/core/mcp/index.js");
+ const order: string[] = [];
+ let release: () => void = () => {};
+ const gate = new Promise((resolve) => {
+ release = resolve;
+ });
+ const spy = vi
+ .spyOn(InspectorClient.prototype, "readResource")
+ .mockImplementation(async (uri: string) => {
+ order.push(`start:${uri}`);
+ if (uri === "test://a") await gate;
+ order.push(`end:${uri}`);
+ return { result: { contents: [] } } as never;
+ });
+ try {
+ const first = server.handle({
+ id: "1",
+ op: "rpc",
+ params: { method: "resources/read", uri: "test://a", name: "s" },
+ });
+ const second = server.handle({
+ id: "2",
+ op: "rpc",
+ params: { method: "resources/read", uri: "test://b", name: "s" },
+ });
+ const deadline = Date.now() + 3000;
+ while (!order.includes("start:test://a") && Date.now() < deadline) {
+ await new Promise((resolve) => setImmediate(resolve));
+ }
+ await new Promise((resolve) => setTimeout(resolve, 30));
+ // The second rpc must not have started while the first is in flight.
+ expect(order).toEqual(["start:test://a"]);
+ release();
+ const [r1, r2] = await Promise.all([first, second]);
+ expect(r1.ok).toBe(true);
+ expect(r2.ok).toBe(true);
+ expect(order).toEqual([
+ "start:test://a",
+ "end:test://a",
+ "start:test://b",
+ "end:test://b",
+ ]);
+ } finally {
+ spy.mockRestore();
+ }
+ });
+
+ it("cancels a daemon-side connect end-to-end when the caller's socket closes", async () => {
+ dir = fs.mkdtempSync(path.join(os.tmpdir(), "mcp-daemon-cancel-"));
+ server = new DaemonServer({ dir, idleMs: 0 });
+ await server.start();
+
+ const { InspectorClient } = await import("@inspector/core/mcp/index.js");
+ let releaseConnect!: () => void;
+ const gate = new Promise((resolve) => (releaseConnect = resolve));
+ const connectSpy = vi
+ .spyOn(InspectorClient.prototype, "connect")
+ .mockImplementation(() => gate);
+ const disconnectSpy = vi
+ .spyOn(InspectorClient.prototype, "disconnect")
+ .mockResolvedValue(undefined);
+ try {
+ // Full real-socket path: this is the seam test that unit tests on
+ // either side of the IPC adapter cannot cover (a dropped signal
+ // argument in the adapter would pass both and fail here).
+ const ac = new AbortController();
+ const pending = callDaemon(
+ "connect",
+ {
+ name: "hung",
+ serverConfig: {
+ type: "streamable-http",
+ url: "https://mcp.example.com/mcp",
+ },
+ serverIdentity: "https://mcp.example.com/mcp",
+ },
+ { socketPath: server.socketPath, timeoutMs: 0, signal: ac.signal },
+ );
+ let deadline = Date.now() + 3000;
+ while (connectSpy.mock.calls.length === 0 && Date.now() < deadline) {
+ await new Promise((resolve) => setImmediate(resolve));
+ }
+ expect(connectSpy).toHaveBeenCalledTimes(1);
+ // Frontend hangs up (Ctrl-C): its socket is destroyed…
+ ac.abort();
+ await expect(pending).rejects.toThrow(/cancelled/);
+ // …and the daemon-side dial is torn down without ever completing.
+ deadline = Date.now() + 3000;
+ while (disconnectSpy.mock.calls.length === 0 && Date.now() < deadline) {
+ await new Promise((resolve) => setImmediate(resolve));
+ }
+ expect(disconnectSpy).toHaveBeenCalledTimes(1);
+ expect(server.registry.connectionCount()).toBe(0);
+ // A late success is discarded, never registered.
+ releaseConnect();
+ await new Promise((resolve) => setTimeout(resolve, 10));
+ expect(server.registry.connectionCount()).toBe(0);
+ } finally {
+ connectSpy.mockRestore();
+ disconnectSpy.mockRestore();
+ }
+ });
+
+ it("strips format from rpc method args so JSON tool calls skip the app-info probe", async () => {
+ dir = fs.mkdtempSync(path.join(os.tmpdir(), "mcp-daemon-fmt-"));
+ server = new DaemonServer({ dir, idleMs: 0 });
+ const { command, args } = getTestMcpServerCommand();
+ await server.registry.connect({
+ name: "s",
+ serverConfig: { type: "stdio", command, args },
+ serverIdentity: "test-stdio",
+ });
+
+ const res = await server.handle({
+ id: "1",
+ op: "rpc",
+ params: {
+ method: "tools/call",
+ name: "s",
+ toolName: "echo",
+ toolArg: { message: "hi" },
+ format: "json",
+ },
+ });
+ expect(res.ok).toBe(true);
+ const rpc = (res as { result: { kind: string; appInfo?: unknown } }).result;
+ expect(rpc.kind).toBe("result");
+ // format is a frontend-only output concern: forwarding it used to make
+ // runMethod collect app info (a hidden extra resources/read) whose
+ // result the frontend discards.
+ expect(rpc.appInfo).toBeUndefined();
+
+ // Explicit --app-info still probes.
+ const withApp = await server.handle({
+ id: "2",
+ op: "rpc",
+ params: {
+ method: "tools/call",
+ name: "s",
+ toolName: "echo",
+ appInfo: true,
+ },
+ });
+ expect(withApp.ok).toBe(true);
+ const appRes = (
+ withApp as { result: { result: { hasApp?: boolean; toolName?: string } } }
+ ).result;
+ expect(appRes.result).toMatchObject({ hasApp: false, toolName: "echo" });
+ });
+});
diff --git a/clients/daemon-cli/__tests__/daemon-coverage.test.ts b/clients/daemon-cli/__tests__/daemon-coverage.test.ts
new file mode 100644
index 0000000000..1da5e31f05
--- /dev/null
+++ b/clients/daemon-cli/__tests__/daemon-coverage.test.ts
@@ -0,0 +1,1292 @@
+import { describe, it, expect, afterEach, vi } from "vitest";
+import * as fs from "node:fs";
+
+// Wrap renameSync in a pass-through vi.fn so the lock-reclaim race test can
+// inject a concurrent contender in the read→rename window (ESM namespaces
+// cannot be spied on directly).
+vi.mock("node:fs", async (importOriginal) => {
+ const actual = await importOriginal();
+ return { ...actual, renameSync: vi.fn(actual.renameSync) };
+});
+import * as net from "node:net";
+import * as os from "node:os";
+import * as path from "node:path";
+import { getTestMcpServerCommand } from "@modelcontextprotocol/inspector-test-server";
+import { DaemonServer } from "../src/daemon/server.js";
+import { callDaemon, daemonTokenDir } from "../src/daemon/client.js";
+import {
+ ensureDaemon,
+ readLogTail,
+ resolveDaemonScriptPath,
+ waitForDaemonExit,
+} from "../src/daemon/ensure.js";
+import { spawn, spawnSync } from "node:child_process";
+import { ConnectionRegistry } from "../src/daemon/connections.js";
+import { CliExitCodeError } from "@inspector/cli/error-handler.js";
+import { runMcp } from "./helpers/mcp-runner.js";
+import {
+ createSampleTestConfig,
+ deleteConfigFile,
+} from "../../cli/__tests__/helpers/fixtures.js";
+import {
+ expectCliSuccess,
+ expectCliFailure,
+} from "../../cli/__tests__/helpers/assertions.js";
+
+describe("daemon coverage", () => {
+ let server: DaemonServer | undefined;
+ let dir: string | undefined;
+ let configPath: string | undefined;
+
+ afterEach(async () => {
+ if (server) {
+ await server.stop("stop");
+ server = undefined;
+ }
+ if (dir) {
+ fs.rmSync(dir, { recursive: true, force: true });
+ dir = undefined;
+ }
+ if (configPath) {
+ deleteConfigFile(configPath);
+ configPath = undefined;
+ }
+ });
+
+ function freshDir(): string {
+ dir = fs.mkdtempSync(path.join(os.tmpdir(), "mcp-cov-"));
+ return dir;
+ }
+
+ it("handle() covers invalid connect / connections/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: "connections/use",
+ params: {},
+ });
+ expect(badUse.ok).toBe(false);
+
+ // connections/show with no `params` at all exercises the `request.params ??
+ // {}` fallback; with no active connection it still fails, same shape as
+ // connections/use above.
+ const badShow = await server.handle({ id: "2b", op: "connections/show" });
+ expect(badShow.ok).toBe(false);
+
+ const unknown = await server.handle({
+ 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: "connections/list" });
+ expect(listed.ok).toBe(false);
+ if (!listed.ok) expect(listed.error.code).toBe("cli_error");
+
+ vi.spyOn(server.registry, "list").mockImplementationOnce(() => {
+ throw new Error("boom");
+ });
+ const boom = await server.handle({ id: "5", op: "connections/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: "connections/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 daemon while a live one holds the lock", async () => {
+ const d = freshDir();
+ server = new DaemonServer({ dir: d, idleMs: 0 });
+ await server.start();
+ const other = new DaemonServer({ dir: d, idleMs: 0 });
+ await expect(other.start()).rejects.toThrow(/held by running pid/);
+ });
+
+ it("reclaims a lock left by a dead pid", async () => {
+ const d = freshDir();
+ // No live process can have this pid-space value in practice; write a
+ // plausible-but-dead pid by spawning nothing and using an exited child.
+ fs.writeFileSync(path.join(d, "daemon.lock"), "999999999\n");
+ server = new DaemonServer({ dir: d, idleMs: 0 });
+ await server.start();
+ expect(fs.readFileSync(path.join(d, "daemon.lock"), "utf8").trim()).toBe(
+ String(process.pid),
+ );
+ });
+
+ it("does not delete a lock it no longer owns on stop", async () => {
+ const d = freshDir();
+ server = new DaemonServer({ dir: d, idleMs: 0 });
+ await server.start();
+ const lockPath = path.join(d, "daemon.lock");
+ // Simulate a successor's lock at the same path (reclaim race / manual
+ // operator cleanup): release must be ownership-checked.
+ fs.writeFileSync(lockPath, "424242\n");
+ await server.stop("stop");
+ server = undefined;
+ expect(fs.readFileSync(lockPath, "utf8").trim()).toBe("424242");
+ fs.unlinkSync(lockPath);
+ });
+
+ it("restores a live lock created between the dead-pid read and the rename", async () => {
+ const d = freshDir();
+ const lockPath = path.join(d, "daemon.lock");
+ fs.writeFileSync(lockPath, "999999999\n"); // dead pid
+ const actualFs = await vi.importActual("node:fs");
+ vi.mocked(fs.renameSync).mockImplementationOnce(((
+ ...args: Parameters
+ ) => {
+ // Simulate a concurrent starter finishing its own reclaim + O_EXCL
+ // create in the window between readLockPid() and renameSync().
+ fs.writeFileSync(lockPath, `${process.pid}\n`);
+ return actualFs.renameSync(...args);
+ }) as typeof fs.renameSync);
+ const contender = new DaemonServer({ dir: d, idleMs: 0 });
+ await expect(contender.start()).rejects.toThrow(/held by running pid/);
+ // The stolen live lock was restored at the canonical path.
+ expect(fs.readFileSync(lockPath, "utf8").trim()).toBe(String(process.pid));
+ expect(fs.existsSync(`${lockPath}.reclaim.${process.pid}`)).toBe(false);
+ });
+
+ it("treats a young pidless lock as held instead of stealing it", async () => {
+ const d = freshDir();
+ const lockPath = path.join(d, "daemon.lock");
+ // A concurrent starter between its O_EXCL create and its pid write.
+ fs.writeFileSync(lockPath, "");
+ const contender = new DaemonServer({ dir: d, idleMs: 0 });
+ await expect(contender.start()).rejects.toThrow(/Could not acquire/);
+ // The other starter's lock survived untouched.
+ expect(fs.readFileSync(lockPath, "utf8")).toBe("");
+ });
+
+ it("reclaims a pidless lock older than the write grace period", async () => {
+ const d = freshDir();
+ const lockPath = path.join(d, "daemon.lock");
+ // A starter that died between create and pid write, long ago.
+ fs.writeFileSync(lockPath, "");
+ const past = (Date.now() - 60_000) / 1000;
+ fs.utimesSync(lockPath, past, past);
+ server = new DaemonServer({ dir: d, idleMs: 0 });
+ await server.start();
+ expect(fs.readFileSync(lockPath, "utf8").trim()).toBe(String(process.pid));
+ });
+
+ it("restores a young pidless lock renamed aside mid-reclaim", async () => {
+ const d = freshDir();
+ const lockPath = path.join(d, "daemon.lock");
+ fs.writeFileSync(lockPath, "999999999\n"); // dead pid triggers the reclaim
+ const actualFs = await vi.importActual("node:fs");
+ vi.mocked(fs.renameSync).mockImplementationOnce(((
+ ...args: Parameters
+ ) => {
+ actualFs.renameSync(...args);
+ // Simulate the renamed-aside file really belonging to a concurrent
+ // starter that created it but has not written its pid yet: empty,
+ // freshly touched.
+ actualFs.truncateSync(args[1] as string);
+ }) as typeof fs.renameSync);
+ const contender = new DaemonServer({ dir: d, idleMs: 0 });
+ await expect(contender.start()).rejects.toThrow(/Could not acquire/);
+ // The lock was restored at the canonical path, not stolen.
+ expect(fs.existsSync(lockPath)).toBe(true);
+ expect(fs.existsSync(`${lockPath}.reclaim.${process.pid}`)).toBe(false);
+ });
+
+ it("stop() force-destroys sockets whose shutdown flush never drains", async () => {
+ const d = freshDir();
+ server = new DaemonServer({ dir: d, idleMs: 0, flushTimeoutMs: 100 });
+ await server.start();
+ const client = net.connect(server.socketPath);
+ await new Promise((resolve) => client.on("connect", () => resolve()));
+ client.pause();
+ const ipcSockets = (server as unknown as { ipcSockets: Set })
+ .ipcSockets;
+ await vi.waitFor(() => expect(ipcSockets.size).toBe(1));
+ // Backpressure: buffer a payload the paused client never reads, so
+ // destroySoon()'s drain never completes and server.close() would wait
+ // forever without the bounded force-destroy.
+ const [serverSocket] = ipcSockets;
+ serverSocket!.write("x".repeat(4 * 1024 * 1024));
+ const stopped = await Promise.race([
+ server.stop("stop").then(() => true),
+ new Promise((r) => setTimeout(() => r(false), 5000)),
+ ]);
+ expect(stopped).toBe(true);
+ server = undefined;
+ client.destroy();
+ });
+
+ it("removes a stale socket before binding", async () => {
+ const d = freshDir();
+ const sock = path.join(d, "daemon.sock");
+ 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("resolves the daemon.token directory without deriving it from pipe paths", () => {
+ // Explicit dir always wins.
+ expect(daemonTokenDir({ dir: "/x", socketPath: "/y/daemon.sock" })).toBe(
+ "/x",
+ );
+ // A Unix socket path implies its directory.
+ expect(daemonTokenDir({ socketPath: "/y/daemon.sock" })).toBe("/y");
+ // A Windows named pipe has no meaningful dirname: fall back to the
+ // configured daemon directory, where the token is actually published.
+ const prev = process.env.MCP_INSPECTOR_DAEMON_DIR;
+ process.env.MCP_INSPECTOR_DAEMON_DIR = "/daemon/dir";
+ try {
+ expect(daemonTokenDir({ socketPath: "\\\\.\\pipe\\mcp-conn-abc" })).toBe(
+ path.resolve("/daemon/dir"),
+ );
+ expect(daemonTokenDir({})).toBe(path.resolve("/daemon/dir"));
+ } finally {
+ if (prev === undefined) delete process.env.MCP_INSPECTOR_DAEMON_DIR;
+ else process.env.MCP_INSPECTOR_DAEMON_DIR = prev;
+ }
+ });
+
+ it("callDaemon rejects immediately on a pre-aborted signal instead of hanging", async () => {
+ const d = freshDir();
+ server = new DaemonServer({ dir: d, idleMs: 0 });
+ await server.start();
+ const ac = new AbortController();
+ ac.abort();
+ // timeoutMs 0 = no timer: without the pre-aborted check (AbortSignal
+ // does not replay) this request would hang forever.
+ await expect(
+ callDaemon(
+ "ping",
+ {},
+ { socketPath: server.socketPath, timeoutMs: 0, signal: ac.signal },
+ ),
+ ).rejects.toThrow(/cancelled/);
+ });
+
+ it("callDaemon maps error responses and unreachable sockets", async () => {
+ await expect(
+ callDaemon(
+ "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("connections/use", {}, { socketPath: server.socketPath }),
+ ).rejects.toThrow(/requires a connection name/);
+ });
+
+ it("callDaemon reassembles a multi-byte UTF-8 character split across chunks", async () => {
+ const d = freshDir();
+ const sock = path.join(d, "daemon.sock");
+ const value = "héllo 👋 wörld";
+ const splitter = net.createServer((socket) => {
+ socket.on("error", () => {});
+ socket.once("data", (buf) => {
+ const req = JSON.parse(String(buf).trim()) as { id: string };
+ const payload = Buffer.from(
+ JSON.stringify({ id: req.id, ok: true, result: { value } }) + "\n",
+ "utf8",
+ );
+ // Split mid-emoji (0xf0 opens the 4-byte sequence) so the two TCP
+ // chunks each carry half of one UTF-8 character.
+ const mid = payload.indexOf(0xf0) + 2;
+ socket.write(payload.subarray(0, mid));
+ setTimeout(() => socket.write(payload.subarray(mid)), 20);
+ });
+ });
+ await new Promise((resolve) => splitter.listen(sock, resolve));
+ try {
+ const result = await callDaemon<{ value: string }>(
+ "ping",
+ {},
+ { socketPath: sock, timeoutMs: 2000 },
+ );
+ expect(result.value).toBe(value);
+ } finally {
+ splitter.close();
+ try {
+ fs.unlinkSync(sock);
+ } catch {
+ // ignore
+ }
+ }
+ });
+
+ it("callDaemon with an already-aborted signal rejects without dialing", async () => {
+ const d = freshDir();
+ const ac = new AbortController();
+ ac.abort();
+ // A nonexistent socket path proves no connect is attempted: dialing it
+ // would fail with daemon_unreachable, not cancelled.
+ await expect(
+ callDaemon(
+ "ping",
+ {},
+ {
+ socketPath: path.join(d, "absent.sock"),
+ signal: ac.signal,
+ timeoutMs: 2000,
+ },
+ ),
+ ).rejects.toMatchObject({ envelope: { code: "cancelled" } });
+ });
+
+ it("callDaemon rejects malformed response JSON", async () => {
+ const d = freshDir();
+ const sock = path.join(d, "daemon.sock");
+ 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("repeated stop() returns the same in-flight cleanup promise", async () => {
+ // A second SIGINT used to see `stopping` and resolve immediately,
+ // letting its caller process.exit() mid-teardown and strand the
+ // socket/token/lock. Both calls must await the same cleanup.
+ server = new DaemonServer({ dir: freshDir(), idleMs: 0 });
+ await server.start();
+ const first = server.stop("signal");
+ const second = server.stop("signal");
+ expect(second).toBe(first);
+ await first;
+ expect(fs.existsSync(server.socketPath)).toBe(false);
+ server = undefined;
+ });
+
+ it("rejects new ops while stopping but keeps status ops answerable", async () => {
+ server = new DaemonServer({ dir: freshDir(), idleMs: 0 });
+ let release!: () => void;
+ const gate = new Promise((resolve) => (release = resolve));
+ vi.spyOn(server.registry, "disconnectAll").mockImplementation(() => gate);
+ const stopP = server.stop("stop");
+
+ const rejected = await server.handle({ id: "q1", op: "connections/list" });
+ expect(rejected.ok).toBe(false);
+ if (!rejected.ok) expect(rejected.error.code).toBe("daemon_stopping");
+
+ const status = await server.handle({ id: "q2", op: "daemon/status" });
+ expect(status.ok).toBe(true);
+ const pong = await server.handle({ id: "q3", op: "ping" });
+ expect(pong.ok).toBe(true);
+
+ release();
+ await stopP;
+ server = undefined;
+ });
+
+ it("stop() quiesces in-flight ops before disconnecting connections", async () => {
+ // A connect racing shutdown used to register its client *after*
+ // disconnectAll's snapshot, leaking a live child process. Shutdown now
+ // waits for in-flight ops so the late registration is included.
+ server = new DaemonServer({ dir: freshDir(), idleMs: 0 });
+ const order: string[] = [];
+ let releaseConnect!: () => void;
+ const gate = new Promise((resolve) => (releaseConnect = resolve));
+ vi.spyOn(server.registry, "connect").mockImplementation(async () => {
+ order.push("connect:start");
+ await gate;
+ order.push("connect:end");
+ return { name: "a" } as never;
+ });
+ vi.spyOn(server.registry, "disconnectAll").mockImplementation(async () => {
+ order.push("disconnectAll");
+ });
+
+ const opP = server.handle({
+ id: "c1",
+ op: "connect",
+ params: {
+ name: "a",
+ serverConfig: { type: "stdio", command: "x" },
+ serverIdentity: "x",
+ } as never,
+ });
+ await vi.waitFor(() => expect(order).toContain("connect:start"));
+ const stopP = server.stop("stop");
+ await new Promise((resolve) => setTimeout(resolve, 20));
+ expect(order).toEqual(["connect:start"]); // stop is waiting, not tearing down
+
+ releaseConnect();
+ await opP;
+ await stopP;
+ expect(order).toEqual(["connect:start", "connect:end", "disconnectAll"]);
+ server = undefined;
+ });
+
+ it("callDaemon times out a hung server", async () => {
+ const d = freshDir();
+ const sock = path.join(d, "daemon.sock");
+ 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("callDaemon with timeoutMs 0 arms no local deadline; daemon close still fails it", async () => {
+ // rpc/connect callers pass 0 because the daemon enforces the configured
+ // MCP timeouts; a fixed 60s local timer falsely failed long tool calls.
+ const d = freshDir();
+ const sock = path.join(d, "daemon.sock");
+ const sockets: net.Socket[] = [];
+ const silent = net.createServer((socket) => {
+ sockets.push(socket);
+ socket.on("error", () => {});
+ });
+ await new Promise((resolve) => silent.listen(sock, resolve));
+ try {
+ const pending = callDaemon(
+ "ping",
+ {},
+ { socketPath: sock, timeoutMs: 0 },
+ );
+ let settled = false;
+ // void: observer only; the promise itself is asserted on below.
+ void pending.catch(() => (settled = true)).then(() => (settled = true));
+ // Longer than the "times out a hung server" test's deadline: nothing
+ // fires locally.
+ await new Promise((resolve) => setTimeout(resolve, 200));
+ expect(settled).toBe(false);
+ for (const socket of sockets) socket.destroy();
+ await expect(pending).rejects.toThrow(/closed the connection/);
+ } finally {
+ silent.close();
+ try {
+ fs.unlinkSync(sock);
+ } catch {
+ // ignore
+ }
+ }
+ }, 5000);
+
+ it("connections/use and reconnect replace an existing connection", async () => {
+ const { command, args } = getTestMcpServerCommand();
+ const registry = new ConnectionRegistry(0);
+ 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 ConnectionRegistry(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 ConnectionRegistry(0);
+ registry.touch("missing");
+
+ await registry.connect({
+ name: "s",
+ serverConfig: { type: "stdio", command, args },
+ serverIdentity: "s",
+ });
+ const connection = registry.resolve("s", false);
+ vi.spyOn(connection.client, "disconnect").mockRejectedValueOnce(
+ new Error("teardown boom"),
+ );
+ await expect(registry.disconnect("s", false)).resolves.toEqual({
+ name: "s",
+ });
+ expect(registry.getMruName()).toBeNull();
+
+ const { AuthRecoveryRequiredError } =
+ await import("@inspector/core/auth/challenge.js");
+ const { InspectorClient } = await import("@inspector/core/mcp/index.js");
+ vi.spyOn(InspectorClient.prototype, "connect").mockRejectedValueOnce(
+ new AuthRecoveryRequiredError(new URL("https://as.example/authorize"), {
+ reason: "unauthorized",
+ }),
+ );
+ await expect(
+ registry.connect({
+ name: "auth",
+ serverConfig: { type: "stdio", command, args },
+ serverIdentity: "auth",
+ }),
+ ).rejects.toMatchObject({ exitCode: 3 });
+
+ // SDK token-exchange failure (empty redirectUrl / stale store) must surface
+ // as auth_required so the front-end can re-prompt — not a hard ErrorEnvelope.
+ vi.spyOn(InspectorClient.prototype, "connect").mockRejectedValueOnce(
+ new Error(
+ "Either provider.prepareTokenRequest() or authorizationCode is required",
+ ),
+ );
+ await expect(
+ registry.connect({
+ name: "reauth",
+ serverConfig: {
+ type: "streamable-http",
+ url: "https://example.com/mcp",
+ },
+ serverIdentity: "reauth",
+ }),
+ ).rejects.toMatchObject({
+ exitCode: 3,
+ envelope: { code: "auth_required" },
+ });
+
+ await expect(
+ registry.connect({
+ name: "http",
+ serverConfig: {
+ type: "streamable-http",
+ url: "http://127.0.0.1:1/mcp",
+ },
+ serverIdentity: "http",
+ }),
+ ).rejects.toThrow();
+
+ const idleReg = new ConnectionRegistry(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("ping and daemon/status report stopping during shutdown", async () => {
+ const d = freshDir();
+ const srv = new DaemonServer({ dir: d, idleMs: 0 });
+ await srv.start();
+ const before = await srv.handle({ id: "p1", op: "ping" });
+ expect(before).toMatchObject({ ok: true, result: { stopping: false } });
+ await srv.stop("stop");
+ // Ping never fails — a stopping (or just-stopped in-process) daemon
+ // still answers, flagged so ensureDaemon knows to wait it out.
+ const after = await srv.handle({ id: "p2", op: "ping" });
+ expect(after).toMatchObject({
+ ok: true,
+ result: { pong: true, stopping: true },
+ });
+ const status = await srv.handle({ id: "s1", op: "daemon/status" });
+ expect(status).toMatchObject({ ok: true, result: { stopping: true } });
+ });
+
+ it("waitForDaemonExit resolves for a dead pid and times out on a live one", async () => {
+ const dead = spawnSync(process.execPath, ["-e", ""]);
+ expect(dead.pid).toBeGreaterThan(0);
+ await waitForDaemonExit(dead.pid, "/nonexistent.sock", 2000, 10);
+ await expect(
+ waitForDaemonExit(process.pid, "/nonexistent.sock", 150, 25),
+ ).rejects.toMatchObject({ envelope: { code: "daemon_stopping" } });
+ });
+
+ it("waitForDaemonExit falls back to socket reachability without a pid", async () => {
+ const d = freshDir();
+ await waitForDaemonExit(undefined, path.join(d, "absent.sock"), 500, 10);
+ });
+
+ it("ensureDaemon waits out a stopping daemon and spawns a fresh one", async () => {
+ const d = freshDir();
+ const sock = path.join(d, "daemon.sock");
+ // The "old daemon": a process that takes a moment to exit, and a socket
+ // that answers ping with stopping:true (as the real dispatch does).
+ const oldDaemon = spawn(
+ process.execPath,
+ ["-e", "setTimeout(()=>{},800)"],
+ {
+ stdio: "ignore",
+ },
+ );
+ const stoppingSocket = net.createServer((socket) => {
+ socket.on("error", () => {});
+ socket.once("data", (buf) => {
+ const req = JSON.parse(String(buf).trim()) as { id: string };
+ socket.write(
+ JSON.stringify({
+ id: req.id,
+ ok: true,
+ result: { pong: true, pid: oldDaemon.pid, stopping: true },
+ }) + "\n",
+ );
+ });
+ });
+ await new Promise((r) => stoppingSocket.listen(sock, r));
+ // Mid-shutdown the socket goes away before the process does — the gap
+ // where spawning too early would die on the still-held lock.
+ setTimeout(() => {
+ stoppingSocket.close();
+ try {
+ fs.unlinkSync(sock);
+ } catch {
+ // already gone
+ }
+ }, 200);
+ try {
+ const ensured = await ensureDaemon({
+ dir: d,
+ daemonScript: resolveDaemonScriptPath(),
+ });
+ expect(ensured.spawned).toBe(true);
+ await callDaemon("daemon/stop", {}, { socketPath: ensured.socketPath });
+ await new Promise((r) => setTimeout(r, 150));
+ } finally {
+ oldDaemon.kill();
+ }
+ }, 15000);
+
+ it("start-timeout error quotes the daemon's stderr log", async () => {
+ const d = freshDir();
+ // A "daemon" that logs a failure and dies without ever binding a socket
+ // — the silent-death case the 0600 log exists to explain.
+ const script = path.join(d, "dying-daemon.js");
+ fs.writeFileSync(
+ script,
+ 'console.error("boom: could not start"); setTimeout(() => {}, 3000);\n',
+ );
+ await expect(
+ ensureDaemon({ dir: d, daemonScript: script, readyTimeoutMs: 700 }),
+ ).rejects.toMatchObject({
+ envelope: { code: "daemon_start_timeout" },
+ message: expect.stringContaining("boom: could not start"),
+ });
+ }, 15000);
+
+ it("start-timeout error stays clean when the daemon logged nothing", async () => {
+ const d = freshDir();
+ const script = path.join(d, "silent-daemon.js");
+ fs.writeFileSync(script, "setTimeout(() => {}, 3000);\n");
+ await expect(
+ ensureDaemon({ dir: d, daemonScript: script, readyTimeoutMs: 700 }),
+ ).rejects.toMatchObject({
+ envelope: { code: "daemon_start_timeout" },
+ message: expect.not.stringContaining("Daemon log"),
+ });
+ }, 15000);
+
+ it("readLogTail returns the last lines and empty string when unreadable", () => {
+ const d = freshDir();
+ const logPath = path.join(d, "daemon.log");
+ const lines = Array.from({ length: 15 }, (_, i) => `line-${i}`);
+ fs.writeFileSync(logPath, lines.join("\n") + "\n");
+ const tail = readLogTail(logPath);
+ expect(tail.split("\n")).toHaveLength(10);
+ expect(tail).toContain("line-14");
+ expect(tail).not.toContain("line-4\n");
+ expect(readLogTail(path.join(d, "missing.log"))).toBe("");
+ });
+
+ it("ensureDaemon fails loudly when a live listener rejects ping (no takeover)", async () => {
+ // Regression test for the daemon-takeover hole: a socket that ACCEPTS
+ // connections is owned by a live process. ensureDaemon must never unlink
+ // it and install a replacement daemon — it must surface the ping failure.
+ const d = freshDir();
+ const sock = path.join(d, "daemon.sock");
+ const occupant = net.createServer((socket) => {
+ socket.on("error", () => {});
+ socket.end();
+ });
+ await new Promise((resolve) => occupant.listen(sock, resolve));
+ try {
+ await expect(
+ ensureDaemon({ dir: d, daemonScript: resolveDaemonScriptPath() }),
+ ).rejects.toThrow(/closed the connection during 'ping'/);
+ // The occupant's socket must still be in place, untouched.
+ expect(fs.existsSync(sock)).toBe(true);
+ } finally {
+ occupant.close();
+ }
+ });
+
+ it("ensureDaemon fails loudly on daemon_auth_failed (wrong token is not a stale socket)", async () => {
+ const d = freshDir();
+ server = new DaemonServer({ dir: d, idleMs: 0, requiredToken: "good" });
+ await server.start();
+ await expect(
+ ensureDaemon({
+ dir: d,
+ daemonScript: resolveDaemonScriptPath(),
+ token: "wrong",
+ }),
+ ).rejects.toMatchObject({ envelope: { code: "daemon_auth_failed" } });
+ // The live daemon keeps its socket and still serves the right token.
+ const pong = await callDaemon(
+ "ping",
+ {},
+ {
+ socketPath: server.socketPath,
+ token: "good",
+ },
+ );
+ expect(pong).toBeDefined();
+ });
+
+ it("connection-less start arms idle and self-reaps", async () => {
+ const d = freshDir();
+ let shut = false;
+ server = new DaemonServer({
+ dir: d,
+ idleMs: 40,
+ onShutdown: () => {
+ shut = true;
+ },
+ });
+ await server.start();
+ // ensureDaemon from tools/list with no connections must not leak forever.
+ expect(server.registry.idleRemainingMs()).not.toBeNull();
+ await new Promise((r) => setTimeout(r, 100));
+ expect(shut).toBe(true);
+ server = undefined;
+ });
+
+ it("connect failure for a dead stdio command is surfaced and re-arms idle", async () => {
+ const registry = new ConnectionRegistry(5_000);
+ let idle = false;
+ registry.setIdleHandler(() => {
+ idle = true;
+ });
+ await expect(
+ registry.connect({
+ name: "dead",
+ serverConfig: {
+ type: "stdio",
+ command: path.join(os.tmpdir(), "no-such-mcp-server-binary"),
+ args: [],
+ },
+ serverIdentity: "dead",
+ }),
+ ).rejects.toThrow();
+ expect(registry.idleRemainingMs()).not.toBeNull();
+ expect(idle).toBe(false);
+ });
+
+ it("re-arms idle when createConnectionClient fails before client.connect", async () => {
+ const registry = new ConnectionRegistry(5_000);
+ registry.setIdleHandler(() => {});
+ const prev = process.env.MCP_OAUTH_CALLBACK_URL;
+ process.env.MCP_OAUTH_CALLBACK_URL = "https://example.com/oauth/callback";
+ try {
+ await expect(
+ registry.connect({
+ name: "http",
+ serverConfig: {
+ type: "streamable-http",
+ url: "http://127.0.0.1:1/mcp",
+ },
+ serverIdentity: "http",
+ }),
+ ).rejects.toThrow(/http scheme|callback URL/i);
+ expect(registry.idleRemainingMs()).not.toBeNull();
+ } finally {
+ if (prev === undefined) delete process.env.MCP_OAUTH_CALLBACK_URL;
+ else process.env.MCP_OAUTH_CALLBACK_URL = prev;
+ }
+ });
+
+ it("callDaemon fails immediately when the peer closes without a response", async () => {
+ const d = freshDir();
+ const sock = path.join(d, "daemon.sock");
+ const peer = net.createServer((socket) => {
+ socket.on("error", () => {});
+ // Accept then FIN with no NDJSON reply.
+ socket.end();
+ });
+ await new Promise((resolve) => peer.listen(sock, resolve));
+ try {
+ await expect(
+ callDaemon("ping", {}, { socketPath: sock, timeoutMs: 60_000 }),
+ ).rejects.toMatchObject({
+ envelope: { code: "daemon_unreachable" },
+ });
+ } finally {
+ peer.close();
+ try {
+ fs.unlinkSync(sock);
+ } catch {
+ // ignore
+ }
+ }
+ });
+
+ it("connections/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: "connections/use",
+ params: { name: "s" },
+ });
+ expect(used.ok).toBe(true);
+ expect(server.registry.idleRemainingMs()).toBeNull();
+
+ // connections/show over the same live connection — exercises the full case
+ // body (serverInfo/protocolVersion/protocolEra/capabilities lookups)
+ // in-process, where coverage instrumentation can see it.
+ const shown = await server.handle({
+ id: "s2",
+ op: "connections/show",
+ params: { name: "s" },
+ });
+ expect(shown.ok).toBe(true);
+ if (shown.ok) {
+ const result = shown.result as { protocolVersion?: string };
+ expect(result.protocolVersion).toBeTruthy();
+ }
+
+ await new Promise((resolve, reject) => {
+ const socket = new net.Socket();
+ socket.on("error", reject);
+ socket.connect(server!.socketPath, () => {
+ socket.write("\n\n");
+ socket.end();
+ resolve();
+ });
+ });
+
+ await callDaemon(
+ "disconnect",
+ { name: "s" },
+ { socketPath: server.socketPath },
+ );
+ // Idle timer armed — remaining countdown is positive and ≤ configured idleMs.
+ const remaining = server.registry.idleRemainingMs();
+ expect(remaining).not.toBeNull();
+ expect(remaining!).toBeLessThanOrEqual(60_000);
+ expect(remaining!).toBeGreaterThan(0);
+ });
+});
+
+describe("mcp connection 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_CONNECTION: "1",
+ };
+ }
+
+ it("covers connections/use, daemon status, @connection 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(
+ ["connections/use", "@alpha", "--format", "text"],
+ {
+ env: e,
+ },
+ );
+ expectCliSuccess(used);
+ expect(used.stdout).toContain("alpha");
+
+ const status = await runMcp(["daemon", "status"], { env: e });
+ expectCliSuccess(status);
+
+ const listed = await runMcp(["connections/list"], { env: e });
+ expectCliSuccess(listed);
+
+ const viaServer = await runMcp(
+ [
+ "connect",
+ "--server",
+ "test-stdio",
+ "--config",
+ configPath,
+ "--connection",
+ "via-flag",
+ "--format",
+ "json",
+ ],
+ { env: e, timeout: 20000 },
+ );
+ expectCliSuccess(viaServer);
+
+ const stopped = await runMcp(["daemon", "stop", "--format", "json"], {
+ env: e,
+ });
+ expectCliSuccess(stopped);
+ expect(stopped.stdout).toContain("stopping");
+ });
+
+ it("rejects connect with no target and invalid --format", async () => {
+ const e = env();
+ const missing = await runMcp(["connect"], { env: e });
+ expectCliFailure(missing);
+
+ const badFormat = await runMcp(["servers/list", "--format", "xml"], {
+ env: e,
+ });
+ expectCliFailure(badFormat);
+
+ const badTransport = await runMcp(["connect", "x", "--transport", "ftp"], {
+ env: e,
+ });
+ expectCliFailure(badTransport);
+
+ const badTimeout = await runMcp(
+ ["connect", "x", "--connect-timeout", "-1"],
+ { env: e },
+ );
+ expectCliFailure(badTimeout);
+
+ const emptyUse = await runMcp(["connections/use", ""], { env: e });
+ expectCliFailure(emptyUse);
+ });
+
+ it("connects an ad-hoc stdio target", async () => {
+ const { command, args } = getTestMcpServerCommand();
+ const e = env();
+ // Multi-token positional target → ad-hoc (not a catalog entry name).
+ const result = await runMcp(
+ [
+ "connect",
+ "--connection",
+ "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",
+ "--connection",
+ "url",
+ "--connect-timeout",
+ "100",
+ "--format",
+ "json",
+ ],
+ { env: e, timeout: 10000 },
+ );
+ // Connection should fail (nothing listening) but the ad-hoc URL path ran.
+ expectCliFailure(result);
+ });
+
+ it("requires explicit connection in non-interactive mode without opt-in", async () => {
+ configPath = createSampleTestConfig();
+ storageDir = fs.mkdtempSync(path.join(os.tmpdir(), "mcp-sess-ci-"));
+ const e = {
+ MCP_STORAGE_DIR: storageDir,
+ MCP_INSPECTOR_DAEMON_DIR: storageDir,
+ // no MCP_ALLOW_DEFAULT_CONNECTION
+ };
+ const connected = await runMcp(
+ ["connect", "test-stdio", "--config", configPath, "--format", "json"],
+ { env: e, timeout: 20000 },
+ );
+ expectCliSuccess(connected);
+
+ // Force requireExplicit by stubbing isTTY false is default in vitest forks.
+ const disc = await runMcp(["disconnect", "--format", "json"], { env: e });
+ expectCliFailure(disc);
+ expect(disc.stderr).toMatch(/Explicit|--connection|non-interactive/i);
+
+ await runMcp(["disconnect", "--connection", "test-stdio"], { env: e });
+ });
+});
diff --git a/clients/daemon-cli/__tests__/daemon-elicitation-park.test.ts b/clients/daemon-cli/__tests__/daemon-elicitation-park.test.ts
new file mode 100644
index 0000000000..38b19d4c92
--- /dev/null
+++ b/clients/daemon-cli/__tests__/daemon-elicitation-park.test.ts
@@ -0,0 +1,661 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+import * as fs from "node:fs";
+import * as os from "node:os";
+import * as path from "node:path";
+import { DaemonServer } from "../src/daemon/server.js";
+import {
+ ElicitationParkRegistry,
+ ParkingElicitationChannel,
+} from "../src/daemon/elicitation-park.js";
+import type {
+ ElicitationPendingInfo,
+ ElicitationRespondResult,
+ RpcResult,
+} from "../src/daemon/protocol.js";
+import type { InspectorClient } from "@inspector/core/mcp/inspectorClient.js";
+
+/**
+ * Covers daemon-side elicitation parking (dual-era support, phase 2):
+ * `rpc` with `parkElicitations` returning `elicitation-pending` instead of
+ * relaying an inline prompt, `elicitation/respond` resuming the parked call
+ * (final result, error, or the next round), expiry, the
+ * one-parked-call-per-connection guard, and the registry/channel primitives.
+ */
+
+const runMethodMock = vi.hoisted(() => ({
+ impl: undefined as unknown as (...args: unknown[]) => Promise,
+}));
+vi.mock("@inspector/cli/handlers/run-method.js", () => ({
+ runMethod: (...args: unknown[]) => runMethodMock.impl(...args),
+}));
+
+type FakeElicitationMessage = {
+ id: string;
+ origin: string;
+ request: { method: string; params: Record };
+ respond: ReturnType;
+ cancel: ReturnType;
+};
+
+function deferred() {
+ let resolve!: (value: T) => void;
+ let reject!: (error: unknown) => void;
+ const promise = new Promise((res, rej) => {
+ resolve = res;
+ reject = rej;
+ });
+ return { promise, resolve, reject };
+}
+
+const FORM_SCHEMA = {
+ type: "object",
+ properties: { color: { type: "string" } },
+ required: ["color"],
+};
+
+function makeFormMessage(id: string): {
+ message: FakeElicitationMessage;
+ answered: Promise<{ action: string; content?: Record }>;
+ cancelled: Promise;
+} {
+ const answer = deferred<{
+ action: string;
+ content?: Record;
+ }>();
+ const cancel = deferred();
+ const message: FakeElicitationMessage = {
+ id,
+ origin: "server-request",
+ request: {
+ method: "elicitation/create",
+ params: { message: "Pick a color", requestedSchema: FORM_SCHEMA },
+ },
+ respond: vi.fn(async (response) => {
+ answer.resolve(response as never);
+ }),
+ cancel: vi.fn(() => {
+ cancel.resolve();
+ answer.resolve({ action: "cancel" });
+ }),
+ };
+ return { message, answered: answer.promise, cancelled: cancel.promise };
+}
+
+function makeUrlMessage(id: string): {
+ message: FakeElicitationMessage;
+ answered: Promise<{ action: string; content?: Record }>;
+} {
+ const answer = deferred<{
+ action: string;
+ content?: Record;
+ }>();
+ const message: FakeElicitationMessage = {
+ id,
+ origin: "server-request",
+ request: {
+ method: "elicitation/create",
+ params: {
+ message: "Finish signup",
+ url: "https://example.com/signup?flow=abc",
+ },
+ },
+ respond: vi.fn(async (response) => {
+ answer.resolve(response as never);
+ }),
+ cancel: vi.fn(() => answer.resolve({ action: "cancel" })),
+ };
+ return { message, answered: answer.promise };
+}
+
+function fakeClient(): { client: InspectorClient; emit: (m: unknown) => void } {
+ const target = new EventTarget();
+ const client = {
+ addEventListener: (type: string, listener: EventListener) =>
+ target.addEventListener(type, listener),
+ removeEventListener: (type: string, listener: EventListener) =>
+ target.removeEventListener(type, listener),
+ getStatus: () => "connected",
+ } as unknown as InspectorClient;
+ return {
+ client,
+ emit: (detail) =>
+ target.dispatchEvent(
+ new CustomEvent("newPendingElicitation", { detail }),
+ ),
+ };
+}
+
+describe("daemon elicitation parking", () => {
+ let dir: string;
+ let server: DaemonServer;
+ let client: InspectorClient;
+ let emit: (m: unknown) => void;
+
+ beforeEach(() => {
+ dir = fs.mkdtempSync(path.join(os.tmpdir(), "mcp-elicit-park-"));
+ server = new DaemonServer({ dir, idleMs: 0 });
+ const fake = fakeClient();
+ client = fake.client;
+ emit = fake.emit;
+ const registry = server.registry as unknown as Record;
+ registry.connectionFor = () => ({ name: "srv", client });
+ registry.liveClientFor = async () => client;
+ });
+
+ afterEach(() => {
+ fs.rmSync(dir, { recursive: true, force: true });
+ vi.restoreAllMocks();
+ });
+
+ function rpcCallTool(id: string) {
+ return server.handle({
+ id,
+ op: "rpc",
+ params: {
+ method: "tools/call",
+ toolName: "collect",
+ name: "srv",
+ parkElicitations: true,
+ },
+ });
+ }
+
+ function respond(
+ id: string,
+ params: Record,
+ ): ReturnType {
+ return server.handle({ id, op: "elicitation/respond", params });
+ }
+
+ it("parks a form elicitation, then respond accept resumes to the final result", async () => {
+ const { message, answered } = makeFormMessage("elicit-1");
+ runMethodMock.impl = async () => {
+ emit(message);
+ const answer = await answered;
+ return {
+ kind: "result",
+ result: { echoed: answer.content, action: answer.action },
+ };
+ };
+
+ const first = await rpcCallTool("r1");
+ expect(first.ok).toBe(true);
+ const pending = (first as { result: RpcResult }).result;
+ expect(pending.kind).toBe("elicitation-pending");
+ const info = (pending as { elicitation: ElicitationPendingInfo })
+ .elicitation;
+ expect(info).toMatchObject({
+ elicitationId: "elicit-1",
+ connection: "srv",
+ method: "tools/call",
+ toolName: "collect",
+ mode: "form",
+ message: "Pick a color",
+ requestedSchema: FORM_SCHEMA,
+ origin: "server-request",
+ });
+ expect(info.expiresAt).toBeGreaterThan(Date.now());
+
+ const second = await respond("r2", {
+ elicitationId: "elicit-1",
+ action: "accept",
+ content: { color: "teal" },
+ });
+ expect(second.ok).toBe(true);
+ const result = (second as { result: ElicitationRespondResult }).result;
+ expect(result.method).toBe("tools/call");
+ expect(result.toolName).toBe("collect");
+ expect(result.outcome).toEqual({
+ kind: "result",
+ result: { echoed: { color: "teal" }, action: "accept" },
+ appInfo: undefined,
+ });
+ expect(message.respond).toHaveBeenCalledWith({
+ action: "accept",
+ content: { color: "teal" },
+ });
+ });
+
+ it("chains rounds: respond returns the next pending elicitation, then the result", async () => {
+ const round1 = makeFormMessage("elicit-a");
+ const round2 = makeFormMessage("elicit-b");
+ runMethodMock.impl = async () => {
+ emit(round1.message);
+ await round1.answered;
+ emit(round2.message);
+ const answer = await round2.answered;
+ return { kind: "result", result: { final: answer.content } };
+ };
+
+ const first = await rpcCallTool("r1");
+ expect((first as { result: RpcResult }).result.kind).toBe(
+ "elicitation-pending",
+ );
+
+ const mid = await respond("r2", {
+ elicitationId: "elicit-a",
+ action: "accept",
+ content: { color: "red" },
+ });
+ expect(mid.ok).toBe(true);
+ const midOutcome = (mid as { result: ElicitationRespondResult }).result
+ .outcome;
+ expect(midOutcome.kind).toBe("elicitation-pending");
+ const nextId = (midOutcome as { elicitation: ElicitationPendingInfo })
+ .elicitation.elicitationId;
+ expect(nextId).toBe("elicit-b");
+ // The answered round's id is no longer respondable.
+ const stale = await respond("r3", {
+ elicitationId: "elicit-a",
+ action: "cancel",
+ });
+ expect(stale.ok).toBe(false);
+ expect((stale as { error: { code: string } }).error.code).toBe(
+ "elicitation_not_found",
+ );
+
+ const done = await respond("r4", {
+ elicitationId: "elicit-b",
+ action: "accept",
+ content: { color: "blue" },
+ });
+ expect(done.ok).toBe(true);
+ expect(
+ (done as { result: ElicitationRespondResult }).result.outcome,
+ ).toMatchObject({ kind: "result", result: { final: { color: "blue" } } });
+ });
+
+ it("relays decline and cancel; url mode accepts --done and rejects decline/content", async () => {
+ // decline (form)
+ const declineRound = makeFormMessage("elicit-d");
+ runMethodMock.impl = async () => {
+ emit(declineRound.message);
+ const answer = await declineRound.answered;
+ return { kind: "result", result: { action: answer.action } };
+ };
+ await rpcCallTool("r1");
+ const declined = await respond("r2", {
+ elicitationId: "elicit-d",
+ action: "decline",
+ });
+ expect(
+ (declined as { result: ElicitationRespondResult }).result.outcome,
+ ).toMatchObject({ kind: "result", result: { action: "decline" } });
+ expect(declineRound.message.respond).toHaveBeenCalledWith({
+ action: "decline",
+ content: undefined,
+ });
+
+ // url mode
+ const urlRound = makeUrlMessage("elicit-u");
+ runMethodMock.impl = async () => {
+ emit(urlRound.message);
+ const answer = await urlRound.answered;
+ return { kind: "result", result: { action: answer.action } };
+ };
+ const parked = await rpcCallTool("r3");
+ const info = (
+ (parked as { result: RpcResult }).result as {
+ elicitation: ElicitationPendingInfo;
+ }
+ ).elicitation;
+ expect(info.mode).toBe("url");
+ expect(info.url).toBe("https://example.com/signup?flow=abc");
+
+ const badDecline = await respond("r4", {
+ elicitationId: "elicit-u",
+ action: "decline",
+ });
+ expect(badDecline.ok).toBe(false);
+ expect((badDecline as { error: { code: string } }).error.code).toBe(
+ "invalid_params",
+ );
+ const badContent = await respond("r5", {
+ elicitationId: "elicit-u",
+ action: "accept",
+ content: { nope: 1 },
+ });
+ expect(badContent.ok).toBe(false);
+
+ // Validation failures put the entry back — a corrected accept still works.
+ const done = await respond("r6", {
+ elicitationId: "elicit-u",
+ action: "accept",
+ });
+ expect(done.ok).toBe(true);
+ expect(
+ (done as { result: ElicitationRespondResult }).result.outcome,
+ ).toMatchObject({ kind: "result", result: { action: "accept" } });
+ expect(urlRound.message.respond).toHaveBeenCalledWith({
+ action: "accept",
+ content: undefined,
+ });
+ });
+
+ it("returns the plain result when a parked-mode call never elicits, and propagates failures", async () => {
+ runMethodMock.impl = async () => ({ kind: "result", result: { n: 1 } });
+ const plain = await rpcCallTool("r1");
+ expect((plain as { result: RpcResult }).result).toMatchObject({
+ kind: "result",
+ result: { n: 1 },
+ });
+
+ runMethodMock.impl = async () => {
+ throw new Error("server exploded");
+ };
+ const failed = await rpcCallTool("r2");
+ expect(failed.ok).toBe(false);
+ expect((failed as { error: { message: string } }).error.message).toContain(
+ "server exploded",
+ );
+ });
+
+ it("propagates a failure that lands after the elicitation was answered", async () => {
+ const round = makeFormMessage("elicit-f");
+ runMethodMock.impl = async () => {
+ emit(round.message);
+ await round.answered;
+ throw new Error("tool failed after input");
+ };
+ await rpcCallTool("r1");
+ const failed = await respond("r2", {
+ elicitationId: "elicit-f",
+ action: "accept",
+ content: { color: "red" },
+ });
+ expect(failed.ok).toBe(false);
+ expect((failed as { error: { message: string } }).error.message).toContain(
+ "tool failed after input",
+ );
+ });
+
+ it("rejects new rpcs on a connection with a parked call", async () => {
+ const round = makeFormMessage("elicit-g");
+ runMethodMock.impl = async () => {
+ emit(round.message);
+ await round.answered;
+ return { kind: "result", result: {} };
+ };
+ await rpcCallTool("r1");
+ const blocked = await server.handle({
+ id: "r2",
+ op: "rpc",
+ params: { method: "tools/list", name: "srv" },
+ });
+ expect(blocked.ok).toBe(false);
+ expect((blocked as { error: { code: string } }).error.code).toBe(
+ "elicitation_pending",
+ );
+ expect((blocked as { error: { message: string } }).error.message).toContain(
+ "elicitation/respond elicit-g",
+ );
+ // Unblock: cancel it.
+ const cancelled = await respond("r3", {
+ elicitationId: "elicit-g",
+ action: "cancel",
+ });
+ expect(cancelled.ok).toBe(true);
+ });
+
+ it("expires an unanswered parked elicitation and cancels the message", async () => {
+ server = new DaemonServer({ dir, idleMs: 0, elicitationTtlMs: 40 });
+ const registry = server.registry as unknown as Record;
+ registry.connectionFor = () => ({ name: "srv", client });
+ registry.liveClientFor = async () => client;
+
+ const round = makeFormMessage("elicit-x");
+ runMethodMock.impl = async () => {
+ emit(round.message);
+ await round.answered;
+ return { kind: "result", result: {} };
+ };
+ const parked = await rpcCallTool("r1");
+ expect((parked as { result: RpcResult }).result.kind).toBe(
+ "elicitation-pending",
+ );
+ await round.cancelled;
+ expect(round.message.cancel).toHaveBeenCalled();
+ const late = await respond("r2", {
+ elicitationId: "elicit-x",
+ action: "accept",
+ content: { color: "red" },
+ });
+ expect(late.ok).toBe(false);
+ expect((late as { error: { code: string } }).error.code).toBe(
+ "elicitation_not_found",
+ );
+ });
+
+ it("expired park cannot swallow a new call's elicitation (stale subscriber unwired)", async () => {
+ server = new DaemonServer({ dir, idleMs: 0, elicitationTtlMs: 40 });
+ const registry = server.registry as unknown as Record;
+ registry.connectionFor = () => ({ name: "srv", client });
+ registry.liveClientFor = async () => client;
+
+ // First call parks, then its park expires while the server-side call
+ // keeps running (the server never gives up) — so its outcome `finally`
+ // (the normal unwire point) has not fired.
+ const round1 = makeFormMessage("elicit-stale");
+ const neverSettles = deferred();
+ runMethodMock.impl = async () => {
+ emit(round1.message);
+ await neverSettles.promise;
+ return { kind: "result", result: {} };
+ };
+ const parked1 = await rpcCallTool("r1");
+ expect((parked1 as { result: RpcResult }).result.kind).toBe(
+ "elicitation-pending",
+ );
+ await round1.cancelled; // expiry fired
+
+ // A new call in that window: its elicitation must reach ITS subscriber,
+ // not the expired park's closed channel (which would auto-cancel it).
+ const round2 = makeFormMessage("elicit-fresh");
+ runMethodMock.impl = async () => {
+ emit(round2.message);
+ await round2.answered;
+ return { kind: "result", result: { ok: true } };
+ };
+ const parked2 = await rpcCallTool("r2");
+ expect((parked2 as { result: RpcResult }).result.kind).toBe(
+ "elicitation-pending",
+ );
+ expect(round2.message.cancel).not.toHaveBeenCalled();
+ const done = await respond("r3", {
+ elicitationId: "elicit-fresh",
+ action: "accept",
+ content: { color: "red" },
+ });
+ expect(done.ok).toBe(true);
+ });
+
+ it("disconnect cancels the parked call; respond then reports not found", async () => {
+ const registry = server.registry as unknown as Record;
+ registry.disconnect = async () => ({ name: "srv" });
+
+ const round = makeFormMessage("elicit-z");
+ runMethodMock.impl = async () => {
+ emit(round.message);
+ await round.answered;
+ return { kind: "result", result: {} };
+ };
+ await rpcCallTool("r1");
+ const gone = await server.handle({
+ id: "r2",
+ op: "disconnect",
+ params: { name: "srv" },
+ });
+ expect(gone.ok).toBe(true);
+ expect(round.message.cancel).toHaveBeenCalled();
+ const late = await respond("r3", {
+ elicitationId: "elicit-z",
+ action: "cancel",
+ });
+ expect(late.ok).toBe(false);
+ expect((late as { error: { code: string } }).error.code).toBe(
+ "elicitation_not_found",
+ );
+ });
+
+ it("picks up a call that settled on its own while parked (server gave up waiting)", async () => {
+ const round = makeFormMessage("elicit-s");
+ runMethodMock.impl = async () => {
+ emit(round.message);
+ // Server-side timeout: the call completes without our answer.
+ return { kind: "result", result: { timedOut: true } };
+ };
+ const parked = await rpcCallTool("r1");
+ expect((parked as { result: RpcResult }).result.kind).toBe(
+ "elicitation-pending",
+ );
+ const done = await respond("r2", {
+ elicitationId: "elicit-s",
+ action: "accept",
+ content: { color: "red" },
+ });
+ expect(done.ok).toBe(true);
+ expect(
+ (done as { result: ElicitationRespondResult }).result.outcome,
+ ).toMatchObject({ kind: "result", result: { timedOut: true } });
+ });
+
+ it("validates respond params", async () => {
+ const missing = await respond("r1", { action: "accept" });
+ expect((missing as { error: { code: string } }).error.code).toBe(
+ "invalid_params",
+ );
+ const badAction = await respond("r2", {
+ elicitationId: "x",
+ action: "shrug",
+ });
+ expect((badAction as { error: { code: string } }).error.code).toBe(
+ "invalid_params",
+ );
+ const unknown = await respond("r3", {
+ elicitationId: "nope",
+ action: "cancel",
+ });
+ expect((unknown as { error: { code: string } }).error.code).toBe(
+ "elicitation_not_found",
+ );
+ });
+});
+
+describe("ParkingElicitationChannel / ElicitationParkRegistry primitives", () => {
+ const frame = (elicitationId: string) =>
+ ({
+ id: "req-1",
+ kind: "elicitation-request",
+ elicitationId,
+ mode: "form",
+ message: "hi",
+ origin: "server-request",
+ }) as const;
+
+ it("answer() is a no-op with nothing pending; request after close rejects", async () => {
+ const channel = new ParkingElicitationChannel();
+ channel.answer({
+ id: "req-1",
+ kind: "elicitation-response",
+ elicitationId: "none",
+ action: "cancel",
+ });
+ channel.close(new Error("gone"));
+ await expect(channel.request(frame("later"))).rejects.toThrow("gone");
+ });
+
+ it("close rejects a pending request and clears the waiter", async () => {
+ const channel = new ParkingElicitationChannel();
+ const pending = channel.request(frame("e1"));
+ expect(channel.pendingFrame()?.elicitationId).toBe("e1");
+ channel.close(new Error("teardown"));
+ await expect(pending).rejects.toThrow("teardown");
+ expect(channel.pendingFrame()).toBeNull();
+ });
+
+ it("waitForElicitation resolves immediately when a request is already pending", async () => {
+ const channel = new ParkingElicitationChannel();
+ const pending = channel.request(frame("e1"));
+ const seen = await channel.waitForElicitation();
+ expect(seen.elicitationId).toBe("e1");
+ channel.close(new Error("teardown"));
+ await expect(pending).rejects.toThrow("teardown");
+ });
+
+ it("forClient and cancelForConnection ignore non-matching entries", async () => {
+ const registry = new ElicitationParkRegistry(0);
+ const channel = new ParkingElicitationChannel();
+ const pending = channel.request(frame("e1"));
+ const client = {} as InspectorClient;
+ registry.add({
+ info: {
+ elicitationId: "e1",
+ connection: "srv",
+ method: "tools/call",
+ mode: "form",
+ message: "hi",
+ origin: "server-request",
+ },
+ client,
+ channel,
+ outcome: new Promise(() => {}),
+ unwire: () => {},
+ });
+ expect(registry.forClient({} as InspectorClient)).toBeUndefined();
+ expect(registry.forClient(client)).toBeDefined();
+ // A different connection's teardown must not cancel this parked call.
+ registry.cancelForConnection("other");
+ expect(registry.forClient(client)).toBeDefined();
+ registry.cancelAll();
+ await expect(pending).rejects.toThrow(/going away/);
+ });
+
+ it("cancelAll settles every parked entry", async () => {
+ const registry = new ElicitationParkRegistry(0);
+ const channel = new ParkingElicitationChannel();
+ const pending = channel.request(frame("e1"));
+ const unwire = vi.fn();
+ registry.add({
+ info: {
+ elicitationId: "e1",
+ connection: "srv",
+ method: "tools/call",
+ mode: "form",
+ message: "hi",
+ origin: "server-request",
+ },
+ client: {} as InspectorClient,
+ channel,
+ outcome: new Promise(() => {}),
+ unwire,
+ });
+ registry.cancelAll();
+ await expect(pending).rejects.toThrow(/going away/);
+ expect(() => registry.take("e1")).toThrow(/No pending elicitation/);
+ // Cancel must also unwire the bridge subscriber of the abandoned call.
+ expect(unwire).toHaveBeenCalled();
+ });
+
+ it("expiry unwires the bridge subscriber of the abandoned call", async () => {
+ const registry = new ElicitationParkRegistry(20);
+ const channel = new ParkingElicitationChannel();
+ const pending = channel.request(frame("e2"));
+ const unwire = vi.fn();
+ registry.add({
+ info: {
+ elicitationId: "e2",
+ connection: "srv",
+ method: "tools/call",
+ mode: "form",
+ message: "hi",
+ origin: "server-request",
+ },
+ client: {} as InspectorClient,
+ channel,
+ outcome: new Promise(() => {}),
+ unwire,
+ });
+ await expect(pending).rejects.toThrow(/expired/);
+ expect(unwire).toHaveBeenCalled();
+ });
+});
diff --git a/clients/daemon-cli/__tests__/daemon-ipc-glue.test.ts b/clients/daemon-cli/__tests__/daemon-ipc-glue.test.ts
new file mode 100644
index 0000000000..3bc30bc45b
--- /dev/null
+++ b/clients/daemon-cli/__tests__/daemon-ipc-glue.test.ts
@@ -0,0 +1,343 @@
+/**
+ * Unit tests for `acceptDaemonConnection`'s per-connection wiring: the
+ * elicitation channel, destroyed-socket guards, and stream cleanup. A fake
+ * in-memory Duplex stands in for the net.Socket so every path is exercised
+ * deterministically (no accept/connect races).
+ */
+import { describe, it, expect } from "vitest";
+import { Duplex } from "node:stream";
+import type * as net from "node:net";
+import {
+ acceptDaemonConnection,
+ MAX_REQUEST_LINE_BYTES,
+ type ElicitationChannel,
+} from "../src/daemon/ipc-glue.js";
+import type {
+ DaemonRequest,
+ ElicitationRequestFrame,
+ ElicitationResponseFrame,
+} from "../src/daemon/protocol.js";
+
+class FakeSocket extends Duplex {
+ written: string[] = [];
+ override _read(): void {}
+ override _write(
+ chunk: unknown,
+ _encoding: BufferEncoding,
+ callback: (error?: Error | null) => void,
+ ): void {
+ this.written.push(String(chunk));
+ callback();
+ }
+ pushLine(line: string): void {
+ this.push(line + "\n");
+ }
+ get all(): string {
+ return this.written.join("");
+ }
+}
+
+function accept(
+ handle: (
+ request: DaemonRequest,
+ elicitation: ElicitationChannel,
+ signal?: AbortSignal,
+ ) => Promise<{
+ response: { id: string; ok: true; result: unknown };
+ startStream?: (
+ writeData: (data: unknown) => void,
+ endStream: () => void,
+ ) => () => void;
+ }>,
+): FakeSocket {
+ const socket = new FakeSocket();
+ acceptDaemonConnection(socket as unknown as net.Socket, handle);
+ return socket;
+}
+
+/** Await an event-driven condition (no fixed sleeps). */
+async function until(condition: () => boolean): Promise {
+ while (!condition()) {
+ await new Promise((resolve) => setImmediate(resolve));
+ }
+}
+
+const REQUEST = JSON.stringify({ id: "r1", op: "rpc", params: {} });
+
+function elicitationRequest(id: string): ElicitationRequestFrame {
+ return {
+ id,
+ kind: "elicitation-request",
+ elicitationId: `elicit-${id}`,
+ mode: "form",
+ message: "pick one",
+ origin: "server-request",
+ };
+}
+
+function elicitationResponse(id: string): ElicitationResponseFrame {
+ return {
+ id,
+ kind: "elicitation-response",
+ elicitationId: `elicit-${id}`,
+ action: "accept",
+ content: {},
+ };
+}
+
+describe("acceptDaemonConnection elicitation channel", () => {
+ it("pauses a call for an elicitation exchange and resumes on the answer", async () => {
+ const socket = accept(async (request, elicitation) => {
+ const answer = await elicitation.request(elicitationRequest("e1"));
+ return {
+ response: { id: request.id, ok: true, result: { action: answer } },
+ };
+ });
+
+ socket.pushLine(REQUEST);
+ await until(() => socket.all.includes('"elicitation-request"'));
+
+ socket.pushLine(JSON.stringify(elicitationResponse("e1")));
+ await until(() => socket.all.includes('"ok":true'));
+ expect(socket.all).toContain('"action"');
+ });
+
+ it("ignores non-answer lines while an exchange is pending", async () => {
+ const socket = accept(async (request, elicitation) => {
+ const answer = await elicitation.request(elicitationRequest("e2"));
+ return { response: { id: request.id, ok: true, result: answer } };
+ });
+
+ socket.pushLine(REQUEST);
+ await until(() => socket.all.includes('"elicitation-request"'));
+
+ // None of these are elicitation answers; each falls through to the
+ // request parser and earns an invalid_request response.
+ socket.pushLine("not-json");
+ socket.pushLine("null");
+ socket.pushLine(JSON.stringify({ kind: "other" }));
+ await until(() => socket.all.split('"invalid_request"').length - 1 === 3);
+
+ socket.pushLine(JSON.stringify(elicitationResponse("e2")));
+ await until(() => socket.all.includes('"ok":true'));
+ });
+
+ it("rejects a second exchange while one is already pending", async () => {
+ let secondError: Error | undefined;
+ const socket = accept(async (request, elicitation) => {
+ const first = elicitation.request(elicitationRequest("e3"));
+ await elicitation
+ .request(elicitationRequest("e4"))
+ .catch((error: Error) => {
+ secondError = error;
+ });
+ socket.pushLine(JSON.stringify(elicitationResponse("e3")));
+ await first;
+ return { response: { id: request.id, ok: true, result: {} } };
+ });
+
+ socket.pushLine(REQUEST);
+ await until(() => socket.all.includes('"ok":true'));
+ expect(secondError?.message).toMatch(/already pending/);
+ });
+
+ it("rejects a pending exchange when the connection drops", async () => {
+ let rejection: Error | undefined;
+ const settled = { done: false };
+ const socket = accept(async (request, elicitation) => {
+ elicitation.request(elicitationRequest("e5")).catch((error: Error) => {
+ rejection = error;
+ settled.done = true;
+ });
+ return { response: { id: request.id, ok: true, result: {} } };
+ });
+
+ socket.pushLine(REQUEST);
+ await until(() => socket.all.includes('"elicitation-request"'));
+ socket.destroy();
+ await until(() => settled.done);
+ expect(rejection?.message).toMatch(/Connection closed/);
+ });
+
+ it("rejects immediately when the socket is already destroyed", async () => {
+ let rejection: Error | undefined;
+ const settled = { done: false };
+ let channel: ElicitationChannel | undefined;
+ const socket = accept(async (request, elicitation) => {
+ channel = elicitation;
+ return { response: { id: request.id, ok: true, result: {} } };
+ });
+
+ socket.pushLine(REQUEST);
+ await until(() => socket.all.includes('"ok":true'));
+ socket.destroy();
+ await until(() => socket.destroyed);
+ await channel!.request(elicitationRequest("e6")).catch((error: Error) => {
+ rejection = error;
+ settled.done = true;
+ });
+ expect(settled.done).toBe(true);
+ expect(rejection?.message).toMatch(/Connection closed/);
+ });
+});
+
+describe("acceptDaemonConnection guards", () => {
+ it("aborts the per-request signal when the caller's socket closes", async () => {
+ let seen: AbortSignal | undefined;
+ let release: () => void = () => {};
+ const gate = new Promise((resolve) => {
+ release = resolve;
+ });
+ const socket = accept(async (request, _elicitation, signal) => {
+ seen = signal;
+ await gate;
+ return { response: { id: request.id, ok: true, result: {} } };
+ });
+
+ socket.pushLine(REQUEST);
+ await until(() => seen !== undefined);
+ // Caller still attached: nothing aborted.
+ expect(seen!.aborted).toBe(false);
+ socket.destroy();
+ await until(() => seen!.aborted === true);
+ release();
+ });
+
+ it("drops the response when the socket dies mid-handle", async () => {
+ let release: () => void = () => {};
+ const gate = new Promise((resolve) => {
+ release = resolve;
+ });
+ const handled = { done: false };
+ const socket = accept(async (request) => {
+ await gate;
+ handled.done = true;
+ return { response: { id: request.id, ok: true, result: {} } };
+ });
+
+ socket.pushLine(REQUEST);
+ socket.destroy();
+ await until(() => socket.destroyed);
+ release();
+ await until(() => handled.done);
+ // One more tick for the post-await destroyed guard.
+ await new Promise((resolve) => setImmediate(resolve));
+ expect(socket.all).toBe("");
+ });
+
+ it("disposes an opened stream when the socket died mid-handle", async () => {
+ let release: () => void = () => {};
+ const gate = new Promise((resolve) => {
+ release = resolve;
+ });
+ let started = 0;
+ let stops = 0;
+ const socket = accept(async (request) => {
+ await gate;
+ return {
+ response: { id: request.id, ok: true, result: {} },
+ // e.g. resources/subscribe: producer-side state exists before the
+ // starter runs; the glue must start it inert and stop it so the
+ // daemon doesn't keep a hidden subscription with no consumer.
+ startStream: (writeData, endStream) => {
+ started += 1;
+ // The inert writer/end are safe to call: nothing reaches the wire.
+ writeData({ n: 1 });
+ endStream();
+ return () => {
+ stops += 1;
+ };
+ },
+ };
+ });
+
+ socket.pushLine(REQUEST);
+ socket.destroy();
+ await until(() => socket.destroyed);
+ release();
+ await until(() => stops === 1);
+ expect(started).toBe(1);
+ expect(socket.all).toBe("");
+ });
+
+ it("ends the stream when the producer invokes endStream", async () => {
+ let end: () => void = () => {};
+ let stops = 0;
+ const socket = accept(async (request) => ({
+ response: { id: request.id, ok: true, result: {} },
+ startStream: (writeData, endStream) => {
+ end = endStream;
+ writeData({ n: 1 });
+ return () => {
+ stops += 1;
+ };
+ },
+ }));
+
+ socket.pushLine(REQUEST);
+ await until(() => socket.all.includes('"stream":"data"'));
+
+ end();
+ await until(() => socket.all.includes('"stream":"end"'));
+ expect(stops).toBe(1);
+
+ // A duplicate end (or a later close event) does not double-stop.
+ end();
+ socket.emit("close");
+ await new Promise((resolve) => setImmediate(resolve));
+ expect(stops).toBe(1);
+ });
+
+ it("cleans up a stream once on socket error and ignores late writes", async () => {
+ let lateWrite: (data: unknown) => void = () => {};
+ let stops = 0;
+ const socket = accept(async (request) => ({
+ response: { id: request.id, ok: true, result: {} },
+ startStream: (writeData) => {
+ lateWrite = writeData;
+ writeData({ n: 1 });
+ return () => {
+ stops += 1;
+ };
+ },
+ }));
+
+ socket.pushLine(REQUEST);
+ await until(() => socket.all.includes('"stream":"data"'));
+
+ // Error on a still-writable socket: cleanup must emit the end frame,
+ // half-close, and the readline teardown must not throw.
+ socket.emit("error", new Error("peer reset"));
+ await until(() => socket.all.includes('"stream":"end"'));
+ expect(stops).toBe(1);
+
+ // Late writes after cleanup are no-ops, and a duplicate cleanup
+ // (close after error) does not double-stop.
+ lateWrite({ n: 2 });
+ socket.emit("close");
+ await new Promise((resolve) => setImmediate(resolve));
+ expect(stops).toBe(1);
+ expect(socket.all.split('"stream":"data"').length - 1).toBe(1);
+ });
+});
+
+describe("request line cap", () => {
+ it("never hands a terminated oversized line to the handler", async () => {
+ // Deterministic cross-chunk variant of the e2e cap tests: a valid JSON
+ // request padded past the cap, split so the chunk that crosses the limit
+ // also carries the terminating newline. Both the byte accounting and the
+ // post-reject line guard must hold, or the handler sees the request.
+ let handled = 0;
+ const socket = accept(async (request) => {
+ handled += 1;
+ return { response: { id: request.id, ok: true, result: {} } };
+ });
+ const padded =
+ REQUEST + " ".repeat(MAX_REQUEST_LINE_BYTES + 1024 - REQUEST.length);
+ socket.push(padded.slice(0, 600 * 1024));
+ socket.push(padded.slice(600 * 1024) + "\n");
+ await until(() => socket.destroyed);
+ await new Promise((resolve) => setImmediate(resolve));
+ expect(handled).toBe(0);
+ });
+});
diff --git a/clients/daemon-cli/__tests__/daemon-paths.test.ts b/clients/daemon-cli/__tests__/daemon-paths.test.ts
new file mode 100644
index 0000000000..d61f948a72
--- /dev/null
+++ b/clients/daemon-cli/__tests__/daemon-paths.test.ts
@@ -0,0 +1,183 @@
+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 {
+ assertSocketPathWithinLimit,
+ assertTrustedPrivateRoot,
+ createPrivateDaemonDir,
+ ensureDaemonDir,
+ getDaemonDir,
+ getDaemonLockPath,
+ getDaemonSocketPath,
+} from "../src/daemon/paths.js";
+import { writeFormattedResult } from "@inspector/cli/handlers/format-output.js";
+
+describe("daemon paths", () => {
+ const backup: Record = {};
+
+ afterEach(() => {
+ for (const key of [
+ "MCP_INSPECTOR_DAEMON_DIR",
+ "MCP_STORAGE_DIR",
+ "HOME",
+ "TMPDIR",
+ ]) {
+ 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("tightens a pre-existing loose daemon directory to 0700 and rejects symlinks", () => {
+ // mkdirSync never re-modes an existing dir; ~/.mcp-inspector commonly
+ // pre-exists at 0755, so ensureDaemonDir must tighten it itself.
+ const base = fs.mkdtempSync(path.join(os.tmpdir(), "daemon-tighten-"));
+ const loose = path.join(base, "loose");
+ fs.mkdirSync(loose, { mode: 0o755 });
+ fs.chmodSync(loose, 0o755);
+ ensureDaemonDir(loose);
+ expect(fs.statSync(loose).mode & 0o077).toBe(0);
+
+ const target = path.join(base, "target");
+ fs.mkdirSync(target, { mode: 0o700 });
+ const link = path.join(base, "link");
+ fs.symlinkSync(target, link);
+ expect(() => ensureDaemonDir(link)).toThrow(/not a directory/);
+ fs.rmSync(base, { recursive: true, force: true });
+ });
+
+ it("createPrivateDaemonDir nests under a short 0700 tmpdir layout", () => {
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "mcp-conn-t-"));
+ setEnv("TMPDIR", tmp + path.sep);
+ const dir = createPrivateDaemonDir();
+ // $TMPDIR/mcp-conn-/<8-hex>; short enough that daemon.sock stays inside
+ // the platform sun_path limit even for macOS /var/folders tmpdirs.
+ expect(dir.startsWith(tmp)).toBe(true);
+ expect(path.basename(dir)).toMatch(/^[0-9a-f]{8}$/);
+ expect(path.basename(path.dirname(dir))).toMatch(/^mcp-conn-/);
+ expect(fs.statSync(dir).isDirectory()).toBe(true);
+ if (process.platform !== "win32") {
+ expect(fs.statSync(dir).mode & 0o777).toBe(0o700);
+ expect(fs.statSync(path.dirname(dir)).mode & 0o777).toBe(0o700);
+ }
+ fs.rmSync(tmp, { recursive: true, force: true });
+ });
+
+ it("createPrivateDaemonDir refuses a symlinked mcp-conn root", () => {
+ if (process.platform === "win32") return;
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "mcp-conn-sym-"));
+ setEnv("TMPDIR", tmp + path.sep);
+ // Another user pre-planting the predictable root as a symlink to a dir
+ // they control must fail closed, not be adopted by recursive mkdir.
+ const target = path.join(tmp, "attacker-controlled");
+ fs.mkdirSync(target, { mode: 0o700 });
+ fs.symlinkSync(target, path.join(tmp, `mcp-conn-${process.getuid!()}`));
+ expect(() => createPrivateDaemonDir()).toThrow(/not a directory/);
+ fs.rmSync(tmp, { recursive: true, force: true });
+ });
+
+ it("assertTrustedPrivateRoot tightens a loose pre-existing root", () => {
+ if (process.platform === "win32") return;
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "mcp-conn-loose-"));
+ const root = path.join(tmp, "root");
+ fs.mkdirSync(root, { mode: 0o755 });
+ assertTrustedPrivateRoot(root);
+ expect(fs.statSync(root).mode & 0o777).toBe(0o700);
+ // A file in the root's place fails closed too.
+ const file = path.join(tmp, "not-a-dir");
+ fs.writeFileSync(file, "");
+ expect(() => assertTrustedPrivateRoot(file)).toThrow(/not a directory/);
+ fs.rmSync(tmp, { recursive: true, force: true });
+ });
+
+ it("assertSocketPathWithinLimit rejects paths over the sun_path limit", () => {
+ expect(() =>
+ assertSocketPathWithinLimit("/tmp/short/daemon.sock"),
+ ).not.toThrow();
+ const long = "/" + "x".repeat(150) + "/daemon.sock";
+ expect(() => assertSocketPathWithinLimit(long)).toThrow(
+ /too long for this platform/,
+ );
+ });
+
+ it("uses a deterministic named-pipe path on Windows with no sun_path limit", () => {
+ const realPlatform = Object.getOwnPropertyDescriptor(
+ process,
+ "platform",
+ ) as PropertyDescriptor;
+ Object.defineProperty(process, "platform", { value: "win32" });
+ try {
+ const pipe = getDaemonSocketPath("/some/daemon/dir");
+ expect(pipe).toMatch(/^\\\\\.\\pipe\\mcp-conn-[0-9a-f]{16}$/);
+ // Same dir (any casing) -> same pipe; different dir -> different pipe.
+ expect(getDaemonSocketPath("/SOME/DAEMON/DIR")).toBe(pipe);
+ expect(getDaemonSocketPath("/other/daemon/dir")).not.toBe(pipe);
+ // Pipe names are not sun_path-constrained.
+ const long = "\\\\.\\pipe\\" + "x".repeat(300);
+ expect(() => assertSocketPathWithinLimit(long)).not.toThrow();
+ } finally {
+ Object.defineProperty(process, "platform", realPlatform);
+ }
+ });
+});
+
+describe("writeFormattedResult", () => {
+ 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/daemon-cli/__tests__/daemon-private.test.ts b/clients/daemon-cli/__tests__/daemon-private.test.ts
new file mode 100644
index 0000000000..01bb62d2c2
--- /dev/null
+++ b/clients/daemon-cli/__tests__/daemon-private.test.ts
@@ -0,0 +1,345 @@
+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 { MAX_REQUEST_LINE_BYTES } from "../src/daemon/ipc-glue.js";
+import {
+ createPrivateDaemonDir,
+ DAEMON_DIR_ENV,
+ DAEMON_TOKEN_ENV,
+ getDaemonTokenPath,
+} from "../src/daemon/paths.js";
+import { DaemonServer } from "../src/daemon/server.js";
+import { CliExitCodeError } from "@inspector/cli/error-handler.js";
+import { runMcp } from "./helpers/mcp-runner.js";
+import {
+ expectCliSuccess,
+ expectCliFailure,
+} from "../../cli/__tests__/helpers/assertions.js";
+import {
+ createSampleTestConfig,
+ deleteConfigFile,
+} from "../../cli/__tests__/helpers/fixtures.js";
+import {
+ createPrivateBinding,
+ formatPrivateEnvExports,
+} from "../src/connection/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("mcpdo 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}='[^']+/mcp-conn-[^/']+/[0-9a-f]{8}'`,
+ ),
+ );
+ 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 a short 0700 dir under the tmpdir", () => {
+ useTempHome();
+ const binding = createPrivateBinding();
+ expect(path.basename(binding.dir)).toMatch(/^[0-9a-f]{8}$/);
+ expect(path.basename(path.dirname(binding.dir))).toMatch(/^mcp-conn-/);
+ expect(binding.dir.startsWith(os.tmpdir())).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 with a wrong token and accepts with the right one", async () => {
+ dir = fs.mkdtempSync(path.join(os.tmpdir(), "mcp-priv-"));
+ const token = "test-token-value";
+ server = new DaemonServer({ dir, idleMs: 0, requiredToken: token });
+ await server.start();
+
+ // The daemon publishes daemon.token (0600) for same-user clients, so a
+ // tokenless call auto-discovers it; only a wrong token must fail.
+ await expect(
+ callDaemon(
+ "ping",
+ {},
+ { socketPath: server.socketPath, timeoutMs: 2000, token: "wrong" },
+ ),
+ ).rejects.toMatchObject({ envelope: { code: "daemon_auth_failed" } });
+
+ // Tokenless call discovers the published token file next to the socket.
+ const discovered = await callDaemon<{ pong: boolean }>(
+ "ping",
+ {},
+ { socketPath: server.socketPath, timeoutMs: 2000 },
+ );
+ expect(discovered.pong).toBe(true);
+
+ const pong = await callDaemon<{ pong: boolean }>(
+ "ping",
+ {},
+ { socketPath: server.socketPath, timeoutMs: 2000, token },
+ );
+ expect(pong.pong).toBe(true);
+ });
+
+ it("publishes daemon.token (0600) on start and removes it on stop", async () => {
+ dir = fs.mkdtempSync(path.join(os.tmpdir(), "mcp-priv-tok-"));
+ const token = "published-token";
+ server = new DaemonServer({ dir, idleMs: 0, requiredToken: token });
+ await server.start();
+
+ const tokenPath = getDaemonTokenPath(dir);
+ expect(fs.readFileSync(tokenPath, "utf8").trim()).toBe(token);
+ if (process.platform !== "win32") {
+ expect(fs.statSync(tokenPath).mode & 0o777).toBe(0o600);
+ }
+
+ await server.stop("stop");
+ server = undefined;
+ expect(fs.existsSync(tokenPath)).toBe(false);
+ });
+
+ it("drops a connection whose request line exceeds the cap", async () => {
+ dir = fs.mkdtempSync(path.join(os.tmpdir(), "mcp-priv-cap-"));
+ server = new DaemonServer({ dir, idleMs: 0 });
+ await server.start();
+
+ const net = await import("node:net");
+ const closed = await new Promise((resolve) => {
+ const socket = net.connect(server!.socketPath, () => {
+ // One oversized line, never newline-terminated.
+ socket.write(Buffer.alloc(MAX_REQUEST_LINE_BYTES + 64 * 1024, 0x61));
+ });
+ const done = () => resolve(true);
+ socket.once("close", done);
+ socket.once("error", done);
+ setTimeout(() => {
+ socket.destroy();
+ resolve(false);
+ }, 5000).unref();
+ });
+ expect(closed).toBe(true);
+ });
+
+ it("rejects an oversized line even when its terminator arrives with it", async () => {
+ // Regression: the old cap only counted bytes after a chunk's last
+ // newline, so an oversized line whose terminating "\n" arrived in the
+ // crossing chunk reset the counter and reached readline.
+ dir = fs.mkdtempSync(path.join(os.tmpdir(), "mcp-priv-cap2-"));
+ server = new DaemonServer({ dir, idleMs: 0 });
+ await server.start();
+
+ const net = await import("node:net");
+ const closed = await new Promise((resolve) => {
+ const socket = net.connect(server!.socketPath, () => {
+ socket.write(Buffer.alloc(600 * 1024, 0x61));
+ socket.write(
+ Buffer.concat([Buffer.alloc(600 * 1024, 0x61), Buffer.from("\n")]),
+ );
+ });
+ const done = () => resolve(true);
+ socket.once("close", done);
+ socket.once("error", done);
+ setTimeout(() => {
+ socket.destroy();
+ resolve(false);
+ }, 5000).unref();
+ });
+ expect(closed).toBe(true);
+ });
+
+ it("adopts the winner's published token when a concurrent starter wins the lock", async () => {
+ // Two concurrent first invocations each generate a token and spawn; the
+ // pid lock lets one daemon survive. The loser must finish against the
+ // winner's daemon by re-reading its published daemon.token, not poll
+ // with its own dead token until daemon_start_timeout.
+ dir = fs.mkdtempSync(path.join(os.tmpdir(), "mcp-priv-race-"));
+ const prevTok = process.env[DAEMON_TOKEN_ENV];
+ delete process.env[DAEMON_TOKEN_ENV];
+ try {
+ // Our child lost the O_EXCL pid lock: it exits without binding.
+ const stub = path.join(dir, "losing-daemon.js");
+ fs.writeFileSync(stub, "process.exit(0);\n");
+ const ensured = ensureDaemon({ dir, daemonScript: stub });
+ // The concurrent winner, holding a different (published) token.
+ server = new DaemonServer({
+ dir,
+ idleMs: 0,
+ requiredToken: "winner-token",
+ });
+ await server.start();
+
+ const { socketPath, spawned } = await ensured;
+ expect(spawned).toBe(true);
+ const pong = await callDaemon<{ pong: boolean }>(
+ "ping",
+ {},
+ { socketPath, timeoutMs: 2000, token: "winner-token" },
+ );
+ expect(pong.pong).toBe(true);
+ } finally {
+ if (prevTok === undefined) delete process.env[DAEMON_TOKEN_ENV];
+ else process.env[DAEMON_TOKEN_ENV] = prevTok;
+ }
+ });
+
+ it("connection front-end rethrows non-unreachable daemon errors", async () => {
+ dir = fs.mkdtempSync(path.join(os.tmpdir(), "mcp-priv-rethrow-"));
+ const token = "good-token";
+ server = new DaemonServer({ dir, idleMs: 0, requiredToken: token });
+ await server.start();
+
+ const env = {
+ MCP_STORAGE_DIR: dir,
+ [DAEMON_DIR_ENV]: dir,
+ [DAEMON_TOKEN_ENV]: "wrong-token",
+ };
+
+ const listed = await runMcp(["connections/list"], { env });
+ expectCliFailure(listed);
+ expect(listed.stderr).toMatch(/authentication failed|daemon_auth_failed/i);
+
+ const status = await runMcp(["daemon", "status"], { env });
+ expectCliFailure(status);
+
+ const configPath = createSampleTestConfig();
+ try {
+ const servers = await runMcp(["servers/list", "--config", configPath], {
+ env,
+ });
+ // Optional daemon probe must not swallow auth failures as empty connections.
+ expectCliFailure(servers);
+ } finally {
+ deleteConfigFile(configPath);
+ }
+ });
+
+ it("ensureDaemon spawns a token-gated daemon from env", async () => {
+ const home = fs.mkdtempSync(path.join(os.tmpdir(), "mcp-home-spawn-"));
+ const prevHome = process.env.HOME;
+ process.env.HOME = home;
+ try {
+ dir = createPrivateDaemonDir();
+ const token = "spawn-token-xyz";
+ const prevDir = process.env[DAEMON_DIR_ENV];
+ const prevTok = process.env[DAEMON_TOKEN_ENV];
+ process.env[DAEMON_DIR_ENV] = dir;
+ process.env[DAEMON_TOKEN_ENV] = token;
+ try {
+ const { socketPath, spawned } = await ensureDaemon({ dir, token });
+ expect(spawned).toBe(true);
+
+ // Explicit wrong token — do not rely on clearing env (callDaemon
+ // falls back to MCP_INSPECTOR_DAEMON_TOKEN when options.token omitted).
+ await expect(
+ callDaemon(
+ "ping",
+ {},
+ { socketPath, timeoutMs: 2000, token: "wrong" },
+ ),
+ ).rejects.toMatchObject({ envelope: { code: "daemon_auth_failed" } });
+
+ const pong = await callDaemon<{ pong: boolean }>(
+ "ping",
+ {},
+ { socketPath, timeoutMs: 2000, token },
+ );
+ expect(pong.pong).toBe(true);
+
+ const { command, args } = getTestMcpServerCommand();
+ await callDaemon(
+ "connect",
+ {
+ name: "s",
+ serverConfig: { type: "stdio", command, args },
+ serverIdentity: "s",
+ },
+ { socketPath, timeoutMs: 15000, token },
+ );
+ await callDaemon("daemon/stop", {}, { socketPath, token });
+ } finally {
+ if (prevDir === undefined) delete process.env[DAEMON_DIR_ENV];
+ else process.env[DAEMON_DIR_ENV] = prevDir;
+ if (prevTok === undefined) delete process.env[DAEMON_TOKEN_ENV];
+ else process.env[DAEMON_TOKEN_ENV] = prevTok;
+ }
+ } finally {
+ if (prevHome === undefined) delete process.env.HOME;
+ else process.env.HOME = prevHome;
+ fs.rmSync(home, { recursive: true, force: true });
+ }
+ });
+});
diff --git a/clients/daemon-cli/__tests__/daemon-stream.test.ts b/clients/daemon-cli/__tests__/daemon-stream.test.ts
new file mode 100644
index 0000000000..6898e7fb17
--- /dev/null
+++ b/clients/daemon-cli/__tests__/daemon-stream.test.ts
@@ -0,0 +1,582 @@
+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 { streamDaemon } from "../src/daemon/stream-client.js";
+import {
+ acceptDaemonConnection,
+ removeStaleDaemonSocket,
+} from "../src/daemon/ipc-glue.js";
+import { CliExitCodeError, EXIT_CODES } from "@inspector/cli/error-handler.js";
+
+describe("streamDaemon + ipc-glue", () => {
+ let dir: string | undefined;
+ let server: net.Server | undefined;
+ const sockets = new Set();
+
+ afterEach(async () => {
+ for (const s of sockets) {
+ s.destroy();
+ }
+ sockets.clear();
+ if (server) {
+ await new Promise((resolve) => {
+ server!.close(() => resolve());
+ });
+ server = undefined;
+ }
+ if (dir) {
+ fs.rmSync(dir, { recursive: true, force: true });
+ dir = undefined;
+ }
+ });
+
+ function freshSock(): string {
+ dir = fs.mkdtempSync(path.join(os.tmpdir(), "mcp-stream-"));
+ return path.join(dir, "daemon.sock");
+ }
+
+ async function listen(
+ sock: string,
+ onSocket: (socket: net.Socket) => void,
+ ): Promise {
+ server = net.createServer((socket) => {
+ sockets.add(socket);
+ socket.on("error", () => {});
+ socket.on("close", () => sockets.delete(socket));
+ onSocket(socket);
+ });
+ await new Promise((resolve) => server!.listen(sock, resolve));
+ }
+
+ it("resolves immediately on an already-aborted signal without dialing", async () => {
+ const sock = freshSock();
+ const ac = new AbortController();
+ ac.abort();
+ // No server listens at `sock`: a dial would reject with
+ // daemon_unreachable, so resolving proves connect() was never called.
+ await expect(
+ streamDaemon(
+ { method: "logging/tail" },
+ {
+ socketPath: sock,
+ timeoutMs: 2000,
+ signal: ac.signal,
+ onData: () => {},
+ },
+ ),
+ ).resolves.toBeUndefined();
+ });
+
+ it("delivers data frames then end (skips blank/mismatched ids)", async () => {
+ const sock = freshSock();
+ await listen(sock, (socket) => {
+ 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("pauses socket reads while an async onData callback is pending", async () => {
+ const sock = freshSock();
+ let serverSocket: net.Socket | undefined;
+ let requestId: string | undefined;
+ await listen(sock, (socket) => {
+ serverSocket = socket;
+ socket.once("data", (buf) => {
+ const req = JSON.parse(String(buf).trim()) as { id: string };
+ requestId = req.id;
+ socket.write(
+ JSON.stringify({ id: req.id, ok: true, result: {} }) + "\n",
+ );
+ socket.write(
+ JSON.stringify({ id: req.id, stream: "data", data: { n: 1 } }) + "\n",
+ );
+ });
+ });
+
+ const until = async (cond: () => boolean) => {
+ const deadline = Date.now() + 3000;
+ while (!cond()) {
+ if (Date.now() > deadline) throw new Error("condition timed out");
+ await new Promise((r) => setTimeout(r, 5));
+ }
+ };
+
+ const seen: unknown[] = [];
+ let release!: () => void;
+ const gate = new Promise((resolve) => {
+ release = resolve;
+ });
+ const done = streamDaemon(
+ { method: "logging/tail" },
+ {
+ socketPath: sock,
+ timeoutMs: 5000,
+ // First callback stalls on the gate; the client must stop reading
+ // instead of queueing further frames behind an unbounded chain.
+ onData: (d) => {
+ seen.push(d);
+ return seen.length === 1 ? gate : undefined;
+ },
+ },
+ );
+
+ await until(() => seen.length === 1);
+ // Send a second frame + end while the first callback is still pending.
+ serverSocket!.write(
+ JSON.stringify({ id: requestId, stream: "data", data: { n: 2 } }) + "\n",
+ );
+ serverSocket!.write(
+ JSON.stringify({ id: requestId, stream: "end" }) + "\n",
+ );
+ await new Promise((r) => setTimeout(r, 100));
+ // Reads are paused, so the second frame must not have been dispatched.
+ expect(seen.length).toBe(1);
+
+ release();
+ await done;
+ expect(seen).toEqual([{ n: 1 }, { n: 2 }]);
+ });
+
+ it("continues the stream when an async onData callback rejects", async () => {
+ const sock = freshSock();
+ await listen(sock, (socket) => {
+ socket.once("data", (buf) => {
+ const req = JSON.parse(String(buf).trim()) as { id: string };
+ socket.write(
+ JSON.stringify({ id: req.id, ok: true, result: {} }) + "\n",
+ );
+ socket.write(
+ JSON.stringify({ id: req.id, stream: "data", data: { n: 1 } }) + "\n",
+ );
+ socket.write(
+ JSON.stringify({ id: req.id, stream: "data", data: { n: 2 } }) + "\n",
+ );
+ socket.write(JSON.stringify({ id: req.id, stream: "end" }) + "\n");
+ });
+ });
+
+ const seen: unknown[] = [];
+ // Write errors are non-fatal: a rejected callback promise must not kill
+ // the stream or surface as an unhandled rejection.
+ await streamDaemon(
+ { method: "logging/tail" },
+ {
+ socketPath: sock,
+ timeoutMs: 5000,
+ onData: (d) => {
+ seen.push(d);
+ return Promise.reject(new Error("write failed"));
+ },
+ },
+ );
+ expect(seen).toEqual([{ n: 1 }, { n: 2 }]);
+ });
+
+ it("rejects on socket error after the stream has opened", async () => {
+ const sock = freshSock();
+ await listen(sock, (socket) => {
+ 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 expect(
+ streamDaemon({}, { socketPath: sock, timeoutMs: 2000, onData: () => {} }),
+ ).rejects.toMatchObject({
+ envelope: { code: "daemon_unreachable" },
+ });
+ });
+
+ 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("finishes immediately on a pre-aborted signal instead of hanging", async () => {
+ const sock = freshSock();
+ await listen(sock, () => {
+ // Never respond: only the pre-aborted check can settle this promptly.
+ });
+ const ac = new AbortController();
+ ac.abort();
+ await streamDaemon(
+ {},
+ {
+ socketPath: sock,
+ timeoutMs: 5000,
+ signal: ac.signal,
+ onData: () => {},
+ },
+ );
+ });
+
+ it("aborts via signal after the stream opens", async () => {
+ const sock = freshSock();
+ await listen(sock, (socket) => {
+ 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("disables the open deadline entirely with timeoutMs 0", async () => {
+ // Regression: an unconditional setTimeout(..., 0) fired on the next
+ // tick, so timeoutMs 0 (documented as "no deadline") failed every
+ // stream immediately instead of waiting indefinitely.
+ const sock = freshSock();
+ await listen(sock, (socket) => {
+ socket.once("data", (buf) => {
+ const req = JSON.parse(String(buf).trim()) as { id: string };
+ setTimeout(() => {
+ socket.write(
+ JSON.stringify({ id: req.id, ok: true, result: {} }) + "\n",
+ );
+ socket.write(JSON.stringify({ id: req.id, stream: "end" }) + "\n");
+ }, 120);
+ });
+ });
+ await expect(
+ streamDaemon({}, { socketPath: sock, timeoutMs: 0, onData: () => {} }),
+ ).resolves.toBeUndefined();
+ }, 5000);
+
+ it("fails when the peer FINs before the stream ok frame", async () => {
+ const sock = freshSock();
+ await listen(sock, (socket) => {
+ socket.on("error", () => {});
+ socket.once("data", () => {
+ socket.end();
+ });
+ });
+ await expect(
+ streamDaemon(
+ {},
+ { socketPath: sock, timeoutMs: 60_000, onData: () => {} },
+ ),
+ ).rejects.toMatchObject({
+ envelope: { code: "daemon_unreachable" },
+ });
+ });
+
+ it("rejects when the peer closes mid-stream without an end frame", 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 expect(
+ streamDaemon({}, { socketPath: sock, timeoutMs: 2000, onData: () => {} }),
+ ).rejects.toMatchObject({
+ envelope: { code: "daemon_unreachable" },
+ message: expect.stringMatching(/closed the stream before it ended/),
+ });
+ });
+
+ it("uses env-derived defaults and sends an explicit token", async () => {
+ const sock = freshSock();
+ const prevDir = process.env.MCP_INSPECTOR_DAEMON_DIR;
+ const prevToken = process.env.MCP_INSPECTOR_DAEMON_TOKEN;
+ process.env.MCP_INSPECTOR_DAEMON_DIR = path.dirname(sock);
+ delete process.env.MCP_INSPECTOR_DAEMON_TOKEN;
+ try {
+ let seenToken: string | undefined;
+ await listen(sock, (socket) => {
+ socket.once("data", (buf) => {
+ const req = JSON.parse(String(buf).trim()) as {
+ id: string;
+ token?: string;
+ };
+ seenToken = req.token;
+ socket.write(
+ JSON.stringify({ id: req.id, ok: true, result: {} }) + "\n",
+ );
+ socket.write(JSON.stringify({ id: req.id, stream: "end" }) + "\n");
+ });
+ });
+ // No socketPath / timeoutMs: both fall back to defaults (the daemon
+ // dir env pins the socket path; the 60s default timer is cleared by
+ // the ok frame).
+ await streamDaemon({}, { token: "tok-1", onData: () => {} });
+ expect(seenToken).toBe("tok-1");
+ } finally {
+ if (prevDir === undefined) delete process.env.MCP_INSPECTOR_DAEMON_DIR;
+ else process.env.MCP_INSPECTOR_DAEMON_DIR = prevDir;
+ if (prevToken !== undefined)
+ process.env.MCP_INSPECTOR_DAEMON_TOKEN = prevToken;
+ }
+ });
+
+ it("ignores frames after the stream has already ended", async () => {
+ const sock = freshSock();
+ await listen(sock, (socket) => {
+ socket.once("data", (buf) => {
+ const req = JSON.parse(String(buf).trim()) as { id: string };
+ // ok + two end frames in one chunk: the second is handled by the
+ // same buffered-line loop after the promise has settled.
+ socket.write(
+ JSON.stringify({ id: req.id, ok: true, result: {} }) +
+ "\n" +
+ JSON.stringify({ id: req.id, stream: "end" }) +
+ "\n" +
+ JSON.stringify({ id: req.id, stream: "end" }) +
+ "\n",
+ );
+ });
+ });
+ await streamDaemon(
+ {},
+ { socketPath: sock, timeoutMs: 2000, onData: () => {} },
+ );
+ });
+
+ it("removeStaleDaemonSocket handles absent, dead, and live sockets", async () => {
+ const sock = freshSock();
+ await removeStaleDaemonSocket(sock);
+
+ 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("terminates a stream once the socket write buffer exceeds the cap", async () => {
+ const sock = freshSock();
+ let stopCalled = false;
+ let writeFn: ((data: unknown) => void) | undefined;
+ await listen(sock, (socket) => {
+ acceptDaemonConnection(socket, async (req) => ({
+ response: { id: req.id, ok: true, result: {} },
+ startStream: (writeData) => {
+ writeFn = writeData;
+ return () => {
+ stopCalled = true;
+ };
+ },
+ }));
+ });
+
+ let sawEnd = false;
+ let client!: net.Socket;
+ const closed = new Promise((resolve) => {
+ client = net.connect(sock, () => {
+ sockets.add(client);
+ // Never read: the daemon-side write buffer must hit the cap instead
+ // of growing without bound.
+ client.pause();
+ client.write(
+ JSON.stringify({ id: "s1", op: "stream", params: {} }) + "\n",
+ );
+ });
+ client.on("data", (c) => {
+ if (String(c).includes('"stream":"end"')) sawEnd = true;
+ });
+ client.on("close", () => resolve());
+ client.on("error", () => {});
+ });
+
+ await vi.waitFor(() => expect(writeFn).toBeDefined());
+ const chunk = "x".repeat(64 * 1024);
+ for (let i = 0; i < 200 && !stopCalled; i++) {
+ writeFn!({ chunk });
+ }
+ // Producer unsubscribed and socket destroyed — no clean end frame.
+ expect(stopCalled).toBe(true);
+ // The paused client never drains, so its "close" only fires once the
+ // test tears the socket down.
+ client.destroy();
+ await closed;
+ expect(sawEnd).toBe(false);
+ });
+
+ it("unreachable socket path fails before streaming", async () => {
+ await expect(
+ streamDaemon(
+ {},
+ {
+ socketPath: path.join(os.tmpdir(), "no-such-mcp-daemon.sock"),
+ timeoutMs: 500,
+ onData: () => {},
+ },
+ ),
+ ).rejects.toBeInstanceOf(CliExitCodeError);
+ });
+});
diff --git a/clients/daemon-cli/__tests__/dispatch.test.ts b/clients/daemon-cli/__tests__/dispatch.test.ts
new file mode 100644
index 0000000000..23a294017e
--- /dev/null
+++ b/clients/daemon-cli/__tests__/dispatch.test.ts
@@ -0,0 +1,501 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+
+const callDaemon = vi.fn();
+const ensureDaemon = vi.fn();
+const streamDaemon = vi.fn();
+const promptElicitation = vi.fn();
+
+vi.mock("../src/daemon/index.js", () => ({
+ callDaemon: (...args: unknown[]) => callDaemon(...args),
+ ensureDaemon: (...args: unknown[]) => ensureDaemon(...args),
+ streamDaemon: (...args: unknown[]) => streamDaemon(...args),
+}));
+
+vi.mock("../src/connection/elicitation-prompt.js", () => ({
+ promptElicitation: (...args: unknown[]) => promptElicitation(...args),
+}));
+
+// Pass-through wrapper so tests can delay writes and observe completion
+// order (the stream path must flush queued writes before returning).
+const writeDelayMs = { value: 0 };
+const writeReject = { value: false };
+const writeCompletions: unknown[] = [];
+vi.mock("../src/connection/format-connection.js", async (importOriginal) => {
+ const actual =
+ await importOriginal<
+ typeof import("../src/connection/format-connection.js")
+ >();
+ return {
+ ...actual,
+ writeConnectionOutput: async (...args: unknown[]) => {
+ if (writeReject.value) throw new Error("stdout write failed");
+ if (writeDelayMs.value > 0) {
+ await new Promise((r) => setTimeout(r, writeDelayMs.value));
+ }
+ await (
+ actual.writeConnectionOutput as (...a: unknown[]) => Promise
+ )(...args);
+ writeCompletions.push(args[1]);
+ },
+ };
+});
+
+describe("dispatchConnectionRpc", () => {
+ let stdout: string;
+ let originalWrite: typeof process.stdout.write;
+
+ beforeEach(() => {
+ stdout = "";
+ originalWrite = process.stdout.write;
+ process.stdout.write = ((chunk: unknown, ...rest: unknown[]) => {
+ stdout += typeof chunk === "string" ? chunk : String(chunk);
+ const cb = rest.find((r) => typeof r === "function") as
+ | (() => void)
+ | undefined;
+ cb?.();
+ return true;
+ }) as typeof process.stdout.write;
+ ensureDaemon.mockResolvedValue({ socketPath: "/tmp/t.sock" });
+ callDaemon.mockReset();
+ streamDaemon.mockReset();
+ promptElicitation.mockReset();
+ writeDelayMs.value = 0;
+ writeReject.value = false;
+ writeCompletions.length = 0;
+ });
+
+ afterEach(() => {
+ process.stdout.write = originalWrite;
+ });
+
+ it("writes pretty JSON for --format json", async () => {
+ callDaemon.mockResolvedValue({
+ kind: "result",
+ result: { tools: [] },
+ });
+ const { dispatchConnectionRpc } =
+ await import("../src/connection/dispatch.js");
+ await dispatchConnectionRpc(
+ "tools/list",
+ {},
+ { format: "json", requireExplicit: false },
+ );
+ expect(JSON.parse(stdout.trim())).toEqual({ tools: [] });
+ expect(stdout).toContain("\n");
+ });
+
+ it("omits format from the daemon rpc params (frontend-only concern)", async () => {
+ callDaemon.mockResolvedValue({ kind: "result", result: {} });
+ const { dispatchConnectionRpc } =
+ await import("../src/connection/dispatch.js");
+ await dispatchConnectionRpc(
+ "tools/call",
+ { toolName: "echo" },
+ { format: "json", requireExplicit: false },
+ );
+ const [op, params] = callDaemon.mock.calls[0] as [
+ string,
+ Record,
+ ];
+ expect(op).toBe("rpc");
+ // Forwarding format would make the daemon's runMethod issue a hidden
+ // app-info resources/read for JSON tool calls.
+ expect("format" in params).toBe(false);
+ expect(params).toMatchObject({ method: "tools/call", toolName: "echo" });
+ });
+
+ it("writes human text for tools/list by default", async () => {
+ callDaemon.mockResolvedValue({
+ kind: "result",
+ result: {
+ tools: [{ name: "echo", description: "Echo", inputSchema: {} }],
+ },
+ });
+ const { dispatchConnectionRpc } =
+ await import("../src/connection/dispatch.js");
+ await dispatchConnectionRpc("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 { dispatchConnectionRpc } =
+ await import("../src/connection/dispatch.js");
+ await dispatchConnectionRpc(
+ "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 () => {
+ // Invoke only the SIGINT listener dispatch registers, rather than
+ // broadcasting `process.emit("SIGINT")` process-wide: `atomically`
+ // (loaded via core's secret-store persistence) pulls in `when-exit`,
+ // whose module-level SIGINT handler re-raises the signal and kills the
+ // vitest worker fork mid-run (#1941).
+ const listenersBefore = new Set(process.listeners("SIGINT"));
+ streamDaemon.mockImplementation(
+ async (
+ _params: unknown,
+ opts: { onData: (d: unknown) => void; signal?: AbortSignal },
+ ) => {
+ opts.onData({
+ type: "subscribed",
+ uri: "test://x",
+ });
+ const added = process
+ .listeners("SIGINT")
+ .filter((listener) => !listenersBefore.has(listener));
+ expect(added).toHaveLength(1);
+ for (const listener of added) listener("SIGINT");
+ expect(opts.signal?.aborted).toBe(true);
+ },
+ );
+ const { dispatchConnectionRpc } =
+ await import("../src/connection/dispatch.js");
+ await dispatchConnectionRpc(
+ "logging/tail",
+ {},
+ { requireExplicit: false, connection: "@s" },
+ );
+ expect(stdout).toContain("Subscribed:");
+ expect(streamDaemon).toHaveBeenCalled();
+ // Core enforces the configured MCP request timeout daemon-side; the
+ // stream path must disable the fixed local deadline like the rpc path.
+ expect(streamDaemon).toHaveBeenCalledWith(
+ expect.anything(),
+ expect.objectContaining({ timeoutMs: 0 }),
+ );
+ });
+
+ it("flushes queued stream writes before returning", async () => {
+ // Regression: stream writes were fire-and-forget, so mcp-bin's
+ // process.exit() right after dispatch resolved could truncate the final
+ // event when stdout is piped or backpressured.
+ writeDelayMs.value = 10;
+ streamDaemon.mockImplementation(
+ async (_params: unknown, opts: { onData: (d: unknown) => void }) => {
+ opts.onData({ type: "subscribed", uri: "test://one" });
+ opts.onData({ type: "subscribed", uri: "test://two" });
+ },
+ );
+ const { dispatchConnectionRpc } =
+ await import("../src/connection/dispatch.js");
+ await dispatchConnectionRpc(
+ "logging/tail",
+ {},
+ { requireExplicit: false, connection: "@s" },
+ );
+ expect(writeCompletions.length).toBe(2);
+ expect(stdout).toContain("test://two");
+ });
+
+ it("recovers the write chain after a failed write and keeps streaming", async () => {
+ // Regression: one rejected write left the chain permanently rejected, so
+ // every later frame's `.then` was skipped and the stream went silent.
+ writeReject.value = true;
+ streamDaemon.mockImplementation(
+ async (
+ _params: unknown,
+ opts: { onData: (d: unknown) => void | Promise },
+ ) => {
+ await opts.onData({ type: "subscribed", uri: "test://failed" });
+ writeReject.value = false;
+ await opts.onData({ type: "subscribed", uri: "test://recovered" });
+ },
+ );
+ const { dispatchConnectionRpc } =
+ await import("../src/connection/dispatch.js");
+ await dispatchConnectionRpc(
+ "logging/tail",
+ {},
+ { requireExplicit: false, connection: "@s" },
+ );
+ expect(stdout).not.toContain("test://failed");
+ expect(stdout).toContain("test://recovered");
+ });
+
+ it("keeps stream write failures non-fatal, as when they were fire-and-forget", async () => {
+ writeReject.value = true;
+ streamDaemon.mockImplementation(
+ async (_params: unknown, opts: { onData: (d: unknown) => void }) => {
+ opts.onData({ type: "subscribed", uri: "test://x" });
+ opts.onData({ type: "subscribed", uri: "test://y" });
+ },
+ );
+ const { dispatchConnectionRpc } =
+ await import("../src/connection/dispatch.js");
+ await expect(
+ dispatchConnectionRpc(
+ "logging/tail",
+ {},
+ { requireExplicit: false, connection: "@s" },
+ ),
+ ).resolves.toBeUndefined();
+ });
+
+ it("wires SIGINT/SIGTERM abort for the general rpc path (not just streams)", async () => {
+ // Same when-exit hazard as the stream test above: invoke only the
+ // SIGTERM listener dispatch registered, never a process-wide emit.
+ const listenersBefore = new Set(process.listeners("SIGTERM"));
+ callDaemon.mockImplementation(
+ async (_op: string, _params: unknown, opts: { signal?: AbortSignal }) => {
+ const added = process
+ .listeners("SIGTERM")
+ .filter((listener) => !listenersBefore.has(listener));
+ expect(added).toHaveLength(1);
+ for (const listener of added) listener("SIGTERM");
+ expect(opts.signal?.aborted).toBe(true);
+ return { kind: "result", result: {} };
+ },
+ );
+ const { dispatchConnectionRpc } =
+ await import("../src/connection/dispatch.js");
+ await dispatchConnectionRpc(
+ "tools/call",
+ {},
+ { format: "json", requireExplicit: false },
+ );
+ expect(callDaemon).toHaveBeenCalled();
+ });
+
+ it("removes the SIGINT/SIGTERM listeners after the rpc call settles", async () => {
+ callDaemon.mockResolvedValue({ kind: "result", result: {} });
+ const before = process.listenerCount("SIGINT");
+ const { dispatchConnectionRpc } =
+ await import("../src/connection/dispatch.js");
+ await dispatchConnectionRpc(
+ "tools/call",
+ {},
+ { format: "json", requireExplicit: false },
+ );
+ expect(process.listenerCount("SIGINT")).toBe(before);
+ });
+
+ it("wires onElicitation as interactive when text format + TTY stdin/stdout", async () => {
+ callDaemon.mockResolvedValue({ kind: "result", result: {} });
+ const stdinDesc = Object.getOwnPropertyDescriptor(process.stdin, "isTTY");
+ const stdoutDesc = Object.getOwnPropertyDescriptor(process.stdout, "isTTY");
+ Object.defineProperty(process.stdin, "isTTY", {
+ configurable: true,
+ value: true,
+ });
+ Object.defineProperty(process.stdout, "isTTY", {
+ configurable: true,
+ value: true,
+ });
+ try {
+ const { dispatchConnectionRpc } =
+ await import("../src/connection/dispatch.js");
+ await dispatchConnectionRpc(
+ "tools/call",
+ {},
+ { format: "text", requireExplicit: false },
+ );
+ const opts = callDaemon.mock.calls[0][2] as {
+ onElicitation: (frame: unknown) => unknown;
+ };
+ expect(opts.onElicitation).toBeInstanceOf(Function);
+ promptElicitation.mockResolvedValue({ action: "cancel" });
+ await opts.onElicitation({ id: "x" });
+ expect(promptElicitation).toHaveBeenCalledWith(
+ { id: "x" },
+ expect.objectContaining({ interactive: true }),
+ );
+ } finally {
+ if (stdinDesc) Object.defineProperty(process.stdin, "isTTY", stdinDesc);
+ if (stdoutDesc)
+ Object.defineProperty(process.stdout, "isTTY", stdoutDesc);
+ }
+ });
+
+ it("wires onElicitation as non-interactive for --format json", async () => {
+ callDaemon.mockResolvedValue({ kind: "result", result: {} });
+ const { dispatchConnectionRpc } =
+ await import("../src/connection/dispatch.js");
+ await dispatchConnectionRpc(
+ "tools/call",
+ {},
+ { format: "json", requireExplicit: false },
+ );
+ const opts = callDaemon.mock.calls[0][2] as {
+ onElicitation: (frame: unknown) => unknown;
+ };
+ promptElicitation.mockResolvedValue({ action: "cancel" });
+ await opts.onElicitation({ id: "x" });
+ expect(promptElicitation).toHaveBeenCalledWith(
+ { id: "x" },
+ expect.objectContaining({ interactive: false }),
+ );
+ });
+
+ it("asks the daemon to park elicitations for --format json and for non-TTY text", async () => {
+ callDaemon.mockResolvedValue({ kind: "result", result: {} });
+ const stdinDesc = Object.getOwnPropertyDescriptor(process.stdin, "isTTY");
+ const stderrDesc = Object.getOwnPropertyDescriptor(process.stderr, "isTTY");
+ Object.defineProperty(process.stdin, "isTTY", {
+ configurable: true,
+ value: undefined,
+ });
+ Object.defineProperty(process.stderr, "isTTY", {
+ configurable: true,
+ value: undefined,
+ });
+ try {
+ const { dispatchConnectionRpc } =
+ await import("../src/connection/dispatch.js");
+ await dispatchConnectionRpc(
+ "tools/call",
+ {},
+ { format: "text", requireExplicit: false },
+ );
+ await dispatchConnectionRpc(
+ "tools/call",
+ {},
+ { format: "json", requireExplicit: false },
+ );
+ expect(callDaemon.mock.calls[0][1]).toMatchObject({
+ parkElicitations: true,
+ });
+ expect(callDaemon.mock.calls[1][1]).toMatchObject({
+ parkElicitations: true,
+ });
+ } finally {
+ if (stdinDesc) Object.defineProperty(process.stdin, "isTTY", stdinDesc);
+ if (stderrDesc)
+ Object.defineProperty(process.stderr, "isTTY", stderrDesc);
+ }
+ });
+
+ it("omits parkElicitations for interactive text (TTY)", async () => {
+ callDaemon.mockResolvedValue({ kind: "result", result: {} });
+ const stderrDesc = Object.getOwnPropertyDescriptor(process.stderr, "isTTY");
+ Object.defineProperty(process.stderr, "isTTY", {
+ configurable: true,
+ value: true,
+ });
+ try {
+ const { dispatchConnectionRpc } =
+ await import("../src/connection/dispatch.js");
+ await dispatchConnectionRpc(
+ "tools/call",
+ {},
+ { format: "text", requireExplicit: false },
+ );
+ expect(
+ (callDaemon.mock.calls[0][1] as Record)
+ .parkElicitations,
+ ).toBeUndefined();
+ } finally {
+ if (stderrDesc)
+ Object.defineProperty(process.stderr, "isTTY", stderrDesc);
+ }
+ });
+
+ it("renders an elicitation-pending outcome (json and human)", async () => {
+ const elicitation = {
+ elicitationId: "e-1",
+ connection: "srv",
+ method: "tools/call",
+ toolName: "collect",
+ mode: "form",
+ message: "Pick a color",
+ requestedSchema: {
+ type: "object",
+ properties: { color: { type: "string" } },
+ required: ["color"],
+ },
+ origin: "server-request",
+ expiresAt: Date.now() + 600_000,
+ };
+ callDaemon.mockResolvedValue({ kind: "elicitation-pending", elicitation });
+ const { dispatchConnectionRpc } =
+ await import("../src/connection/dispatch.js");
+ await dispatchConnectionRpc(
+ "tools/call",
+ { toolName: "collect" },
+ { format: "json", requireExplicit: false },
+ );
+ const parsed = JSON.parse(stdout) as {
+ elicitationPending: { elicitationId: string };
+ };
+ expect(parsed.elicitationPending.elicitationId).toBe("e-1");
+
+ stdout = "";
+ await dispatchConnectionRpc(
+ "tools/call",
+ { toolName: "collect" },
+ // --plain: human rendering must stay assertable when this test runs
+ // under a stderr TTY (styled output would interleave ANSI codes).
+ { format: "text", plain: true, requireExplicit: false },
+ );
+ expect(stdout).toContain("Input required");
+ expect(stdout).toContain("Pick a color");
+ expect(stdout).toContain("elicitation/respond e-1");
+ expect(stdout).toContain("color (string, required)");
+ });
+});
+
+describe("hoistAtConnection / stripAt / requireExplicitConnection", () => {
+ it("stripAt removes leading @", async () => {
+ const { stripAt, requireExplicitConnection } =
+ await import("../src/connection/dispatch.js");
+ expect(stripAt("@x")).toBe("x");
+ expect(stripAt(undefined)).toBeUndefined();
+ const prev = process.env.MCP_ALLOW_DEFAULT_CONNECTION;
+ process.env.MCP_ALLOW_DEFAULT_CONNECTION = "1";
+ expect(requireExplicitConnection()).toBe(false);
+ if (prev === undefined) delete process.env.MCP_ALLOW_DEFAULT_CONNECTION;
+ else process.env.MCP_ALLOW_DEFAULT_CONNECTION = prev;
+ });
+
+ it("requireExplicitConnection keys off stdin TTY (piping stdout still OK)", async () => {
+ const { requireExplicitConnection } =
+ await import("../src/connection/dispatch.js");
+ const prevEnv = process.env.MCP_ALLOW_DEFAULT_CONNECTION;
+ delete process.env.MCP_ALLOW_DEFAULT_CONNECTION;
+ const stdinDesc = Object.getOwnPropertyDescriptor(process.stdin, "isTTY");
+ const stdoutDesc = Object.getOwnPropertyDescriptor(process.stdout, "isTTY");
+ try {
+ Object.defineProperty(process.stdin, "isTTY", {
+ configurable: true,
+ value: true,
+ });
+ Object.defineProperty(process.stdout, "isTTY", {
+ configurable: true,
+ value: false,
+ });
+ expect(requireExplicitConnection()).toBe(false);
+
+ Object.defineProperty(process.stdin, "isTTY", {
+ configurable: true,
+ value: false,
+ });
+ expect(requireExplicitConnection()).toBe(true);
+ } finally {
+ if (stdinDesc) Object.defineProperty(process.stdin, "isTTY", stdinDesc);
+ else
+ Object.defineProperty(process.stdin, "isTTY", {
+ configurable: true,
+ value: undefined,
+ });
+ if (stdoutDesc)
+ Object.defineProperty(process.stdout, "isTTY", stdoutDesc);
+ else
+ Object.defineProperty(process.stdout, "isTTY", {
+ configurable: true,
+ value: undefined,
+ });
+ if (prevEnv === undefined)
+ delete process.env.MCP_ALLOW_DEFAULT_CONNECTION;
+ else process.env.MCP_ALLOW_DEFAULT_CONNECTION = prevEnv;
+ }
+ });
+});
diff --git a/clients/daemon-cli/__tests__/elicitation-bridge.test.ts b/clients/daemon-cli/__tests__/elicitation-bridge.test.ts
new file mode 100644
index 0000000000..03048d1786
--- /dev/null
+++ b/clients/daemon-cli/__tests__/elicitation-bridge.test.ts
@@ -0,0 +1,241 @@
+import { describe, it, expect, vi } from "vitest";
+import { wireElicitationBridge } from "../src/daemon/elicitation-bridge.js";
+import type { ElicitationChannel } from "../src/daemon/ipc-glue.js";
+import type { ElicitationResponseFrame } from "../src/daemon/protocol.js";
+import type { InspectorClient } from "@inspector/core/mcp/inspectorClient.js";
+
+/**
+ * Covers `wireElicitationBridge`'s event routing: origin filtering
+ * (task-input-required elicitations are left for a future tasks/-based
+ * command, not answered here), URL vs form mode frame shaping, and the
+ * channel-failure fallback to `cancel()` (since some construction sites,
+ * notably legacy URL-mode, never wire a reject callback).
+ */
+function fakeClient(): {
+ client: InspectorClient;
+ emit: (detail: unknown) => void;
+} {
+ const target = new EventTarget();
+ const client = {
+ addEventListener: (type: string, listener: EventListener) =>
+ target.addEventListener(type, listener),
+ removeEventListener: (type: string, listener: EventListener) =>
+ target.removeEventListener(type, listener),
+ } as unknown as InspectorClient;
+ return {
+ client,
+ emit: (detail: unknown) =>
+ target.dispatchEvent(
+ new CustomEvent("newPendingElicitation", { detail }),
+ ),
+ };
+}
+
+function fakeMessage(overrides: Partial> = {}) {
+ return {
+ id: "elicitation-x",
+ origin: "server-request",
+ request: { method: "elicitation/create", params: { message: "hi" } },
+ respond: vi.fn().mockResolvedValue(undefined),
+ cancel: vi.fn(),
+ reject: vi.fn(),
+ ...overrides,
+ };
+}
+
+describe("wireElicitationBridge", () => {
+ it("skips task-input-required origin elicitations entirely", () => {
+ const { client, emit } = fakeClient();
+ const channel: ElicitationChannel = { request: vi.fn() };
+ const unwire = wireElicitationBridge(client, channel, "req-1");
+ const message = fakeMessage({ origin: "task-input-required" });
+ emit(message);
+ expect(channel.request).not.toHaveBeenCalled();
+ expect(message.respond).not.toHaveBeenCalled();
+ unwire();
+ });
+
+ it("builds a url-mode frame and responds with the channel's answer", async () => {
+ const { client, emit } = fakeClient();
+ const answer: ElicitationResponseFrame = {
+ id: "req-1",
+ kind: "elicitation-response",
+ elicitationId: "elicitation-x",
+ action: "accept",
+ };
+ const request = vi.fn().mockResolvedValue(answer);
+ const channel: ElicitationChannel = { request };
+ const unwire = wireElicitationBridge(client, channel, "req-1");
+ const message = fakeMessage({
+ request: {
+ method: "elicitation/create",
+ params: { message: "Please visit", url: "https://example.com" },
+ },
+ });
+ emit(message);
+ await Promise.resolve();
+ await Promise.resolve();
+ await Promise.resolve();
+
+ expect(request).toHaveBeenCalledWith(
+ expect.objectContaining({
+ kind: "elicitation-request",
+ mode: "url",
+ url: "https://example.com",
+ elicitationId: "elicitation-x",
+ origin: "server-request",
+ }),
+ );
+ expect(message.respond).toHaveBeenCalledWith({
+ action: "accept",
+ content: undefined,
+ });
+ unwire();
+ });
+
+ it("builds a form-mode frame with requestedSchema", async () => {
+ const { client, emit } = fakeClient();
+ const answer: ElicitationResponseFrame = {
+ id: "req-1",
+ kind: "elicitation-response",
+ elicitationId: "elicitation-x",
+ action: "decline",
+ };
+ const request = vi.fn().mockResolvedValue(answer);
+ const channel: ElicitationChannel = { request };
+ const unwire = wireElicitationBridge(client, channel, "req-1");
+ const message = fakeMessage({
+ request: {
+ method: "elicitation/create",
+ params: {
+ message: "Confirm?",
+ requestedSchema: { type: "object", properties: {} },
+ },
+ },
+ });
+ emit(message);
+ await Promise.resolve();
+ await Promise.resolve();
+ await Promise.resolve();
+
+ expect(request).toHaveBeenCalledWith(
+ expect.objectContaining({
+ mode: "form",
+ requestedSchema: { type: "object", properties: {} },
+ url: undefined,
+ }),
+ );
+ expect(message.respond).toHaveBeenCalledWith({
+ action: "decline",
+ content: undefined,
+ });
+ unwire();
+ });
+
+ it("cancels the pending elicitation when the channel rejects", async () => {
+ const { client, emit } = fakeClient();
+ const request = vi.fn().mockRejectedValue(new Error("disconnected"));
+ const channel: ElicitationChannel = { request };
+ const unwire = wireElicitationBridge(client, channel, "req-1");
+ const message = fakeMessage();
+ emit(message);
+ await Promise.resolve();
+ await Promise.resolve();
+ await Promise.resolve();
+
+ expect(message.cancel).toHaveBeenCalled();
+ expect(message.respond).not.toHaveBeenCalled();
+ unwire();
+ });
+
+ it("processes multiple elicitations in arrival order (serialized)", async () => {
+ const { client, emit } = fakeClient();
+ const order: string[] = [];
+ const request = vi.fn().mockImplementation(async (frame) => {
+ order.push(`start:${frame.elicitationId}`);
+ await Promise.resolve();
+ order.push(`end:${frame.elicitationId}`);
+ return {
+ id: frame.id,
+ kind: "elicitation-response",
+ elicitationId: frame.elicitationId,
+ action: "cancel",
+ } satisfies ElicitationResponseFrame;
+ });
+ const channel: ElicitationChannel = { request };
+ const unwire = wireElicitationBridge(client, channel, "req-1");
+ emit(fakeMessage({ id: "e1" }));
+ emit(fakeMessage({ id: "e2" }));
+ await vi.waitFor(() => {
+ expect(order).toEqual(["start:e1", "end:e1", "start:e2", "end:e2"]);
+ });
+
+ unwire();
+ });
+
+ it("delivers each elicitation to exactly one of two concurrent callers (oldest first)", async () => {
+ const { client, emit } = fakeClient();
+ const answerFor = (frame: {
+ id: string;
+ elicitationId: string;
+ }): ElicitationResponseFrame => ({
+ id: frame.id,
+ kind: "elicitation-response",
+ elicitationId: frame.elicitationId,
+ action: "cancel",
+ });
+ const requestA = vi
+ .fn()
+ .mockImplementation(async (frame) => answerFor(frame));
+ const requestB = vi
+ .fn()
+ .mockImplementation(async (frame) => answerFor(frame));
+ const unwireA = wireElicitationBridge(
+ client,
+ { request: requestA },
+ "req-a",
+ );
+ const unwireB = wireElicitationBridge(
+ client,
+ { request: requestB },
+ "req-b",
+ );
+
+ const first = fakeMessage({ id: "e1" });
+ emit(first);
+ await vi.waitFor(() => expect(first.respond).toHaveBeenCalled());
+ expect(requestA).toHaveBeenCalledTimes(1);
+ expect(requestB).not.toHaveBeenCalled();
+ expect(first.respond).toHaveBeenCalledTimes(1);
+
+ // Once the oldest caller settles, the next event goes to the survivor.
+ unwireA();
+ const second = fakeMessage({ id: "e2" });
+ emit(second);
+ await vi.waitFor(() => expect(second.respond).toHaveBeenCalled());
+ expect(requestA).toHaveBeenCalledTimes(1);
+ expect(requestB).toHaveBeenCalledTimes(1);
+ unwireB();
+ });
+
+ it("cancels an event already queued when every caller settled before dispatch", async () => {
+ const { client, emit } = fakeClient();
+ const request = vi.fn();
+ const unwire = wireElicitationBridge(client, { request }, "req-1");
+ const message = fakeMessage();
+ emit(message);
+ unwire(); // settle before the queued microtask dispatches
+ await vi.waitFor(() => expect(message.cancel).toHaveBeenCalled());
+ expect(request).not.toHaveBeenCalled();
+ expect(message.respond).not.toHaveBeenCalled();
+ });
+
+ it("unwire stops the listener from reacting to further events", () => {
+ const { client, emit } = fakeClient();
+ const channel: ElicitationChannel = { request: vi.fn() };
+ const unwire = wireElicitationBridge(client, channel, "req-1");
+ unwire();
+ emit(fakeMessage());
+ expect(channel.request).not.toHaveBeenCalled();
+ });
+});
diff --git a/clients/daemon-cli/__tests__/elicitation-client.test.ts b/clients/daemon-cli/__tests__/elicitation-client.test.ts
new file mode 100644
index 0000000000..37bcb2ad95
--- /dev/null
+++ b/clients/daemon-cli/__tests__/elicitation-client.test.ts
@@ -0,0 +1,305 @@
+import { describe, it, expect, afterEach } from "vitest";
+import * as fs from "node:fs";
+import * as net from "node:net";
+import * as os from "node:os";
+import * as path from "node:path";
+import { callDaemon } from "../src/daemon/client.js";
+import type {
+ ElicitationRequestFrame,
+ ElicitationResponseFrame,
+} from "../src/daemon/protocol.js";
+
+/**
+ * Covers `callDaemon`'s duplex elicitation handling (dual-era support, phase
+ * 1): a mid-`rpc` `elicitation-request` frame arriving before the final
+ * response, answered via `onElicitation` (or auto-cancelled without one),
+ * with the connect timeout cleared once the exchange starts.
+ */
+describe("callDaemon elicitation duplex", () => {
+ let dir: string | undefined;
+ let server: net.Server | undefined;
+ const sockets = new Set();
+
+ afterEach(async () => {
+ for (const s of sockets) s.destroy();
+ sockets.clear();
+ if (server) {
+ await new Promise((resolve) => server!.close(() => resolve()));
+ server = undefined;
+ }
+ if (dir) {
+ fs.rmSync(dir, { recursive: true, force: true });
+ dir = undefined;
+ }
+ });
+
+ function freshSock(): string {
+ dir = fs.mkdtempSync(path.join(os.tmpdir(), "mcp-elicit-client-"));
+ return path.join(dir, "daemon.sock");
+ }
+
+ async function listen(
+ sock: string,
+ onSocket: (socket: net.Socket) => void,
+ ): Promise {
+ server = net.createServer((socket) => {
+ sockets.add(socket);
+ socket.on("error", () => {});
+ socket.on("close", () => sockets.delete(socket));
+ onSocket(socket);
+ });
+ await new Promise((resolve) => server!.listen(sock, resolve));
+ }
+
+ it("routes an elicitation-request frame to onElicitation and writes its answer", async () => {
+ const sock = freshSock();
+ let receivedAnswer: ElicitationResponseFrame | undefined;
+ await listen(sock, (socket) => {
+ let buffer = "";
+ socket.on("data", (chunk) => {
+ buffer += String(chunk);
+ let idx: number;
+ while ((idx = buffer.indexOf("\n")) >= 0) {
+ const line = buffer.slice(0, idx);
+ buffer = buffer.slice(idx + 1);
+ if (!line.trim()) continue;
+ const msg = JSON.parse(line) as { id: string; kind?: string };
+ if (msg.kind === "elicitation-response") {
+ receivedAnswer = msg as ElicitationResponseFrame;
+ socket.write(
+ JSON.stringify({ id: msg.id, ok: true, result: { done: true } }) +
+ "\n",
+ );
+ continue;
+ }
+ const frame: ElicitationRequestFrame = {
+ id: msg.id,
+ kind: "elicitation-request",
+ elicitationId: "elicitation-1",
+ mode: "url",
+ message: "Please confirm",
+ url: "https://example.com/confirm",
+ origin: "server-request",
+ };
+ socket.write(JSON.stringify(frame) + "\n");
+ }
+ });
+ });
+
+ const seenFrames: ElicitationRequestFrame[] = [];
+ const result = await callDaemon<{ done: boolean }>(
+ "rpc",
+ { method: "tools/call" },
+ {
+ socketPath: sock,
+ timeoutMs: 5000,
+ onElicitation: async (frame) => {
+ seenFrames.push(frame);
+ return {
+ id: frame.id,
+ kind: "elicitation-response",
+ elicitationId: frame.elicitationId,
+ action: "accept",
+ };
+ },
+ },
+ );
+
+ expect(result).toEqual({ done: true });
+ expect(seenFrames).toHaveLength(1);
+ expect(seenFrames[0].mode).toBe("url");
+ expect(seenFrames[0].url).toBe("https://example.com/confirm");
+ expect(receivedAnswer?.action).toBe("accept");
+ expect(receivedAnswer?.elicitationId).toBe("elicitation-1");
+ });
+
+ it("auto-cancels when no onElicitation callback is provided", async () => {
+ const sock = freshSock();
+ let receivedAnswer: ElicitationResponseFrame | undefined;
+ await listen(sock, (socket) => {
+ let buffer = "";
+ socket.on("data", (chunk) => {
+ buffer += String(chunk);
+ let idx: number;
+ while ((idx = buffer.indexOf("\n")) >= 0) {
+ const line = buffer.slice(0, idx);
+ buffer = buffer.slice(idx + 1);
+ if (!line.trim()) continue;
+ const msg = JSON.parse(line) as { id: string; kind?: string };
+ if (msg.kind === "elicitation-response") {
+ receivedAnswer = msg as ElicitationResponseFrame;
+ socket.write(
+ JSON.stringify({ id: msg.id, ok: true, result: { done: true } }) +
+ "\n",
+ );
+ continue;
+ }
+ const frame: ElicitationRequestFrame = {
+ id: msg.id,
+ kind: "elicitation-request",
+ elicitationId: "elicitation-2",
+ mode: "url",
+ message: "Please confirm",
+ url: "https://example.com/confirm",
+ origin: "input-required",
+ };
+ socket.write(JSON.stringify(frame) + "\n");
+ }
+ });
+ });
+
+ const result = await callDaemon<{ done: boolean }>(
+ "rpc",
+ { method: "tools/call" },
+ { socketPath: sock, timeoutMs: 5000 },
+ );
+
+ expect(result).toEqual({ done: true });
+ expect(receivedAnswer?.action).toBe("cancel");
+ expect(receivedAnswer?.elicitationId).toBe("elicitation-2");
+ });
+
+ it("ignores an elicitation-request frame whose id doesn't match this call", async () => {
+ const sock = freshSock();
+ await listen(sock, (socket) => {
+ let buffer = "";
+ let answered = false;
+ socket.on("data", (chunk) => {
+ buffer += String(chunk);
+ let idx: number;
+ while ((idx = buffer.indexOf("\n")) >= 0) {
+ const line = buffer.slice(0, idx);
+ buffer = buffer.slice(idx + 1);
+ if (!line.trim()) continue;
+ const msg = JSON.parse(line) as { id: string };
+ if (!answered) {
+ answered = true;
+ const frame: ElicitationRequestFrame = {
+ id: "not-this-call",
+ kind: "elicitation-request",
+ elicitationId: "elicitation-3",
+ mode: "url",
+ message: "stray frame",
+ url: "https://example.com",
+ origin: "server-request",
+ };
+ socket.write(JSON.stringify(frame) + "\n");
+ socket.write(
+ JSON.stringify({ id: msg.id, ok: true, result: { done: true } }) +
+ "\n",
+ );
+ }
+ }
+ });
+ });
+
+ const onElicitation = async () =>
+ ({
+ id: "n/a",
+ kind: "elicitation-response",
+ elicitationId: "n/a",
+ action: "cancel",
+ }) satisfies ElicitationResponseFrame;
+
+ const result = await callDaemon<{ done: boolean }>(
+ "rpc",
+ { method: "tools/call" },
+ { socketPath: sock, timeoutMs: 5000, onElicitation },
+ );
+ expect(result).toEqual({ done: true });
+ });
+
+ it("fails the call if onElicitation itself throws", async () => {
+ const sock = freshSock();
+ await listen(sock, (socket) => {
+ let buffer = "";
+ socket.on("data", (chunk) => {
+ buffer += String(chunk);
+ let idx: number;
+ while ((idx = buffer.indexOf("\n")) >= 0) {
+ const line = buffer.slice(0, idx);
+ buffer = buffer.slice(idx + 1);
+ if (!line.trim()) continue;
+ const msg = JSON.parse(line) as { id: string; kind?: string };
+ if (msg.kind === "elicitation-response") continue;
+ const frame: ElicitationRequestFrame = {
+ id: msg.id,
+ kind: "elicitation-request",
+ elicitationId: "elicitation-4",
+ mode: "url",
+ message: "boom",
+ url: "https://example.com",
+ origin: "server-request",
+ };
+ socket.write(JSON.stringify(frame) + "\n");
+ }
+ });
+ });
+
+ await expect(
+ callDaemon<{ done: boolean }>(
+ "rpc",
+ { method: "tools/call" },
+ {
+ socketPath: sock,
+ timeoutMs: 5000,
+ onElicitation: async () => {
+ throw new Error("prompt blew up");
+ },
+ },
+ ),
+ ).rejects.toThrow("prompt blew up");
+ });
+
+ it("fails with a clear cancellation error when the abort signal fires mid-call", async () => {
+ const sock = freshSock();
+ await listen(sock, () => {
+ // Never respond — the call should hang until aborted, not until
+ // timeoutMs, proving the signal (not the timeout) ended it.
+ });
+
+ const ac = new AbortController();
+ const promise = callDaemon(
+ "rpc",
+ { method: "tools/call" },
+ { socketPath: sock, timeoutMs: 60_000, signal: ac.signal },
+ );
+ ac.abort();
+ await expect(promise).rejects.toThrow("cancelled");
+ });
+
+ it("silently swallows a post-settle socket error (e.g. late ECONNRESET)", async () => {
+ const sock = freshSock();
+ let serverSocket: net.Socket | undefined;
+ await listen(sock, (socket) => {
+ serverSocket = socket;
+ let buffer = "";
+ socket.on("data", (chunk) => {
+ buffer += String(chunk);
+ let idx: number;
+ while ((idx = buffer.indexOf("\n")) >= 0) {
+ const line = buffer.slice(0, idx);
+ buffer = buffer.slice(idx + 1);
+ if (!line.trim()) continue;
+ const msg = JSON.parse(line) as { id: string };
+ socket.write(
+ JSON.stringify({ id: msg.id, ok: true, result: { done: true } }) +
+ "\n",
+ );
+ }
+ });
+ });
+
+ const result = await callDaemon<{ done: boolean }>(
+ "rpc",
+ { method: "tools/call" },
+ { socketPath: sock, timeoutMs: 5000 },
+ );
+ expect(result).toEqual({ done: true });
+ // Force a client-side 'error' after the call already settled; the
+ // no-op listener installed by settle() must swallow it without
+ // rethrowing or crashing the test.
+ serverSocket?.destroy(new Error("late reset"));
+ await new Promise((resolve) => setTimeout(resolve, 50));
+ });
+});
diff --git a/clients/daemon-cli/__tests__/elicitation-prompt.test.ts b/clients/daemon-cli/__tests__/elicitation-prompt.test.ts
new file mode 100644
index 0000000000..bfb4d17ccc
--- /dev/null
+++ b/clients/daemon-cli/__tests__/elicitation-prompt.test.ts
@@ -0,0 +1,274 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+import { createStyle } from "@inspector/cli/style.js";
+import type { ElicitationRequestFrame } from "../src/daemon/protocol.js";
+
+const question = vi.fn();
+const promptFormMock = vi.fn();
+
+const once = vi.fn();
+
+vi.mock("../src/connection/prompt-reader.js", () => ({
+ getSharedPromptReader: () => ({ question, once }),
+}));
+
+vi.mock("../src/connection/form-prompt.js", async () => {
+ const actual = await vi.importActual<
+ typeof import("../src/connection/form-prompt.js")
+ >("../src/connection/form-prompt.js");
+ return {
+ promptForm: (...args: unknown[]) => promptFormMock(...args),
+ watchForClose: actual.watchForClose,
+ };
+});
+
+/**
+ * Covers `promptElicitation`'s terminal UI: form mode always declines
+ * (rendering isn't built yet), non-interactive callers auto-cancel with a
+ * clear message instead of hanging, and interactive URL mode reads the
+ * user's accept/cancel choice.
+ */
+describe("promptElicitation", () => {
+ let stderr: string;
+ let originalWrite: typeof process.stderr.write;
+
+ beforeEach(() => {
+ stderr = "";
+ originalWrite = process.stderr.write;
+ process.stderr.write = ((chunk: unknown, ...rest: unknown[]) => {
+ stderr += typeof chunk === "string" ? chunk : String(chunk);
+ const cb = rest.find((r) => typeof r === "function") as
+ | (() => void)
+ | undefined;
+ cb?.();
+ return true;
+ }) as typeof process.stderr.write;
+ question.mockReset();
+ once.mockReset();
+ promptFormMock.mockReset();
+ });
+
+ afterEach(() => {
+ process.stderr.write = originalWrite;
+ });
+
+ const style = createStyle(false);
+
+ function urlFrame(
+ overrides: Partial = {},
+ ): ElicitationRequestFrame {
+ return {
+ id: "req-1",
+ kind: "elicitation-request",
+ elicitationId: "elicitation-1",
+ mode: "url",
+ message: "Please confirm",
+ url: "https://example.com/confirm",
+ origin: "server-request",
+ ...overrides,
+ };
+ }
+
+ function formFrame(
+ overrides: Partial = {},
+ ): ElicitationRequestFrame {
+ return {
+ id: "req-1",
+ kind: "elicitation-request",
+ elicitationId: "elicitation-1",
+ mode: "form",
+ message: "Please provide your name",
+ requestedSchema: {
+ type: "object",
+ properties: { name: { type: "string" } },
+ required: ["name"],
+ },
+ origin: "server-request",
+ ...overrides,
+ };
+ }
+
+ it("declines form-mode elicitations whose schema isn't the restricted primitive shape", async () => {
+ const { promptElicitation } =
+ await import("../src/connection/elicitation-prompt.js");
+ const frame = urlFrame({ mode: "form", url: undefined });
+ const answer = await promptElicitation(frame, { interactive: true, style });
+ expect(answer).toEqual({
+ id: "req-1",
+ kind: "elicitation-response",
+ elicitationId: "elicitation-1",
+ action: "decline",
+ });
+ expect(question).not.toHaveBeenCalled();
+ expect(stderr).toContain("doesn't support");
+ });
+
+ it("declines form-mode elicitations non-interactively without prompting", async () => {
+ const { promptElicitation } =
+ await import("../src/connection/elicitation-prompt.js");
+ const frame = urlFrame({
+ mode: "form",
+ url: undefined,
+ requestedSchema: {
+ type: "object",
+ properties: { name: { type: "string" } },
+ },
+ });
+ const answer = await promptElicitation(frame, {
+ interactive: false,
+ style,
+ });
+ expect(answer).toEqual({
+ id: "req-1",
+ kind: "elicitation-response",
+ elicitationId: "elicitation-1",
+ action: "decline",
+ });
+ expect(question).not.toHaveBeenCalled();
+ expect(stderr).toContain("--format json");
+ });
+
+ it("cancels when the caller isn't interactive (e.g. --format json) without prompting", async () => {
+ const { promptElicitation } =
+ await import("../src/connection/elicitation-prompt.js");
+ const frame = urlFrame();
+ const answer = await promptElicitation(frame, {
+ interactive: false,
+ style,
+ });
+ expect(answer).toEqual({
+ id: "req-1",
+ kind: "elicitation-response",
+ elicitationId: "elicitation-1",
+ action: "cancel",
+ });
+ expect(question).not.toHaveBeenCalled();
+ expect(stderr).toContain("--format json");
+ });
+
+ it("cancels non-interactively without a url line when the frame has none", async () => {
+ const { promptElicitation } =
+ await import("../src/connection/elicitation-prompt.js");
+ const frame = urlFrame({ url: undefined });
+ const answer = await promptElicitation(frame, {
+ interactive: false,
+ style,
+ });
+ expect(answer.action).toBe("cancel");
+ expect(stderr).not.toContain("undefined");
+ });
+
+ it("accepts when the interactive user confirms completion", async () => {
+ question.mockResolvedValue("");
+ const { promptElicitation } =
+ await import("../src/connection/elicitation-prompt.js");
+ const frame = urlFrame();
+ const answer = await promptElicitation(frame, { interactive: true, style });
+ expect(answer).toEqual({
+ id: "req-1",
+ kind: "elicitation-response",
+ elicitationId: "elicitation-1",
+ action: "accept",
+ });
+ expect(stderr).toContain("Please confirm");
+ expect(stderr).toContain("https://example.com/confirm");
+ });
+
+ it("renders only allowlisted schemes as OSC 8 links in URL mode", async () => {
+ question.mockResolvedValue("");
+ const ansi = createStyle(true);
+ const { promptElicitation } =
+ await import("../src/connection/elicitation-prompt.js");
+
+ await promptElicitation(urlFrame(), { interactive: true, style: ansi });
+ expect(stderr).toContain("\u001b]8;;https://example.com/confirm");
+
+ stderr = "";
+ const answer = await promptElicitation(
+ urlFrame({ url: "file:///etc/passwd" }),
+ { interactive: true, style: ansi },
+ );
+ // A server-supplied file:/custom-handler URL is shown as plain text —
+ // never as a clickable link inviting the local protocol handler.
+ expect(answer.action).toBe("accept");
+ expect(stderr).not.toContain("]8;;");
+ expect(stderr).toContain("file:///etc/passwd");
+ });
+
+ it("cancels when the interactive user types 'c'", async () => {
+ question.mockResolvedValue("c");
+ const { promptElicitation } =
+ await import("../src/connection/elicitation-prompt.js");
+ const frame = urlFrame();
+ const answer = await promptElicitation(frame, { interactive: true, style });
+ expect(answer.action).toBe("cancel");
+ });
+
+ it("falls back to cancel if reading input throws", async () => {
+ question.mockRejectedValue(new Error("stdin closed"));
+ const { promptElicitation } =
+ await import("../src/connection/elicitation-prompt.js");
+ const frame = urlFrame();
+ const answer = await promptElicitation(frame, { interactive: true, style });
+ expect(answer.action).toBe("cancel");
+ });
+
+ it("cancels URL mode if stdin closes before the user answers", async () => {
+ // Simulates a non-TTY stdin (e.g. an agent-driven pipe) hitting EOF
+ // before an answer arrives: question() hangs, but the "close" listener
+ // registered via watchForClose() fires and wins the race.
+ question.mockImplementation(() => new Promise(() => {}));
+ once.mockImplementation((event: string, cb: () => void) => {
+ if (event === "close") cb();
+ });
+ const { promptElicitation } =
+ await import("../src/connection/elicitation-prompt.js");
+ const frame = urlFrame();
+ const answer = await promptElicitation(frame, { interactive: true, style });
+ expect(answer.action).toBe("cancel");
+ });
+
+ it("accepts an interactive form submission and returns its content", async () => {
+ promptFormMock.mockResolvedValue({
+ action: "accept",
+ content: { name: "octocat" },
+ });
+ const { promptElicitation } =
+ await import("../src/connection/elicitation-prompt.js");
+ const frame = formFrame();
+ const answer = await promptElicitation(frame, { interactive: true, style });
+ expect(answer).toEqual({
+ id: "req-1",
+ kind: "elicitation-response",
+ elicitationId: "elicitation-1",
+ action: "accept",
+ content: { name: "octocat" },
+ });
+ });
+
+ it("declines an interactive form when promptForm reports decline", async () => {
+ promptFormMock.mockResolvedValue({ action: "decline" });
+ const { promptElicitation } =
+ await import("../src/connection/elicitation-prompt.js");
+ const frame = formFrame();
+ const answer = await promptElicitation(frame, { interactive: true, style });
+ expect(answer.action).toBe("decline");
+ });
+
+ it("cancels an interactive form when promptForm reports cancel", async () => {
+ promptFormMock.mockResolvedValue({ action: "cancel" });
+ const { promptElicitation } =
+ await import("../src/connection/elicitation-prompt.js");
+ const frame = formFrame();
+ const answer = await promptElicitation(frame, { interactive: true, style });
+ expect(answer.action).toBe("cancel");
+ });
+
+ it("falls back to cancel if promptForm throws", async () => {
+ promptFormMock.mockRejectedValue(new Error("stdin closed"));
+ const { promptElicitation } =
+ await import("../src/connection/elicitation-prompt.js");
+ const frame = formFrame();
+ const answer = await promptElicitation(frame, { interactive: true, style });
+ expect(answer.action).toBe("cancel");
+ });
+});
diff --git a/clients/daemon-cli/__tests__/ema-commands.test.ts b/clients/daemon-cli/__tests__/ema-commands.test.ts
new file mode 100644
index 0000000000..fa084936b5
--- /dev/null
+++ b/clients/daemon-cli/__tests__/ema-commands.test.ts
@@ -0,0 +1,152 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+import { PLAIN } from "@inspector/cli/style.js";
+import { formatEmaStatusHuman } from "../src/connection/format-human.js";
+
+const getEmaStatus = vi.fn();
+const emaLogin = vi.fn();
+const emaLogout = vi.fn();
+
+vi.mock("../src/connection/ema.js", () => ({
+ getEmaStatus: (...args: unknown[]) => getEmaStatus(...args),
+ emaLogin: (...args: unknown[]) => emaLogin(...args),
+ emaLogout: (...args: unknown[]) => emaLogout(...args),
+}));
+
+describe("auth/ema-* commands", () => {
+ let stdout: string;
+ let originalStdoutWrite: typeof process.stdout.write;
+
+ beforeEach(() => {
+ stdout = "";
+ originalStdoutWrite = process.stdout.write;
+ process.stdout.write = ((chunk: unknown, ...rest: unknown[]) => {
+ stdout += typeof chunk === "string" ? chunk : String(chunk);
+ const cb = rest.find((r) => typeof r === "function") as
+ | (() => void)
+ | undefined;
+ cb?.();
+ return true;
+ }) as typeof process.stdout.write;
+ getEmaStatus.mockReset();
+ emaLogin.mockReset();
+ emaLogout.mockReset();
+ });
+
+ afterEach(() => {
+ process.stdout.write = originalStdoutWrite;
+ });
+
+ it("auth/ema-status prints the status as JSON", async () => {
+ getEmaStatus.mockResolvedValue({
+ clientConfigPath: "/tmp/client.json",
+ configured: true,
+ enabled: true,
+ issuer: "https://idp.example.com",
+ clientId: "idp-client",
+ loginState: "logged_in",
+ });
+ const { runMcp } = await import("../src/connection/mcp.js");
+ await runMcp(["node", "mcpdo", "auth/ema-status", "--format", "json"]);
+ const parsed = JSON.parse(stdout.trim());
+ expect(parsed.issuer).toBe("https://idp.example.com");
+ expect(parsed.loginState).toBe("logged_in");
+ });
+
+ it("auth/ema-status prints a human summary in text mode", async () => {
+ getEmaStatus.mockResolvedValue({
+ clientConfigPath: "/tmp/client.json",
+ configured: true,
+ enabled: true,
+ issuer: "https://idp.example.com",
+ clientId: "idp-client",
+ loginState: "none",
+ });
+ const { runMcp } = await import("../src/connection/mcp.js");
+ await runMcp(["node", "mcpdo", "auth/ema-status"]);
+ expect(stdout).toContain("EMA (enterprise-managed auth):");
+ expect(stdout).toContain("https://idp.example.com");
+ expect(stdout).toContain("IdP session: none");
+ });
+
+ it("auth/ema-login forwards --relogin and prints the outcome", async () => {
+ emaLogin.mockResolvedValue({
+ issuer: "https://idp.example.com",
+ loginState: "logged_in",
+ alreadyLoggedIn: false,
+ });
+ const { runMcp } = await import("../src/connection/mcp.js");
+ await runMcp(["node", "mcpdo", "auth/ema-login", "--relogin"]);
+ expect(emaLogin).toHaveBeenCalledWith({ relogin: true });
+ expect(stdout).toContain("Signed in");
+ expect(stdout).toContain("https://idp.example.com");
+ });
+
+ it("auth/ema-login reports an already-active connection", async () => {
+ emaLogin.mockResolvedValue({
+ issuer: "https://idp.example.com",
+ loginState: "logged_in",
+ alreadyLoggedIn: true,
+ });
+ const { runMcp } = await import("../src/connection/mcp.js");
+ await runMcp(["node", "mcpdo", "auth/ema-login"]);
+ expect(emaLogin).toHaveBeenCalledWith({ relogin: false });
+ expect(stdout).toContain("Already signed in");
+ });
+
+ it("auth/ema-logout prints the signed-out issuer", async () => {
+ emaLogout.mockResolvedValue({ issuer: "https://idp.example.com" });
+ const { runMcp } = await import("../src/connection/mcp.js");
+ await runMcp(["node", "mcpdo", "auth/ema-logout"]);
+ expect(stdout).toContain("Signed out");
+ expect(stdout).toContain("https://idp.example.com");
+ });
+});
+
+describe("formatEmaStatusHuman", () => {
+ it("renders the unconfigured state with configuration pointers", () => {
+ const text = formatEmaStatusHuman(
+ { clientConfigPath: "/tmp/client.json", configured: false },
+ PLAIN,
+ );
+ expect(text).toContain("not configured");
+ expect(text).toContain("/tmp/client.json");
+ });
+
+ it("renders a configured, disabled IdP without a clientId", () => {
+ const text = formatEmaStatusHuman(
+ {
+ clientConfigPath: "/tmp/client.json",
+ configured: true,
+ enabled: false,
+ issuer: "https://idp.example.com",
+ loginState: "expired",
+ },
+ PLAIN,
+ );
+ expect(text).toContain("https://idp.example.com");
+ expect(text).toContain("Enabled: no");
+ expect(text).toContain("IdP session: expired");
+ expect(text).not.toContain("client:");
+ });
+
+ it("highlights a live IdP session and defaults missing fields", () => {
+ const loggedIn = formatEmaStatusHuman(
+ {
+ clientConfigPath: "/tmp/client.json",
+ configured: true,
+ enabled: true,
+ issuer: "https://idp.example.com",
+ clientId: "idp-client",
+ loginState: "logged_in",
+ },
+ PLAIN,
+ );
+ expect(loggedIn).toContain("IdP session: logged_in");
+ expect(loggedIn).toContain("(client: idp-client)");
+
+ // Defensive fallbacks when a JSON payload omits optional fields.
+ const sparse = formatEmaStatusHuman({ configured: true }, PLAIN);
+ expect(sparse).toContain("IdP: `?`");
+ expect(sparse).toContain("IdP session: none");
+ });
+});
diff --git a/clients/daemon-cli/__tests__/ema.test.ts b/clients/daemon-cli/__tests__/ema.test.ts
new file mode 100644
index 0000000000..377c77e386
--- /dev/null
+++ b/clients/daemon-cli/__tests__/ema.test.ts
@@ -0,0 +1,279 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+import * as fs from "node:fs";
+import * as os from "node:os";
+import * as path from "node:path";
+import { CliExitCodeError } from "@inspector/cli/error-handler.js";
+import {
+ NodeOAuthStorage,
+ resetNodeOAuthStorageCache,
+} from "@inspector/core/auth/node/storage-node.js";
+
+const runRunnerInteractiveOAuth = vi.fn();
+const startIdpOidcAuthorization = vi.fn();
+const completeIdpOidcAuthorization = vi.fn();
+
+vi.mock("@inspector/core/auth/node/index.js", async (importOriginal) => {
+ const actual =
+ await importOriginal();
+ return {
+ ...actual,
+ runRunnerInteractiveOAuth: (...args: unknown[]) =>
+ runRunnerInteractiveOAuth(...args),
+ };
+});
+
+vi.mock("@inspector/core/auth/ema/idpOidc.js", () => ({
+ startIdpOidcAuthorization: (...args: unknown[]) =>
+ startIdpOidcAuthorization(...args),
+ completeIdpOidcAuthorization: (...args: unknown[]) =>
+ completeIdpOidcAuthorization(...args),
+}));
+
+const ISSUER = "https://idp.example.com";
+
+/** Unexpired unsigned JWT ({ exp } one hour out). */
+function fakeIdToken(): string {
+ const b64 = (obj: object) =>
+ Buffer.from(JSON.stringify(obj)).toString("base64url");
+ return `${b64({ alg: "none" })}.${b64({
+ exp: Math.floor(Date.now() / 1000) + 3600,
+ })}.sig`;
+}
+
+describe("mcpdo ema helpers", () => {
+ let dir: string;
+ let savedEnv: Record;
+
+ beforeEach(() => {
+ dir = fs.mkdtempSync(path.join(os.tmpdir(), "mcp-conn-ema-"));
+ savedEnv = {
+ MCP_CLIENT_CONFIG_PATH: process.env.MCP_CLIENT_CONFIG_PATH,
+ MCP_INSPECTOR_OAUTH_STATE_PATH:
+ process.env.MCP_INSPECTOR_OAUTH_STATE_PATH,
+ };
+ process.env.MCP_CLIENT_CONFIG_PATH = path.join(dir, "client.json");
+ process.env.MCP_INSPECTOR_OAUTH_STATE_PATH = path.join(dir, "oauth.json");
+ resetNodeOAuthStorageCache();
+ runRunnerInteractiveOAuth.mockReset();
+ startIdpOidcAuthorization.mockReset();
+ completeIdpOidcAuthorization.mockReset();
+ });
+
+ afterEach(() => {
+ for (const [key, value] of Object.entries(savedEnv)) {
+ if (value === undefined) delete process.env[key];
+ else process.env[key] = value;
+ }
+ resetNodeOAuthStorageCache();
+ fs.rmSync(dir, { recursive: true, force: true });
+ });
+
+ function writeClientConfig(config: unknown): void {
+ fs.writeFileSync(
+ process.env.MCP_CLIENT_CONFIG_PATH!,
+ JSON.stringify(config),
+ );
+ }
+
+ function emaClientConfig(enabled?: boolean): unknown {
+ return {
+ enterpriseManagedAuth: {
+ ...(enabled !== undefined && { enabled }),
+ idp: {
+ issuer: ISSUER,
+ clientId: "idp-client",
+ clientSecret: "idp-secret",
+ },
+ },
+ };
+ }
+
+ async function seedIdpSession(): Promise {
+ const storage = new NodeOAuthStorage();
+ await storage.saveIdpSession(ISSUER, {
+ idToken: fakeIdToken(),
+ idTokenExpiresAt: Date.now() + 3600_000,
+ });
+ }
+
+ it("getEmaStatus reports unconfigured when client.json has no EMA block", async () => {
+ const { getEmaStatus } = await import("../src/connection/ema.js");
+ const status = await getEmaStatus();
+ expect(status.configured).toBe(false);
+ expect(status.enabled).toBe(false);
+ expect(status.loginState).toBe("unconfigured");
+ expect(status.clientConfigPath).toBe(process.env.MCP_CLIENT_CONFIG_PATH);
+ });
+
+ it("getEmaStatus reports configured+enabled with no IdP session as 'none'", async () => {
+ writeClientConfig(emaClientConfig());
+ const { getEmaStatus } = await import("../src/connection/ema.js");
+ const status = await getEmaStatus();
+ expect(status.configured).toBe(true);
+ expect(status.enabled).toBe(true);
+ expect(status.issuer).toBe(ISSUER);
+ expect(status.clientId).toBe("idp-client");
+ expect(status.loginState).toBe("none");
+ });
+
+ it("getEmaStatus reports a disabled config (still shows issuer + connection state)", async () => {
+ writeClientConfig(emaClientConfig(false));
+ await seedIdpSession();
+ const { getEmaStatus } = await import("../src/connection/ema.js");
+ const status = await getEmaStatus();
+ expect(status.configured).toBe(true);
+ expect(status.enabled).toBe(false);
+ expect(status.loginState).toBe("logged_in");
+ });
+
+ it("emaLogin fails with actionable guidance when EMA is not configured", async () => {
+ const { emaLogin } = await import("../src/connection/ema.js");
+ await expect(emaLogin()).rejects.toThrow(
+ /not configured.*client settings/is,
+ );
+ await expect(emaLogin()).rejects.toThrow(
+ process.env.MCP_CLIENT_CONFIG_PATH!,
+ );
+ });
+
+ it("emaLogin fails with actionable guidance when EMA is disabled", async () => {
+ writeClientConfig(emaClientConfig(false));
+ const { emaLogin } = await import("../src/connection/ema.js");
+ await expect(emaLogin()).rejects.toThrow(/disabled/i);
+ });
+
+ it("emaLogout fails when EMA is not configured", async () => {
+ const { emaLogout } = await import("../src/connection/ema.js");
+ await expect(emaLogout()).rejects.toThrow(CliExitCodeError);
+ });
+
+ it("emaLogout works even when EMA is disabled, and clears the IdP session", async () => {
+ writeClientConfig(emaClientConfig(false));
+ await seedIdpSession();
+ const { emaLogout, getEmaStatus } =
+ await import("../src/connection/ema.js");
+ const result = await emaLogout();
+ expect(result.issuer).toBe(ISSUER);
+ expect((await getEmaStatus()).loginState).toBe("none");
+ });
+
+ it("emaLogin short-circuits when already signed in", async () => {
+ writeClientConfig(emaClientConfig());
+ await seedIdpSession();
+ const { emaLogin } = await import("../src/connection/ema.js");
+ const result = await emaLogin();
+ expect(result).toEqual({
+ issuer: ISSUER,
+ loginState: "logged_in",
+ alreadyLoggedIn: true,
+ });
+ expect(runRunnerInteractiveOAuth).not.toHaveBeenCalled();
+ });
+
+ it("emaLogin runs the IdP flow via the runner adapter and reports the new connection", async () => {
+ writeClientConfig(emaClientConfig());
+ let stderr = "";
+ const originalWrite = process.stderr.write;
+ process.stderr.write = ((chunk: unknown, ...rest: unknown[]) => {
+ stderr += typeof chunk === "string" ? chunk : String(chunk);
+ const cb = rest.find((r) => typeof r === "function") as
+ | (() => void)
+ | undefined;
+ cb?.();
+ return true;
+ }) as typeof process.stderr.write;
+
+ startIdpOidcAuthorization.mockResolvedValue({
+ authorizationUrl: new URL("https://idp.example.com/authorize?x=1"),
+ });
+ completeIdpOidcAuthorization.mockImplementation(async () => {
+ await seedIdpSession();
+ return { idToken: fakeIdToken() };
+ });
+ runRunnerInteractiveOAuth.mockImplementation(
+ async (options: {
+ client: {
+ authenticate: () => Promise;
+ completeOAuthFlow: (code: string, iss?: string) => Promise;
+ };
+ redirectUrlProvider: { redirectUrl: string };
+ }) => {
+ // Mirror the real runner: bind the loopback redirect before leg 1.
+ options.redirectUrlProvider.redirectUrl =
+ "http://127.0.0.1:45678/oauth/callback";
+ const url = await options.client.authenticate();
+ expect(url?.href).toContain("idp.example.com/authorize");
+ await options.client.completeOAuthFlow("code-1", ISSUER);
+ return { kind: "success" };
+ },
+ );
+
+ try {
+ const { emaLogin } = await import("../src/connection/ema.js");
+ const result = await emaLogin();
+ expect(result).toEqual({
+ issuer: ISSUER,
+ loginState: "logged_in",
+ alreadyLoggedIn: false,
+ });
+ } finally {
+ process.stderr.write = originalWrite;
+ }
+
+ expect(startIdpOidcAuthorization).toHaveBeenCalledWith(
+ expect.objectContaining({
+ redirectUrl: "http://127.0.0.1:45678/oauth/callback",
+ }),
+ );
+ expect(completeIdpOidcAuthorization).toHaveBeenCalledWith(
+ expect.objectContaining({ authorizationCode: "code-1", iss: ISSUER }),
+ );
+ // Agent-attended wording: vitest's stderr is not a TTY, so the printed
+ // line must direct an agent to relay the IdP link to the human user.
+ expect(stderr).toContain(
+ "The user needs to sign in to the enterprise identity provider",
+ );
+ });
+
+ it("emaLogin --relogin clears the existing connection and re-runs the flow", async () => {
+ writeClientConfig(emaClientConfig());
+ await seedIdpSession();
+ startIdpOidcAuthorization.mockResolvedValue({
+ authorizationUrl: new URL("https://idp.example.com/authorize"),
+ });
+ completeIdpOidcAuthorization.mockImplementation(async () => {
+ await seedIdpSession();
+ return { idToken: fakeIdToken() };
+ });
+ runRunnerInteractiveOAuth.mockImplementation(
+ async (options: {
+ client: {
+ authenticate: () => Promise;
+ completeOAuthFlow: (code: string) => Promise;
+ };
+ redirectUrlProvider: { redirectUrl: string };
+ }) => {
+ // The pre-existing connection must already be gone before leg 1 runs.
+ const storage = new NodeOAuthStorage();
+ expect(await storage.getIdpSession(ISSUER)).toBeUndefined();
+ await options.client.authenticate();
+ await options.client.completeOAuthFlow("code-2");
+ return { kind: "success" };
+ },
+ );
+
+ const { emaLogin } = await import("../src/connection/ema.js");
+ const result = await emaLogin({ relogin: true });
+ expect(result.alreadyLoggedIn).toBe(false);
+ expect(result.loginState).toBe("logged_in");
+ expect(runRunnerInteractiveOAuth).toHaveBeenCalledOnce();
+ });
+
+ it("mcpdoEmaGuidance names both configuration routes", async () => {
+ const { mcpdoEmaGuidance } = await import("../src/connection/ema.js");
+ expect(mcpdoEmaGuidance("not_configured")).toMatch(
+ /Client Settings.*enterpriseManagedAuth/is,
+ );
+ expect(mcpdoEmaGuidance("disabled")).toContain("enabled");
+ });
+});
diff --git a/clients/daemon-cli/__tests__/form-prompt.test.ts b/clients/daemon-cli/__tests__/form-prompt.test.ts
new file mode 100644
index 0000000000..768f878395
--- /dev/null
+++ b/clients/daemon-cli/__tests__/form-prompt.test.ts
@@ -0,0 +1,521 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+import { createStyle } from "@inspector/cli/style.js";
+import { promptForm } from "../src/connection/form-prompt.js";
+import type { FormField } from "../src/connection/form-schema.js";
+
+/**
+ * Covers `promptForm`'s field-by-field prompting (one branch per
+ * `FormField.kind`, including validation retry loops and defaults) and the
+ * review step (submit / edit-by-name / cancel).
+ */
+describe("promptForm", () => {
+ let stderr: string;
+ let originalWrite: typeof process.stderr.write;
+ const style = createStyle(false);
+
+ beforeEach(() => {
+ stderr = "";
+ originalWrite = process.stderr.write;
+ process.stderr.write = ((chunk: unknown, ...rest: unknown[]) => {
+ stderr += typeof chunk === "string" ? chunk : String(chunk);
+ const cb = rest.find((r) => typeof r === "function") as
+ | (() => void)
+ | undefined;
+ cb?.();
+ return true;
+ }) as typeof process.stderr.write;
+ });
+
+ afterEach(() => {
+ process.stderr.write = originalWrite;
+ });
+
+ function fakeRl(answers: string[]) {
+ let i = 0;
+ const closeHandlers: Array<() => void> = [];
+ return {
+ question: vi.fn(async () => {
+ const answer = answers[i];
+ i += 1;
+ if (answer === undefined) {
+ throw new Error("no more scripted answers");
+ }
+ return answer;
+ }),
+ once: vi.fn((event: string, cb: () => void) => {
+ if (event === "close") closeHandlers.push(cb);
+ }),
+ // Test-only hook: simulates the underlying stdin closing (e.g. a
+ // redirected/piped input hitting EOF) so we can exercise the
+ // watchForClose() race without a real stream.
+ __triggerClose: () => closeHandlers.forEach((cb) => cb()),
+ } as unknown as Parameters[0] & {
+ __triggerClose: () => void;
+ };
+ }
+
+ const stringField: FormField = {
+ name: "name",
+ required: true,
+ title: "Name",
+ kind: "string",
+ };
+
+ it("collects a required string field and submits on blank review answer", async () => {
+ const rl = fakeRl(["octocat", ""]);
+ const outcome = await promptForm(
+ rl,
+ "Enter your name",
+ [stringField],
+ style,
+ );
+ expect(outcome).toEqual({ action: "accept", content: { name: "octocat" } });
+ expect(stderr).toContain("Enter your name");
+ });
+
+ it("re-prompts a required string field left blank, then accepts a default", async () => {
+ const field: FormField = {
+ ...stringField,
+ required: false,
+ default: "anon",
+ };
+ const rl = fakeRl(["", ""]);
+ const outcome = await promptForm(rl, "msg", [field], style);
+ expect(outcome).toEqual({ action: "accept", content: { name: "anon" } });
+ });
+
+ it("omits an optional string field left blank with no default", async () => {
+ const field: FormField = { ...stringField, required: false };
+ const rl = fakeRl(["", ""]);
+ const outcome = await promptForm(rl, "msg", [field], style);
+ expect(outcome).toEqual({ action: "accept", content: {} });
+ });
+
+ it("accepts a blank answer for a required string field as an empty string", async () => {
+ // JSON Schema `required` means present, not non-empty.
+ const rl = fakeRl(["", ""]);
+ const outcome = await promptForm(rl, "msg", [stringField], style);
+ expect(outcome).toEqual({ action: "accept", content: { name: "" } });
+ expect(stderr).not.toContain("This field is required");
+ });
+
+ it("preserves a schema property named __proto__ as an own property", async () => {
+ // On a plain object, `content["__proto__"] = v` hits the prototype
+ // setter instead of creating an own property, silently dropping the
+ // answer; the accepted payload is built with a null prototype.
+ const field: FormField = {
+ name: "__proto__",
+ required: true,
+ title: "Proto",
+ kind: "string",
+ };
+ const rl = fakeRl(["value", ""]);
+ const outcome = await promptForm(rl, "msg", [field], style);
+ expect(outcome.action).toBe("accept");
+ const content = (outcome as { content: Record }).content;
+ expect(Object.prototype.hasOwnProperty.call(content, "__proto__")).toBe(
+ true,
+ );
+ expect(content["__proto__"]).toBe("value");
+ });
+
+ it("lets minLength reject a blank required answer", async () => {
+ const field: FormField = { ...stringField, minLength: 3 };
+ const rl = fakeRl(["", "abc", ""]);
+ const outcome = await promptForm(rl, "msg", [field], style);
+ expect(outcome).toEqual({ action: "accept", content: { name: "abc" } });
+ expect(stderr).toContain("at least 3");
+ });
+
+ it("keeps an empty-string default instead of dropping the field", async () => {
+ const field: FormField = { ...stringField, required: false, default: "" };
+ const rl = fakeRl(["", ""]);
+ const outcome = await promptForm(rl, "msg", [field], style);
+ expect(outcome).toEqual({ action: "accept", content: { name: "" } });
+ });
+
+ it("enforces minLength/maxLength on a string field", async () => {
+ const field: FormField = { ...stringField, minLength: 3, maxLength: 5 };
+ const rl = fakeRl(["ab", "toolong", "oka", ""]);
+ const outcome = await promptForm(rl, "msg", [field], style);
+ expect(outcome).toEqual({ action: "accept", content: { name: "oka" } });
+ expect(stderr).toContain("at least 3");
+ expect(stderr).toContain("at most 5");
+ });
+
+ it("measures length bounds in code points, not UTF-16 units", async () => {
+ // "😀😀😀" is 3 code points (6 UTF-16 units): valid for min 3 / max 5.
+ const field: FormField = { ...stringField, minLength: 3, maxLength: 5 };
+ const rl = fakeRl(["😀😀😀", ""]);
+ const outcome = await promptForm(rl, "msg", [field], style);
+ expect(outcome).toEqual({ action: "accept", content: { name: "😀😀😀" } });
+ });
+
+ it("collects a required number field with range validation", async () => {
+ const field: FormField = {
+ name: "age",
+ required: true,
+ title: "Age",
+ kind: "number",
+ integer: false,
+ minimum: 18,
+ maximum: 100,
+ };
+ const rl = fakeRl(["notanumber", "5", "30", ""]);
+ const outcome = await promptForm(rl, "msg", [field], style);
+ expect(outcome).toEqual({ action: "accept", content: { age: 30 } });
+ expect(stderr).toContain("Enter a valid number");
+ });
+
+ it("rejects a non-integer value for an integer field", async () => {
+ const field: FormField = {
+ name: "count",
+ required: true,
+ title: "Count",
+ kind: "number",
+ integer: true,
+ };
+ const rl = fakeRl(["1.5", "3", ""]);
+ const outcome = await promptForm(rl, "msg", [field], style);
+ expect(outcome).toEqual({ action: "accept", content: { count: 3 } });
+ expect(stderr).toContain("Enter a valid integer");
+ });
+
+ it("uses a number field's default on blank, or omits when optional with none", async () => {
+ const withDefault: FormField = {
+ name: "age",
+ required: false,
+ title: "Age",
+ kind: "number",
+ integer: false,
+ default: 21,
+ };
+ const rl1 = fakeRl(["", ""]);
+ expect(await promptForm(rl1, "msg", [withDefault], style)).toEqual({
+ action: "accept",
+ content: { age: 21 },
+ });
+
+ const noDefault: FormField = {
+ name: "age",
+ required: false,
+ title: "Age",
+ kind: "number",
+ integer: false,
+ };
+ const rl2 = fakeRl(["", ""]);
+ expect(await promptForm(rl2, "msg", [noDefault], style)).toEqual({
+ action: "accept",
+ content: {},
+ });
+ });
+
+ it("re-prompts a required number field left blank", async () => {
+ const field: FormField = {
+ name: "age",
+ required: true,
+ title: "Age",
+ kind: "number",
+ integer: false,
+ };
+ const rl = fakeRl(["", "42", ""]);
+ const outcome = await promptForm(rl, "msg", [field], style);
+ expect(outcome).toEqual({ action: "accept", content: { age: 42 } });
+ });
+
+ it("collects a boolean field via y/n, defaulting on blank", async () => {
+ const field: FormField = {
+ name: "confirm",
+ required: false,
+ title: "Confirm",
+ kind: "boolean",
+ default: true,
+ };
+ const rl = fakeRl(["", ""]);
+ const outcome = await promptForm(rl, "msg", [field], style);
+ expect(outcome).toEqual({ action: "accept", content: { confirm: true } });
+ });
+
+ it("re-prompts on an invalid boolean answer and accepts yes/no variants", async () => {
+ const field: FormField = {
+ name: "confirm",
+ required: true,
+ title: "Confirm",
+ kind: "boolean",
+ };
+ const rl = fakeRl(["maybe", "yes", ""]);
+ const outcome = await promptForm(rl, "msg", [field], style);
+ expect(outcome).toEqual({ action: "accept", content: { confirm: true } });
+ expect(stderr).toContain("Please answer y or n");
+
+ const rl2 = fakeRl(["no", ""]);
+ expect(
+ await promptForm(rl2, "msg", [{ ...field, required: false }], style),
+ ).toEqual({ action: "accept", content: { confirm: false } });
+ });
+
+ it("omits an optional boolean field left blank with no default", async () => {
+ const field: FormField = {
+ name: "confirm",
+ required: false,
+ title: "Confirm",
+ kind: "boolean",
+ };
+ const rl = fakeRl(["", ""]);
+ const outcome = await promptForm(rl, "msg", [field], style);
+ expect(outcome).toEqual({ action: "accept", content: {} });
+ });
+
+ it("collects a single-select enum by number, and accepts a default on blank", async () => {
+ const field: FormField = {
+ name: "color",
+ required: true,
+ title: "Color",
+ kind: "enum",
+ choices: [
+ { value: "red", label: "Red" },
+ { value: "blue", label: "Blue" },
+ ],
+ };
+ const rl = fakeRl(["2", ""]);
+ const outcome = await promptForm(rl, "msg", [field], style);
+ expect(outcome).toEqual({ action: "accept", content: { color: "blue" } });
+
+ const withDefault: FormField = { ...field, default: "red" };
+ const rl2 = fakeRl(["", ""]);
+ expect(await promptForm(rl2, "msg", [withDefault], style)).toEqual({
+ action: "accept",
+ content: { color: "red" },
+ });
+ });
+
+ it("re-prompts on an out-of-range enum choice and a required blank", async () => {
+ const field: FormField = {
+ name: "color",
+ required: true,
+ title: "Color",
+ kind: "enum",
+ choices: [{ value: "red", label: "Red" }],
+ };
+ const rl = fakeRl(["", "9", "1", ""]);
+ const outcome = await promptForm(rl, "msg", [field], style);
+ expect(outcome).toEqual({ action: "accept", content: { color: "red" } });
+ expect(stderr).toContain("This field is required");
+ expect(stderr).toContain("Enter a number between 1 and 1");
+ });
+
+ it("rejects malformed and multi-token single-select answers", async () => {
+ const field: FormField = {
+ name: "color",
+ required: true,
+ title: "Color",
+ kind: "enum",
+ choices: [
+ { value: "red", label: "Red" },
+ { value: "blue", label: "Blue" },
+ ],
+ };
+ // "1abc" must not be silently accepted as choice 1 (parseInt prefix),
+ // and "1,2" on a single-select must not silently submit only "red".
+ const rl = fakeRl(["1abc", "1,2", "2", ""]);
+ const outcome = await promptForm(rl, "msg", [field], style);
+ expect(outcome).toEqual({ action: "accept", content: { color: "blue" } });
+ expect(stderr).toContain("Enter a number between 1 and 2");
+ expect(stderr).toContain("Enter exactly one number");
+ });
+
+ it("omits an optional enum field left blank with no default", async () => {
+ const field: FormField = {
+ name: "color",
+ required: false,
+ title: "Color",
+ kind: "enum",
+ choices: [{ value: "red", label: "Red" }],
+ };
+ const rl = fakeRl(["", ""]);
+ const outcome = await promptForm(rl, "msg", [field], style);
+ expect(outcome).toEqual({ action: "accept", content: {} });
+ });
+
+ it("collects a multi-select enum via comma-separated numbers, enforcing minItems/maxItems", async () => {
+ const field: FormField = {
+ name: "colors",
+ required: true,
+ title: "Colors",
+ kind: "multiselect",
+ choices: [
+ { value: "red", label: "Red" },
+ { value: "green", label: "Green" },
+ { value: "blue", label: "Blue" },
+ ],
+ minItems: 1,
+ maxItems: 2,
+ };
+ const rl = fakeRl(["1,2,3", "1,2", ""]);
+ const outcome = await promptForm(rl, "msg", [field], style);
+ expect(outcome).toEqual({
+ action: "accept",
+ content: { colors: ["red", "green"] },
+ });
+ expect(stderr).toContain("Select at most 2");
+ });
+
+ it("enforces minItems on a multi-select enum", async () => {
+ const field: FormField = {
+ name: "colors",
+ required: true,
+ title: "Colors",
+ kind: "multiselect",
+ choices: [
+ { value: "red", label: "Red" },
+ { value: "green", label: "Green" },
+ ],
+ minItems: 2,
+ };
+ const rl = fakeRl(["1", "1,2", ""]);
+ const outcome = await promptForm(rl, "msg", [field], style);
+ expect(outcome).toEqual({
+ action: "accept",
+ content: { colors: ["red", "green"] },
+ });
+ expect(stderr).toContain("Select at least 2");
+ });
+
+ it("accepts blank on a required multi-select as an empty array when minItems permits", async () => {
+ // JSON Schema `required` means the key must be present; [] is a valid
+ // value unless minItems forbids it. Previously this looped forever.
+ const field: FormField = {
+ name: "colors",
+ required: true,
+ title: "Colors",
+ kind: "multiselect",
+ choices: [
+ { value: "red", label: "Red" },
+ { value: "green", label: "Green" },
+ ],
+ };
+ const rl = fakeRl(["", ""]);
+ const outcome = await promptForm(rl, "msg", [field], style);
+ expect(outcome).toEqual({ action: "accept", content: { colors: [] } });
+ expect(stderr).not.toContain("This field is required");
+ });
+
+ it("re-prompts blank on a required multi-select when minItems demands entries", async () => {
+ const field: FormField = {
+ name: "colors",
+ required: true,
+ title: "Colors",
+ kind: "multiselect",
+ choices: [
+ { value: "red", label: "Red" },
+ { value: "green", label: "Green" },
+ ],
+ minItems: 1,
+ };
+ const rl = fakeRl(["", "1", ""]);
+ const outcome = await promptForm(rl, "msg", [field], style);
+ expect(outcome).toEqual({
+ action: "accept",
+ content: { colors: ["red"] },
+ });
+ expect(stderr).toContain("Select at least 1");
+ });
+
+ it("uses a multi-select default on blank, formatted in the field description", async () => {
+ const field: FormField = {
+ name: "colors",
+ required: false,
+ title: "Colors",
+ kind: "multiselect",
+ choices: [{ value: "red", label: "Red" }],
+ default: ["red"],
+ };
+ const rl = fakeRl(["", ""]);
+ const outcome = await promptForm(rl, "msg", [field], style);
+ expect(outcome).toEqual({ action: "accept", content: { colors: ["red"] } });
+ expect(
+ (rl.question as ReturnType).mock.calls[0][0],
+ ).toContain("[default: red]");
+ });
+
+ it("omits an optional multi-select field left blank with no default", async () => {
+ const field: FormField = {
+ name: "colors",
+ required: false,
+ title: "Colors",
+ kind: "multiselect",
+ choices: [{ value: "red", label: "Red" }],
+ };
+ const rl = fakeRl(["", ""]);
+ const outcome = await promptForm(rl, "msg", [field], style);
+ expect(outcome).toEqual({ action: "accept", content: {} });
+ });
+
+ it("shows a description when the field has one", async () => {
+ const field: FormField = {
+ ...stringField,
+ description: "Your full display name",
+ };
+ const rl = fakeRl(["octocat", ""]);
+ await promptForm(rl, "msg", [field], style);
+ expect(
+ (rl.question as ReturnType).mock.calls[0][0],
+ ).toContain("Your full display name");
+ });
+
+ it("cancels from the review step", async () => {
+ const rl = fakeRl(["octocat", "c"]);
+ const outcome = await promptForm(rl, "msg", [stringField], style);
+ expect(outcome).toEqual({ action: "cancel" });
+ });
+
+ it("re-prompts a review answer that doesn't name a known field", async () => {
+ const rl = fakeRl(["octocat", "bogus", "c"]);
+ const outcome = await promptForm(rl, "msg", [stringField], style);
+ expect(outcome).toEqual({ action: "cancel" });
+ expect(stderr).toContain('Unknown field "bogus"');
+ });
+
+ it("lets the review step re-edit a named field before submitting", async () => {
+ const rl = fakeRl(["octocat", "name", "edited", ""]);
+ const outcome = await promptForm(rl, "msg", [stringField], style);
+ expect(outcome).toEqual({ action: "accept", content: { name: "edited" } });
+ });
+
+ it("shows the property name in the review when it differs from the title, and edits by title", async () => {
+ const field: FormField = {
+ name: "emailAddress",
+ required: true,
+ title: "Email address",
+ kind: "string",
+ };
+ // Initial value, edit via the display title, new value, submit.
+ const rl = fakeRl(["a@example.com", "Email address", "b@example.com", ""]);
+ const outcome = await promptForm(rl, "msg", [field], style);
+ expect(outcome).toEqual({
+ action: "accept",
+ content: { emailAddress: "b@example.com" },
+ });
+ // The review label must reveal the editable property name.
+ expect(stderr).toContain("Email address (emailAddress):");
+ });
+
+ it("shows '(none)' in the review for a field with no value", async () => {
+ const field: FormField = { ...stringField, required: false };
+ const rl = fakeRl(["", ""]);
+ await promptForm(rl, "msg", [field], style);
+ expect(stderr).toContain("(none)");
+ });
+
+ it("rejects instead of hanging when stdin closes before an answer arrives", async () => {
+ const rl = fakeRl([]);
+ (rl.question as ReturnType).mockImplementation(
+ () => new Promise(() => {}), // never resolves on its own
+ );
+ const outcome = promptForm(rl, "msg", [stringField], style);
+ (rl as unknown as { __triggerClose: () => void }).__triggerClose();
+ await expect(outcome).rejects.toThrow(
+ "stdin closed before an answer was given",
+ );
+ });
+});
diff --git a/clients/daemon-cli/__tests__/form-schema.test.ts b/clients/daemon-cli/__tests__/form-schema.test.ts
new file mode 100644
index 0000000000..4a554b7af6
--- /dev/null
+++ b/clients/daemon-cli/__tests__/form-schema.test.ts
@@ -0,0 +1,443 @@
+import { describe, it, expect } from "vitest";
+import { parseFormSchema } from "../src/connection/form-schema.js";
+
+describe("parseFormSchema", () => {
+ it("returns null for a non-object schema", () => {
+ expect(parseFormSchema(undefined)).toBeNull();
+ expect(parseFormSchema({ type: "string" })).toBeNull();
+ });
+
+ it("returns null when properties is missing or not an object", () => {
+ expect(parseFormSchema({ type: "object" })).toBeNull();
+ expect(parseFormSchema({ type: "object", properties: "nope" })).toBeNull();
+ });
+
+ it("parses a string field with title/description/length/format/default", () => {
+ const fields = parseFormSchema({
+ type: "object",
+ properties: {
+ name: {
+ type: "string",
+ title: "Display Name",
+ description: "Your name",
+ minLength: 2,
+ maxLength: 20,
+ format: "email",
+ default: "octocat",
+ },
+ },
+ required: ["name"],
+ });
+ expect(fields).toEqual([
+ {
+ name: "name",
+ required: true,
+ title: "Display Name",
+ description: "Your name",
+ kind: "string",
+ minLength: 2,
+ maxLength: 20,
+ format: "email",
+ default: "octocat",
+ },
+ ]);
+ });
+
+ it("parses a number field, distinguishing integer from number", () => {
+ const fields = parseFormSchema({
+ type: "object",
+ properties: {
+ age: { type: "number", minimum: 18, maximum: 100, default: 30 },
+ count: { type: "integer" },
+ },
+ properties2: undefined,
+ } as Record);
+ expect(fields).toEqual([
+ {
+ name: "age",
+ required: false,
+ title: "age",
+ description: undefined,
+ kind: "number",
+ integer: false,
+ minimum: 18,
+ maximum: 100,
+ default: 30,
+ },
+ {
+ name: "count",
+ required: false,
+ title: "count",
+ description: undefined,
+ kind: "number",
+ integer: true,
+ minimum: undefined,
+ maximum: undefined,
+ default: undefined,
+ },
+ ]);
+ });
+
+ it("parses a boolean field with a default", () => {
+ const fields = parseFormSchema({
+ type: "object",
+ properties: { confirm: { type: "boolean", default: false } },
+ });
+ expect(fields).toEqual([
+ {
+ name: "confirm",
+ required: false,
+ title: "confirm",
+ description: undefined,
+ kind: "boolean",
+ default: false,
+ },
+ ]);
+ });
+
+ it("parses a single-select enum without titles", () => {
+ const fields = parseFormSchema({
+ type: "object",
+ properties: {
+ color: {
+ type: "string",
+ title: "Color",
+ enum: ["Red", "Green", "Blue"],
+ default: "Red",
+ },
+ },
+ });
+ expect(fields).toEqual([
+ {
+ name: "color",
+ required: false,
+ title: "Color",
+ description: undefined,
+ kind: "enum",
+ choices: [
+ { value: "Red", label: "Red" },
+ { value: "Green", label: "Green" },
+ { value: "Blue", label: "Blue" },
+ ],
+ default: "Red",
+ },
+ ]);
+ });
+
+ it("parses a single-select enum with titled oneOf", () => {
+ const fields = parseFormSchema({
+ type: "object",
+ properties: {
+ color: {
+ type: "string",
+ oneOf: [{ const: "#FF0000", title: "Red" }, { const: "#00FF00" }],
+ },
+ },
+ });
+ expect(fields).toEqual([
+ {
+ name: "color",
+ required: false,
+ title: "color",
+ description: undefined,
+ kind: "enum",
+ choices: [
+ { value: "#FF0000", label: "Red" },
+ { value: "#00FF00", label: "#00FF00" },
+ ],
+ default: undefined,
+ },
+ ]);
+ });
+
+ it("returns null when oneOf entries are malformed", () => {
+ expect(
+ parseFormSchema({
+ type: "object",
+ properties: {
+ color: { type: "string", oneOf: [{ notConst: true }] },
+ },
+ }),
+ ).toBeNull();
+ expect(
+ parseFormSchema({
+ type: "object",
+ properties: { color: { type: "string", oneOf: "nope" } },
+ }),
+ ).toBeNull();
+ });
+
+ it("returns null for empty choice arrays (unwinnable required prompt otherwise)", () => {
+ // A required field with zero options renders no choices and rejects
+ // every answer (1..0 range) — treat the schema as malformed instead.
+ expect(
+ parseFormSchema({
+ type: "object",
+ properties: { color: { type: "string", enum: [] } },
+ required: ["color"],
+ }),
+ ).toBeNull();
+ expect(
+ parseFormSchema({
+ type: "object",
+ properties: { color: { type: "string", oneOf: [] } },
+ }),
+ ).toBeNull();
+ expect(
+ parseFormSchema({
+ type: "object",
+ properties: {
+ colors: { type: "array", items: { type: "string", enum: [] } },
+ },
+ }),
+ ).toBeNull();
+ expect(
+ parseFormSchema({
+ type: "object",
+ properties: {
+ colors: { type: "array", items: { type: "string", anyOf: [] } },
+ },
+ }),
+ ).toBeNull();
+ // Non-string enum entries stay malformed too (not a freeform string).
+ expect(
+ parseFormSchema({
+ type: "object",
+ properties: { color: { type: "string", enum: [1, 2] } },
+ }),
+ ).toBeNull();
+ });
+
+ it("parses a multi-select enum without titles, with min/maxItems and default", () => {
+ const fields = parseFormSchema({
+ type: "object",
+ properties: {
+ colors: {
+ type: "array",
+ title: "Colors",
+ minItems: 1,
+ maxItems: 2,
+ items: { type: "string", enum: ["Red", "Green", "Blue"] },
+ default: ["Red", "Green"],
+ },
+ },
+ });
+ expect(fields).toEqual([
+ {
+ name: "colors",
+ required: false,
+ title: "Colors",
+ description: undefined,
+ kind: "multiselect",
+ choices: [
+ { value: "Red", label: "Red" },
+ { value: "Green", label: "Green" },
+ { value: "Blue", label: "Blue" },
+ ],
+ minItems: 1,
+ maxItems: 2,
+ default: ["Red", "Green"],
+ },
+ ]);
+ });
+
+ it("parses a multi-select enum with titled anyOf", () => {
+ const fields = parseFormSchema({
+ type: "object",
+ properties: {
+ colors: {
+ type: "array",
+ items: {
+ anyOf: [
+ { const: "#FF0000", title: "Red" },
+ { const: "#00FF00", title: "Green" },
+ ],
+ },
+ },
+ },
+ });
+ expect(fields?.[0]).toMatchObject({
+ kind: "multiselect",
+ choices: [
+ { value: "#FF0000", label: "Red" },
+ { value: "#00FF00", label: "Green" },
+ ],
+ });
+ });
+
+ it("returns null for an array field without items or without enum/anyOf", () => {
+ expect(
+ parseFormSchema({
+ type: "object",
+ properties: { colors: { type: "array" } },
+ }),
+ ).toBeNull();
+ expect(
+ parseFormSchema({
+ type: "object",
+ properties: {
+ colors: { type: "array", items: { type: "string" } },
+ },
+ }),
+ ).toBeNull();
+ });
+
+ it("ignores a non-string-array default on a multiselect field", () => {
+ const fields = parseFormSchema({
+ type: "object",
+ properties: {
+ colors: {
+ type: "array",
+ items: { type: "string", enum: ["Red"] },
+ default: [1, 2],
+ },
+ },
+ });
+ expect(fields?.[0]).toMatchObject({ default: undefined });
+ });
+
+ it("returns null for an unsupported/unknown property type", () => {
+ expect(
+ parseFormSchema({
+ type: "object",
+ properties: { nested: { type: "object", properties: {} } },
+ }),
+ ).toBeNull();
+ });
+
+ it("returns null when a property isn't an object", () => {
+ expect(
+ parseFormSchema({
+ type: "object",
+ properties: { name: "not-a-schema" },
+ }),
+ ).toBeNull();
+ });
+
+ it("treats non-array/malformed required as no required fields", () => {
+ const fields = parseFormSchema({
+ type: "object",
+ properties: { name: { type: "string" } },
+ required: "name",
+ });
+ expect(fields?.[0].required).toBe(false);
+ });
+
+ // Internally inconsistent fields are rejected like any other malformed
+ // schema: unsatisfiable constraints or a default violating its own
+ // constraints would render unwinnable / instantly-invalid prompts.
+ it("returns null for unsatisfiable constraints", () => {
+ const cases: Record[] = [
+ { n: { type: "number", minimum: 10, maximum: 5 } },
+ { s: { type: "string", minLength: 5, maxLength: 2 } },
+ { m: { type: "array", items: { enum: ["a"] }, minItems: 2 } },
+ {
+ m: {
+ type: "array",
+ items: { enum: ["a", "b"] },
+ minItems: 2,
+ maxItems: 1,
+ },
+ },
+ ];
+ for (const properties of cases) {
+ expect(parseFormSchema({ type: "object", properties })).toBeNull();
+ }
+ });
+
+ it("returns null for negative or non-integer length/count keywords", () => {
+ const cases: Record[] = [
+ { s: { type: "string", maxLength: -1 } },
+ { s: { type: "string", minLength: -3 } },
+ { s: { type: "string", minLength: 1.5 } },
+ { m: { type: "array", items: { enum: ["a", "b"] }, maxItems: -1 } },
+ { m: { type: "array", items: { enum: ["a", "b"] }, minItems: -2 } },
+ { m: { type: "array", items: { enum: ["a", "b"] }, minItems: 0.5 } },
+ ];
+ for (const properties of cases) {
+ expect(parseFormSchema({ type: "object", properties })).toBeNull();
+ }
+ // Zero is a valid bound.
+ expect(
+ parseFormSchema({
+ type: "object",
+ properties: { s: { type: "string", minLength: 0 } },
+ }),
+ ).toHaveLength(1);
+ });
+
+ it("measures default length bounds in code points, not UTF-16 units", () => {
+ // "😀" is 1 code point (2 UTF-16 units): a valid default for maxLength 1.
+ expect(
+ parseFormSchema({
+ type: "object",
+ properties: { s: { type: "string", maxLength: 1, default: "😀" } },
+ }),
+ ).toHaveLength(1);
+ // ...and 1 code point still violates minLength 2.
+ expect(
+ parseFormSchema({
+ type: "object",
+ properties: { s: { type: "string", minLength: 2, default: "😀" } },
+ }),
+ ).toBeNull();
+ });
+
+ it("returns null for defaults that violate the field's own constraints", () => {
+ const cases: Record[] = [
+ { n: { type: "number", minimum: 1, maximum: 10, default: 11 } },
+ { n: { type: "number", minimum: 1, default: 0 } },
+ { i: { type: "integer", default: 1.5 } },
+ { s: { type: "string", minLength: 3, default: "ab" } },
+ { s: { type: "string", maxLength: 2, default: "abc" } },
+ { e: { type: "string", enum: ["a", "b"], default: "c" } },
+ {
+ e: {
+ type: "string",
+ oneOf: [{ const: "a", title: "A" }],
+ default: "b",
+ },
+ },
+ { m: { type: "array", items: { enum: ["a", "b"] }, default: ["c"] } },
+ {
+ m: {
+ type: "array",
+ items: { enum: ["a", "b"] },
+ minItems: 2,
+ default: ["a"],
+ },
+ },
+ {
+ m: {
+ type: "array",
+ items: { enum: ["a", "b"] },
+ maxItems: 1,
+ default: ["a", "b"],
+ },
+ },
+ ];
+ for (const properties of cases) {
+ expect(parseFormSchema({ type: "object", properties })).toBeNull();
+ }
+ });
+
+ it("accepts consistent constraints with in-range defaults", () => {
+ const fields = parseFormSchema({
+ type: "object",
+ properties: {
+ n: { type: "number", minimum: 1, maximum: 10, default: 5 },
+ i: { type: "integer", minimum: 0, default: 0 },
+ s: { type: "string", minLength: 1, maxLength: 3, default: "ab" },
+ e: { type: "string", enum: ["a", "b"], default: "b" },
+ m: {
+ type: "array",
+ items: { enum: ["a", "b"] },
+ minItems: 1,
+ maxItems: 2,
+ default: ["a", "b"],
+ },
+ },
+ });
+ expect(fields).toHaveLength(5);
+ });
+});
diff --git a/clients/daemon-cli/__tests__/format-connection.test.ts b/clients/daemon-cli/__tests__/format-connection.test.ts
new file mode 100644
index 0000000000..d32bd8714c
--- /dev/null
+++ b/clients/daemon-cli/__tests__/format-connection.test.ts
@@ -0,0 +1,1202 @@
+import { describe, it, expect, beforeEach, afterEach } from "vitest";
+import {
+ formatCallToolResultHuman,
+ formatToolsHuman,
+ formatResourcesHuman,
+ formatResourceTemplatesHuman,
+ formatPromptsHuman,
+ formatResourceReadHuman,
+ formatPromptResultHuman,
+ formatCompletionsHuman,
+ formatTasksHuman,
+ formatTaskHuman,
+ formatInitializeHuman,
+ formatRootsHuman,
+ formatAuthListHuman,
+ formatServersListHuman,
+ formatServerShowHuman,
+ formatConnectionsListHuman,
+ formatConnectionInfoHuman,
+ formatAppInfoListHuman,
+ formatAppInfoHuman,
+ formatSkillVerifyListHuman,
+ formatStreamEventHuman,
+ formatRpcResultHuman,
+ formatElicitationPendingHuman,
+} from "../src/connection/format-human.js";
+import { writeConnectionOutput } from "../src/connection/format-connection.js";
+import { CliExitCodeError, EXIT_CODES } from "@inspector/cli/error-handler.js";
+import { createStyle, PLAIN } from "@inspector/cli/style.js";
+
+describe("format-human", () => {
+ it("formats tools with schema variants and empty list", () => {
+ expect(formatToolsHuman([])).toContain("(none)");
+ const text = formatToolsHuman([
+ {
+ name: "echo",
+ description: "Echo back\nmore",
+ inputSchema: {
+ type: "object",
+ properties: {
+ message: { type: "string" },
+ n: { type: "number" },
+ tags: { type: "array", items: { type: "string" } },
+ extra: { type: "boolean" },
+ },
+ required: ["message"],
+ },
+ annotations: {
+ readOnlyHint: true,
+ destructiveHint: true,
+ idempotentHint: true,
+ openWorldHint: true,
+ },
+ },
+ {
+ name: "types",
+ inputSchema: {
+ type: "object",
+ properties: {
+ emptyArr: { type: "array" },
+ multi: { type: ["string", "number"] },
+ bare: {},
+ },
+ },
+ },
+ {
+ name: "more",
+ inputSchema: {
+ type: "object",
+ properties: {
+ flag: { type: ["boolean", "null"] },
+ choice: { enum: ["a", "b"] },
+ obj: { type: "object" },
+ },
+ },
+ },
+ {
+ name: "ints",
+ inputSchema: {
+ type: "object",
+ properties: {
+ i: { type: "integer" },
+ unknownType: { type: "custom" },
+ nonObjProp: "x",
+ },
+ },
+ annotations: {},
+ },
+ { name: "plain", inputSchema: null },
+ { name: "emptyProps", inputSchema: { type: "object", properties: {} } },
+ { name: "noProps", inputSchema: { type: "object" } },
+ {},
+ ]);
+ expect(text).toContain("Tools (8):");
+ expect(text).toContain("`echo(message:str, n?:num, tags?:[str], …)`");
+ expect(text).toContain("[read-only, destructive, idempotent, open-world]");
+ expect(text).toContain("emptyArr?:[any]");
+ expect(text).toContain("multi?:str | num");
+ expect(text).toContain("bare?:any");
+ expect(text).toContain("flag?:bool");
+ expect(text).toContain("choice?:enum");
+ expect(text).toContain("`plain()`");
+ expect(text).toContain("`?()`");
+ });
+
+ it("formats list helpers for resources, templates, prompts, roots, tasks", () => {
+ expect(
+ formatResourcesHuman([
+ { name: "r", uri: "u://x", description: "d\n2" },
+ { uri: "u://only" },
+ { name: "n", uri: 1, description: " " },
+ { name: "no-uri" },
+ ]),
+ ).toContain("`r` (u://x)");
+ expect(formatResourcesHuman([])).toContain("(none)");
+
+ expect(
+ formatResourceTemplatesHuman([
+ { name: "t", uriTemplate: "u://{id}", description: "tpl" },
+ { description: " " },
+ { name: "x", uriTemplate: 1 },
+ ]),
+ ).toContain("u://{id}");
+ expect(formatResourceTemplatesHuman([])).toContain("(none)");
+
+ expect(
+ formatPromptsHuman([
+ { name: "p", description: "hi\nmore" },
+ { description: " " },
+ {},
+ ]),
+ ).toContain("`p`");
+ expect(formatPromptsHuman([])).toContain("(none)");
+
+ expect(
+ formatRootsHuman([{ uri: "file:///a", name: "a" }, { uri: "file:///b" }]),
+ ).toContain("file:///a (a)");
+ expect(formatRootsHuman([])).toContain("(none)");
+
+ expect(
+ formatTasksHuman([
+ { taskId: "1", status: "running", statusMessage: "go" },
+ { id: "2", status: "done" },
+ {},
+ ]),
+ ).toContain("`1` running");
+ expect(formatTasksHuman([])).toContain("(none)");
+
+ expect(
+ formatTaskHuman({
+ taskId: "t1",
+ status: "ok",
+ statusMessage: "fine",
+ createdAt: "c",
+ lastUpdatedAt: "u",
+ }),
+ ).toContain("Created: c");
+ expect(formatTaskHuman(null)).toContain("Task: `?`");
+ expect(formatTaskHuman({})).toContain("Status: ?");
+ });
+
+ it("formats call tool results across content block types", () => {
+ const structured = { ok: true };
+ const withDupe = formatCallToolResultHuman({
+ isError: true,
+ content: [
+ { type: "text", text: JSON.stringify(structured) },
+ { type: "text", text: "hello" },
+ { type: "text", text: "{not-json" },
+ {
+ type: "resource_link",
+ uri: "u://r",
+ name: "n",
+ description: "d",
+ mimeType: "text/plain",
+ },
+ { type: "image", mimeType: "image/png", data: "abc" },
+ { type: "audio", mimeType: "audio/wav" },
+ {
+ type: "resource",
+ resource: { uri: "u://e", mimeType: "text/plain", text: "body" },
+ },
+ { type: "custom", x: 1 },
+ ],
+ structuredContent: structured,
+ _meta: { a: 1 },
+ });
+ expect(withDupe).toContain("Tool error:");
+ expect(withDupe).toContain("hello");
+ expect(withDupe).toContain("Resource link");
+ expect(withDupe).toContain("[Image:");
+ expect(withDupe).toContain("[Audio:");
+ expect(withDupe).toContain("Embedded resource");
+ expect(withDupe).toContain('"x": 1');
+ // The JSON duplicate of structuredContent is filtered from the content
+ // blocks, but the structured payload itself must still be rendered once.
+ expect(withDupe).toContain("Structured content:");
+ expect(withDupe).toContain('"ok": true');
+
+ expect(
+ formatCallToolResultHuman({
+ 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([], PLAIN, {
+ kind: "catalog",
+ path: "/home/u/.mcp-inspector/mcp.json",
+ }),
+ ).toContain("Source: catalog /home/u/.mcp-inspector/mcp.json");
+ expect(
+ formatServersListHuman([], PLAIN, {
+ kind: "config",
+ path: "./mcp.json",
+ }),
+ ).toContain("Source: config ./mcp.json");
+ expect(
+ formatServersListHuman([{ name: "s", type: "stdio", detail: "x" }]),
+ ).toContain("`s`");
+ expect(
+ formatServersListHuman([
+ {
+ name: "s",
+ type: "stdio",
+ detail: "x",
+ connection: "s",
+ isMru: true,
+ },
+ ]),
+ ).toMatch(/@s \(MRU\)/);
+ expect(
+ formatServerShowHuman(
+ { name: "s", type: "stdio", detail: "x", config: {} },
+ PLAIN,
+ { kind: "catalog", path: "/tmp/cat.json" },
+ ),
+ ).toContain("Source: catalog /tmp/cat.json");
+ expect(
+ formatServerShowHuman({
+ name: "s",
+ type: "stdio",
+ detail: "node x",
+ config: { type: "stdio", command: "node" },
+ }),
+ ).toMatch(/Server[\s\S]*`s`[\s\S]*node x/);
+
+ expect(formatConnectionsListHuman([])).toContain("connect first");
+ expect(
+ formatConnectionsListHuman([
+ { name: "a", isMru: true, serverIdentity: "id" },
+ ]),
+ ).toContain("(MRU)");
+ // protocolEra is on every ConnectionInfo now (#2298 follow-up), not just
+ // connections/show — connections/list renders it inline; its absence (an older
+ // daemon reply, hypothetically) must not print a bare "[undefined]".
+ expect(
+ formatConnectionsListHuman([
+ { name: "a", isMru: true, serverIdentity: "id", protocolEra: "modern" },
+ ]),
+ ).toContain("— id [modern]");
+ expect(
+ formatConnectionsListHuman([
+ { name: "a", isMru: false, serverIdentity: "id" },
+ ]),
+ ).not.toContain("[");
+ expect(
+ formatConnectionInfoHuman({
+ name: "a",
+ isMru: true,
+ serverIdentity: "id",
+ }),
+ ).toContain("Connection `@a`");
+ // connections/show enrichment: era without a protocolVersion, serverInfo
+ // without a version, empty capabilities, an empty/non-array
+ // supportedVersions, and blank instructions each take the "nothing to
+ // append" branch rather than the populated one exercised elsewhere.
+ expect(
+ formatConnectionInfoHuman({
+ name: "a",
+ protocolEra: "legacy",
+ serverInfo: { name: "demo" },
+ capabilities: {},
+ supportedVersions: [],
+ instructions: "",
+ }),
+ ).toMatch(/Era: legacy\nServer info: demo\nCapabilities: \(none\)/);
+ expect(
+ formatConnectionInfoHuman({
+ name: "a",
+ protocolEra: undefined,
+ protocolVersion: "2025-11-25",
+ serverInfo: { name: "demo", version: "1.2.3" },
+ supportedVersions: ["2025-11-25", "2025-06-18"],
+ instructions: "Say hi.",
+ }),
+ ).toMatch(
+ /Era: unknown \(2025-11-25\)[\s\S]*demo v1\.2\.3[\s\S]*Supported versions: 2025-11-25, 2025-06-18[\s\S]*Instructions: Say hi\./,
+ );
+
+ // Auth snapshot line: OAuth with full detail, EMA with IdP session state,
+ // and a bare not-authorized snapshot (no scope/clientId branches).
+ expect(
+ formatConnectionInfoHuman({
+ name: "a",
+ auth: {
+ method: "oauth",
+ authorized: true,
+ scope: "mcp:tools",
+ clientId: "client-123",
+ },
+ }),
+ ).toContain(
+ "Auth: OAuth (authorized; scope: mcp:tools; client: client-123)",
+ );
+ expect(
+ formatConnectionInfoHuman({
+ name: "a",
+ auth: { method: "ema", authorized: true, idpSession: "logged_in" },
+ }),
+ ).toContain("Auth: EMA (authorized; IdP session: logged_in)");
+ expect(
+ formatConnectionInfoHuman({
+ name: "a",
+ auth: { method: "oauth", authorized: false },
+ }),
+ ).toContain("Auth: OAuth (not authorized)");
+
+ expect(
+ formatAppInfoListHuman([
+ { toolName: "with", hasApp: true, resourceUri: "ui://x" },
+ { toolName: "err", hasApp: false, resourceError: "boom" },
+ { toolName: "no", hasApp: false },
+ ]),
+ ).toContain("no app");
+
+ const verifyText = formatSkillVerifyListHuman([
+ { name: "ok-skill", uri: "skill://ok/SKILL.md", outcome: "verified" },
+ {
+ name: "bad-skill",
+ uri: "skill://bad/SKILL.md",
+ outcome: "failed",
+ conformance: [{ severity: "error" }],
+ files: [{ status: "mismatch" }],
+ },
+ {
+ name: "cut-short",
+ uri: "skill://cut/SKILL.md",
+ outcome: "incomplete",
+ incomplete: "read bounds hit",
+ },
+ ]);
+ expect(verifyText).toContain("Skill verification (3):");
+ expect(verifyText).toContain("`ok-skill`");
+ expect(verifyText).toContain("verified");
+ expect(verifyText).toContain(
+ "`bad-skill` (skill://bad/SKILL.md) — failed — 1 issue(s), 1 file mismatch(es)",
+ );
+ expect(verifyText).toContain("`cut-short`");
+ expect(verifyText).toContain("read bounds hit");
+
+ expect(
+ formatAppInfoHuman({
+ toolName: "t",
+ hasApp: true,
+ resourceUri: "ui://x",
+ csp: { a: 1 },
+ }),
+ ).toContain("CSP:");
+ expect(
+ formatAppInfoHuman({ toolName: "t", hasApp: false, resourceError: "e" }),
+ ).toContain("e");
+ expect(formatAppInfoHuman({ toolName: "t", hasApp: false })).toContain(
+ "No MCP App",
+ );
+ });
+
+ it("formats stream events and rpc dispatch", () => {
+ expect(formatStreamEventHuman(null)).toBe("null");
+ // Empty URI: formatUri must pass it through without linkifying.
+ expect(formatStreamEventHuman({ type: "subscribed" })).toBe("Subscribed: ");
+ // colorLevel groups: red, yellow, dim, and the default (cyan) bucket.
+ const s = createStyle(true);
+ for (const [level, colored] of [
+ ["error", s.red("error")],
+ ["warning", s.yellow("warning")],
+ ["debug", s.dim("debug")],
+ ["notice", s.dim("notice")],
+ ["info", s.cyan("info")],
+ ] as const) {
+ expect(
+ formatStreamEventHuman(
+ {
+ direction: "notification",
+ message: { params: { level, data: "x" } },
+ },
+ s,
+ ),
+ ).toContain(`[${colored}]`);
+ }
+ expect(formatStreamEventHuman({ type: "subscribed", uri: "u" })).toBe(
+ "Subscribed: u",
+ );
+ 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");
+ // Object `data` renders as JSON, not "[object Object]".
+ expect(
+ formatStreamEventHuman({
+ direction: "notification",
+ message: {
+ method: "notifications/message",
+ params: { level: "info", data: { job: "sync", ok: true } },
+ },
+ }),
+ ).toBe('[info] {"job":"sync","ok":true}');
+ expect(
+ formatStreamEventHuman({
+ direction: "notification",
+ 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("formatElicitationPendingHuman", () => {
+ it("formatElicitationPendingHuman renders form fields and respond guidance", () => {
+ const text = formatElicitationPendingHuman({
+ elicitationId: "e-9",
+ connection: "srv",
+ method: "tools/call",
+ toolName: "collect",
+ mode: "form",
+ message: "Pick a color",
+ requestedSchema: {
+ type: "object",
+ properties: {
+ color: { type: "string", description: "Favourite color" },
+ size: { type: "string", enum: ["s", "m", "l"] },
+ count: { type: "integer" },
+ },
+ required: ["color"],
+ },
+ origin: "server-request",
+ expiresAt: Date.now() + 600_000,
+ });
+ expect(text).toContain("Input required");
+ expect(text).toContain("@srv");
+ expect(text).toContain("Pick a color");
+ expect(text).toContain("color (string, required)");
+ expect(text).toContain("Favourite color");
+ expect(text).toContain("size (enum) [s, m, l]");
+ expect(text).toContain("count (integer)");
+ expect(text).toContain("elicitation/respond e-9 field:=value");
+ expect(text).toContain("--decline | --cancel");
+ expect(text).toContain("expires");
+ });
+
+ it("formatElicitationPendingHuman renders url mode with --done guidance; unsafe schemes stay plain", () => {
+ const info = {
+ elicitationId: "e-u",
+ connection: "srv",
+ method: "tools/call",
+ mode: "url",
+ message: "Finish signup",
+ url: "https://example.com/signup?flow=abc",
+ origin: "server-request",
+ expiresAt: 0,
+ };
+ const styled = formatElicitationPendingHuman(info, createStyle(true));
+ expect(styled).toContain("https://example.com/signup?flow=abc");
+ expect(styled).toContain("\u001b]8;;https://example.com/signup?flow=abc");
+ expect(styled).toContain("elicitation/respond e-u --done");
+ expect(styled).toContain("elicitation/respond e-u --cancel");
+
+ const unsafe = formatElicitationPendingHuman(
+ { ...info, url: "file:///etc/passwd" },
+ createStyle(true),
+ );
+ expect(unsafe).toContain("file:///etc/passwd");
+ expect(unsafe).not.toContain("\u001b]8");
+ });
+});
+
+describe("writeConnectionOutput", () => {
+ let stdout: string;
+ let stderr: string;
+ let original: typeof process.stdout.write;
+ let originalErr: typeof process.stderr.write;
+
+ beforeEach(() => {
+ stdout = "";
+ stderr = "";
+ original = process.stdout.write;
+ originalErr = process.stderr.write;
+ process.stdout.write = ((chunk: unknown, ...rest: unknown[]) => {
+ stdout += typeof chunk === "string" ? chunk : String(chunk);
+ const cb = rest.find((r) => typeof r === "function") as
+ | (() => void)
+ | undefined;
+ cb?.();
+ return true;
+ }) as typeof process.stdout.write;
+ process.stderr.write = ((chunk: unknown, ...rest: unknown[]) => {
+ stderr += typeof chunk === "string" ? chunk : String(chunk);
+ const cb = rest.find((r) => typeof r === "function") as
+ | (() => void)
+ | undefined;
+ cb?.();
+ return true;
+ }) as typeof process.stderr.write;
+ });
+
+ afterEach(() => {
+ process.stdout.write = original;
+ process.stderr.write = originalErr;
+ });
+
+ it("connection with authUrl: json carries the URL verbatim (query intact), human prints relay guidance", async () => {
+ const authUrl = "https://as.example/authorize?client_id=abc&state=xyz";
+ await writeConnectionOutput(
+ { format: "json" },
+ {
+ kind: "connection",
+ connection: {
+ name: "api",
+ serverIdentity: "https://mcp.example.com/mcp",
+ pendingAuth: true,
+ auth: { method: "oauth", authorized: false },
+ },
+ authUrl,
+ },
+ );
+ const parsed = JSON.parse(stdout) as Record;
+ expect(parsed.pendingAuth).toBe(true);
+ expect(parsed.authUrl).toBe(authUrl);
+
+ stdout = "";
+ await writeConnectionOutput(
+ { format: "text" },
+ {
+ kind: "connection",
+ connection: {
+ name: "api",
+ serverIdentity: "https://mcp.example.com/mcp",
+ pendingAuth: true,
+ auth: { method: "oauth", authorized: false },
+ },
+ authUrl,
+ },
+ );
+ expect(stdout).toContain("Sign-in required");
+ expect(stdout).toContain(authUrl);
+ expect(stdout).toContain("Sign-in: pending");
+ expect(stdout).toContain("connections/show @api");
+ });
+
+ it("pendingAuthSignedIn: human output flips to completed / completing-on-next-use", async () => {
+ stdout = "";
+ await writeConnectionOutput(
+ { format: "text" },
+ {
+ kind: "connection",
+ connection: {
+ name: "api",
+ serverIdentity: "https://mcp.example.com/mcp",
+ pendingAuth: true,
+ pendingAuthSignedIn: true,
+ auth: { method: "oauth", authorized: true },
+ },
+ },
+ );
+ expect(stdout).toContain("Sign-in: completed");
+ expect(stdout).toContain("finishes on next use");
+ expect(stdout).not.toContain("Sign-in: pending");
+
+ stdout = "";
+ await writeConnectionOutput(
+ { format: "text" },
+ {
+ kind: "connections/list",
+ connections: [
+ {
+ name: "api",
+ serverIdentity: "https://mcp.example.com/mcp",
+ connectedAt: 1,
+ lastAccessedAt: 1,
+ isMru: true,
+ pendingAuth: true,
+ pendingAuthSignedIn: true,
+ },
+ {
+ name: "other",
+ serverIdentity: "https://mcp2.example.com/mcp",
+ connectedAt: 1,
+ lastAccessedAt: 1,
+ isMru: false,
+ pendingAuth: true,
+ },
+ ],
+ },
+ );
+ expect(stdout).toContain("signed in — completing on next use");
+ expect(stdout).toContain("(sign-in pending)");
+ });
+
+ it("connection authUrl: only allowlisted schemes become OSC 8 links", async () => {
+ const connection = {
+ name: "api",
+ serverIdentity: "https://mcp.example.com/mcp",
+ pendingAuth: true,
+ auth: { method: "oauth", authorized: false },
+ };
+ const style = createStyle(true);
+ stdout = "";
+ await writeConnectionOutput(
+ { format: "text", style },
+ {
+ kind: "connection",
+ connection,
+ authUrl: "https://as.example/authorize?state=ok",
+ },
+ );
+ expect(stdout).toContain("\u001b]8;;https://as.example/authorize?state=ok");
+
+ // Server-controlled OAuth metadata: an unsafe scheme renders as plain
+ // text, never a clickable link.
+ stdout = "";
+ await writeConnectionOutput(
+ { format: "text", style },
+ {
+ kind: "connection",
+ connection,
+ authUrl: "file:///etc/passwd",
+ },
+ );
+ expect(stdout).toContain("file:///etc/passwd");
+ expect(stdout).not.toContain("\u001b]8");
+ });
+
+ it("connection without authUrl renders exactly as before (no sign-in block)", async () => {
+ await writeConnectionOutput(
+ { format: "text" },
+ {
+ kind: "connection",
+ connection: { name: "api", serverIdentity: "id" },
+ },
+ );
+ expect(stdout).not.toContain("Sign-in");
+ });
+
+ it("pretty-prints json without a result envelope", async () => {
+ await writeConnectionOutput(
+ { format: "json" },
+ {
+ kind: "rpc",
+ method: "tools/list",
+ result: { tools: [] },
+ },
+ );
+ expect(stdout).toBe('{\n "tools": []\n}\n');
+ });
+
+ it("escapes C1 controls in json output (JSON.stringify only escapes C0)", async () => {
+ await writeConnectionOutput(
+ { format: "json" },
+ {
+ kind: "rpc",
+ method: "tools/call",
+ result: {
+ content: [{ type: "text", text: "before\u009b31mafter" }],
+ },
+ },
+ );
+ // U+009B is 8-bit CSI: it must reach the terminal as a \u escape, and
+ // parsing the output must restore the original value byte-for-byte.
+ expect(stdout).not.toContain("\u009b");
+ expect(stdout).toContain("\\u009b");
+ const parsed = JSON.parse(stdout) as {
+ content: { text: string }[];
+ };
+ expect(parsed.content[0]!.text).toBe("before\u009b31mafter");
+ });
+
+ it("sanitizes server-supplied terminal escapes in text mode", async () => {
+ await writeConnectionOutput(
+ { format: "text" },
+ {
+ kind: "rpc",
+ method: "tools/call",
+ result: {
+ content: [{ type: "text", text: "\u001b]52;c;c3RvbGVu\u0007hi" }],
+ },
+ },
+ );
+ expect(stdout).not.toContain("\u001b");
+ expect(stdout).not.toContain("\u0007");
+ expect(stdout).toContain("\u241b]52;c;c3RvbGVu\u2407hi");
+ });
+
+ it("leaves json output verbatim (JSON escaping already protects it)", async () => {
+ await writeConnectionOutput(
+ { format: "json" },
+ {
+ kind: "rpc",
+ method: "tools/call",
+ result: { content: [{ type: "text", text: "\u001bhi" }] },
+ },
+ );
+ expect(JSON.parse(stdout)).toEqual({
+ content: [{ type: "text", text: "\u001bhi" }],
+ });
+ expect(stdout).toContain("\\u001bhi");
+ });
+
+ it("sanitizes the ndjson stderr summary line", async () => {
+ await writeConnectionOutput(
+ { format: "text" },
+ {
+ kind: "ndjson",
+ variant: "skill-verify",
+ lines: [],
+ summary: "done \u001b[2J",
+ },
+ );
+ expect(stderr).toContain("done \u241b[2J");
+ expect(stderr).not.toContain("\u001b");
+ });
+
+ it("ignores auto-collected appInfo on tools/call json", async () => {
+ await writeConnectionOutput(
+ { 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(
+ writeConnectionOutput(
+ { 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 writeConnectionOutput(
+ { 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(
+ writeConnectionOutput(
+ { format: "json" },
+ {
+ kind: "rpc",
+ method: "tools/call",
+ result: { isError: true, content: [] },
+ toolName: "echo",
+ },
+ ),
+ ).rejects.toBeInstanceOf(CliExitCodeError);
+ await expect(
+ writeConnectionOutput(
+ { format: "json" },
+ {
+ kind: "rpc",
+ method: "tools/call",
+ result: { isError: true, content: [] },
+ },
+ ),
+ ).rejects.toMatchObject({ message: expect.stringContaining("tool") });
+ // Server-influenced tool names are sanitized before reaching the
+ // terminal-bound error message (C0/C1 → visible stand-ins).
+ await expect(
+ writeConnectionOutput(
+ { format: "json" },
+ {
+ kind: "rpc",
+ method: "tools/call",
+ result: { isError: true, content: [] },
+ toolName: "evil\u001b]0;pwned\u0007",
+ },
+ ),
+ ).rejects.toMatchObject({
+ message: expect.not.stringContaining("\u001b"),
+ });
+ });
+
+ it("falls back to pretty JSON for unknown rpc methods in text mode", async () => {
+ await writeConnectionOutput(
+ { format: "text" },
+ {
+ kind: "rpc",
+ method: "custom/x",
+ result: { ok: 1 },
+ },
+ );
+ expect(stdout).toContain('"ok": 1');
+ });
+
+ it("renders skill-verify NDJSON with its own formatter, not app-info's", async () => {
+ await writeConnectionOutput(
+ { format: "text" },
+ {
+ kind: "ndjson",
+ variant: "skill-verify",
+ lines: [
+ { name: "ok-skill", uri: "skill://ok/SKILL.md", outcome: "verified" },
+ ],
+ summary: "Verified 1 skill and 0 files: no conformance errors.",
+ },
+ );
+ expect(stdout).toContain("Skill verification (1):");
+ expect(stdout).not.toContain("App info");
+ expect(stderr).toBe(
+ "Verified 1 skill and 0 files: no conformance errors.\n",
+ );
+ });
+
+ it("throws with the verify exit code after printing the report and summary", async () => {
+ await expect(
+ writeConnectionOutput(
+ { format: "json" },
+ {
+ kind: "ndjson",
+ variant: "skill-verify",
+ lines: [
+ {
+ name: "bad-skill",
+ uri: "skill://bad/SKILL.md",
+ outcome: "failed",
+ },
+ ],
+ summary: "1 of 1 skill failed verification.",
+ exitCode: EXIT_CODES.SKILL_NONCONFORMANT,
+ },
+ ),
+ ).rejects.toMatchObject({
+ exitCode: EXIT_CODES.SKILL_NONCONFORMANT,
+ envelope: { code: "skills_nonconformant" },
+ });
+ // Report already on stdout, summary on stderr — both happen before the throw.
+ expect(stdout).toContain("bad-skill");
+ expect(stderr).toBe("1 of 1 skill failed verification.\n");
+ });
+
+ it("formats every admin/stream payload kind", async () => {
+ const kinds = [
+ {
+ kind: "ndjson" as const,
+ lines: [{ toolName: "a", hasApp: false }],
+ },
+ { kind: "stream-event" as const, data: { type: "subscribed", uri: "u" } },
+ {
+ kind: "servers/list" as const,
+ servers: [{ name: "s", type: "stdio", detail: "d" }],
+ },
+ {
+ kind: "servers/show" as const,
+ server: {
+ name: "s",
+ type: "stdio",
+ detail: "d",
+ config: { type: "stdio", command: "n" },
+ },
+ },
+ {
+ kind: "connections/list" as const,
+ connections: [{ name: "a", serverIdentity: "id" }],
+ },
+ {
+ kind: "connection" as const,
+ connection: { name: "a", serverIdentity: "id" },
+ },
+ { kind: "disconnect" as const, name: "a" },
+ {
+ kind: "daemon/status" as const,
+ status: { pid: 1, socketPath: "/tmp/s", connections: [] },
+ },
+ {
+ kind: "daemon/status" as const,
+ status: { pid: 2, connections: "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 writeConnectionOutput({ format: "text" }, payload);
+ expect(stdout.length).toBeGreaterThan(0);
+ stdout = "";
+ await writeConnectionOutput({ format: "json" }, payload);
+ expect(() => JSON.parse(stdout)).not.toThrow();
+ }
+ });
+
+ it("defaults undefined format to text", async () => {
+ await writeConnectionOutput(
+ {},
+ {
+ kind: "disconnect",
+ name: "z",
+ },
+ );
+ expect(stdout).toContain("Disconnected `@z`");
+ });
+});
+
+describe("format-human ANSI styling", () => {
+ it("styles human tool lists and log levels when enabled", () => {
+ const s = createStyle(true);
+ const tools = formatToolsHuman(
+ [
+ {
+ name: "echo",
+ description: "hi",
+ inputSchema: {
+ type: "object",
+ properties: { message: { type: "string" } },
+ required: ["message"],
+ },
+ },
+ ],
+ s,
+ );
+ expect(tools).toContain("\u001b[1m"); // bold name
+ expect(tools).toContain("\u001b[36m"); // cyan params
+ expect(tools).toContain("\u001b[2m"); // dim description
+ expect(tools).toContain("echo");
+
+ const log = formatStreamEventHuman(
+ {
+ direction: "notification",
+ message: { params: { level: "error", data: "boom" } },
+ },
+ s,
+ );
+ expect(log).toContain("\u001b[31m");
+ expect(log).toContain("boom");
+ });
+
+ it("hyperlinks only allowlisted schemes as OSC 8", () => {
+ const s = createStyle(true);
+ const out = formatResourcesHuman(
+ [
+ { uri: "https://example.com/r", name: "web" },
+ { uri: "file:///etc/passwd", name: "local" },
+ { uri: "vscode://malicious/payload", name: "custom" },
+ ],
+ s,
+ );
+ // https renders as a clickable link; file:/custom-handler URIs must not
+ // invite the terminal to invoke a local protocol handler.
+ expect(out).toContain("\u001b]8;;https://example.com/r");
+ expect(out).not.toContain("]8;;file://");
+ expect(out).not.toContain("]8;;vscode://");
+ expect(out).toContain("file:///etc/passwd");
+ expect(out).toContain("vscode://malicious/payload");
+ });
+});
diff --git a/clients/daemon-cli/__tests__/helpers/mcp-runner.ts b/clients/daemon-cli/__tests__/helpers/mcp-runner.ts
new file mode 100644
index 0000000000..3952aa6a1d
--- /dev/null
+++ b/clients/daemon-cli/__tests__/helpers/mcp-runner.ts
@@ -0,0 +1,88 @@
+import { runMcp as invokeMcp } from "../../src/connection/mcp.js";
+import { formatErrorOutput } from "@inspector/cli/error-handler.js";
+
+export interface McpResult {
+ exitCode: number | null;
+ stdout: string;
+ stderr: string;
+ output: string;
+}
+
+export interface McpOptions {
+ timeout?: number;
+ env?: Record;
+}
+
+type WriteArgs = [
+ chunk: unknown,
+ encoding?: unknown,
+ callback?: (() => void) | undefined,
+];
+
+function captureWrite(append: (text: string) => void) {
+ return (...args: WriteArgs): boolean => {
+ const [chunk, encoding, callback] = args;
+ append(typeof chunk === "string" ? chunk : String(chunk));
+ const cb = typeof encoding === "function" ? encoding : callback;
+ if (typeof cb === "function") cb();
+ return true;
+ };
+}
+
+/**
+ * In-process runner for `runMcp` (connection 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", "mcpdo", ...args];
+ const timeoutMs = options.timeout ?? 15000;
+ let timer: ReturnType | undefined;
+ const timeout = new Promise((_, reject) => {
+ timer = setTimeout(
+ () => reject(new Error(`mcpdo 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/daemon-cli/__tests__/hoist-connection.test.ts b/clients/daemon-cli/__tests__/hoist-connection.test.ts
new file mode 100644
index 0000000000..13dedf89a8
--- /dev/null
+++ b/clients/daemon-cli/__tests__/hoist-connection.test.ts
@@ -0,0 +1,79 @@
+import { describe, it, expect } from "vitest";
+import { hoistAtConnection } from "../src/connection/dispatch.js";
+import { expandConnAlias } from "../src/connection/mcp.js";
+
+describe("hoistAtConnection", () => {
+ it("lifts a leading @name into connectionFromAt", () => {
+ const { argv, connectionFromAt } = hoistAtConnection([
+ "node",
+ "mcpdo",
+ "@alpha",
+ "tools/list",
+ "--format",
+ "json",
+ ]);
+ expect(connectionFromAt).toBe("alpha");
+ expect(argv).toEqual(["node", "mcpdo", "tools/list", "--format", "json"]);
+ });
+
+ it("leaves argv unchanged when there is no @name", () => {
+ const input = ["node", "mcpdo", "tools/list"];
+ expect(hoistAtConnection(input)).toEqual({ argv: input });
+ });
+});
+
+describe("expandConnAlias", () => {
+ it("expands --conn and --conn= to --connection forms", () => {
+ expect(
+ expandConnAlias(["node", "mcpdo", "--conn", "alpha", "tools/list"]),
+ ).toEqual(["node", "mcpdo", "--connection", "alpha", "tools/list"]);
+ expect(expandConnAlias(["node", "mcpdo", "--conn=alpha"])).toEqual([
+ "node",
+ "mcpdo",
+ "--connection=alpha",
+ ]);
+ });
+
+ it("leaves --connection, --config, and other args unchanged", () => {
+ const input = [
+ "node",
+ "mcpdo",
+ "--connection",
+ "alpha",
+ "--config",
+ "x.json",
+ "--connect-timeout",
+ "5",
+ ];
+ expect(expandConnAlias(input)).toEqual(input);
+ });
+
+ it("passes tokens after -- through verbatim (child-process args)", () => {
+ expect(
+ expandConnAlias([
+ "node",
+ "mcpdo",
+ "--conn",
+ "alpha",
+ "connect",
+ "srv",
+ "--",
+ "--conn=value",
+ "--conn",
+ ]),
+ ).toEqual([
+ "node",
+ "mcpdo",
+ "--connection",
+ "alpha",
+ "connect",
+ "srv",
+ "--",
+ "--conn=value",
+ "--conn",
+ ]);
+ // Only the first separator ends expansion; later ones are child args too.
+ const onlyAfter = ["node", "mcpdo", "--", "--conn", "--", "--conn=x"];
+ expect(expandConnAlias(onlyAfter)).toEqual(onlyAfter);
+ });
+});
diff --git a/clients/daemon-cli/__tests__/mcp-auth-coverage.test.ts b/clients/daemon-cli/__tests__/mcp-auth-coverage.test.ts
new file mode 100644
index 0000000000..5f245265f5
--- /dev/null
+++ b/clients/daemon-cli/__tests__/mcp-auth-coverage.test.ts
@@ -0,0 +1,423 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+import {
+ createSampleTestConfig,
+ deleteConfigFile,
+} from "../../cli/__tests__/helpers/fixtures.js";
+import { CliExitCodeError, EXIT_CODES } from "@inspector/cli/error-handler.js";
+
+const callDaemon = vi.fn();
+const ensureDaemon = vi.fn();
+const authorizeInFrontend = vi.fn();
+const obtainPendingAuthUrl = vi.fn();
+
+vi.mock("../src/daemon/index.js", () => ({
+ callDaemon: (...args: unknown[]) => callDaemon(...args),
+ ensureDaemon: (...args: unknown[]) => ensureDaemon(...args),
+ streamDaemon: vi.fn(),
+}));
+
+vi.mock("../src/connection/authorize.js", () => ({
+ authorizeInFrontend: (...args: unknown[]) => authorizeInFrontend(...args),
+}));
+
+vi.mock("../src/connection/auth-helper.js", () => ({
+ AUTH_HELPER_COMMAND: "auth/complete-signin",
+ runAuthHelper: vi.fn(),
+ obtainPendingAuthUrl: (...args: unknown[]) => obtainPendingAuthUrl(...args),
+}));
+
+describe("mcp.ts auth / daemon error paths", () => {
+ let configPath: string | undefined;
+ let stdout: string;
+ let originalStdoutWrite: typeof process.stdout.write;
+ let originalStderrWrite: typeof process.stderr.write;
+
+ beforeEach(() => {
+ stdout = "";
+ originalStdoutWrite = process.stdout.write;
+ originalStderrWrite = process.stderr.write;
+ process.stdout.write = ((chunk: unknown, ...rest: unknown[]) => {
+ stdout += typeof chunk === "string" ? chunk : String(chunk);
+ const cb = rest.find((r) => typeof r === "function") as
+ | (() => void)
+ | undefined;
+ cb?.();
+ return true;
+ }) as typeof process.stdout.write;
+ process.stderr.write = ((chunk: unknown, ...rest: unknown[]) => {
+ const cb = rest.find((r) => typeof r === "function") as
+ | (() => void)
+ | undefined;
+ cb?.();
+ return true;
+ }) as typeof process.stderr.write;
+
+ ensureDaemon.mockReset();
+ ensureDaemon.mockResolvedValue({ socketPath: "/tmp/mcp-auth-cov.sock" });
+ callDaemon.mockReset();
+ authorizeInFrontend.mockReset();
+ authorizeInFrontend.mockResolvedValue(undefined);
+ obtainPendingAuthUrl.mockReset();
+ });
+
+ const originalStderrIsTTY = process.stderr.isTTY;
+ const originalStdinIsTTY = process.stdin.isTTY;
+
+ afterEach(() => {
+ process.stderr.isTTY = originalStderrIsTTY;
+ process.stdin.isTTY = originalStdinIsTTY;
+ process.stdout.write = originalStdoutWrite;
+ process.stderr.write = originalStderrWrite;
+ if (configPath) {
+ deleteConfigFile(configPath);
+ configPath = undefined;
+ }
+ });
+
+ it("connect --ema overlays enterpriseManaged onto the resolved settings", async () => {
+ configPath = createSampleTestConfig();
+ callDaemon.mockResolvedValueOnce({
+ name: "test-stdio",
+ isMru: true,
+ serverIdentity: "stdio",
+ });
+
+ const { runMcp } = await import("../src/connection/mcp.js");
+ await runMcp([
+ "node",
+ "mcpdo",
+ "connect",
+ "test-stdio",
+ "--config",
+ configPath,
+ "--ema",
+ "--format",
+ "json",
+ ]);
+
+ const connectCall = callDaemon.mock.calls.find((c) => c[0] === "connect");
+ const params = connectCall?.[1] as {
+ serverSettings?: { enterpriseManaged?: boolean };
+ };
+ expect(params.serverSettings?.enterpriseManaged).toBe(true);
+ });
+
+ it("retries connect after auth_required via authorizeInFrontend", async () => {
+ process.stderr.isTTY = true; // human path: blocking interactive OAuth
+ configPath = createSampleTestConfig();
+ const connection = {
+ name: "test-stdio",
+ isMru: true,
+ serverIdentity: "stdio",
+ };
+ callDaemon
+ .mockRejectedValueOnce(
+ new CliExitCodeError(EXIT_CODES.AUTH_REQUIRED, "need auth", {
+ code: "auth_required",
+ }),
+ )
+ .mockResolvedValueOnce(connection);
+
+ const { runMcp } = await import("../src/connection/mcp.js");
+ await runMcp([
+ "node",
+ "mcpdo",
+ "connect",
+ "test-stdio",
+ "--config",
+ configPath,
+ "--format",
+ "json",
+ ]);
+
+ expect(authorizeInFrontend).toHaveBeenCalledOnce();
+ expect(callDaemon).toHaveBeenCalledTimes(2);
+ expect(JSON.parse(stdout.trim()).name).toBe("test-stdio");
+ });
+
+ it("re-ensures the daemon after authorizeInFrontend, in case interactive OAuth outlasted its idle timeout", async () => {
+ process.stderr.isTTY = true; // human path: blocking interactive OAuth
+ configPath = createSampleTestConfig();
+ const connection = {
+ name: "test-stdio",
+ isMru: true,
+ serverIdentity: "stdio",
+ };
+ callDaemon
+ .mockRejectedValueOnce(
+ new CliExitCodeError(EXIT_CODES.AUTH_REQUIRED, "need auth", {
+ code: "auth_required",
+ }),
+ )
+ .mockResolvedValueOnce(connection);
+ // Simulate the pre-auth daemon having idled out while OAuth ran: the
+ // retry's ensureDaemon() call returns a different (freshly respawned)
+ // socket than the one used for the first attempt.
+ ensureDaemon
+ .mockResolvedValueOnce({ socketPath: "/tmp/mcp-auth-cov-stale.sock" })
+ .mockResolvedValueOnce({ socketPath: "/tmp/mcp-auth-cov-fresh.sock" });
+
+ const { runMcp } = await import("../src/connection/mcp.js");
+ await runMcp([
+ "node",
+ "mcpdo",
+ "connect",
+ "test-stdio",
+ "--config",
+ configPath,
+ "--format",
+ "json",
+ ]);
+
+ expect(ensureDaemon).toHaveBeenCalledTimes(2);
+ expect(callDaemon).toHaveBeenCalledTimes(2);
+ expect(callDaemon.mock.calls[0][2]).toMatchObject({
+ socketPath: "/tmp/mcp-auth-cov-stale.sock",
+ });
+ expect(callDaemon.mock.calls[1][2]).toMatchObject({
+ socketPath: "/tmp/mcp-auth-cov-fresh.sock",
+ });
+ });
+
+ it("non-TTY connect on auth_required: hands off to the helper, registers a pending entry, and prints the auth URL", async () => {
+ // Agent path: no TTY on stdin or stderr.
+ process.stdin.isTTY = undefined as unknown as boolean;
+ process.stderr.isTTY = undefined as unknown as boolean;
+ configPath = createSampleTestConfig();
+ callDaemon
+ .mockRejectedValueOnce(
+ new CliExitCodeError(EXIT_CODES.AUTH_REQUIRED, "need auth", {
+ code: "auth_required",
+ }),
+ )
+ .mockResolvedValueOnce({
+ name: "test-stdio",
+ isMru: true,
+ serverIdentity: "stdio",
+ pendingAuth: true,
+ auth: { method: "oauth", authorized: false },
+ });
+ obtainPendingAuthUrl.mockResolvedValueOnce(
+ "https://as.example/authorize?client_id=abc&state=xyz",
+ );
+
+ const { runMcp } = await import("../src/connection/mcp.js");
+ await runMcp([
+ "node",
+ "mcpdo",
+ "connect",
+ "test-stdio",
+ "--config",
+ configPath,
+ "--format",
+ "json",
+ ]);
+
+ // Never the blocking interactive flow on the agent path.
+ expect(authorizeInFrontend).not.toHaveBeenCalled();
+ expect(obtainPendingAuthUrl).toHaveBeenCalledOnce();
+ // The re-dial carries the pending-intent flag.
+ const second = callDaemon.mock.calls[1];
+ expect(second[0]).toBe("connect");
+ expect(second[1]).toMatchObject({ pendingOnAuthRequired: true });
+ // The auth URL rides the normal JSON payload, query string intact
+ // (the error envelope would redact it).
+ const out = JSON.parse(stdout.trim()) as Record;
+ expect(out.pendingAuth).toBe(true);
+ expect(out.authUrl).toBe(
+ "https://as.example/authorize?client_id=abc&state=xyz",
+ );
+ });
+
+ it("non-TTY connect omits authUrl when the pending re-dial actually connected (sign-in already finished)", async () => {
+ process.stdin.isTTY = undefined as unknown as boolean;
+ process.stderr.isTTY = undefined as unknown as boolean;
+ configPath = createSampleTestConfig();
+ callDaemon
+ .mockRejectedValueOnce(
+ new CliExitCodeError(EXIT_CODES.AUTH_REQUIRED, "need auth", {
+ code: "auth_required",
+ }),
+ )
+ .mockResolvedValueOnce({
+ name: "test-stdio",
+ isMru: true,
+ serverIdentity: "stdio",
+ auth: { method: "oauth", authorized: true },
+ });
+ obtainPendingAuthUrl.mockResolvedValueOnce("https://as.example/authorize");
+
+ const { runMcp } = await import("../src/connection/mcp.js");
+ await runMcp([
+ "node",
+ "mcpdo",
+ "connect",
+ "test-stdio",
+ "--config",
+ configPath,
+ "--format",
+ "json",
+ ]);
+
+ const out = JSON.parse(stdout.trim()) as Record;
+ expect(out.pendingAuth).toBeUndefined();
+ expect(out.authUrl).toBeUndefined();
+ });
+
+ it("MCP_AUTO_OPEN_ENABLED=true keeps the blocking interactive flow even without a TTY", async () => {
+ process.stdin.isTTY = undefined as unknown as boolean;
+ process.stderr.isTTY = undefined as unknown as boolean;
+ const prev = process.env.MCP_AUTO_OPEN_ENABLED;
+ process.env.MCP_AUTO_OPEN_ENABLED = "true";
+ configPath = createSampleTestConfig();
+ callDaemon
+ .mockRejectedValueOnce(
+ new CliExitCodeError(EXIT_CODES.AUTH_REQUIRED, "need auth", {
+ code: "auth_required",
+ }),
+ )
+ .mockResolvedValueOnce({
+ name: "test-stdio",
+ isMru: true,
+ serverIdentity: "stdio",
+ });
+
+ try {
+ const { runMcp } = await import("../src/connection/mcp.js");
+ await runMcp([
+ "node",
+ "mcpdo",
+ "connect",
+ "test-stdio",
+ "--config",
+ configPath,
+ "--format",
+ "json",
+ ]);
+ expect(authorizeInFrontend).toHaveBeenCalledOnce();
+ expect(obtainPendingAuthUrl).not.toHaveBeenCalled();
+ } finally {
+ if (prev === undefined) delete process.env.MCP_AUTO_OPEN_ENABLED;
+ else process.env.MCP_AUTO_OPEN_ENABLED = prev;
+ }
+ });
+
+ it("rejects --relogin with --stored-auth-only", async () => {
+ configPath = createSampleTestConfig();
+ const { runMcp } = await import("../src/connection/mcp.js");
+ await expect(
+ runMcp([
+ "node",
+ "mcpdo",
+ "--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/connection/mcp.js");
+ await runMcp([
+ "node",
+ "mcpdo",
+ "connect",
+ "--connection",
+ "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/connection/mcp.js");
+ await expect(
+ runMcp([
+ "node",
+ "mcpdo",
+ "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/connection/mcp.js");
+ await expect(
+ runMcp(["node", "mcpdo", "daemon", "stop", "--format", "json"]),
+ ).rejects.toMatchObject({
+ exitCode: EXIT_CODES.USAGE,
+ envelope: { code: "usage" },
+ });
+ });
+});
diff --git a/clients/daemon-cli/__tests__/mcp-connection.test.ts b/clients/daemon-cli/__tests__/mcp-connection.test.ts
new file mode 100644
index 0000000000..7453b05359
--- /dev/null
+++ b/clients/daemon-cli/__tests__/mcp-connection.test.ts
@@ -0,0 +1,224 @@
+import { describe, it, expect, afterEach, beforeAll } from "vitest";
+import * as fs from "node:fs";
+import * as os from "node:os";
+import * as path from "node:path";
+import { runMcp } from "./helpers/mcp-runner.js";
+import { runCli } from "../../cli/__tests__/helpers/cli-runner.js";
+import {
+ createSampleTestConfig,
+ deleteConfigFile,
+} from "../../cli/__tests__/helpers/fixtures.js";
+import { expectCliSuccess } from "../../cli/__tests__/helpers/assertions.js";
+import { resolveDaemonScriptPath } from "../src/daemon/ensure.js";
+import { callDaemon } from "../src/daemon/client.js";
+
+describe("mcp connection 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-connection-"));
+ return {
+ MCP_STORAGE_DIR: storageDir,
+ MCP_INSPECTOR_DAEMON_DIR: storageDir,
+ MCP_ALLOW_DEFAULT_CONNECTION: "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 connections, 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 connection = JSON.parse(connected.stdout) as {
+ name: string;
+ isMru: boolean;
+ };
+ expect(connection.name).toBe("test-stdio");
+ expect(connection.isMru).toBe(true);
+
+ const listed = await runMcp(["connections/list", "--format", "json"], {
+ env: e,
+ });
+ expectCliSuccess(listed);
+ const connections = JSON.parse(listed.stdout) as {
+ connections: { name: string; isMru: boolean }[];
+ };
+ expect(connections.connections).toHaveLength(1);
+ expect(connections.connections[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;
+ connection?: string;
+ isMru?: boolean;
+ }[];
+ };
+ const stdio = serverBody.servers.find((s) => s.name === "test-stdio");
+ expect(stdio?.connection).toBe("test-stdio");
+ expect(stdio?.isMru).toBe(true);
+ expect(
+ serverBody.servers.find((s) => s.name === "test-http")?.connection,
+ ).toBeUndefined();
+
+ const disc = await runMcp(
+ ["disconnect", "--connection", "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 connection 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 connections/show over a live connection", 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:=connection", "--format", "json"],
+ { env: e, timeout: 20000 },
+ );
+ expectCliSuccess(called);
+
+ const calledJson = await runMcp(
+ [
+ "tools/call",
+ "echo",
+ '{"message":"connection-json"}',
+ "--format",
+ "json",
+ ],
+ { env: e, timeout: 20000 },
+ );
+ expectCliSuccess(calledJson);
+
+ const resources = await runMcp(["resources/list", "--format", "json"], {
+ env: e,
+ timeout: 20000,
+ });
+ expectCliSuccess(resources);
+
+ const shown = await runMcp(
+ ["@test-stdio", "connections/show", "--format", "json"],
+ { env: e, timeout: 20000 },
+ );
+ expectCliSuccess(shown);
+ const shownBody = JSON.parse(shown.stdout) as {
+ name?: string;
+ serverInfo?: { name?: string };
+ protocolVersion?: string;
+ protocolEra?: string;
+ };
+ expect(shownBody.protocolVersion).toBeTruthy();
+ expect(shownBody.protocolEra).toBeTruthy();
+
+ // `connections/show ` (positional, no `@name`/--connection) exercises
+ // the opts.connection-absent fallback to the command's own argument.
+ const shownByArg = await runMcp(
+ ["connections/show", "test-stdio", "--format", "json"],
+ { env: e, timeout: 20000 },
+ );
+ expectCliSuccess(shownByArg);
+
+ await runMcp(
+ ["disconnect", "--connection", "test-stdio", "--format", "json"],
+ {
+ env: e,
+ },
+ );
+ await runMcp(["daemon", "stop", "--format", "json"], { env: e });
+ });
+});
diff --git a/clients/daemon-cli/__tests__/mcp-coverage.test.ts b/clients/daemon-cli/__tests__/mcp-coverage.test.ts
new file mode 100644
index 0000000000..7bfa305025
--- /dev/null
+++ b/clients/daemon-cli/__tests__/mcp-coverage.test.ts
@@ -0,0 +1,560 @@
+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,
+ createTestConfig,
+ deleteConfigFile,
+} from "../../cli/__tests__/helpers/fixtures.js";
+import {
+ expectCliSuccess,
+ expectCliFailure,
+} from "../../cli/__tests__/helpers/assertions.js";
+import { resolveDaemonScriptPath } from "../src/daemon/ensure.js";
+import { callDaemon } from "../src/daemon/client.js";
+
+describe("mcp.ts coverage", () => {
+ let configPath: string | undefined;
+ let storageDir: string | undefined;
+
+ beforeAll(() => {
+ expect(fs.existsSync(resolveDaemonScriptPath())).toBe(true);
+ });
+
+ afterEach(async () => {
+ if (storageDir) {
+ const socketPath = path.join(storageDir, "daemon.sock");
+ if (fs.existsSync(socketPath)) {
+ try {
+ await callDaemon("daemon/stop", {}, { socketPath, timeoutMs: 2000 });
+ } catch {
+ // already stopped
+ }
+ const deadline = Date.now() + 2000;
+ while (fs.existsSync(socketPath) && Date.now() < deadline) {
+ await new Promise((r) => setTimeout(r, 50));
+ }
+ }
+ fs.rmSync(storageDir, { recursive: true, force: true });
+ storageDir = undefined;
+ }
+ if (configPath) {
+ deleteConfigFile(configPath);
+ configPath = undefined;
+ }
+ });
+
+ function env(): Record {
+ storageDir = fs.mkdtempSync(path.join(os.tmpdir(), "mcp-cov-"));
+ return {
+ MCP_STORAGE_DIR: storageDir,
+ MCP_INSPECTOR_DAEMON_DIR: storageDir,
+ MCP_ALLOW_DEFAULT_CONNECTION: "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=connection-cov",
+ "--metadata",
+ "count=1",
+ // Object value must JSON.stringify (not String → "[object Object]").
+ "--metadata",
+ 'nested={"a":1}',
+ "--plain",
+ "--format",
+ "json",
+ ],
+ { env: e, timeout: 20000 },
+ );
+ expectCliSuccess(withMeta);
+
+ const badMeta = await runMcp(["tools/list", "--metadata", "novalue"], {
+ env: e,
+ });
+ expectCliFailure(badMeta);
+
+ const emptyMeta = await runMcp(["tools/list", "--metadata", "k="], {
+ env: e,
+ });
+ expectCliFailure(emptyMeta);
+
+ const read = await runMcp(
+ [
+ "resources/read",
+ "demo://resource/static/document/architecture.md",
+ "--format",
+ "json",
+ ],
+ { env: e, timeout: 20000 },
+ );
+ expectCliSuccess(read);
+
+ // Same Commander action as subscribe (uri positional / --uri); prefer
+ // unsubscribe so we don't open a long-lived stream in this suite.
+ const unsub = await runMcp(
+ ["resources/unsubscribe", "test://env", "--format", "json"],
+ { env: e, timeout: 20000 },
+ );
+ // Default test server does not advertise subscriptions.
+ expectCliFailure(unsub);
+ expect(unsub.stderr).toMatch(/unsubscribe|Method not found/i);
+
+ const prompt = await runMcp(
+ [
+ "prompts/get",
+ "simple_prompt",
+ "--prompt-args",
+ "unused=1",
+ "--format",
+ "json",
+ ],
+ { env: e, timeout: 20000 },
+ );
+ expectCliSuccess(prompt);
+
+ const completeBad = await runMcp(
+ ["prompts/complete", "--complete-ref-type", "nope"],
+ { env: e },
+ );
+ expectCliFailure(completeBad);
+
+ const complete = await runMcp(
+ [
+ "prompts/complete",
+ "--complete-ref-type",
+ "ref/prompt",
+ "--complete-ref",
+ "simple_prompt",
+ "--complete-arg-name",
+ "name",
+ "--complete-arg-value",
+ "s",
+ "--format",
+ "json",
+ ],
+ { env: e, timeout: 20000 },
+ );
+ // Completion support varies; assert the command ran (not a usage parse error).
+ expect(complete.stderr).not.toMatch(/complete-ref-type/);
+ expect([0, 1]).toContain(complete.exitCode);
+
+ const logOk = await runMcp(
+ ["logging/setLevel", "debug", "--format", "json"],
+ { env: e, timeout: 20000 },
+ );
+ expectCliSuccess(logOk);
+
+ const logBad = await runMcp(["logging/setLevel", "--log-level", "nope"], {
+ env: e,
+ });
+ expectCliFailure(logBad);
+
+ const taskGet = await runMcp(
+ ["tasks/get", "missing-task", "--format", "json"],
+ {
+ env: e,
+ timeout: 20000,
+ },
+ );
+ expectCliFailure(taskGet);
+
+ const taskCancel = await runMcp(
+ ["tasks/cancel", "--task-id", "missing-task", "--format", "json"],
+ { env: e, timeout: 20000 },
+ );
+ expectCliFailure(taskCancel);
+
+ const taskResult = await runMcp(
+ ["tasks/result", "missing-task", "--format", "json"],
+ { env: e, timeout: 20000 },
+ );
+ expectCliFailure(taskResult);
+
+ const taskUpdateNoBody = await runMcp(
+ ["tasks/update", "missing-task", "--format", "json"],
+ { env: e, timeout: 20000 },
+ );
+ expectCliFailure(taskUpdateNoBody);
+ expect(taskUpdateNoBody.stderr).toMatch(/--input-responses/);
+
+ const taskUpdateBadJson = await runMcp(
+ [
+ "tasks/update",
+ "missing-task",
+ "--input-responses",
+ "not-json",
+ "--format",
+ "json",
+ ],
+ { env: e, timeout: 20000 },
+ );
+ expectCliFailure(taskUpdateBadJson);
+ expect(taskUpdateBadJson.stderr).toMatch(/--input-responses is invalid/);
+
+ const taskUpdate = await runMcp(
+ [
+ "tasks/update",
+ "missing-task",
+ "--input-responses",
+ '{"req-1":"answer"}',
+ "--format",
+ "json",
+ ],
+ { env: e, timeout: 20000 },
+ );
+ expectCliFailure(taskUpdate);
+
+ const roots = await runMcp(
+ ["roots/set", "--roots-json", "[]", "--format", "json"],
+ { env: e, timeout: 20000 },
+ );
+ expectCliSuccess(roots);
+
+ const called = await runMcp(
+ [
+ "tools/call",
+ "--tool-name",
+ "echo",
+ "--tool-arg",
+ "message=cov",
+ "--tool-metadata",
+ "src=test",
+ "--format",
+ "json",
+ ],
+ { env: e, timeout: 20000 },
+ );
+ expectCliSuccess(called);
+
+ const templates = await runMcp(
+ ["resources/templates/list", "--format", "json"],
+ { env: e, timeout: 20000 },
+ );
+ expectCliSuccess(templates);
+
+ const prompts = await runMcp(["prompts/list", "--format", "json"], {
+ env: e,
+ timeout: 20000,
+ });
+ expectCliSuccess(prompts);
+
+ const tasks = await runMcp(["tasks/list", "--format", "json"], {
+ env: e,
+ timeout: 20000,
+ });
+ expectCliSuccess(tasks);
+ expect(JSON.parse(tasks.stdout)).toHaveProperty("tasks");
+
+ const rootsList = await runMcp(["roots/list", "--format", "json"], {
+ env: e,
+ timeout: 20000,
+ });
+ expectCliSuccess(rootsList);
+ expect(JSON.parse(rootsList.stdout)).toHaveProperty("roots");
+
+ const show = await runMcp(
+ [
+ "servers/show",
+ "test-stdio",
+ "--config",
+ configPath,
+ "--format",
+ "json",
+ ],
+ { env: e, timeout: 20000 },
+ );
+ expectCliSuccess(show);
+ // Entry provenance rides along, mirroring servers/list.
+ expect(JSON.parse(show.stdout).source).toMatchObject({ kind: "config" });
+
+ // No name: falls back to the MRU connection's entry (test-stdio is
+ // connected above and MCP_ALLOW_DEFAULT_CONNECTION opts non-TTY in).
+ const showMru = await runMcp(
+ ["servers/show", "--config", configPath, "--format", "json"],
+ { env: e, timeout: 20000 },
+ );
+ expectCliSuccess(showMru);
+ expect(JSON.parse(showMru.stdout).name).toBe("test-stdio");
+
+ // MRU-inferred name missing from the shell's source: the error must
+ // explain the name came from the MRU connection, not look like a typo.
+ const otherConfig = createTestConfig({
+ mcpServers: { unrelated: { type: "stdio", command: "true" } },
+ });
+ try {
+ const crossCatalog = await runMcp(
+ ["servers/show", "--config", otherConfig],
+ { env: e, timeout: 20000 },
+ );
+ expectCliFailure(crossCatalog);
+ expect(crossCatalog.stderr).toMatch(
+ /most-recently-used connection 'test-stdio' has no entry in config/,
+ );
+ } finally {
+ deleteConfigFile(otherConfig);
+ }
+
+ // Skills support is optional; the default test server may not advertise
+ // it. Either way, the RPC action itself should run (not a usage error).
+ const skillsList = await runMcp(["skills/list", "--format", "json"], {
+ env: e,
+ timeout: 20000,
+ });
+ expect([0, 1]).toContain(skillsList.exitCode);
+
+ const skillsListVerify = await runMcp(
+ ["skills/list", "--verify", "--format", "json"],
+ { env: e, timeout: 20000 },
+ );
+ expect([0, 1]).toContain(skillsListVerify.exitCode);
+
+ const skillsGet = await runMcp(
+ ["skills/get", "test://skill", "--verify", "--format", "json"],
+ { env: e, timeout: 20000 },
+ );
+ expect([0, 1]).toContain(skillsGet.exitCode);
+
+ const skillsGetFlagUri = await runMcp(
+ ["skills/get", "--uri", "test://skill", "--format", "json"],
+ { env: e, timeout: 20000 },
+ );
+ expect([0, 1]).toContain(skillsGetFlagUri.exitCode);
+
+ await runMcp(
+ ["disconnect", "--connection", "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",
+ "--connection",
+ "opts",
+ "--transport",
+ "stdio",
+ "--cwd",
+ process.cwd(),
+ "-e",
+ "COV_FLAG=1",
+ "--connect-timeout",
+ "15000",
+ "--era",
+ "auto",
+ "--elicit",
+ "url",
+ "--format",
+ "json",
+ command,
+ ...args,
+ ],
+ { env: e, timeout: 20000 },
+ );
+ expectCliSuccess(adHoc);
+
+ // --ema is rejected for ad-hoc targets: EMA needs per-server OAuth
+ // client id/secret, which only a catalog entry can supply.
+ const emaAdHoc = await runMcp(
+ ["connect", "--ema", "--format", "json", command, ...args],
+ { env: e, timeout: 20000 },
+ );
+ expectCliFailure(emaAdHoc);
+ expect(emaAdHoc.stderr).toMatch(/--ema cannot be used with an ad-hoc/);
+
+ // Invalid --era is rejected before any connection is attempted.
+ const badEra = await runMcp(
+ ["connect", "--era", "bogus", "--format", "json", command, ...args],
+ { env: e, timeout: 20000 },
+ );
+ expectCliFailure(badEra);
+ expect(badEra.stderr).toMatch(/Invalid --era/);
+
+ // Invalid --elicit is rejected before any connection is attempted.
+ const badElicit = await runMcp(
+ ["connect", "--elicit", "bogus", "--format", "json", command, ...args],
+ { env: e, timeout: 20000 },
+ );
+ expectCliFailure(badElicit);
+ expect(badElicit.stderr).toMatch(/Invalid --elicit/);
+
+ await runMcp(["disconnect", "--connection", "opts", "--format", "json"], {
+ env: e,
+ });
+
+ // Ad-hoc HTTP with --server-url and no positional rest (empty-rest branch).
+ const urlOnly = await runMcp(
+ [
+ "connect",
+ "--connection",
+ "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("treats a single path-like token as ad-hoc stdio, a bare word as a catalog name", async () => {
+ configPath = createSampleTestConfig();
+ const e = { ...env(), MCP_CATALOG_PATH: configPath };
+
+ // Bare word: catalog/config lookup — fails as a catalog miss, before
+ // any daemon or spawn work.
+ const bareWord = await runMcp(
+ ["connect", "no-such-catalog-entry", "--format", "json"],
+ { env: e, timeout: 20000 },
+ );
+ expectCliFailure(bareWord);
+ expect(bareWord.stderr).toMatch(/not found/i);
+
+ // Path-like token: ad-hoc stdio target — never touches the catalog, so
+ // the failure is a spawn/connect failure, not a catalog miss.
+ const pathToken = await runMcp(
+ ["connect", "./no-such-server-binary", "--format", "json"],
+ { env: e, timeout: 20000 },
+ );
+ expectCliFailure(pathToken);
+ expect(pathToken.stderr).not.toMatch(/not found\. Available servers/);
+ });
+
+ it("bare mcpdo / --help print usage without an ErrorEnvelope", async () => {
+ // Bare invocation: Commander writes help to stderr (help-after-error).
+ const bare = await runMcp([]);
+ 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",
+ "mcpdo",
+ "servers/list",
+ "--config",
+ configPath,
+ "--format",
+ "json",
+ ];
+ try {
+ const { runMcp: invoke } = await import("../src/connection/mcp.js");
+ await invoke();
+ } finally {
+ process.argv = originalArgv;
+ }
+ });
+
+ it("servers/show without a name explains the MRU rules instead of a bare parse error", async () => {
+ configPath = createSampleTestConfig();
+ const e = env();
+
+ // Non-interactive without the default-connection opt-in: name required.
+ const strict = await runMcp(["servers/show", "--config", configPath], {
+ env: { ...e, MCP_ALLOW_DEFAULT_CONNECTION: "" },
+ timeout: 20000,
+ });
+ expectCliFailure(strict);
+ expect(strict.stderr).toMatch(/requires an entry name/);
+
+ // Opted in but nothing connected (no daemon): no MRU to infer from.
+ const noMru = await runMcp(["servers/show", "--config", configPath], {
+ env: e,
+ timeout: 20000,
+ });
+ expectCliFailure(noMru);
+ expect(noMru.stderr).toMatch(/no most-recently-used connection/);
+
+ // Explicit unknown name: keep the core message but say which file was
+ // searched, so a cross-catalog mismatch is self-explanatory.
+ const unknown = await runMcp(
+ ["servers/show", "no-such-entry", "--config", configPath],
+ { env: e, timeout: 20000 },
+ );
+ expectCliFailure(unknown);
+ expect(unknown.stderr).toMatch(/Server 'no-such-entry' not found/);
+ expect(unknown.stderr).toContain(`(config ${configPath}`);
+ });
+
+ it("connections/list and daemon status do not auto-spawn the daemon", async () => {
+ const e = env();
+ const listed = await runMcp(["connections/list", "--format", "json"], {
+ env: e,
+ });
+ expectCliSuccess(listed);
+ expect(JSON.parse(listed.stdout)).toEqual({ connections: [] });
+
+ 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/daemon-cli/__tests__/mcp-elicitation.test.ts b/clients/daemon-cli/__tests__/mcp-elicitation.test.ts
new file mode 100644
index 0000000000..17a0525ebf
--- /dev/null
+++ b/clients/daemon-cli/__tests__/mcp-elicitation.test.ts
@@ -0,0 +1,187 @@
+import { describe, it, expect, afterEach, beforeAll } from "vitest";
+import * as fs from "node:fs";
+import * as os from "node:os";
+import * as path from "node:path";
+import { fileURLToPath } from "node:url";
+import { runMcp } from "./helpers/mcp-runner.js";
+import {
+ expectCliSuccess,
+ expectCliFailure,
+} from "../../cli/__tests__/helpers/assertions.js";
+import { resolveDaemonScriptPath } from "../src/daemon/ensure.js";
+import { callDaemon } from "../src/daemon/client.js";
+import type { ElicitationPendingInfo } from "../src/daemon/protocol.js";
+
+/**
+ * End-to-end non-interactive elicitation: a real daemon, a real composable
+ * test server (stdio) whose `collect_elicitation` tool sends a legacy
+ * `elicitation/create` mid-call, a non-TTY front-end that gets the exchange
+ * parked (`elicitationPending`), and `elicitation/respond` resuming the call
+ * to its final result.
+ */
+describe("mcp non-interactive elicitation (e2e)", () => {
+ let storageDir: string | undefined;
+ let configPath: string | undefined;
+ const ttyDescriptors: Array<{
+ stream: NodeJS.ReadStream | NodeJS.WriteStream;
+ desc: PropertyDescriptor | undefined;
+ }> = [];
+
+ beforeAll(() => {
+ expect(fs.existsSync(resolveDaemonScriptPath())).toBe(true);
+ });
+
+ afterEach(async () => {
+ for (const { stream, desc } of ttyDescriptors.splice(0)) {
+ if (desc) Object.defineProperty(stream, "isTTY", desc);
+ }
+ if (storageDir) {
+ const socketPath = path.join(storageDir, "daemon.sock");
+ if (fs.existsSync(socketPath)) {
+ try {
+ await callDaemon("daemon/stop", {}, { socketPath, timeoutMs: 2000 });
+ } catch {
+ // already stopped
+ }
+ const deadline = Date.now() + 2000;
+ while (fs.existsSync(socketPath) && Date.now() < deadline) {
+ await new Promise((r) => setTimeout(r, 50));
+ }
+ }
+ fs.rmSync(storageDir, { recursive: true, force: true });
+ storageDir = undefined;
+ }
+ if (configPath) {
+ fs.rmSync(configPath, { force: true });
+ configPath = undefined;
+ }
+ });
+
+ /** runMcp is in-process: force the non-TTY (parking) path regardless of
+ * how vitest itself was launched. */
+ function stubNonTty(): void {
+ for (const stream of [process.stdin, process.stderr] as const) {
+ ttyDescriptors.push({
+ stream,
+ desc: Object.getOwnPropertyDescriptor(stream, "isTTY"),
+ });
+ Object.defineProperty(stream, "isTTY", {
+ configurable: true,
+ value: undefined,
+ });
+ }
+ }
+
+ function env(): Record {
+ storageDir = fs.mkdtempSync(path.join(os.tmpdir(), "mcp-elicit-e2e-"));
+ return {
+ MCP_STORAGE_DIR: storageDir,
+ MCP_INSPECTOR_DAEMON_DIR: storageDir,
+ MCP_ALLOW_DEFAULT_CONNECTION: "1",
+ };
+ }
+
+ function elicitServerArgs(): string[] {
+ const here = path.dirname(fileURLToPath(import.meta.url));
+ const serverScript = path.resolve(
+ here,
+ "../../../test-servers/build/server-composable.js",
+ );
+ expect(fs.existsSync(serverScript)).toBe(true);
+ configPath = path.join(
+ os.tmpdir(),
+ `elicit-server-${process.pid}-${Date.now()}.json`,
+ );
+ fs.writeFileSync(
+ configPath,
+ JSON.stringify({
+ serverInfo: { name: "elicit-e2e", version: "1.0.0" },
+ tools: [{ preset: "collect_elicitation" }],
+ transport: { type: "stdio" },
+ }),
+ );
+ return ["node", serverScript, "--config", configPath];
+ }
+
+ it("parks a legacy form elicitation and elicitation/respond resumes to the tool result", async () => {
+ const e = env();
+ stubNonTty();
+
+ const connected = await runMcp(
+ [
+ "connect",
+ "--connection",
+ "el",
+ "--transport",
+ "stdio",
+ "--format",
+ "json",
+ "--",
+ ...elicitServerArgs(),
+ ],
+ { env: e, timeout: 20000 },
+ );
+ expectCliSuccess(connected);
+
+ const parked = await runMcp(
+ [
+ "tools/call",
+ "collect_elicitation",
+ "message:=Pick a color",
+ 'schema:={"type":"object","properties":{"color":{"type":"string"}},"required":["color"]}',
+ "--format",
+ "json",
+ "--connection",
+ "el",
+ ],
+ { env: e, timeout: 20000 },
+ );
+ expectCliSuccess(parked);
+ const pending = JSON.parse(parked.stdout) as {
+ elicitationPending: ElicitationPendingInfo;
+ };
+ expect(pending.elicitationPending).toMatchObject({
+ connection: "el",
+ method: "tools/call",
+ toolName: "collect_elicitation",
+ mode: "form",
+ message: "Pick a color",
+ });
+ const id = pending.elicitationPending.elicitationId;
+ expect(id).toBeTruthy();
+
+ // The connection refuses new rpcs while the call is parked.
+ const blocked = await runMcp(
+ ["tools/list", "--format", "json", "--connection", "el"],
+ { env: e, timeout: 20000 },
+ );
+ expectCliFailure(blocked);
+ expect(blocked.output).toContain(`elicitation/respond ${id}`);
+
+ const done = await runMcp(
+ ["elicitation/respond", id, "color:=teal", "--format", "json"],
+ { env: e, timeout: 20000 },
+ );
+ expectCliSuccess(done);
+ expect(done.stdout).toContain("accept");
+ expect(done.stdout).toContain("teal");
+ }, 40000);
+
+ it("validates flag exclusivity before contacting the daemon", async () => {
+ const e = env();
+ stubNonTty();
+ const conflicting = await runMcp(
+ ["elicitation/respond", "e-1", "--done", "--cancel"],
+ { env: e, timeout: 10000 },
+ );
+ expectCliFailure(conflicting);
+ expect(conflicting.output).toContain("exactly one of");
+
+ const empty = await runMcp(["elicitation/respond", "e-1"], {
+ env: e,
+ timeout: 10000,
+ });
+ expectCliFailure(empty);
+ expect(empty.output).toContain("key:=value");
+ });
+});
diff --git a/clients/daemon-cli/__tests__/parse-tool-args.test.ts b/clients/daemon-cli/__tests__/parse-tool-args.test.ts
new file mode 100644
index 0000000000..8885c53897
--- /dev/null
+++ b/clients/daemon-cli/__tests__/parse-tool-args.test.ts
@@ -0,0 +1,157 @@
+import { describe, it, expect } from "vitest";
+import {
+ parseToolCallPositionals,
+ resolveToolCallArgs,
+} from "../src/connection/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('keeps a literal "__proto__" key as an own property, matching the inline-JSON path', () => {
+ // On a plain {} accumulator this key would hit the prototype setter and
+ // vanish while remapping the accumulator's prototype.
+ const out = parseToolCallPositionals(['__proto__:={"polluted":true}']);
+ expect(Object.getOwnPropertyNames(out)).toContain("__proto__");
+ expect(Object.getOwnPropertyDescriptor(out, "__proto__")?.value).toEqual({
+ polluted: true,
+ });
+ expect(({} as Record).polluted).toBeUndefined();
+ });
+
+ it("parses a single inline JSON object", () => {
+ expect(parseToolCallPositionals(['{"message":"Foo","count":2}'])).toEqual({
+ message: "Foo",
+ 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 non-finite numbers that JSON cannot represent", () => {
+ // 1e999 parses to Infinity; NDJSON serialization would send null.
+ expect(() => parseToolCallPositionals(["count:=1e999"])).toThrow(
+ /no JSON representation/,
+ );
+ expect(() => parseToolCallPositionals(["count:=-1e999"])).toThrow(
+ /no JSON representation/,
+ );
+ expect(() => parseToolCallPositionals(['{"count":1e999}'])).toThrow(
+ /no JSON representation/,
+ );
+ // Nested values are validated recursively.
+ expect(() => parseToolCallPositionals(['{"a":{"b":[1,2,1e999]}}'])).toThrow(
+ /no JSON representation/,
+ );
+ expect(() =>
+ resolveToolCallArgs({
+ toolNamePos: "echo",
+ toolArgsJson: '{"count":1e999}',
+ }),
+ ).toThrow(/no JSON representation/);
+ // Large-but-finite numbers still round-trip and are accepted.
+ expect(parseToolCallPositionals(["count:=1e308"])).toEqual({
+ count: 1e308,
+ });
+ });
+
+ it("rejects invalid --tool-args-json", () => {
+ expect(() =>
+ resolveToolCallArgs({
+ 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/daemon-cli/__tests__/pin-stdio-config.test.ts b/clients/daemon-cli/__tests__/pin-stdio-config.test.ts
new file mode 100644
index 0000000000..b4badadce6
--- /dev/null
+++ b/clients/daemon-cli/__tests__/pin-stdio-config.test.ts
@@ -0,0 +1,57 @@
+import { describe, it, expect } from "vitest";
+import * as path from "node:path";
+import { getDefaultEnvironment } from "@modelcontextprotocol/client/stdio";
+import type { StdioServerConfig } from "@inspector/core/mcp/types.js";
+import { pinStdioConfigToCaller } from "../src/connection/mcp.js";
+
+type StdioConfig = StdioServerConfig;
+
+describe("pinStdioConfigToCaller", () => {
+ it("returns non-stdio configs unchanged", () => {
+ const config = {
+ type: "streamable-http",
+ url: "https://example.com/mcp",
+ } as const;
+ expect(pinStdioConfigToCaller(config)).toBe(config);
+ });
+
+ it("pins a missing cwd to the caller's cwd and resolves a relative one", () => {
+ const base: StdioConfig = { type: "stdio", command: process.execPath };
+ expect(pinStdioConfigToCaller({ ...base }).cwd).toBe(process.cwd());
+ expect(pinStdioConfigToCaller({ ...base, cwd: "sub/dir" }).cwd).toBe(
+ path.resolve("sub/dir"),
+ );
+ });
+
+ it("treats a config without an explicit type as stdio", () => {
+ const pinned = pinStdioConfigToCaller({
+ command: process.execPath,
+ });
+ expect(pinned.cwd).toBe(process.cwd());
+ });
+
+ it("snapshots the caller's default environment under the configured env", () => {
+ const pinned = pinStdioConfigToCaller({
+ type: "stdio",
+ command: process.execPath,
+ env: { PATH: "/configured/bin", EXTRA: "1" },
+ });
+ const defaults: Record = getDefaultEnvironment();
+ // Configured values win over the snapshot...
+ expect(pinned.env).toMatchObject({ PATH: "/configured/bin", EXTRA: "1" });
+ // ...and every other default-inherited var is filled from THIS process,
+ // so the daemon's SDK transport never falls back to its own stale env.
+ for (const [key, value] of Object.entries(defaults)) {
+ if (key === "PATH") continue;
+ expect(pinned.env?.[key]).toBe(value);
+ }
+ });
+
+ it("resolves a bare command against the caller's PATH", () => {
+ const pinned = pinStdioConfigToCaller({
+ type: "stdio",
+ command: "node",
+ });
+ expect(path.isAbsolute(pinned.command)).toBe(true);
+ });
+});
diff --git a/clients/daemon-cli/__tests__/prompt-reader.test.ts b/clients/daemon-cli/__tests__/prompt-reader.test.ts
new file mode 100644
index 0000000000..2b3752e7fa
--- /dev/null
+++ b/clients/daemon-cli/__tests__/prompt-reader.test.ts
@@ -0,0 +1,101 @@
+import { describe, it, expect, afterEach } from "vitest";
+import { PassThrough } from "node:stream";
+import {
+ PromptReader,
+ getSharedPromptReader,
+ resetSharedPromptReader,
+} from "../src/connection/prompt-reader.js";
+
+/**
+ * Covers the persistent line-queue reader that backs interactive prompts:
+ * piped input buffered ahead of the questions (the `printf 'a\nb\n' |
+ * mcpdo tools/call …` case) must answer every question, and "close" must
+ * mean input *exhausted* (EOF and empty queue), not merely EOF.
+ */
+describe("PromptReader", () => {
+ let reader: PromptReader | undefined;
+
+ afterEach(() => {
+ reader?.dispose();
+ reader = undefined;
+ resetSharedPromptReader();
+ });
+
+ function make(): {
+ reader: PromptReader;
+ input: PassThrough;
+ output: () => string;
+ } {
+ const input = new PassThrough();
+ const out = new PassThrough();
+ let written = "";
+ out.on("data", (chunk) => {
+ written += String(chunk);
+ });
+ reader = new PromptReader(
+ input as unknown as NodeJS.ReadStream,
+ out as unknown as NodeJS.WriteStream,
+ );
+ return { reader, input, output: () => written };
+ }
+
+ it("answers sequential questions from one up-front piped burst", async () => {
+ const { reader, input, output } = make();
+ // All three answers arrive before any question is asked — the exact
+ // shape of `printf 'alice\n30\n\n' | mcpdo tools/call register`.
+ input.write("alice\n30\n\n");
+ await expect(reader.question("Name: ")).resolves.toBe("alice");
+ await expect(reader.question("Age: ")).resolves.toBe("30");
+ await expect(reader.question("Color: ")).resolves.toBe("");
+ expect(output()).toBe("Name: Age: Color: ");
+ });
+
+ it("resolves a pending question when its line arrives later", async () => {
+ const { reader, input } = make();
+ const pending = reader.question("Name: ");
+ input.write("octocat\n");
+ await expect(pending).resolves.toBe("octocat");
+ });
+
+ it("still answers from the queue after EOF, then reports exhaustion", async () => {
+ const { reader, input } = make();
+ let closed = 0;
+ reader.once("close", () => {
+ closed += 1;
+ });
+ input.end("alice\n");
+ // Give readline a beat to flush the final chunk and see EOF.
+ await expect(reader.question("Name: ")).resolves.toBe("alice");
+ // EOF alone must not have fired "close" while an answer was queued.
+ expect(closed).toBe(0);
+ await expect(reader.question("Age: ")).rejects.toThrow(/stdin closed/);
+ expect(closed).toBe(1);
+ // A listener registered after exhaustion fires immediately.
+ reader.once("close", () => {
+ closed += 1;
+ });
+ expect(closed).toBe(2);
+ // And later questions keep rejecting without hanging.
+ await expect(reader.question("More: ")).rejects.toThrow(/stdin closed/);
+ });
+
+ it("rejects a question pending at EOF and notifies close watchers", async () => {
+ const { reader, input } = make();
+ let closed = false;
+ reader.once("close", () => {
+ closed = true;
+ });
+ const pending = reader.question("Name: ");
+ input.end();
+ await expect(pending).rejects.toThrow(/stdin closed/);
+ expect(closed).toBe(true);
+ });
+
+ it("shares one process-wide reader, and reset disposes it", () => {
+ const first = getSharedPromptReader();
+ expect(getSharedPromptReader()).toBe(first);
+ resetSharedPromptReader();
+ const second = getSharedPromptReader();
+ expect(second).not.toBe(first);
+ });
+});
diff --git a/clients/daemon-cli/__tests__/resolve-command.test.ts b/clients/daemon-cli/__tests__/resolve-command.test.ts
new file mode 100644
index 0000000000..1b9b707c66
--- /dev/null
+++ b/clients/daemon-cli/__tests__/resolve-command.test.ts
@@ -0,0 +1,81 @@
+import fs from "node:fs";
+import os from "node:os";
+import path from "node:path";
+import { afterAll, describe, expect, it } from "vitest";
+import { resolveCommandPath } from "../src/connection/resolve-command.js";
+
+const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "mcp-conn-resolve-"));
+
+afterAll(() => {
+ fs.rmSync(tmpRoot, { recursive: true, force: true });
+});
+
+function makeExecutable(dir: string, name: string): string {
+ fs.mkdirSync(dir, { recursive: true });
+ const file = path.join(dir, name);
+ fs.writeFileSync(file, "#!/bin/sh\n", { mode: 0o755 });
+ return file;
+}
+
+describe("resolveCommandPath", () => {
+ it("resolves a bare name to the first executable on PATH", () => {
+ const first = path.join(tmpRoot, "first");
+ const second = path.join(tmpRoot, "second");
+ const expected = makeExecutable(first, "mytool");
+ makeExecutable(second, "mytool");
+ const env = { PATH: [first, second].join(path.delimiter) };
+ expect(resolveCommandPath("mytool", env)).toBe(expected);
+ });
+
+ it("skips PATH entries where the name is missing or not a file", () => {
+ const missing = path.join(tmpRoot, "missing");
+ const hasDir = path.join(tmpRoot, "has-dir");
+ fs.mkdirSync(path.join(hasDir, "mytool2"), { recursive: true });
+ const real = path.join(tmpRoot, "real");
+ const expected = makeExecutable(real, "mytool2");
+ const env = { PATH: [missing, hasDir, "", real].join(path.delimiter) };
+ expect(resolveCommandPath("mytool2", env)).toBe(expected);
+ });
+
+ it("skips non-executable files", () => {
+ const dir = path.join(tmpRoot, "non-exec");
+ fs.mkdirSync(dir, { recursive: true });
+ fs.writeFileSync(path.join(dir, "mytool3"), "", { mode: 0o644 });
+ const real = path.join(tmpRoot, "exec");
+ const expected = makeExecutable(real, "mytool3");
+ const env = { PATH: [dir, real].join(path.delimiter) };
+ expect(resolveCommandPath("mytool3", env)).toBe(expected);
+ });
+
+ it("returns commands with a path separator unchanged", () => {
+ expect(resolveCommandPath("./server.js", { PATH: tmpRoot })).toBe(
+ "./server.js",
+ );
+ expect(resolveCommandPath("/usr/bin/env", { PATH: tmpRoot })).toBe(
+ "/usr/bin/env",
+ );
+ });
+
+ it("returns the name unchanged when not found on PATH", () => {
+ const env = { PATH: path.join(tmpRoot, "empty-dir") };
+ expect(resolveCommandPath("definitely-not-a-real-tool", env)).toBe(
+ "definitely-not-a-real-tool",
+ );
+ });
+
+ it("handles an empty command and an unset PATH", () => {
+ expect(resolveCommandPath("", { PATH: tmpRoot })).toBe("");
+ expect(resolveCommandPath("mytool", {})).toBe("mytool");
+ });
+
+ it("defaults to process.env", () => {
+ // `sh` exists on every POSIX PATH; on Windows this still exercises the
+ // default-env branch even if the lookup misses.
+ const resolved = resolveCommandPath("sh");
+ if (process.platform !== "win32") {
+ expect(path.isAbsolute(resolved)).toBe(true);
+ } else {
+ expect(typeof resolved).toBe("string");
+ }
+ });
+});
diff --git a/clients/daemon-cli/__tests__/sanitize.test.ts b/clients/daemon-cli/__tests__/sanitize.test.ts
new file mode 100644
index 0000000000..0ecdb24b13
--- /dev/null
+++ b/clients/daemon-cli/__tests__/sanitize.test.ts
@@ -0,0 +1,119 @@
+/**
+ * Terminal-escape sanitization (security). Server-controlled strings must
+ * never reach the terminal as raw control bytes — see src/connection/sanitize.ts
+ * for the threat catalogue (OSC 52 clipboard writes, title spoofing, CSI
+ * rewriting, OSC 8 hyperlink breakout).
+ */
+import { describe, expect, it } from "vitest";
+import {
+ isSafeLinkTarget,
+ sanitizeDeep,
+ sanitizeText,
+} from "../src/connection/sanitize.js";
+
+describe("sanitizeText", () => {
+ it("neutralizes an OSC 52 clipboard-write sequence", () => {
+ const attack = "\u001b]52;c;bWFsaWNpb3Vz\u0007done";
+ const out = sanitizeText(attack);
+ expect(out).not.toContain("\u001b");
+ expect(out).not.toContain("\u0007");
+ expect(out).toBe("\u241b]52;c;bWFsaWNpb3Vz\u2407done");
+ });
+
+ it("neutralizes OSC title spoofing and CSI cursor rewriting", () => {
+ expect(sanitizeText("\u001b]0;fake title\u0007")).toBe(
+ "\u241b]0;fake title\u2407",
+ );
+ expect(sanitizeText("\u001b[2J\u001b[H")).toBe("\u241b[2J\u241b[H");
+ });
+
+ it("neutralizes a BEL/ESC breakout inside a URI (OSC 8 wrapper safety)", () => {
+ const uri = "https://ok.test/\u0007\u001b]8;;https://evil.test\u0007";
+ const out = sanitizeText(uri);
+ expect(out.includes("\u0007")).toBe(false);
+ expect(out.includes("\u001b")).toBe(false);
+ });
+
+ it("replaces C1 controls (8-bit CSI/OSC) with visible text", () => {
+ expect(sanitizeText("\u009b31mred")).toBe("\\u{9b}31mred");
+ expect(sanitizeText("\u009d0;t\u009c")).toBe("\\u{9d}0;t\\u{9c}");
+ });
+
+ it("replaces DEL and CR but preserves newline and tab", () => {
+ expect(sanitizeText("a\u007fb\rc")).toBe("a\u2421b\u240dc");
+ expect(sanitizeText("line1\nline2\tend")).toBe("line1\nline2\tend");
+ });
+
+ it("leaves ordinary text (including non-ASCII) untouched", () => {
+ const s = "hello — ünïcode ✅ 日本語";
+ expect(sanitizeText(s)).toBe(s);
+ });
+});
+
+describe("sanitizeDeep", () => {
+ it("sanitizes nested string values, array items, and object keys", () => {
+ const input = {
+ name: "tool\u001b[1m",
+ items: ["ok", "bad\u0007"],
+ nested: { "\u001bkey": { deep: "\u009btext" } },
+ };
+ expect(sanitizeDeep(input)).toEqual({
+ name: "tool\u241b[1m",
+ items: ["ok", "bad\u2407"],
+ nested: { "\u241bkey": { deep: "\\u{9b}text" } },
+ });
+ });
+
+ it("passes non-string primitives and null through unchanged", () => {
+ expect(sanitizeDeep(42)).toBe(42);
+ expect(sanitizeDeep(true)).toBe(true);
+ expect(sanitizeDeep(null)).toBe(null);
+ expect(sanitizeDeep(undefined)).toBe(undefined);
+ });
+
+ it("does not mutate the input object", () => {
+ const input = { text: "esc\u001b" };
+ const out = sanitizeDeep(input);
+ expect(input.text).toBe("esc\u001b");
+ expect(out.text).toBe("esc\u241b");
+ });
+
+ it('preserves a literal "__proto__" key instead of dropping it', () => {
+ // On a plain {} accumulator, assigning "__proto__" hits the prototype
+ // setter and silently discards the entry; the null-prototype result
+ // keeps it as an ordinary own property.
+ const input = JSON.parse(
+ '{"__proto__": {"polluted": "esc\\u001b"}, "a": 1}',
+ );
+ const out = sanitizeDeep(input) as Record;
+ expect(Object.getOwnPropertyNames(out)).toContain("__proto__");
+ expect(
+ (
+ Object.getOwnPropertyDescriptor(out, "__proto__")?.value as Record<
+ string,
+ unknown
+ >
+ ).polluted,
+ ).toBe("esc\u241b");
+ expect(out.a).toBe(1);
+ // No pollution of shared prototypes either.
+ expect(({} as Record).polluted).toBeUndefined();
+ });
+});
+
+describe("isSafeLinkTarget", () => {
+ it("allows only http(s) URLs as OSC 8 link targets", () => {
+ expect(isSafeLinkTarget("https://example.com/x")).toBe(true);
+ expect(isSafeLinkTarget("http://localhost:3001/mcp")).toBe(true);
+ expect(isSafeLinkTarget("file:///etc/passwd")).toBe(false);
+ expect(isSafeLinkTarget("javascript:alert(1)")).toBe(false);
+ expect(isSafeLinkTarget("vscode://malicious/payload")).toBe(false);
+ expect(isSafeLinkTarget("customproto://x")).toBe(false);
+ });
+
+ it("rejects strings that don't parse as URLs", () => {
+ expect(isSafeLinkTarget("not a url")).toBe(false);
+ expect(isSafeLinkTarget("")).toBe(false);
+ expect(isSafeLinkTarget("example.com/no-scheme")).toBe(false);
+ });
+});
diff --git a/clients/daemon-cli/eslint.config.js b/clients/daemon-cli/eslint.config.js
new file mode 100644
index 0000000000..ff42ec3224
--- /dev/null
+++ b/clients/daemon-cli/eslint.config.js
@@ -0,0 +1,34 @@
+import js from "@eslint/js";
+import globals from "globals";
+import tseslint from "typescript-eslint";
+import { defineConfig, globalIgnores } from "eslint/config";
+
+export default defineConfig([
+ globalIgnores(["build", "coverage"]),
+ {
+ files: ["**/*.ts"],
+ extends: [js.configs.recommended, tseslint.configs.recommended],
+ languageOptions: {
+ ecmaVersion: 2022,
+ sourceType: "module",
+ globals: globals.node,
+ },
+ },
+ {
+ // Type-aware pass for `no-floating-promises` (#1959), mirroring
+ // clients/cli: the rule needs type information, and the parser needs a
+ // project that literally contains the linted file — so both of this
+ // client's tsconfig projects are listed, exactly as `npm run typecheck`
+ // runs them (`src` is in the first, `__tests__` only in the second).
+ files: ["**/*.ts"],
+ languageOptions: {
+ parserOptions: {
+ project: ["./tsconfig.json", "./tsconfig.test.json"],
+ tsconfigRootDir: import.meta.dirname,
+ },
+ },
+ rules: {
+ "@typescript-eslint/no-floating-promises": "error",
+ },
+ },
+]);
diff --git a/clients/daemon-cli/evals/evals.json b/clients/daemon-cli/evals/evals.json
new file mode 100644
index 0000000000..7e1936df00
--- /dev/null
+++ b/clients/daemon-cli/evals/evals.json
@@ -0,0 +1,209 @@
+[
+ {
+ "kind": "trigger",
+ "prompt": "What MCP servers am I connected to?",
+ "expect": "mcpdo"
+ },
+ {
+ "kind": "trigger",
+ "prompt": "Call the echo tool on the everything MCP server and show me the result.",
+ "expect": "mcpdo"
+ },
+ {
+ "kind": "trigger",
+ "prompt": "Do I have access to any MCP tools besides your built-in ones?",
+ "expect": "mcpdo"
+ },
+ {
+ "kind": "trigger",
+ "prompt": "List the resources available on the MCP server I connected to earlier.",
+ "expect": "mcpdo"
+ },
+ {
+ "kind": "trigger",
+ "prompt": "Use mcpdo to list the tools on the everything server.",
+ "expect": "mcpdo"
+ },
+ {
+ "kind": "trigger",
+ "prompt": "What does this regex do? /^\\d{3}-\\d{4}$/",
+ "expect": null
+ },
+ {
+ "kind": "trigger",
+ "prompt": "How do I write a simple HTTP server in Node.js?",
+ "expect": null
+ },
+ {
+ "kind": "trigger",
+ "prompt": "Rename the variable `foo` to `bar` in this snippet: const foo = 1; console.log(foo);",
+ "expect": null
+ },
+ {
+ "kind": "behavior",
+ "prompt": "Connect to the test-stdio MCP server and list its tools.",
+ "expectCalls": [
+ {
+ "cmd": "connect",
+ "connection": "test-stdio"
+ },
+ {
+ "cmd": "tools/list",
+ "connection": "test-stdio"
+ }
+ ]
+ },
+ {
+ "kind": "behavior",
+ "prompt": "Use the test-stdio MCP server to add the numbers 2 and 3, and tell me the result.",
+ "expectCalls": [
+ {
+ "cmd": "tools/call",
+ "connection": "test-stdio",
+ "tool": "get_sum",
+ "args": {
+ "a": 2,
+ "b": 3
+ },
+ "stdoutMatch": "5"
+ }
+ ]
+ },
+ {
+ "kind": "behavior",
+ "autoConsent": true,
+ "prompt": "Connect to the secure-add MCP server and use it to add 2 and 3. It requires signing in — share the sign-in link with me if one comes up, and finish the job once access is granted.",
+ "servers": {
+ "secure-add": {
+ "serverInfo": { "name": "secure-add", "version": "1.0.0" },
+ "tools": [{ "preset": "add" }],
+ "oauth": {
+ "enabled": true,
+ "mode": "combined",
+ "requireAuth": true,
+ "scopesSupported": ["mcp"],
+ "supportDCR": true
+ },
+ "transport": { "type": "streamable-http" }
+ }
+ },
+ "expectCalls": [
+ {
+ "cmd": "connect",
+ "connection": "secure-add",
+ "exit": 0,
+ "stdoutMatch": "oauth/authorize"
+ },
+ {
+ "cmd": "tools/call",
+ "connection": "secure-add",
+ "tool": "add",
+ "args": {
+ "a": 2,
+ "b": 3
+ },
+ "stdoutMatch": "5"
+ }
+ ]
+ },
+ {
+ "kind": "behavior",
+ "prompt": "File a ticket on the helpdesk server with the summary 'Printer on floor 3 is jammed'. If it needs contact details, I'm Ada Lovelace, ada@example.com. Tell me the ticket number.",
+ "servers": {
+ "helpdesk": {
+ "serverInfo": { "name": "helpdesk", "version": "1.0.0" },
+ "tools": [{ "preset": "submit_ticket" }]
+ }
+ },
+ "expectCalls": [
+ {
+ "cmd": "tools/call",
+ "connection": "helpdesk",
+ "tool": "submit_ticket",
+ "args": { "summary": "Printer on floor 3 is jammed" },
+ "exit": 0,
+ "stdoutMatch": "elicitationPending|Input required"
+ },
+ {
+ "cmd": "elicitation/respond",
+ "args": {
+ "contact_name": "Ada Lovelace",
+ "contact_email": "ada@example.com"
+ },
+ "exit": 0,
+ "stdoutMatch": "TCK-"
+ }
+ ]
+ },
+ {
+ "kind": "behavior",
+ "prompt": "What MCP servers am I connected to right now?",
+ "expectCalls": [
+ {
+ "cmd": "connections/list",
+ "exit": 0
+ }
+ ]
+ },
+ {
+ "kind": "behavior",
+ "prompt": "What MCP servers are available for me to connect to? Don't connect to anything, just tell me what's there.",
+ "expectCalls": [
+ {
+ "cmd": "servers/list",
+ "exit": 0,
+ "stdoutMatch": "test-stdio"
+ }
+ ]
+ },
+ {
+ "kind": "behavior",
+ "prompt": "My catalog has two MCP servers, alpha and beta. Use beta to compute the sum of 4 and 9.",
+ "servers": {
+ "alpha": {
+ "serverInfo": { "name": "alpha", "version": "1.0.0" },
+ "tools": [{ "preset": "echo" }]
+ },
+ "beta": {
+ "serverInfo": { "name": "beta", "version": "1.0.0" },
+ "tools": [{ "preset": "get_sum" }]
+ }
+ },
+ "expectCalls": [
+ {
+ "cmd": "tools/call",
+ "connection": "beta",
+ "tool": "get_sum",
+ "args": {
+ "a": 4,
+ "b": 9
+ },
+ "stdoutMatch": "13"
+ }
+ ]
+ },
+ {
+ "kind": "behavior",
+ "prompt": "File a ticket on the helpdesk server with the summary 'Broken keyboard'. If it asks for contact details, do NOT share any personal information — decline that request — and tell me what happened.",
+ "servers": {
+ "helpdesk": {
+ "serverInfo": { "name": "helpdesk", "version": "1.0.0" },
+ "tools": [{ "preset": "submit_ticket" }]
+ }
+ },
+ "expectCalls": [
+ {
+ "cmd": "tools/call",
+ "connection": "helpdesk",
+ "tool": "submit_ticket",
+ "exit": 0,
+ "stdoutMatch": "elicitationPending|Input required"
+ },
+ {
+ "cmd": "elicitation/respond",
+ "exit": 0,
+ "stdoutMatch": "not filed|declined"
+ }
+ ]
+ }
+]
diff --git a/clients/daemon-cli/package-lock.json b/clients/daemon-cli/package-lock.json
new file mode 100644
index 0000000000..c1d87aceaa
--- /dev/null
+++ b/clients/daemon-cli/package-lock.json
@@ -0,0 +1,1583 @@
+{
+ "name": "@modelcontextprotocol/daemon-cli",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "@modelcontextprotocol/daemon-cli",
+ "license": "MIT",
+ "bin": {
+ "mcpdo": "build/mcp-bin.js"
+ },
+ "devDependencies": {
+ "@types/express": "^5.0.6",
+ "tsup": "^8.5.0"
+ }
+ },
+ "node_modules/@esbuild/aix-ppc64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz",
+ "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz",
+ "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz",
+ "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-x64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz",
+ "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-arm64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz",
+ "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-x64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz",
+ "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-arm64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz",
+ "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-x64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz",
+ "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz",
+ "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz",
+ "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ia32": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz",
+ "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-loong64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz",
+ "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-mips64el": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz",
+ "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==",
+ "cpu": [
+ "mips64el"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ppc64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz",
+ "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-riscv64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz",
+ "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-s390x": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz",
+ "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-x64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz",
+ "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-arm64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz",
+ "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-x64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz",
+ "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-arm64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz",
+ "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-x64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz",
+ "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openharmony-arm64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz",
+ "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/sunos-x64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz",
+ "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-arm64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz",
+ "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-ia32": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz",
+ "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-x64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz",
+ "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@jridgewell/gen-mapping": {
+ "version": "0.3.13",
+ "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
+ "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.0",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ }
+ },
+ "node_modules/@jridgewell/resolve-uri": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
+ "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@jridgewell/sourcemap-codec": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz",
+ "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@jridgewell/trace-mapping": {
+ "version": "0.3.31",
+ "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
+ "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/resolve-uri": "^3.1.0",
+ "@jridgewell/sourcemap-codec": "^1.4.14"
+ }
+ },
+ "node_modules/@napi-rs/lzma-linux-x64-gnu": {
+ "version": "1.5.1",
+ "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz",
+ "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^22.20 || ^24.12 || >=25"
+ }
+ },
+ "node_modules/@rollup/rollup-android-arm-eabi": {
+ "version": "4.63.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.63.4.tgz",
+ "integrity": "sha512-I+BSHzTAhKN2n7ZwGZsegGcZjDpLqFOMAtJz/u6uFGe0pUFbq56dEHjqJV/ZUdRJtNXNxA+hREUatZBvMR3Oiw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-android-arm64": {
+ "version": "4.63.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.63.4.tgz",
+ "integrity": "sha512-pu3BdjS2LtEzRu2elmGzS3fIeWSZy4BMDIaLNwjorO76+k2d0LMluijhsDx3KQyQBQ/lLUZCQA9/s6csvUfuhw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-arm64": {
+ "version": "4.63.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.63.4.tgz",
+ "integrity": "sha512-xfSrj9MHnWK9GaSqT9U0ImHtH/N8WZlHLx4cZHiuLcqs640hvZ3hLPd5UR2AZS57FaE8HrRUSpltbZdWRxHiDA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-x64": {
+ "version": "4.63.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.63.4.tgz",
+ "integrity": "sha512-bqU99PLJb/dqb3S0GIMdeuyAEETSUgZBoqXYd3Sd+WCsV+MmPhnN6JrotWyir31+QgH7EvvE5/mwGJlEoci8Fw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-arm64": {
+ "version": "4.63.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.63.4.tgz",
+ "integrity": "sha512-JinsFZ5G40oXQb+sUuiA5x689vhr6dDYK0H0NL+rwKdL6CqnmYN8PE4ZwfRSoIjrCxqTQG/SLfTtSvHeGxoVlw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-x64": {
+ "version": "4.63.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.63.4.tgz",
+ "integrity": "sha512-GAdA4UxpiNm27cLHr2GqXBpAD0x9FqwYBY7/YSP0Ss0/PNi4k8gbviqpIpYbVSRBaS2ZcegXEzgTQMbRNCwxCw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
+ "version": "4.63.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.63.4.tgz",
+ "integrity": "sha512-qDd6NoA1znaLjp4jR5U/KWCdLAKDJNB8W9ChbbDaKbo0xA+Atln5HK6LFCZ4oJQpemtRZA288DCirFRjrspptw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-musleabihf": {
+ "version": "4.63.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.63.4.tgz",
+ "integrity": "sha512-WtB5Tz5KTNINb8ZA+8sQ7bmjuS1JrRT7YverYIhUGdWWDlpzVWmIwuZE+jidkEXUn1l0zrEkaIMa8dHF3NGcsA==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-gnu": {
+ "version": "4.63.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.63.4.tgz",
+ "integrity": "sha512-VcQ3L1tjnkKzWjryAVaFhHEWcqOfICX9uxVVoDzm2t0DpgKRHd2zOpVrJc0xsWeBZcBFyYROCIBdyR/fS174pg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-musl": {
+ "version": "4.63.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.63.4.tgz",
+ "integrity": "sha512-6+ZQX6P5s0cMDN2Ypb8Lbm2+/sZYmZjdaYny992ujUU9UKi/4CWoJWsl1pNvjWJHNHGK51m+jKGLlh1ylb2ifQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-gnu": {
+ "version": "4.63.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.63.4.tgz",
+ "integrity": "sha512-D72ZnvkFkBXOfzMMQLcwfPLyGkKb7HZ9/mf97B7v6/P5Lbv4oFOtSY/uHbS8lH6uKUOxoKiuokdb50XZSzzbJw==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-musl": {
+ "version": "4.63.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.63.4.tgz",
+ "integrity": "sha512-piU6BxeqA3O9KSu3kRCIQQtNqFFaTu21SEV4FwaRZowpnj3bLaWPZHw+xFqCs0XlJ+aOH3PTRWGoglH+mKA/OA==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-gnu": {
+ "version": "4.63.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.63.4.tgz",
+ "integrity": "sha512-/5PGpHwqt2EEEOUs1XwzubE/ucr0dWDQ+to3zqi4Ds7EWpwtQ79wXc4JBoxqj/OwpawTsKWzJxHfSuBOq3DrWA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-musl": {
+ "version": "4.63.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.63.4.tgz",
+ "integrity": "sha512-cX3beZDLWt7G2oJF+nhChiT+qtaihs+S2xi7ziGmVB+2pwPng6D0Ed0HmElQOgv2UsUmSJJLGwpBao/3TDx3VA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-gnu": {
+ "version": "4.63.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.63.4.tgz",
+ "integrity": "sha512-1uz2mGWHyptR7DgHHrlbdRAjXK7v7elGZ9lMja910/RP+ZYbX6xAmCiU9UZSX4hqmgtHMv6lr5l3kq1HIOpcag==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-musl": {
+ "version": "4.63.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.63.4.tgz",
+ "integrity": "sha512-nLS8topojxyz7SRpKR2IODRpQ0XPZ+xaOXvT3+hqK/Uy8Lo5HFgkkIBiIrCu5tL5YqzTvgovGw55PwpahTAGig==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-s390x-gnu": {
+ "version": "4.63.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.63.4.tgz",
+ "integrity": "sha512-gs7DRKotr3l3q+jGPQBjH0ng1FjlEDm5ueQrkw5JtQvtLyEIcLASqAEaor56BhkKRzk+IcQzrcanBdb/bBQn8g==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-gnu": {
+ "version": "4.63.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.63.4.tgz",
+ "integrity": "sha512-791ET7W17NnScOZM7h4dX5hYspxE28htPFsb1awY/NRR8+PRNkS53e475rDdxXXDrP+kwnCcNWg9CX5ztn/Aqw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-musl": {
+ "version": "4.63.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.63.4.tgz",
+ "integrity": "sha512-iwZQRcmj7g88g3tzefIrQY7qvmuA/cfYwhrDtTBhsmukO4U2huVO5W+86XacUMRvdSFVAc6kZUZy21JaRwiB9w==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-openbsd-x64": {
+ "version": "4.63.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.63.4.tgz",
+ "integrity": "sha512-dVHFp9gRWrdTpnqQuGfCwd7hOQDatK1VCP2iWhLY/cGrOQs/ucFzJ6A5SRqbXX12ZDI8EUuejSM5kwg+ja7Png==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-openharmony-arm64": {
+ "version": "4.63.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.63.4.tgz",
+ "integrity": "sha512-t3NlauOW6gxZVVFcBEnO62Cb4wbyDFL416gTg1uFI/2tgqYQlf69FbSE115Ajre9I+c26Lk4mcmdFUsS/DGifQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-arm64-msvc": {
+ "version": "4.63.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.63.4.tgz",
+ "integrity": "sha512-xWuIaSye5FWZF8+UYtVEcHtRJDN5kN9Kfgxx3Kq8XIov9KSKbc1fiqQCm90SKrgQbUXZelbnUhnlUJmfSE7P9A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-ia32-msvc": {
+ "version": "4.63.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.63.4.tgz",
+ "integrity": "sha512-9ALJJUOg/ZflMJepVo2PlgsGxSaxN7SQ4Z8GoZfVlarWr6r3rkHUNsd/zAio7p4YMtChSMXPionxej4Hkf6CXQ==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-gnu": {
+ "version": "4.63.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.63.4.tgz",
+ "integrity": "sha512-blj9z5qx/Pv4WU0W1NMFDB97e0JH5ed+aZGywW8WCvp/NhWX/4PFAq5uu6Q0AebNn+Vo6KzUYDT++JzTT5ojlQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-msvc": {
+ "version": "4.63.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.63.4.tgz",
+ "integrity": "sha512-Erx822VRBwLa124shbj+wNXe//BOgMEctDV0m1aqTQdNO1S69DgNUCFKC1RCeZfixs1J31l6igk1ziyXErbigQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@types/body-parser": {
+ "version": "1.19.6",
+ "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
+ "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/connect": "*",
+ "@types/node": "*"
+ }
+ },
+ "node_modules/@types/connect": {
+ "version": "3.4.38",
+ "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz",
+ "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/node": "*"
+ }
+ },
+ "node_modules/@types/estree": {
+ "version": "1.0.9",
+ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
+ "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/express": {
+ "version": "5.0.6",
+ "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.6.tgz",
+ "integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/body-parser": "*",
+ "@types/express-serve-static-core": "^5.0.0",
+ "@types/serve-static": "^2"
+ }
+ },
+ "node_modules/@types/express-serve-static-core": {
+ "version": "5.1.3",
+ "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.3.tgz",
+ "integrity": "sha512-dPfW8NFiOF4wOHc7+N/QSxlY9cfSsenewGbAz8C8U/MULPd/YZ27LvJUIlzaXie7e6Ove9YunJGgC9tbHD2cKw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/node": "*",
+ "@types/qs": "*",
+ "@types/range-parser": "*",
+ "@types/send": "*"
+ }
+ },
+ "node_modules/@types/http-errors": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz",
+ "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/node": {
+ "version": "24.13.3",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz",
+ "integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "undici-types": "~7.18.0"
+ }
+ },
+ "node_modules/@types/qs": {
+ "version": "6.15.1",
+ "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.1.tgz",
+ "integrity": "sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/range-parser": {
+ "version": "1.2.7",
+ "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz",
+ "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/send": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz",
+ "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/node": "*"
+ }
+ },
+ "node_modules/@types/serve-static": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-2.2.0.tgz",
+ "integrity": "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/http-errors": "*",
+ "@types/node": "*"
+ }
+ },
+ "node_modules/acorn": {
+ "version": "8.18.0",
+ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz",
+ "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "acorn": "bin/acorn"
+ },
+ "engines": {
+ "node": ">=0.4.0"
+ }
+ },
+ "node_modules/any-promise": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz",
+ "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/bundle-require": {
+ "version": "5.1.0",
+ "resolved": "https://registry.npmjs.org/bundle-require/-/bundle-require-5.1.0.tgz",
+ "integrity": "sha512-3WrrOuZiyaaZPWiEt4G3+IffISVC9HYlWueJEBWED4ZH4aIAC2PnkdnuRrR94M+w6yGWn4AglWtJtBI8YqvgoA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "load-tsconfig": "^0.2.3"
+ },
+ "engines": {
+ "node": "^12.20.0 || ^14.13.1 || >=16.0.0"
+ },
+ "peerDependencies": {
+ "esbuild": ">=0.18"
+ }
+ },
+ "node_modules/cac": {
+ "version": "6.7.14",
+ "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz",
+ "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/chokidar": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
+ "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "readdirp": "^4.0.1"
+ },
+ "engines": {
+ "node": ">= 14.16.0"
+ },
+ "funding": {
+ "url": "https://paulmillr.com/funding/"
+ }
+ },
+ "node_modules/commander": {
+ "version": "13.1.0",
+ "resolved": "https://registry.npmjs.org/commander/-/commander-13.1.0.tgz",
+ "integrity": "sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/confbox": {
+ "version": "0.1.8",
+ "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz",
+ "integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/consola": {
+ "version": "3.4.2",
+ "resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz",
+ "integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^14.18.0 || >=16.10.0"
+ }
+ },
+ "node_modules/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/esbuild": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz",
+ "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "bin": {
+ "esbuild": "bin/esbuild"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "@esbuild/aix-ppc64": "0.28.2",
+ "@esbuild/android-arm": "0.28.2",
+ "@esbuild/android-arm64": "0.28.2",
+ "@esbuild/android-x64": "0.28.2",
+ "@esbuild/darwin-arm64": "0.28.2",
+ "@esbuild/darwin-x64": "0.28.2",
+ "@esbuild/freebsd-arm64": "0.28.2",
+ "@esbuild/freebsd-x64": "0.28.2",
+ "@esbuild/linux-arm": "0.28.2",
+ "@esbuild/linux-arm64": "0.28.2",
+ "@esbuild/linux-ia32": "0.28.2",
+ "@esbuild/linux-loong64": "0.28.2",
+ "@esbuild/linux-mips64el": "0.28.2",
+ "@esbuild/linux-ppc64": "0.28.2",
+ "@esbuild/linux-riscv64": "0.28.2",
+ "@esbuild/linux-s390x": "0.28.2",
+ "@esbuild/linux-x64": "0.28.2",
+ "@esbuild/netbsd-arm64": "0.28.2",
+ "@esbuild/netbsd-x64": "0.28.2",
+ "@esbuild/openbsd-arm64": "0.28.2",
+ "@esbuild/openbsd-x64": "0.28.2",
+ "@esbuild/openharmony-arm64": "0.28.2",
+ "@esbuild/sunos-x64": "0.28.2",
+ "@esbuild/win32-arm64": "0.28.2",
+ "@esbuild/win32-ia32": "0.28.2",
+ "@esbuild/win32-x64": "0.28.2"
+ }
+ },
+ "node_modules/fdir": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
+ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "peerDependencies": {
+ "picomatch": "^3 || ^4"
+ },
+ "peerDependenciesMeta": {
+ "picomatch": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/fix-dts-default-cjs-exports": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/fix-dts-default-cjs-exports/-/fix-dts-default-cjs-exports-1.0.1.tgz",
+ "integrity": "sha512-pVIECanWFC61Hzl2+oOCtoJ3F17kglZC/6N94eRWycFgBH35hHx0Li604ZIzhseh97mf2p0cv7vVrOZGoqhlEg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "magic-string": "^0.30.17",
+ "mlly": "^1.7.4",
+ "rollup": "^4.34.8"
+ }
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/joycon": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz",
+ "integrity": "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/lilconfig": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz",
+ "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=14"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/antonk52"
+ }
+ },
+ "node_modules/lines-and-columns": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
+ "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/load-tsconfig": {
+ "version": "0.2.5",
+ "resolved": "https://registry.npmjs.org/load-tsconfig/-/load-tsconfig-0.2.5.tgz",
+ "integrity": "sha512-IXO6OCs9yg8tMKzfPZ1YmheJbZCiEsnBdcB03l0OcfK9prKnJb96siuHCr5Fl37/yo9DnKU+TLpxzTUspw9shg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^12.20.0 || ^14.13.1 || >=16.0.0"
+ }
+ },
+ "node_modules/magic-string": {
+ "version": "0.30.21",
+ "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
+ "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.5"
+ }
+ },
+ "node_modules/mlly": {
+ "version": "1.8.2",
+ "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.2.tgz",
+ "integrity": "sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "acorn": "^8.16.0",
+ "pathe": "^2.0.3",
+ "pkg-types": "^1.3.1",
+ "ufo": "^1.6.3"
+ }
+ },
+ "node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/mz": {
+ "version": "2.7.0",
+ "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz",
+ "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "any-promise": "^1.0.0",
+ "object-assign": "^4.0.1",
+ "thenify-all": "^1.0.0"
+ }
+ },
+ "node_modules/object-assign": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
+ "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/pathe": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
+ "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/picocolors": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/picomatch": {
+ "version": "4.0.7",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz",
+ "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/pirates": {
+ "version": "4.0.7",
+ "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz",
+ "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/pkg-types": {
+ "version": "1.3.1",
+ "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz",
+ "integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "confbox": "^0.1.8",
+ "mlly": "^1.7.4",
+ "pathe": "^2.0.1"
+ }
+ },
+ "node_modules/postcss-load-config": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz",
+ "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "lilconfig": "^3.1.1"
+ },
+ "engines": {
+ "node": ">= 18"
+ },
+ "peerDependencies": {
+ "jiti": ">=1.21.0",
+ "postcss": ">=8.0.9",
+ "tsx": "^4.8.1",
+ "yaml": "^2.4.2"
+ },
+ "peerDependenciesMeta": {
+ "jiti": {
+ "optional": true
+ },
+ "postcss": {
+ "optional": true
+ },
+ "tsx": {
+ "optional": true
+ },
+ "yaml": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/readdirp": {
+ "version": "4.1.2",
+ "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
+ "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 14.18.0"
+ },
+ "funding": {
+ "type": "individual",
+ "url": "https://paulmillr.com/funding/"
+ }
+ },
+ "node_modules/resolve-from": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz",
+ "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/rollup": {
+ "version": "4.63.4",
+ "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.63.4.tgz",
+ "integrity": "sha512-4U0liVayNIoLp3GFl1FcI8561WepLnZ1rqfraGh7S9B3Ur5F9S283y8Futii7RUU2C/97tOBmBy7nYvhoiOpbQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "1.0.9"
+ },
+ "bin": {
+ "rollup": "dist/bin/rollup"
+ },
+ "engines": {
+ "node": ">=18.0.0",
+ "npm": ">=8.0.0"
+ },
+ "optionalDependencies": {
+ "@napi-rs/lzma-linux-x64-gnu": "1.5.1",
+ "@rollup/rollup-android-arm-eabi": "4.63.4",
+ "@rollup/rollup-android-arm64": "4.63.4",
+ "@rollup/rollup-darwin-arm64": "4.63.4",
+ "@rollup/rollup-darwin-x64": "4.63.4",
+ "@rollup/rollup-freebsd-arm64": "4.63.4",
+ "@rollup/rollup-freebsd-x64": "4.63.4",
+ "@rollup/rollup-linux-arm-gnueabihf": "4.63.4",
+ "@rollup/rollup-linux-arm-musleabihf": "4.63.4",
+ "@rollup/rollup-linux-arm64-gnu": "4.63.4",
+ "@rollup/rollup-linux-arm64-musl": "4.63.4",
+ "@rollup/rollup-linux-loong64-gnu": "4.63.4",
+ "@rollup/rollup-linux-loong64-musl": "4.63.4",
+ "@rollup/rollup-linux-ppc64-gnu": "4.63.4",
+ "@rollup/rollup-linux-ppc64-musl": "4.63.4",
+ "@rollup/rollup-linux-riscv64-gnu": "4.63.4",
+ "@rollup/rollup-linux-riscv64-musl": "4.63.4",
+ "@rollup/rollup-linux-s390x-gnu": "4.63.4",
+ "@rollup/rollup-linux-x64-gnu": "4.63.4",
+ "@rollup/rollup-linux-x64-musl": "4.63.4",
+ "@rollup/rollup-openbsd-x64": "4.63.4",
+ "@rollup/rollup-openharmony-arm64": "4.63.4",
+ "@rollup/rollup-win32-arm64-msvc": "4.63.4",
+ "@rollup/rollup-win32-ia32-msvc": "4.63.4",
+ "@rollup/rollup-win32-x64-gnu": "4.63.4",
+ "@rollup/rollup-win32-x64-msvc": "4.63.4",
+ "fsevents": "~2.3.2"
+ }
+ },
+ "node_modules/source-map": {
+ "version": "0.7.6",
+ "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz",
+ "integrity": "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">= 12"
+ }
+ },
+ "node_modules/sucrase": {
+ "version": "3.35.1",
+ "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz",
+ "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/gen-mapping": "^0.3.2",
+ "commander": "^4.0.0",
+ "lines-and-columns": "^1.1.6",
+ "mz": "^2.7.0",
+ "pirates": "^4.0.1",
+ "tinyglobby": "^0.2.11",
+ "ts-interface-checker": "^0.1.9"
+ },
+ "bin": {
+ "sucrase": "bin/sucrase",
+ "sucrase-node": "bin/sucrase-node"
+ },
+ "engines": {
+ "node": ">=16 || 14 >=14.17"
+ }
+ },
+ "node_modules/thenify": {
+ "version": "3.3.1",
+ "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz",
+ "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "any-promise": "^1.0.0"
+ }
+ },
+ "node_modules/thenify-all": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz",
+ "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "thenify": ">= 3.1.0 < 4"
+ },
+ "engines": {
+ "node": ">=0.8"
+ }
+ },
+ "node_modules/tinyexec": {
+ "version": "0.3.2",
+ "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz",
+ "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/tinyglobby": {
+ "version": "0.2.17",
+ "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
+ "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.4"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/SuperchupuDev"
+ }
+ },
+ "node_modules/tree-kill": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz",
+ "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "tree-kill": "cli.js"
+ }
+ },
+ "node_modules/ts-interface-checker": {
+ "version": "0.1.13",
+ "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz",
+ "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==",
+ "dev": true,
+ "license": "Apache-2.0"
+ },
+ "node_modules/tsup": {
+ "version": "8.5.1",
+ "resolved": "https://registry.npmjs.org/tsup/-/tsup-8.5.1.tgz",
+ "integrity": "sha512-xtgkqwdhpKWr3tKPmCkvYmS9xnQK3m3XgxZHwSUjvfTjp7YfXe5tT3GgWi0F2N+ZSMsOeWeZFh7ZZFg5iPhing==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "bundle-require": "^5.1.0",
+ "cac": "^6.7.14",
+ "chokidar": "^4.0.3",
+ "consola": "^3.4.0",
+ "debug": "^4.4.0",
+ "esbuild": "^0.27.0",
+ "fix-dts-default-cjs-exports": "^1.0.0",
+ "joycon": "^3.1.1",
+ "picocolors": "^1.1.1",
+ "postcss-load-config": "^6.0.1",
+ "resolve-from": "^5.0.0",
+ "rollup": "^4.34.8",
+ "source-map": "^0.7.6",
+ "sucrase": "^3.35.0",
+ "tinyexec": "^0.3.2",
+ "tinyglobby": "^0.2.11",
+ "tree-kill": "^1.2.2"
+ },
+ "bin": {
+ "tsup": "dist/cli-default.js",
+ "tsup-node": "dist/cli-node.js"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "@microsoft/api-extractor": "^7.36.0",
+ "@swc/core": "^1",
+ "postcss": "^8.4.12",
+ "typescript": ">=4.5.0"
+ },
+ "peerDependenciesMeta": {
+ "@microsoft/api-extractor": {
+ "optional": true
+ },
+ "@swc/core": {
+ "optional": true
+ },
+ "postcss": {
+ "optional": true
+ },
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/ufo": {
+ "version": "1.6.4",
+ "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.4.tgz",
+ "integrity": "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/undici-types": {
+ "version": "7.18.2",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
+ "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
+ "dev": true,
+ "license": "MIT"
+ }
+ }
+}
diff --git a/clients/daemon-cli/package.json b/clients/daemon-cli/package.json
new file mode 100644
index 0000000000..477974c591
--- /dev/null
+++ b/clients/daemon-cli/package.json
@@ -0,0 +1,41 @@
+{
+ "name": "@modelcontextprotocol/daemon-cli",
+ "private": true,
+ "description": "Connection-oriented MCP Inspector CLI (mcpdo) — connect once, run many commands",
+ "license": "MIT",
+ "type": "module",
+ "main": "build/mcp-bin.js",
+ "bin": {
+ "mcpdo": "./build/mcp-bin.js"
+ },
+ "files": [
+ "build",
+ "README.md"
+ ],
+ "scripts": {
+ "build": "tsup",
+ "build:dev": "node scripts/stop-dev-daemon.mjs && tsup",
+ "typecheck": "tsc --noEmit -p tsconfig.json && tsc --noEmit -p tsconfig.test.json",
+ "check": "npm run format:check && npm run lint && npm run typecheck",
+ "validate": "npm run check && npm run test",
+ "test": "vitest run",
+ "test:watch": "vitest",
+ "test:coverage": "npm run test-servers:build && npm run build && vitest run --coverage",
+ "test-servers:build": "tsc -p ../../test-servers --noCheck",
+ "pretest": "npm run test-servers:build && npm run build",
+ "lint": "eslint . --max-warnings 0",
+ "format": "prettier --write src __tests__ scripts \"*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}\"",
+ "format:check": "prettier --check src __tests__ scripts \"*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}\""
+ },
+ "devDependencies": {
+ "@types/express": "^5.0.6",
+ "tsup": "^8.5.0"
+ },
+ "overrides": {
+ "@types/node": "^24.12.4",
+ "esbuild": "^0.28.2",
+ "sucrase": {
+ "commander": "^13.1.0"
+ }
+ }
+}
diff --git a/clients/daemon-cli/scripts/stop-dev-daemon.mjs b/clients/daemon-cli/scripts/stop-dev-daemon.mjs
new file mode 100644
index 0000000000..e5d8d56070
--- /dev/null
+++ b/clients/daemon-cli/scripts/stop-dev-daemon.mjs
@@ -0,0 +1,18 @@
+/**
+ * Pre-build step for `build:dev`: stop a daemon still running from a previous
+ * build so the fresh bundle isn't shadowed by a stale resident process.
+ *
+ * Best-effort by design — a missing `build/` (first build) or no running
+ * daemon must not fail the build. Kept as a script rather than shell syntax
+ * so the npm script works on Windows too (cmd.exe has no `;` sequencing or
+ * `/dev/null`).
+ */
+import { execFileSync } from "node:child_process";
+
+try {
+ execFileSync(process.execPath, ["build/mcp-bin.js", "daemon", "stop"], {
+ stdio: "ignore",
+ });
+} catch {
+ // Nothing to stop (or nothing built yet) — proceed with the build.
+}
diff --git a/clients/daemon-cli/src/connection/auth-helper.ts b/clients/daemon-cli/src/connection/auth-helper.ts
new file mode 100644
index 0000000000..2a5548c389
--- /dev/null
+++ b/clients/daemon-cli/src/connection/auth-helper.ts
@@ -0,0 +1,442 @@
+import { spawn } from "node:child_process";
+import { createHash } from "node:crypto";
+import * as fs from "node:fs";
+import * as path from "node:path";
+import { CallbackNavigation } from "@inspector/core/auth/index.js";
+import type {
+ InspectorServerSettings,
+ MCPServerConfig,
+} from "@inspector/core/mcp/types.js";
+import { CliExitCodeError, EXIT_CODES } from "@inspector/cli/error-handler.js";
+import { getDaemonDir } from "../daemon/paths.js";
+import { authorizeInFrontend } from "./authorize.js";
+import { sanitizeText } from "./sanitize.js";
+
+/**
+ * Detached OAuth completion helper for the non-TTY `connect` path.
+ *
+ * An agent driving mcpdo over pipes cannot sit on a blocking interactive
+ * OAuth flow: the auth URL stays invisible in a buffered foreground pipe, and
+ * killing the foreground process would tear down the loopback callback
+ * listener the URL points at (staling the link). Instead, `connect` spawns
+ * this helper detached: the helper owns the whole interactive flow
+ * (callback listener, authorization-code exchange, token persistence to the
+ * shared `oauth.json`), reports the freshly minted authorize URL back over
+ * its stdout pipe, and keeps running after the parent exits — bounded by the
+ * flow's own 15-minute callback wait. The parent relays the URL and exits;
+ * the daemon-side pending entry completes on first use once tokens land.
+ *
+ * Params travel over **stdin as JSON**, never argv: `serverConfig` may carry
+ * header secrets, and argv is world-visible in `ps`.
+ */
+
+/** Hidden subcommand name (see registerAuthCommands in mcp.ts). */
+export const AUTH_HELPER_COMMAND = "auth/complete-signin";
+
+/** Params the parent writes to the helper's stdin as one JSON document. */
+export type AuthHelperParams = {
+ serverConfig: MCPServerConfig;
+ serverSettings?: InspectorServerSettings;
+};
+
+/** One NDJSON line on the helper's stdout. */
+type AuthHelperEvent =
+ | { event: "auth_url"; url: string }
+ | { event: "done" }
+ | { event: "error"; message: string };
+
+/**
+ * Pending sign-in marker, one per server URL, in the daemon dir (0700).
+ * A repeat `connect` while a helper is still waiting must reprint the SAME
+ * URL rather than mint a second flow: the fixed loopback callback port makes
+ * a second listener fail, and a fresh PKCE state would stale the link the
+ * user is already holding.
+ */
+export type PendingAuthMarker = {
+ url: string;
+ pid: number;
+ /** Epoch ms; matches the flow's own callback-wait bound. */
+ expiresAt: number;
+};
+
+/** Matches the interactive flow's 15-minute loopback callback wait. */
+const PENDING_AUTH_TTL_MS = 15 * 60 * 1000;
+
+/** Bound on the parent's wait for the helper to report the auth URL. */
+const AUTH_URL_WAIT_MS = 60 * 1000;
+
+/** Bound on the helper's wait for params on stdin (parent writes eagerly). */
+const HELPER_STDIN_TIMEOUT_MS = 30 * 1000;
+
+export function pendingAuthMarkerPath(serverUrl: string): string {
+ const hash = createHash("sha256")
+ .update(serverUrl)
+ .digest("hex")
+ .slice(0, 16);
+ return path.join(getDaemonDir(), `pending-auth-${hash}.json`);
+}
+
+/**
+ * Read the marker for `serverUrl` if it is still live: unexpired AND its
+ * helper process is still running (a killed/crashed helper must not pin a
+ * dead URL for up to 15 minutes). Stale markers are removed best-effort.
+ */
+export function readLivePendingAuthMarker(
+ serverUrl: string,
+): PendingAuthMarker | undefined {
+ const markerPath = pendingAuthMarkerPath(serverUrl);
+ let marker: PendingAuthMarker;
+ try {
+ const parsed = JSON.parse(fs.readFileSync(markerPath, "utf8")) as unknown;
+ if (
+ typeof parsed !== "object" ||
+ parsed === null ||
+ typeof (parsed as PendingAuthMarker).url !== "string" ||
+ typeof (parsed as PendingAuthMarker).pid !== "number" ||
+ typeof (parsed as PendingAuthMarker).expiresAt !== "number"
+ ) {
+ throw new Error("malformed marker");
+ }
+ marker = parsed as PendingAuthMarker;
+ } catch {
+ return undefined;
+ }
+ const live =
+ marker.expiresAt > Date.now() &&
+ (() => {
+ try {
+ process.kill(marker.pid, 0);
+ return true;
+ } catch {
+ return false;
+ }
+ })();
+ if (!live) {
+ // Deliberately NOT deleted here: unlinking by pathname after the read
+ // would race a just-spawned helper replacing the marker (TOCTOU — the
+ // rm could delete the fresh URL). Stale markers are inert (re-validated
+ // on every read) and the next flow's writePendingAuthMarker replaces
+ // them.
+ return undefined;
+ }
+ return marker;
+}
+
+/**
+ * Remove the pending-auth marker only if THIS process wrote the one on disk.
+ * Past the 15-minute TTL a replacement flow may have published a fresh
+ * marker at the same shared pathname, and an unconditional rm at helper exit
+ * would delete the replacement's URL out from under its callers. A read→rm
+ * microsecond window remains (POSIX has no compare-and-delete); losing it
+ * costs one extra sign-in prompt, never a wrong URL.
+ */
+export function removeOwnPendingAuthMarker(markerPath: string) {
+ try {
+ const onDisk = JSON.parse(
+ fs.readFileSync(markerPath, "utf8"),
+ ) as PendingAuthMarker;
+ if (onDisk.pid === process.pid) fs.rmSync(markerPath, { force: true });
+ } catch {
+ // Missing or unreadable marker: nothing of ours to clean up.
+ }
+}
+
+function writePendingAuthMarker(markerPath: string, marker: PendingAuthMarker) {
+ // Recreate exclusively (same symlink hardening as the daemon log): an
+ // append/overwrite open would follow a planted symlink and only apply the
+ // 0600 mode on create.
+ fs.rmSync(markerPath, { force: true });
+ fs.writeFileSync(markerPath, `${JSON.stringify(marker)}\n`, {
+ flag: "wx",
+ mode: 0o600,
+ });
+}
+
+/** Read the helper's stdin to EOF and parse the params document. */
+async function readHelperParams(): Promise {
+ const chunks: Buffer[] = [];
+ const body = await new Promise((resolve, reject) => {
+ const timer = setTimeout(() => {
+ reject(new Error("timed out waiting for params on stdin"));
+ }, HELPER_STDIN_TIMEOUT_MS);
+ timer.unref();
+ process.stdin.on("data", (chunk: Buffer) => chunks.push(chunk));
+ process.stdin.on("end", () => {
+ clearTimeout(timer);
+ resolve(Buffer.concat(chunks).toString("utf8"));
+ });
+ process.stdin.on("error", (error) => {
+ clearTimeout(timer);
+ reject(error);
+ });
+ });
+ const parsed = JSON.parse(body) as AuthHelperParams;
+ if (typeof parsed !== "object" || parsed === null || !parsed.serverConfig) {
+ throw new Error("auth helper params must include serverConfig");
+ }
+ return parsed;
+}
+
+/**
+ * Entry point for the hidden helper subcommand. Runs the full interactive
+ * OAuth flow with a navigation that reports the authorize URL as an NDJSON
+ * event on stdout (instead of printing a prompt line) and never opens a
+ * browser — the parent (or the human it relayed the URL to) does that.
+ */
+export async function runAuthHelper(): Promise {
+ // The parent unrefs and exits once it has the URL; every later stdout
+ // write would EPIPE without this guard.
+ const emit = (event: AuthHelperEvent) => {
+ try {
+ process.stdout.write(`${JSON.stringify(event)}\n`);
+ } catch {
+ // Parent is gone; the flow itself is unaffected.
+ }
+ };
+ process.stdout.on("error", () => {});
+
+ const params = await readHelperParams();
+ const serverUrl =
+ "url" in params.serverConfig ? params.serverConfig.url : undefined;
+ let markerPath: string | undefined;
+ try {
+ await authorizeInFrontend(params.serverConfig, params.serverSettings, {
+ makeNavigation: (autoOpenControl) =>
+ new CallbackNavigation(async (url) => {
+ // Mirror createCliOAuthNavigation's arming: SDK-internal auth()
+ // during the plain connect() attempt must not leak a URL the
+ // flow isn't listening for yet.
+ if (!autoOpenControl.armed) return;
+ if (serverUrl !== undefined) {
+ markerPath = pendingAuthMarkerPath(serverUrl);
+ writePendingAuthMarker(markerPath, {
+ url: url.href,
+ pid: process.pid,
+ expiresAt: Date.now() + PENDING_AUTH_TTL_MS,
+ });
+ }
+ emit({ event: "auth_url", url: url.href });
+ }),
+ });
+ emit({ event: "done" });
+ } catch (error) {
+ emit({
+ event: "error",
+ message: error instanceof Error ? error.message : String(error),
+ });
+ throw error;
+ } finally {
+ if (markerPath !== undefined) removeOwnPendingAuthMarker(markerPath);
+ }
+}
+
+/**
+ * Atomically reserve the right to spawn the sign-in helper for one server.
+ * `wx` creation is the atomicity (O_EXCL also refuses a planted symlink).
+ *
+ * A leftover lock from a crashed reserver is stolen once it is older than
+ * the URL wait window. The steal claims the specific stale file by an
+ * atomic rename to a per-pid path — concurrent stealers cannot both win,
+ * and a winner that renamed a lock which turned out to be fresh backs off
+ * (POSIX has no compare-and-delete; the rename makes the claim itself
+ * exclusive, which is what prevents a double spawn).
+ *
+ * @returns true if this process holds the reservation.
+ */
+function tryReserveAuthFlow(lockPath: string): boolean {
+ const isStale = (mtimeMs: number) =>
+ Date.now() - mtimeMs > AUTH_URL_WAIT_MS + 5_000;
+ const create = () =>
+ fs.writeFileSync(lockPath, `${process.pid}\n`, { flag: "wx", mode: 0o600 });
+ try {
+ create();
+ return true;
+ } catch {
+ try {
+ if (!isStale(fs.statSync(lockPath).mtimeMs)) return false;
+ // Claim the stale lock atomically: only one renamer succeeds.
+ const claimPath = `${lockPath}.claim-${process.pid}`;
+ fs.renameSync(lockPath, claimPath);
+ const claimedFresh = !isStale(fs.statSync(claimPath).mtimeMs);
+ fs.rmSync(claimPath, { force: true });
+ // The claimed file was recreated fresh between stat and rename: an
+ // active reserver holds the flow — back off and wait for its marker.
+ // (Un-injectable microsecond race; the guard is what matters.)
+ /* v8 ignore next */
+ if (claimedFresh) return false;
+ create();
+ return true;
+ } catch {
+ // Lock vanished, was claimed by another stealer, or was recreated
+ // mid-steal: treat as held by another process.
+ return false;
+ }
+ }
+}
+
+/**
+ * Another connect holds the flow reservation: wait for its helper to publish
+ * the marker and reuse that URL instead of spawning a competing helper.
+ */
+async function waitForPendingAuthUrl(
+ serverUrl: string,
+ waitMs: number,
+ pollMs: number,
+): Promise {
+ const deadline = Date.now() + waitMs;
+ for (;;) {
+ const marker = readLivePendingAuthMarker(serverUrl);
+ if (marker !== undefined) return marker.url;
+ if (Date.now() >= deadline) {
+ throw new CliExitCodeError(
+ EXIT_CODES.AUTH_REQUIRED,
+ "Timed out waiting for the in-progress sign-in flow to produce an authorization URL.",
+ { code: "auth_required" },
+ );
+ }
+ await new Promise((resolve) => {
+ // NOT unref'ed: for a reservation loser this timer may be the only
+ // live handle, and an unref'ed one would let Node exit cleanly
+ // mid-wait — the connect would print no authorization URL at all.
+ setTimeout(resolve, pollMs);
+ });
+ }
+}
+
+/**
+ * Non-TTY connect path: return the authorize URL for `serverConfig`, either
+ * from a still-live pending marker (helper already waiting — reuse its URL)
+ * or by spawning a fresh detached helper and reading the URL off its stdout.
+ *
+ * The marker check and helper spawn are made atomic by a per-server lock
+ * file: concurrent connects for the same server would otherwise both pass
+ * the check and spawn helpers that contend for the OAuth callback port. The
+ * loser of the reservation waits for the winner's helper to publish the
+ * marker (written before the helper reports the URL) and reuses it.
+ *
+ * After this resolves the helper is unrefed and survives this process: it
+ * holds the loopback callback listener and completes the token exchange when
+ * the user finishes signing in.
+ */
+export async function obtainPendingAuthUrl(
+ serverConfig: MCPServerConfig,
+ serverSettings: InspectorServerSettings | undefined,
+ options?: { helperArgv1?: string; waitMs?: number; pollMs?: number },
+): Promise {
+ const waitMs = options?.waitMs ?? AUTH_URL_WAIT_MS;
+ let lockPath: string | undefined;
+ const serverUrl = "url" in serverConfig ? serverConfig.url : undefined;
+ if (serverUrl !== undefined) {
+ const marker = readLivePendingAuthMarker(serverUrl);
+ if (marker !== undefined) return marker.url;
+ lockPath = `${pendingAuthMarkerPath(serverUrl)}.lock`;
+ if (!tryReserveAuthFlow(lockPath)) {
+ return waitForPendingAuthUrl(serverUrl, waitMs, options?.pollMs ?? 250);
+ }
+ }
+
+ try {
+ return await spawnAuthHelperForUrl(
+ serverConfig,
+ serverSettings,
+ waitMs,
+ options?.helperArgv1,
+ );
+ } finally {
+ // Success: the helper's marker is already on disk (written before the
+ // URL event), so later connects reuse it. Failure: releasing lets the
+ // next attempt spawn a fresh helper.
+ if (lockPath !== undefined) fs.rmSync(lockPath, { force: true });
+ }
+}
+
+/** Spawn the detached helper and read the authorize URL off its stdout. */
+async function spawnAuthHelperForUrl(
+ serverConfig: MCPServerConfig,
+ serverSettings: InspectorServerSettings | undefined,
+ waitMs: number,
+ helperArgv1?: string,
+): Promise {
+ /* v8 ignore next 6 -- argv[1] is always the mcpdo bin in production. */
+ const script = helperArgv1 ?? process.argv[1];
+ if (!script) {
+ throw new CliExitCodeError(
+ EXIT_CODES.USAGE,
+ "Cannot locate the mcpdo entry script to spawn the sign-in helper.",
+ { code: "usage" },
+ );
+ }
+ const child = spawn(process.execPath, [script, AUTH_HELPER_COMMAND], {
+ detached: true,
+ stdio: ["pipe", "pipe", "ignore"],
+ env: process.env,
+ });
+ child.stdin.on("error", () => {});
+ child.stdin.write(JSON.stringify({ serverConfig, serverSettings }));
+ child.stdin.end();
+
+ try {
+ return await new Promise((resolve, reject) => {
+ let buffer = "";
+ const fail = (message: string) => {
+ reject(
+ new CliExitCodeError(EXIT_CODES.AUTH_REQUIRED, message, {
+ code: "auth_required",
+ }),
+ );
+ };
+ const timer = setTimeout(() => {
+ fail(
+ "Timed out waiting for the sign-in helper to produce an authorization URL.",
+ );
+ }, waitMs);
+ timer.unref();
+ child.stdout.setEncoding("utf8");
+ child.stdout.on("data", (chunk: string) => {
+ buffer += chunk;
+ let newline;
+ while ((newline = buffer.indexOf("\n")) !== -1) {
+ const line = buffer.slice(0, newline);
+ buffer = buffer.slice(newline + 1);
+ if (!line.trim()) continue;
+ let event: AuthHelperEvent;
+ try {
+ event = JSON.parse(line) as AuthHelperEvent;
+ } catch {
+ continue;
+ }
+ if (event.event === "auth_url") {
+ clearTimeout(timer);
+ resolve(event.url);
+ return;
+ }
+ if (event.event === "error") {
+ clearTimeout(timer);
+ // The helper relays server-derived text; strip C0/C1 controls
+ // before it reaches a terminal via the error envelope.
+ fail(`Sign-in helper failed: ${sanitizeText(event.message)}`);
+ return;
+ }
+ }
+ });
+ // "close", not "exit": exit can fire while the final stdout line
+ // (e.g. `{"event":"error",...}`) is still buffered; close waits for
+ // the stdio streams to drain so that line is parsed first.
+ child.on("close", (code) => {
+ clearTimeout(timer);
+ fail(
+ `Sign-in helper exited (code ${String(code)}) before producing an authorization URL.`,
+ );
+ });
+ child.on("error", (error) => {
+ clearTimeout(timer);
+ fail(`Failed to spawn sign-in helper: ${error.message}`);
+ });
+ });
+ } finally {
+ // Release the helper: close our ends of its pipes and drop it from this
+ // process's ref graph so `connect` can exit while it keeps waiting.
+ child.stdout.destroy();
+ child.unref();
+ }
+}
diff --git a/clients/daemon-cli/src/connection/authorize.ts b/clients/daemon-cli/src/connection/authorize.ts
new file mode 100644
index 0000000000..260bab3153
--- /dev/null
+++ b/clients/daemon-cli/src/connection/authorize.ts
@@ -0,0 +1,151 @@
+import { MutableRedirectUrlProvider } from "@inspector/core/auth/index.js";
+import { NodeOAuthStorage } from "@inspector/core/auth/node/index.js";
+import {
+ DEFAULT_RUNNER_OAUTH_CALLBACK_URL,
+ formatRunnerOAuthRedirectUrl,
+ parseRunnerOAuthCallbackUrl,
+} from "@inspector/core/auth/node/runner-oauth-callback.js";
+import {
+ buildRunnerClientAuthOptions,
+ isOAuthCapableServerConfig,
+ loadRunnerClientConfig,
+} from "@inspector/core/client/runner.js";
+import { InspectorClient } from "@inspector/core/mcp/index.js";
+import { createTransportNode } from "@inspector/core/mcp/node/index.js";
+import {
+ eraToVersionNegotiation,
+ type InspectorClientEnvironment,
+ type InspectorServerSettings,
+ type MCPServerConfig,
+} from "@inspector/core/mcp/types.js";
+import { readInspectorVersion } from "@inspector/core/node/version.js";
+import { createCliOAuthNavigation } from "@inspector/cli/cli-oauth-navigation.js";
+import { connectInspectorWithOAuth } from "@inspector/cli/cliOAuth.js";
+import { CliExitCodeError, EXIT_CODES } from "@inspector/cli/error-handler.js";
+import { isEmaClientNotConfiguredError } from "@inspector/core/auth/ema/clientConfigError.js";
+import type { CallbackNavigation } from "@inspector/core/auth/index.js";
+import type { CliOAuthAutoOpenControl } from "@inspector/cli/cli-oauth-navigation.js";
+import { mcpdoEmaGuidance } from "./ema.js";
+
+/**
+ * Run interactive (or stored-auth-only) OAuth in the front-end process so tokens
+ * land in the shared `oauth.json` store, then the daemon can reconnect.
+ *
+ * `makeNavigation` overrides how the authorize URL is surfaced once the
+ * flow's interactive window arms it: the default prints the relay-worded
+ * prompt line; the detached auth helper injects a navigation that reports
+ * the raw URL over its stdout pipe instead (see auth-helper.ts).
+ */
+export async function authorizeInFrontend(
+ serverConfig: MCPServerConfig,
+ serverSettings: InspectorServerSettings | undefined,
+ options?: {
+ storedAuthOnly?: boolean;
+ makeNavigation?: (
+ autoOpenControl: CliOAuthAutoOpenControl,
+ ) => CallbackNavigation;
+ },
+): Promise {
+ if (!isOAuthCapableServerConfig(serverConfig)) {
+ return;
+ }
+
+ const environment: InspectorClientEnvironment = {
+ transport: createTransportNode,
+ };
+ const redirectUrlProvider = new MutableRedirectUrlProvider();
+ const callbackUrlConfig = parseRunnerOAuthCallbackUrl(
+ process.env.MCP_OAUTH_CALLBACK_URL ?? DEFAULT_RUNNER_OAUTH_CALLBACK_URL,
+ );
+ redirectUrlProvider.redirectUrl =
+ formatRunnerOAuthRedirectUrl(callbackUrlConfig);
+ // Disarmed until connectInspectorWithOAuth's own interactive-OAuth window
+ // runs — mirrors the one-shot CLI's autoOpenControl (clients/cli/src/cli.ts):
+ // SDK `auth()` during plain connect() must not print/open before that
+ // window (or --stored-auth-only) gates it.
+ const autoOpenControl = { armed: false };
+ environment.oauth = {
+ storage: new NodeOAuthStorage(),
+ // mcpdo always attempts interactive OAuth (see the isTTY override below) —
+ // whoever is running it (human or agent) may not have a real TTY on
+ // stdin/stderr. Reword the printed line so an agent knows it must relay
+ // the link to a human rather than treating "Please navigate to" as
+ // addressed to itself.
+ navigation: options?.makeNavigation
+ ? options.makeNavigation(autoOpenControl)
+ : createCliOAuthNavigation({
+ autoOpenControl,
+ disableAutoOpen: options?.storedAuthOnly,
+ promptMessage: (hrefDisplay, tty) =>
+ tty
+ ? `Please navigate to: ${hrefDisplay}`
+ : `The user needs to navigate to this link to authenticate: ${hrefDisplay}`,
+ }),
+ redirectUrlProvider,
+ };
+
+ 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,
+ // mcpdo runs as a front-end for whatever invoked it (human terminal or
+ // agent subprocess) — always admit interactive OAuth rather than
+ // refusing when stdin/stderr aren't a real TTY. The CI-hang concern
+ // behind that gate (see clients/cli/README.md OAuth section) doesn't
+ // apply here: an agent without a TTY is still expected to relay the
+ // printed URL to an attended human, not run unattended. --stored-auth-only
+ // (checked above assertInteractiveOAuthAllowed, so unaffected by this)
+ // remains the way to opt out of interactive OAuth entirely.
+ isTTY: true,
+ autoOpenControl,
+ },
+ );
+ } catch (err) {
+ // An EMA server without active install-level IdP config: interactive
+ // OAuth cannot fix this, so replace the core error (which points at the
+ // web Client Settings dialog only) with mcpdo-appropriate guidance.
+ if (isEmaClientNotConfiguredError(err)) {
+ throw new CliExitCodeError(
+ EXIT_CODES.AUTH_REQUIRED,
+ mcpdoEmaGuidance(err.reason),
+ { code: "auth_required" },
+ );
+ }
+ throw err;
+ } finally {
+ try {
+ await client.disconnect();
+ } catch {
+ // best-effort
+ }
+ }
+}
diff --git a/clients/daemon-cli/src/connection/dispatch.ts b/clients/daemon-cli/src/connection/dispatch.ts
new file mode 100644
index 0000000000..3d21e76e4c
--- /dev/null
+++ b/clients/daemon-cli/src/connection/dispatch.ts
@@ -0,0 +1,217 @@
+import { callDaemon, ensureDaemon, streamDaemon } from "../daemon/index.js";
+import type { RpcParams, RpcResult } from "../daemon/protocol.js";
+import type {
+ CliAppInfo,
+ MethodArgs,
+} from "@inspector/cli/handlers/method-types.js";
+import type { OutputFormat } from "@inspector/cli/handlers/format-output.js";
+import { writeConnectionOutput } from "./format-connection.js";
+import { styleFromOpts, type Style } from "@inspector/cli/style.js";
+import { promptElicitation } from "./elicitation-prompt.js";
+
+const STREAM_METHODS = new Set(["logging/tail", "resources/subscribe"]);
+
+/**
+ * The only two methods whose NDJSON output is a `--verify` conformance report
+ * rather than `tools/list --app-info` probe lines. Everything else that ever
+ * returns `kind: "ndjson"` is the app-info shape, so this is a short
+ * allow-list rather than the other way round.
+ */
+const NDJSON_VARIANTS = new Set(["skills/list", "skills/get"]);
+
+export type ConnectionDispatchOpts = {
+ format?: OutputFormat;
+ plain?: boolean;
+ connection?: string;
+ requireExplicit: boolean;
+};
+
+/**
+ * Run one connection MCP method via daemon `rpc` or `stream`.
+ */
+export async function dispatchConnectionRpc(
+ method: string,
+ methodArgs: MethodArgs,
+ opts: ConnectionDispatchOpts,
+): Promise {
+ const format: OutputFormat = opts.format ?? "text";
+ const style = styleFromOpts({ plain: opts.plain, format });
+ const params: RpcParams = {
+ ...methodArgs,
+ // `format` stays frontend-only: forwarding it would make the daemon's
+ // runMethod treat `format: "json"` tool calls as app-info requests and
+ // issue a hidden extra resources/read whose result we discard. The
+ // daemon also strips it defensively (see stripConnectionFields).
+ method,
+ name: stripAt(opts.connection),
+ requireExplicit: opts.requireExplicit,
+ };
+
+ 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);
+ // Stream writes are chained and awaited before returning: mcp-bin calls
+ // process.exit() right after, which truncates a still-pending stdout
+ // write when output is piped or backpressured.
+ let writeChain: Promise = Promise.resolve();
+ try {
+ await streamDaemon(params, {
+ socketPath,
+ // Core enforces the configured MCP request timeout daemon-side; a
+ // fixed local deadline would falsely fail stream setups (e.g. a
+ // subscribe against a slow server) that are still valid.
+ timeoutMs: 0,
+ signal: ac.signal,
+ onData: (data) => {
+ writeChain = writeChain
+ .then(() =>
+ writeConnectionOutput(
+ { format, style },
+ {
+ kind: "stream-event",
+ data,
+ },
+ ),
+ )
+ // Recover the chain itself, not just observe it: a rejected
+ // chain would skip every later `.then`, silently dropping all
+ // subsequent events after one failed write. Write errors stay
+ // non-fatal, as they were when these writes were
+ // fire-and-forget.
+ .catch(() => {});
+ // Returning the chain lets streamDaemon pause socket reads until
+ // the write settles, bounding memory when stdout is slow.
+ return writeChain;
+ },
+ });
+ } finally {
+ process.off("SIGINT", onSignal);
+ process.off("SIGTERM", onSignal);
+ await writeChain.catch(() => {});
+ }
+ return;
+ }
+
+ const ac = new AbortController();
+ const onSignal = () => ac.abort();
+ process.on("SIGINT", onSignal);
+ process.on("SIGTERM", onSignal);
+ // Interactive callers get inline prompts; everyone else — `--format json`
+ // (single machine-readable payload) or no TTY at all (an agent's stdin is
+ // not wired to the human, so a prompt would hang until auto-cancel) — has
+ // the daemon park the elicitation and answers via `elicitation/respond`.
+ const interactive =
+ format === "text" &&
+ (process.stdin.isTTY === true || process.stderr.isTTY === true);
+ if (!interactive) params.parkElicitations = true;
+ let outcome: RpcResult;
+ try {
+ outcome = await callDaemon("rpc", params, {
+ socketPath,
+ // Core enforces the configured MCP request timeout daemon-side; a
+ // fixed local deadline would falsely fail long-running tool calls.
+ timeoutMs: 0,
+ signal: ac.signal,
+ onElicitation: (frame) =>
+ promptElicitation(frame, {
+ style,
+ // Prompting only needs a readable stdin and a text-based reply
+ // channel; parked (non-interactive) callers never receive frames,
+ // so this only ever runs interactively.
+ interactive,
+ }),
+ });
+ } finally {
+ process.off("SIGINT", onSignal);
+ process.off("SIGTERM", onSignal);
+ }
+ await writeRpcOutcome(
+ { format, style },
+ method,
+ methodArgs.toolName,
+ outcome,
+ );
+}
+
+/**
+ * Render one rpc outcome — final result, NDJSON report, or a parked
+ * elicitation round. Shared by the originating call above and by
+ * `elicitation/respond`, whose result is the same shape (the resumed call's
+ * outcome or the next round).
+ */
+export async function writeRpcOutcome(
+ out: { format?: OutputFormat; style: Style },
+ method: string,
+ toolName: string | undefined,
+ outcome: RpcResult,
+): Promise {
+ const { format, style } = out;
+ if (outcome.kind === "elicitation-pending") {
+ await writeConnectionOutput(
+ { format, style },
+ { kind: "elicitation-pending", elicitation: outcome.elicitation },
+ );
+ return;
+ }
+ if (outcome.kind === "ndjson") {
+ await writeConnectionOutput(
+ { format, style },
+ {
+ kind: "ndjson",
+ lines: outcome.lines,
+ variant: NDJSON_VARIANTS.has(method) ? "skill-verify" : "app-info",
+ summary: outcome.summary,
+ exitCode: outcome.exitCode,
+ },
+ );
+ return;
+ }
+ await writeConnectionOutput(
+ { format, style },
+ {
+ kind: "rpc",
+ method,
+ result: outcome.result,
+ appInfo: outcome.appInfo as CliAppInfo | undefined,
+ toolName,
+ },
+ );
+}
+
+export function stripAt(name: string | undefined): string | undefined {
+ if (!name) return undefined;
+ return name.startsWith("@") ? name.slice(1) : name;
+}
+
+/**
+ * Non-interactive runs must pass an explicit connection for MRU-targeting ops.
+ * Key off stdin (not stdout) so piping output (`mcpdo tools/list | jq`) still
+ * uses MRU when a human is at the keyboard.
+ */
+export function requireExplicitConnection(): boolean {
+ if (process.env.MCP_ALLOW_DEFAULT_CONNECTION === "1") return false;
+ return process.stdin.isTTY !== true;
+}
+
+/**
+ * Hoist a leading `@name` from argv so `mcpdo @alpha tools/list` works.
+ */
+export function hoistAtConnection(argv: string[]): {
+ argv: string[];
+ connectionFromAt?: 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)],
+ connectionFromAt: token.slice(1),
+ };
+ }
+ return { argv };
+}
diff --git a/clients/daemon-cli/src/connection/elicitation-prompt.ts b/clients/daemon-cli/src/connection/elicitation-prompt.ts
new file mode 100644
index 0000000000..158220060a
--- /dev/null
+++ b/clients/daemon-cli/src/connection/elicitation-prompt.ts
@@ -0,0 +1,177 @@
+/**
+ * Terminal UI for a mid-`rpc` elicitation exchange (dual-era support). Covers
+ * both delivery mechanisms (legacy server→client request, modern non-task
+ * MRTR round) and both modes:
+ *
+ * - **URL mode** mirrors the web client's convention
+ * (`InlineElicitationRequest`/`PendingClientRequestModal`): the actual
+ * out-of-band completion can't be observed here, so the user self-reports
+ * it by answering a confirm prompt — there is no "decline" for URL mode,
+ * only accept (they say they finished) or cancel.
+ * - **Form mode** renders one prompt per field from the schema (see
+ * `form-schema.ts`/`form-prompt.ts`), with a review step before submitting.
+ * Schemas outside the spec's restricted primitive-field shape (should
+ * never happen from a well-behaved server) fall back to a clear decline.
+ */
+import type { Style } from "@inspector/cli/style.js";
+import type {
+ ElicitationRequestFrame,
+ ElicitationResponseFrame,
+} from "../daemon/protocol.js";
+import { parseFormSchema } from "./form-schema.js";
+import { promptForm, watchForClose } from "./form-prompt.js";
+import { getSharedPromptReader } from "./prompt-reader.js";
+import { isSafeLinkTarget, sanitizeText } from "./sanitize.js";
+
+export type PromptElicitationOpts = {
+ /**
+ * False only for callers where a text prompt can't sensibly be shown
+ * (currently just `--format json`, whose stdout is a single
+ * machine-readable payload). A prompt works the same over a plain,
+ * non-TTY stdin/stderr as it does at a real terminal — a human at a
+ * keyboard and an agent relaying/answering on their behalf both just
+ * read a line of text and reply with one. A stdin that's already closed
+ * (e.g. `mcpdo ... {
+ const { style } = opts;
+ // Server-controlled display strings must not reach the terminal raw
+ // (escape injection — see sanitize.ts). Protocol ids on `frame` stay
+ // untouched so responses still correlate.
+ const message = sanitizeText(frame.message);
+ const url = frame.url === undefined ? undefined : sanitizeText(frame.url);
+
+ if (frame.mode === "form") {
+ // The schema is parsed raw: sanitizing it wholesale would mutate protocol
+ // data (property names, enum values, defaults), so the accepted response
+ // could carry keys/values the server never defined. Server-controlled
+ // strings are instead sanitized at each render point in form-prompt.ts.
+ const fields = parseFormSchema(frame.requestedSchema);
+ if (!fields) {
+ // Schema outside the spec's restricted primitive-field shape —
+ // shouldn't happen from a well-behaved server; decline clearly rather
+ // than silently guessing at field values.
+ process.stderr.write(
+ style.yellow(
+ "This server's form request uses a schema mcpdo doesn't support " +
+ "— declining.\n",
+ ) + ` ${message}\n`,
+ );
+ return declineResponse(frame);
+ }
+
+ if (!opts.interactive) {
+ process.stderr.write(
+ style.yellow(
+ "This server is asking for form input, which isn't supported " +
+ "with --format json — declining.\n",
+ ) + ` ${message}\n`,
+ );
+ return declineResponse(frame);
+ }
+
+ // The shared reader outlives this exchange on purpose: piped answers
+ // for later fields/rounds arrive before their questions are asked, and
+ // a per-exchange interface would drop them (see prompt-reader.ts).
+ const rl = getSharedPromptReader();
+ try {
+ const outcome = await promptForm(rl, message, fields, style);
+ if (outcome.action === "accept") {
+ return {
+ id: frame.id,
+ kind: "elicitation-response",
+ elicitationId: frame.elicitationId,
+ action: "accept",
+ content: outcome.content,
+ };
+ }
+ if (outcome.action === "decline") return declineResponse(frame);
+ return cancelResponse(frame);
+ } catch {
+ return cancelResponse(frame);
+ }
+ }
+
+ if (!opts.interactive) {
+ process.stderr.write(
+ style.yellow(
+ "This server is asking for input via a URL (elicitation), which " +
+ "isn't supported with --format json — cancelling.\n",
+ ) +
+ ` ${message}\n` +
+ (url ? ` ${url}\n` : ""),
+ );
+ return cancelResponse(frame);
+ }
+
+ process.stderr.write(
+ "\n" +
+ style.bold("Action required: ") +
+ message +
+ "\n" +
+ " " +
+ // Only allowlisted schemes render as a clickable OSC 8 link; a server
+ // supplying file:/custom-handler URLs gets plain text (see sanitize.ts).
+ (url !== undefined && isSafeLinkTarget(url)
+ ? style.link(url, url)
+ : (url ?? "")) +
+ "\n\n",
+ );
+
+ const rl = getSharedPromptReader();
+ try {
+ const answer = await Promise.race([
+ rl.question(
+ "Open the URL above, complete it, then press Enter to continue " +
+ "(or type 'c' to cancel): ",
+ ),
+ watchForClose(rl),
+ ]);
+ if (answer.trim().toLowerCase() === "c") {
+ return cancelResponse(frame);
+ }
+ return {
+ id: frame.id,
+ kind: "elicitation-response",
+ elicitationId: frame.elicitationId,
+ action: "accept",
+ };
+ } catch {
+ return cancelResponse(frame);
+ }
+}
diff --git a/clients/daemon-cli/src/connection/ema.ts b/clients/daemon-cli/src/connection/ema.ts
new file mode 100644
index 0000000000..1fe1ef3580
--- /dev/null
+++ b/clients/daemon-cli/src/connection/ema.ts
@@ -0,0 +1,237 @@
+import {
+ clearEmaIdpSession,
+ getEmaIdpLoginState,
+ normalizeIdpIssuer,
+ type EmaIdpLoginState,
+} from "@inspector/core/auth/ema/index.js";
+import type { EmaClientNotConfiguredReason } from "@inspector/core/auth/ema/clientConfigError.js";
+import {
+ completeIdpOidcAuthorization,
+ startIdpOidcAuthorization,
+} from "@inspector/core/auth/ema/idpOidc.js";
+import { MutableRedirectUrlProvider } from "@inspector/core/auth/index.js";
+import {
+ NodeOAuthStorage,
+ runRunnerInteractiveOAuth,
+} from "@inspector/core/auth/node/index.js";
+import { resetNodeOAuthStorageCache } from "@inspector/core/auth/node/storage-node.js";
+import {
+ DEFAULT_RUNNER_OAUTH_CALLBACK_URL,
+ formatRunnerOAuthRedirectUrl,
+ parseRunnerOAuthCallbackUrl,
+} from "@inspector/core/auth/node/runner-oauth-callback.js";
+import { getClientConfigFilePath } from "@inspector/core/client/index.js";
+import { loadRunnerClientConfig } from "@inspector/core/client/runner.js";
+import type { EnterpriseManagedAuthIdpConfig } from "@inspector/core/client/types.js";
+import { createCliOAuthNavigation } from "@inspector/cli/cli-oauth-navigation.js";
+import { CliExitCodeError, EXIT_CODES } from "@inspector/cli/error-handler.js";
+
+/** Where install-level EMA IdP config lives (honours MCP_CLIENT_CONFIG_PATH). */
+function clientConfigPath(): string {
+ return getClientConfigFilePath(
+ process.env.MCP_CLIENT_CONFIG_PATH?.trim() || undefined,
+ );
+}
+
+/**
+ * mcpdo-flavoured guidance for a missing/disabled EMA client configuration.
+ * The core `EmaClientNotConfiguredError` message points at the web Client
+ * Settings dialog; mcpdo users may equally well edit `client.json` directly,
+ * so name both, with the resolved path.
+ */
+export function mcpdoEmaGuidance(reason: EmaClientNotConfiguredReason): string {
+ const path = clientConfigPath();
+ if (reason === "disabled") {
+ return (
+ "Enterprise-managed auth (EMA) is configured but disabled. Enable it in " +
+ "the web Inspector's Client Settings, or set " +
+ `enterpriseManagedAuth.enabled to true in ${path}.`
+ );
+ }
+ return (
+ "Enterprise-managed auth (EMA) is not configured. Configure the " +
+ "enterprise IdP (issuer, client ID, client secret) in the web Inspector's " +
+ `Client Settings, or add an enterpriseManagedAuth block to ${path}.`
+ );
+}
+
+export type EmaStatus = {
+ /** Resolved client.json path the config was read from. */
+ clientConfigPath: string;
+ /** An IdP block exists in client.json (even if disabled). */
+ configured: boolean;
+ /** Configured and not explicitly disabled. */
+ enabled: boolean;
+ issuer?: string;
+ clientId?: string;
+ /** IdP session state; "unconfigured" when no IdP block exists. */
+ loginState: EmaIdpLoginState | "unconfigured";
+};
+
+/** Read install-level EMA config; the raw idp block, even when disabled. */
+async function loadEmaIdpConfig(): Promise<{
+ idp: EnterpriseManagedAuthIdpConfig | undefined;
+ enabled: boolean;
+}> {
+ const clientConfig = await loadRunnerClientConfig({});
+ const ema = clientConfig.enterpriseManagedAuth;
+ return {
+ idp: ema?.idp,
+ enabled: Boolean(ema?.idp) && ema?.enabled !== false,
+ };
+}
+
+function requireIdp(
+ idp: EnterpriseManagedAuthIdpConfig | undefined,
+ enabled: boolean,
+ options?: { allowDisabled?: boolean },
+): EnterpriseManagedAuthIdpConfig {
+ if (!idp) {
+ throw new CliExitCodeError(
+ EXIT_CODES.USAGE,
+ mcpdoEmaGuidance("not_configured"),
+ {
+ code: "usage",
+ },
+ );
+ }
+ if (!enabled && !options?.allowDisabled) {
+ throw new CliExitCodeError(EXIT_CODES.USAGE, mcpdoEmaGuidance("disabled"), {
+ code: "usage",
+ });
+ }
+ return idp;
+}
+
+/** EMA configuration + IdP session state for `auth/ema-status`. */
+export async function getEmaStatus(): Promise {
+ const { idp, enabled } = await loadEmaIdpConfig();
+ if (!idp) {
+ return {
+ clientConfigPath: clientConfigPath(),
+ configured: false,
+ enabled: false,
+ loginState: "unconfigured",
+ };
+ }
+ const storage = new NodeOAuthStorage();
+ const loginState = await getEmaIdpLoginState(storage, idp.issuer);
+ return {
+ clientConfigPath: clientConfigPath(),
+ configured: true,
+ enabled,
+ issuer: normalizeIdpIssuer(idp.issuer),
+ clientId: idp.clientId,
+ loginState,
+ };
+}
+
+export type EmaLogoutResult = { issuer: string };
+
+/**
+ * Sign out of the enterprise IdP: clears the cached IdP OIDC connection and all
+ * EMA-minted resource-server tokens. Works even when EMA is disabled (state
+ * cleanup should never be blocked by the enabled flag).
+ */
+export async function emaLogout(): Promise {
+ const { idp, enabled } = await loadEmaIdpConfig();
+ const active = requireIdp(idp, enabled, { allowDisabled: true });
+ const storage = new NodeOAuthStorage();
+ await clearEmaIdpSession(storage, active.issuer);
+ resetNodeOAuthStorageCache();
+ return { issuer: normalizeIdpIssuer(active.issuer) };
+}
+
+export type EmaLoginResult = {
+ issuer: string;
+ loginState: EmaIdpLoginState;
+ alreadyLoggedIn: boolean;
+};
+
+/**
+ * Sign in to the enterprise IdP (EMA leg 1 only — no server required): print
+ * the IdP authorization URL, wait on the loopback callback, and exchange the
+ * code for an IdP session. Subsequent connects to EMA servers mint resource
+ * tokens silently from this connection.
+ *
+ * Non-TTY (agent-attended) callers get wording that directs the agent to
+ * relay the link to the human user, mirroring `authorizeInFrontend`. SIGINT /
+ * SIGTERM and the callback timeout are handled by
+ * {@link runRunnerInteractiveOAuth}.
+ */
+export async function emaLogin(options?: {
+ /** Clear any existing IdP session (and EMA server tokens) first. */
+ relogin?: boolean;
+}): Promise {
+ const { idp, enabled } = await loadEmaIdpConfig();
+ const active = requireIdp(idp, enabled);
+ const issuer = normalizeIdpIssuer(active.issuer);
+ const storage = new NodeOAuthStorage();
+
+ if (options?.relogin) {
+ await clearEmaIdpSession(storage, active.issuer);
+ } else if (
+ (await getEmaIdpLoginState(storage, active.issuer)) === "logged_in"
+ ) {
+ return { issuer, loginState: "logged_in", alreadyLoggedIn: true };
+ }
+
+ const callbackUrlConfig = parseRunnerOAuthCallbackUrl(
+ process.env.MCP_OAUTH_CALLBACK_URL ?? DEFAULT_RUNNER_OAUTH_CALLBACK_URL,
+ );
+ const redirectUrlProvider = new MutableRedirectUrlProvider();
+ redirectUrlProvider.redirectUrl =
+ formatRunnerOAuthRedirectUrl(callbackUrlConfig);
+ // Armed from the start: unlike connect-time OAuth there is no SDK-internal
+ // auth() phase to guard against — this flow owns its one authorize URL.
+ const navigation = createCliOAuthNavigation({
+ autoOpenControl: { armed: true },
+ promptMessage: (hrefDisplay, tty) =>
+ tty
+ ? `Sign in to your enterprise IdP: ${hrefDisplay}`
+ : "The user needs to sign in to the enterprise identity provider " +
+ `(IdP) at this link: ${hrefDisplay}`,
+ });
+
+ // Adapter over the server-bound runner-interactive-OAuth surface: EMA leg 1
+ // is server-less, so authenticate/completeOAuthFlow map straight onto the
+ // IdP OIDC start/complete helpers. This reuses the loopback callback
+ // server, 15-minute timeout, and SIGINT/SIGTERM cancellation.
+ await runRunnerInteractiveOAuth({
+ client: {
+ authenticate: async () => {
+ const { authorizationUrl } = await startIdpOidcAuthorization({
+ idp: active,
+ redirectUrl: redirectUrlProvider.redirectUrl,
+ storage,
+ });
+ navigation.navigateToAuthorization(authorizationUrl);
+ return authorizationUrl;
+ },
+ /* v8 ignore next 2 -- only reached when options.authorizationUrl is set, which this flow never does */
+ beginInteractiveAuthorization: async () => {},
+ completeOAuthFlow: async (authorizationCode, iss) => {
+ await completeIdpOidcAuthorization({
+ idp: active,
+ authorizationCode,
+ iss,
+ redirectUrl: redirectUrlProvider.redirectUrl,
+ storage,
+ });
+ },
+ /* v8 ignore next 2 -- only reached when options.authChallenge is set, which this flow never does */
+ checkAuthChallengeSatisfied: async () => false,
+ },
+ redirectUrlProvider,
+ callbackListen: callbackUrlConfig,
+ // mcpdo is a plain CLI (no Ink); own Ctrl-C during the IdP wait.
+ handleSignals: true,
+ });
+ resetNodeOAuthStorageCache();
+
+ return {
+ issuer,
+ loginState: await getEmaIdpLoginState(storage, active.issuer),
+ alreadyLoggedIn: false,
+ };
+}
diff --git a/clients/daemon-cli/src/connection/form-prompt.ts b/clients/daemon-cli/src/connection/form-prompt.ts
new file mode 100644
index 0000000000..dc27365fec
--- /dev/null
+++ b/clients/daemon-cli/src/connection/form-prompt.ts
@@ -0,0 +1,299 @@
+/**
+ * Interactive terminal renderer for a form-mode elicitation
+ * (dual-era support, phase 3). Prompts once per field (type-appropriate:
+ * text, numeric, y/n, numbered single-select, numbered multi-select),
+ * pre-fills defaults, does light client-side validation (required/length/
+ * range), then shows a review step before submitting so the user can
+ * re-edit any field or cancel outright.
+ */
+import type { Style } from "@inspector/cli/style.js";
+import type { PromptInput } from "./prompt-reader.js";
+import type { FormField } from "./form-schema.js";
+import { codePointLength } from "./form-schema.js";
+import { sanitizeText } from "./sanitize.js";
+
+export type FormOutcome =
+ | { action: "accept"; content: Record }
+ | { action: "decline" }
+ | { action: "cancel" };
+
+/**
+ * A promise that rejects the first time `rl` reports exhausted input (for a
+ * {@link PromptReader}: EOF on a redirected/piped stdin *with no buffered
+ * line left to answer from*, or the reader being disposed elsewhere). Racing
+ * every `rl.question()` against this means a closed-before-answered stdin
+ * (e.g. `mcpdo ... {
+ return new Promise((_, reject) => {
+ rl.once("close", () =>
+ reject(new Error("stdin closed before an answer was given")),
+ );
+ });
+}
+
+/** `rl.question()`, but rejects instead of hanging if stdin closes first. */
+function ask(
+ rl: PromptInput,
+ closed: Promise,
+ prompt: string,
+): Promise {
+ return Promise.race([rl.question(prompt), closed]);
+}
+
+function formatDefault(field: FormField): string | undefined {
+ if (field.default === undefined) return undefined;
+ if (field.kind === "multiselect") {
+ return (field.default as string[]).join(", ");
+ }
+ return String(field.default);
+}
+
+function describeField(field: FormField, style: Style): string {
+ // Titles, descriptions and defaults are server-controlled: sanitize at the
+ // render point only, so the raw values still travel in the response.
+ const req = field.required ? style.yellow(" (required)") : "";
+ const desc = field.description ? ` — ${sanitizeText(field.description)}` : "";
+ const def = formatDefault(field);
+ const defHint =
+ def !== undefined ? style.dim(` [default: ${sanitizeText(def)}]`) : "";
+ return `${style.bold(sanitizeText(field.title))}${req}${desc}${defHint}`;
+}
+
+/** Prompts for one field's value; loops until a valid answer or a default/blank-when-optional. */
+async function promptField(
+ rl: PromptInput,
+ closed: Promise,
+ field: FormField,
+ style: Style,
+): Promise {
+ for (;;) {
+ if (field.kind === "boolean") {
+ const def = field.default;
+ const hint = def === undefined ? "y/n" : def ? "Y/n" : "y/N";
+ const raw = (
+ await ask(rl, closed, `${describeField(field, style)}\n [${hint}]: `)
+ )
+ .trim()
+ .toLowerCase();
+ if (raw === "" && def !== undefined) return def;
+ if (raw === "y" || raw === "yes") return true;
+ if (raw === "n" || raw === "no") return false;
+ if (raw === "" && !field.required) return undefined;
+ process.stderr.write(style.red(" Please answer y or n.\n"));
+ continue;
+ }
+
+ if (field.kind === "enum" || field.kind === "multiselect") {
+ const lines = field.choices.map(
+ (choice, i) => ` ${i + 1}. ${sanitizeText(choice.label)}`,
+ );
+ const multi = field.kind === "multiselect";
+ const prompt = multi
+ ? "Enter one or more numbers separated by commas"
+ : "Enter a number";
+ const raw = (
+ await ask(
+ rl,
+ closed,
+ `${describeField(field, style)}\n${lines.join("\n")}\n ${prompt}: `,
+ )
+ ).trim();
+ if (raw === "") {
+ if (field.default !== undefined) return field.default;
+ if (!field.required) return undefined;
+ if (multi) {
+ const m = field as Extract;
+ // JSON Schema "required" only means the key must be present; an
+ // empty array is a valid value unless minItems forbids it. Without
+ // this, "none selected" on a required multiselect loops forever.
+ if ((m.minItems ?? 0) === 0) return [];
+ process.stderr.write(style.red(` Select at least ${m.minItems}.\n`));
+ continue;
+ }
+ process.stderr.write(style.red(" This field is required.\n"));
+ continue;
+ }
+ // Strict whole-token integers only: parseInt would accept "1abc" as 1,
+ // silently submitting a different answer than the user typed.
+ const tokens = raw.split(",").map((s) => s.trim());
+ const indices = tokens.map((s) =>
+ /^\d+$/.test(s) ? Number.parseInt(s, 10) : Number.NaN,
+ );
+ if (
+ indices.some(
+ (n) => !Number.isInteger(n) || n < 1 || n > field.choices.length,
+ )
+ ) {
+ process.stderr.write(
+ style.red(
+ ` Enter a number between 1 and ${field.choices.length}.\n`,
+ ),
+ );
+ continue;
+ }
+ if (!multi && indices.length !== 1) {
+ // "1,2" on a single-select would silently drop everything after the
+ // first choice — re-prompt instead.
+ process.stderr.write(style.red(" Enter exactly one number.\n"));
+ continue;
+ }
+ const values = indices.map((n) => field.choices[n - 1]!.value);
+ if (multi) {
+ const m = field as Extract;
+ if (m.minItems !== undefined && values.length < m.minItems) {
+ process.stderr.write(style.red(` Select at least ${m.minItems}.\n`));
+ continue;
+ }
+ if (m.maxItems !== undefined && values.length > m.maxItems) {
+ process.stderr.write(style.red(` Select at most ${m.maxItems}.\n`));
+ continue;
+ }
+ return values;
+ }
+ return values[0];
+ }
+
+ if (field.kind === "number") {
+ const def = field.default;
+ const raw = (
+ await ask(
+ rl,
+ closed,
+ `${describeField(field, style)}\n ${def !== undefined ? `[${def}]` : ""}: `,
+ )
+ ).trim();
+ if (raw === "") {
+ if (def !== undefined) return def;
+ if (!field.required) return undefined;
+ process.stderr.write(style.red(" This field is required.\n"));
+ continue;
+ }
+ const n = Number(raw);
+ if (
+ // isFinite (not isNaN): "Infinity" is not a valid JSON number and
+ // would serialize as null in the response frame.
+ !Number.isFinite(n) ||
+ (field.integer && !Number.isInteger(n)) ||
+ (field.minimum !== undefined && n < field.minimum) ||
+ (field.maximum !== undefined && n > field.maximum)
+ ) {
+ const range =
+ field.minimum !== undefined || field.maximum !== undefined
+ ? ` (${field.minimum ?? "-∞"}..${field.maximum ?? "∞"})`
+ : "";
+ process.stderr.write(
+ style.red(
+ ` Enter a valid ${field.integer ? "integer" : "number"}${range}.\n`,
+ ),
+ );
+ continue;
+ }
+ return n;
+ }
+
+ // string
+ const def = field.default;
+ const raw = await ask(
+ rl,
+ closed,
+ `${describeField(field, style)}\n ${def !== undefined ? `[${sanitizeText(def)}]` : ""}: `,
+ );
+ // Enter with a default selects it — even an empty-string default; the
+ // schema gate already rejected defaults violating their own constraints.
+ if (raw === "" && def !== undefined) return def;
+ // A blank answer with no default omits an optional field. For a required
+ // field "" is a value — JSON Schema `required` means present, not
+ // non-empty — so minLength (if any) decides below.
+ if (raw === "" && !field.required) return undefined;
+ const value = raw;
+ // Code points, not UTF-16 units: JSON Schema length semantics.
+ const length = codePointLength(value);
+ if (field.minLength !== undefined && length < field.minLength) {
+ process.stderr.write(
+ style.red(` Must be at least ${field.minLength} characters.\n`),
+ );
+ continue;
+ }
+ if (field.maxLength !== undefined && length > field.maxLength) {
+ process.stderr.write(
+ style.red(` Must be at most ${field.maxLength} characters.\n`),
+ );
+ continue;
+ }
+ return value;
+ }
+}
+
+/**
+ * Collect one value per field, then loop on a review step (submit / edit a
+ * field by name / cancel) until the user submits or cancels.
+ */
+export async function promptForm(
+ rl: PromptInput,
+ message: string,
+ fields: FormField[],
+ style: Style,
+): Promise {
+ process.stderr.write(`\n${style.bold("Input requested: ")}${message}\n\n`);
+ const closed = watchForClose(rl);
+
+ const values = new Map();
+ for (const field of fields) {
+ values.set(field.name, await promptField(rl, closed, field, style));
+ }
+
+ for (;;) {
+ process.stderr.write(`\n${style.bold("Review your answers:")}\n`);
+ for (const field of fields) {
+ const v = values.get(field.name);
+ // The edit prompt below accepts the schema property *name*; show it
+ // whenever it differs from the display title so the user can discover
+ // what to type.
+ const label =
+ field.title === field.name
+ ? field.title
+ : `${field.title} (${field.name})`;
+ process.stderr.write(
+ ` ${sanitizeText(label)}: ${v === undefined ? style.dim("(none)") : sanitizeText(String(v))}\n`,
+ );
+ }
+ const answer = (
+ await ask(
+ rl,
+ closed,
+ "\nPress Enter to submit, type a field name to edit it, or 'c' to cancel: ",
+ )
+ ).trim();
+ if (answer === "") {
+ // Null prototype: a schema is entitled to a property named
+ // "__proto__", which on a plain object would hit the prototype
+ // setter instead of creating an own property, silently dropping the
+ // answer (mirrors sanitizeDeep's handling of untrusted keys).
+ const content: Record = Object.create(null) as Record<
+ string,
+ unknown
+ >;
+ for (const field of fields) {
+ const v = values.get(field.name);
+ if (v !== undefined) content[field.name] = v;
+ }
+ return { action: "accept", content };
+ }
+ if (answer.toLowerCase() === "c") {
+ return { action: "cancel" };
+ }
+ const field =
+ fields.find((f) => f.name === answer) ??
+ fields.find((f) => f.title === answer);
+ if (!field) {
+ process.stderr.write(
+ style.red(` Unknown field "${answer}". Try again.\n`),
+ );
+ continue;
+ }
+ values.set(field.name, await promptField(rl, closed, field, style));
+ }
+}
diff --git a/clients/daemon-cli/src/connection/form-schema.ts b/clients/daemon-cli/src/connection/form-schema.ts
new file mode 100644
index 0000000000..5f807e6113
--- /dev/null
+++ b/clients/daemon-cli/src/connection/form-schema.ts
@@ -0,0 +1,302 @@
+/**
+ * Parses a form-mode elicitation `requestedSchema` into a flat list of
+ * fields mcpdo can prompt for. Per the MRTR elicitation spec (2026-07-28),
+ * form-mode schemas are restricted to a flat object whose properties are
+ * primitive types only — string, number/integer, boolean, single-select
+ * enum (`enum` or titled `oneOf`), or multi-select enum (`array` of one of
+ * those) — so this never needs to handle nesting, arrays of objects, or
+ * other general JSON Schema features.
+ *
+ * Returns `null` if the schema doesn't match that shape (defensive: a
+ * well-behaved server never sends anything else, but this is untrusted
+ * wire input from an arbitrary MCP server).
+ */
+
+export type Choice = { value: string; label: string };
+
+type FieldExtra =
+ | {
+ kind: "string";
+ minLength?: number;
+ maxLength?: number;
+ format?: string;
+ default?: string;
+ }
+ | {
+ kind: "number";
+ integer: boolean;
+ minimum?: number;
+ maximum?: number;
+ default?: number;
+ }
+ | { kind: "boolean"; default?: boolean }
+ | { kind: "enum"; choices: Choice[]; default?: string }
+ | {
+ kind: "multiselect";
+ choices: Choice[];
+ minItems?: number;
+ maxItems?: number;
+ default?: string[];
+ };
+
+export type FormField = {
+ name: string;
+ required: boolean;
+ title: string;
+ description?: string;
+} & FieldExtra;
+
+function isRecord(value: unknown): value is Record {
+ return typeof value === "object" && value !== null && !Array.isArray(value);
+}
+
+function parseChoicesFromEnum(value: unknown): Choice[] | undefined {
+ if (
+ !Array.isArray(value) ||
+ value.length === 0 ||
+ value.some((v) => typeof v !== "string")
+ ) {
+ return undefined;
+ }
+ return (value as string[]).map((v) => ({ value: v, label: v }));
+}
+
+function parseChoicesFromOneOf(value: unknown): Choice[] | undefined {
+ // Empty choice sets are rejected (like empty `enum`): a required field
+ // with zero options would render an unwinnable prompt.
+ if (!Array.isArray(value) || value.length === 0) return undefined;
+ const choices: Choice[] = [];
+ for (const entry of value) {
+ if (!isRecord(entry) || typeof entry.const !== "string") return undefined;
+ choices.push({
+ value: entry.const,
+ label: typeof entry.title === "string" ? entry.title : entry.const,
+ });
+ }
+ return choices;
+}
+
+/**
+ * Length/count keywords (`minLength`, `maxLength`, `minItems`, `maxItems`)
+ * must be non-negative integers. A negative bound (e.g. `maxLength: -1` on a
+ * required string) would otherwise parse fine and then reject every possible
+ * answer — an unwinnable prompt loop.
+ */
+function isValidCount(value: number | undefined): boolean {
+ return value === undefined || (Number.isInteger(value) && value >= 0);
+}
+
+/**
+ * JSON Schema `minLength`/`maxLength` count Unicode code points, not UTF-16
+ * code units — `"😀"` has length 1 under the spec but `.length === 2` in
+ * JavaScript. Every bound check on user-visible strings must use this.
+ */
+export function codePointLength(value: string): number {
+ // String iteration yields code points, unlike .length's UTF-16 units.
+ return [...value].length;
+}
+
+/**
+ * A structurally valid field can still be internally inconsistent —
+ * unsatisfiable constraints (`minimum > maximum`, `minItems` above the
+ * choice count) or a default that violates its own constraints. Those would
+ * render unwinnable or instantly-invalid prompts, so treat them like any
+ * other malformed schema and reject the field.
+ */
+function isConsistent(field: FieldExtra): boolean {
+ switch (field.kind) {
+ case "boolean":
+ return true;
+ case "number":
+ if (
+ field.minimum !== undefined &&
+ field.maximum !== undefined &&
+ field.minimum > field.maximum
+ ) {
+ return false;
+ }
+ if (field.default !== undefined) {
+ if (field.integer && !Number.isInteger(field.default)) return false;
+ if (field.minimum !== undefined && field.default < field.minimum)
+ return false;
+ if (field.maximum !== undefined && field.default > field.maximum)
+ return false;
+ }
+ return true;
+ case "string":
+ if (!isValidCount(field.minLength) || !isValidCount(field.maxLength)) {
+ return false;
+ }
+ if (
+ field.minLength !== undefined &&
+ field.maxLength !== undefined &&
+ field.minLength > field.maxLength
+ ) {
+ return false;
+ }
+ if (field.default !== undefined) {
+ if (
+ field.minLength !== undefined &&
+ codePointLength(field.default) < field.minLength
+ ) {
+ return false;
+ }
+ if (
+ field.maxLength !== undefined &&
+ codePointLength(field.default) > field.maxLength
+ ) {
+ return false;
+ }
+ }
+ return true;
+ case "enum":
+ return (
+ field.default === undefined ||
+ field.choices.some((c) => c.value === field.default)
+ );
+ case "multiselect": {
+ if (!isValidCount(field.minItems) || !isValidCount(field.maxItems)) {
+ return false;
+ }
+ if (
+ field.minItems !== undefined &&
+ field.maxItems !== undefined &&
+ field.minItems > field.maxItems
+ ) {
+ return false;
+ }
+ if (
+ field.minItems !== undefined &&
+ field.minItems > field.choices.length
+ ) {
+ return false;
+ }
+ const def = field.default;
+ if (def !== undefined) {
+ if (!def.every((v) => field.choices.some((c) => c.value === v))) {
+ return false;
+ }
+ if (field.minItems !== undefined && def.length < field.minItems)
+ return false;
+ if (field.maxItems !== undefined && def.length > field.maxItems)
+ return false;
+ }
+ return true;
+ }
+ }
+}
+
+function parseField(prop: unknown): FieldExtra | null {
+ const parsed = parseFieldShape(prop);
+ if (!parsed || !isConsistent(parsed)) return null;
+ return parsed;
+}
+
+function parseFieldShape(prop: unknown): FieldExtra | null {
+ if (!isRecord(prop)) return null;
+ const type = prop.type;
+
+ if (type === "boolean") {
+ return {
+ kind: "boolean",
+ default: typeof prop.default === "boolean" ? prop.default : undefined,
+ };
+ }
+
+ if (type === "number" || type === "integer") {
+ return {
+ kind: "number",
+ integer: type === "integer",
+ minimum: typeof prop.minimum === "number" ? prop.minimum : undefined,
+ maximum: typeof prop.maximum === "number" ? prop.maximum : undefined,
+ default: typeof prop.default === "number" ? prop.default : undefined,
+ };
+ }
+
+ if (type === "string") {
+ if (prop.enum !== undefined) {
+ const enumChoices = parseChoicesFromEnum(prop.enum);
+ // Present-but-invalid (non-string entries or an empty list) is a
+ // malformed schema, not a freeform string field: an empty required
+ // choice prompt would be unwinnable.
+ if (!enumChoices) return null;
+ return {
+ kind: "enum",
+ choices: enumChoices,
+ default: typeof prop.default === "string" ? prop.default : undefined,
+ };
+ }
+ if (prop.oneOf !== undefined) {
+ const oneOfChoices = parseChoicesFromOneOf(prop.oneOf);
+ if (!oneOfChoices) return null;
+ return {
+ kind: "enum",
+ choices: oneOfChoices,
+ default: typeof prop.default === "string" ? prop.default : undefined,
+ };
+ }
+ return {
+ kind: "string",
+ minLength:
+ typeof prop.minLength === "number" ? prop.minLength : undefined,
+ maxLength:
+ typeof prop.maxLength === "number" ? prop.maxLength : undefined,
+ format: typeof prop.format === "string" ? prop.format : undefined,
+ default: typeof prop.default === "string" ? prop.default : undefined,
+ };
+ }
+
+ if (type === "array") {
+ const items = prop.items;
+ if (!isRecord(items)) return null;
+ const choices =
+ parseChoicesFromEnum(items.enum) ?? parseChoicesFromOneOf(items.anyOf);
+ if (!choices) return null;
+ const defaultValue =
+ Array.isArray(prop.default) &&
+ prop.default.every((v) => typeof v === "string")
+ ? (prop.default as string[])
+ : undefined;
+ return {
+ kind: "multiselect",
+ choices,
+ minItems: typeof prop.minItems === "number" ? prop.minItems : undefined,
+ maxItems: typeof prop.maxItems === "number" ? prop.maxItems : undefined,
+ default: defaultValue,
+ };
+ }
+
+ return null;
+}
+
+/** Parse a `requestedSchema` into an ordered list of {@link FormField}s. */
+export function parseFormSchema(
+ schema: Record | undefined,
+): FormField[] | null {
+ if (!isRecord(schema)) return null;
+ const properties = schema.properties;
+ if (!isRecord(properties)) return null;
+ const required = Array.isArray(schema.required)
+ ? (schema.required.filter((v) => typeof v === "string") as string[])
+ : [];
+
+ const fields: FormField[] = [];
+ for (const [name, prop] of Object.entries(properties)) {
+ const parsed = parseField(prop);
+ if (!parsed) return null;
+ const title =
+ isRecord(prop) && typeof prop.title === "string" ? prop.title : name;
+ const description =
+ isRecord(prop) && typeof prop.description === "string"
+ ? prop.description
+ : undefined;
+ fields.push({
+ name,
+ required: required.includes(name),
+ title,
+ description,
+ ...parsed,
+ } as FormField);
+ }
+ return fields;
+}
diff --git a/clients/daemon-cli/src/connection/format-connection.ts b/clients/daemon-cli/src/connection/format-connection.ts
new file mode 100644
index 0000000000..ea0bd08a44
--- /dev/null
+++ b/clients/daemon-cli/src/connection/format-connection.ts
@@ -0,0 +1,393 @@
+import {
+ awaitableError,
+ awaitableLog,
+} from "@inspector/cli/utils/awaitable-log.js";
+import type {
+ ConnectionInfo,
+ ElicitationPendingInfo,
+} from "../daemon/protocol.js";
+import { CliExitCodeError, EXIT_CODES } from "@inspector/cli/error-handler.js";
+import type { OutputFormat } from "@inspector/cli/handlers/format-output.js";
+import type { CliAppInfo } from "@inspector/cli/handlers/method-types.js";
+import {
+ formatAppInfoHuman,
+ formatAppInfoListHuman,
+ formatAuthListHuman,
+ formatEmaStatusHuman,
+ formatRpcResultHuman,
+ formatServersListHuman,
+ formatServerShowHuman,
+ formatConnectionInfoHuman,
+ formatConnectionsListHuman,
+ formatElicitationPendingHuman,
+ formatSkillVerifyListHuman,
+ formatStreamEventHuman,
+} from "./format-human.js";
+import { isSafeLinkTarget, sanitizeDeep, sanitizeText } from "./sanitize.js";
+import { PLAIN, type Style } from "@inspector/cli/style.js";
+
+type JsonObject = Record;
+
+/**
+ * Pretty-print JSON for connection `--format json`.
+ * Unlike one-shot, this does **not** wrap in `{ result }` — the payload is the
+ * MCP / admin object itself (convenient for scripting).
+ *
+ * `JSON.stringify` escapes C0 controls but emits C1 controls (U+0080–U+009F,
+ * including 8-bit CSI/OSC) literally, which terminals can interpret. Escape
+ * them as standard `\uXXXX` sequences: the serialized text is terminal-safe
+ * while parsed values stay byte-identical.
+ */
+export function formatConnectionJson(data: unknown): string {
+ return (
+ JSON.stringify(data, null, 2).replace(
+ /[\u0080-\u009F]/g,
+ (ch) => `\\u${ch.charCodeAt(0).toString(16).padStart(4, "0")}`,
+ ) + "\n"
+ );
+}
+
+export type ConnectionWriteKind =
+ | {
+ kind: "rpc";
+ method: string;
+ result: JsonObject;
+ /**
+ * Auto-collected by `runMethod` for `tools/call` + `--format json`.
+ * Connection output ignores this side-channel (no `{ result, appInfo }`
+ * envelope); only `result` is printed. `--app-info` probes put the
+ * info object in `result` itself.
+ */
+ appInfo?: CliAppInfo;
+ /** For exit-code messages when result.isError. */
+ toolName?: string;
+ }
+ | {
+ kind: "ndjson";
+ lines: unknown[];
+ /** Distinguishes `tools/list --app-info` probe lines from a `--verify` report. */
+ variant?: "app-info" | "skill-verify";
+ /** `--verify` one-line stderr verdict; absent for `--app-info`. */
+ summary?: string;
+ /** Non-zero when the emitted `--verify` report is itself a failure. */
+ exitCode?: number;
+ }
+ | { kind: "stream-event"; data: unknown }
+ | {
+ kind: "servers/list";
+ servers: unknown[];
+ /** Which file produced the entries (writable catalog vs read-only config). */
+ source?: { kind: "catalog" | "config"; path: string };
+ }
+ | {
+ kind: "servers/show";
+ server: JsonObject;
+ /** Which file produced the entry (writable catalog vs read-only config). */
+ source?: { kind: "catalog" | "config"; path: string };
+ }
+ | { kind: "connections/list"; connections: unknown[] }
+ | {
+ kind: "connection";
+ connection: ConnectionInfo | JsonObject;
+ /**
+ * Non-TTY pending sign-in (see auth-helper.ts): the authorize URL the
+ * caller must relay to a human. Rides the normal output payload — the
+ * error envelope redacts URL query strings, which would strip the
+ * client_id/PKCE/state this URL is made of.
+ */
+ authUrl?: string;
+ }
+ | {
+ /**
+ * A parked elicitation (non-interactive caller): everything needed to
+ * relay the request to a human and answer it with
+ * `elicitation/respond`. Rides the normal output payload for the same
+ * redaction reason as `authUrl` (URL-mode elicitations carry a URL
+ * whose query is meaningful).
+ */
+ kind: "elicitation-pending";
+ elicitation: ElicitationPendingInfo;
+ }
+ | { kind: "disconnect"; name: string }
+ | { kind: "daemon/status"; status: JsonObject }
+ | { kind: "daemon/stop"; result: JsonObject }
+ | {
+ kind: "auth/list";
+ list: { oauthStatePath: string; servers: unknown[] };
+ }
+ | {
+ kind: "auth/clear";
+ result: { url?: string; cleared?: number; all?: boolean };
+ }
+ | {
+ kind: "auth/ema-status";
+ status: {
+ clientConfigPath: string;
+ configured: boolean;
+ enabled: boolean;
+ issuer?: string;
+ clientId?: string;
+ loginState: string;
+ };
+ }
+ | {
+ kind: "auth/ema-login";
+ result: { issuer: string; loginState: string; alreadyLoggedIn: boolean };
+ }
+ | {
+ kind: "auth/ema-logout";
+ result: { issuer: string };
+ }
+ | { kind: "generic"; data: unknown; title?: string };
+
+export type ConnectionWriteOpts = {
+ format?: OutputFormat;
+ /** Human-output styling; ignored for `--format json`. Defaults to plain. */
+ style?: Style;
+};
+
+/**
+ * Write connection CLI output honouring `--format text|json`.
+ * One-shot output paths are unchanged (`emitResult` / `writeFormattedResult`).
+ */
+export async function writeConnectionOutput(
+ opts: ConnectionWriteOpts,
+ payload: ConnectionWriteKind,
+): Promise {
+ const format: OutputFormat = opts.format === "json" ? "json" : "text";
+ const style = opts.style ?? PLAIN;
+
+ if (format === "json") {
+ await awaitableLog(formatConnectionJson(jsonPayload(payload)));
+ await writeNdjsonSummary(payload);
+ applyExitCodes(payload);
+ return;
+ }
+
+ // Server-controlled strings must never reach the terminal raw (escape
+ // injection: OSC 52 clipboard writes, title spoofing, output rewriting).
+ // Sanitize the whole payload before human formatting; the formatter's own
+ // ANSI styling is applied afterwards and stays intact. JSON output above
+ // is made safe by formatConnectionJson (C0 via JSON.stringify, C1 via its
+ // own escaping).
+ await awaitableLog(humanPayload(sanitizeDeep(payload), style) + "\n");
+ await writeNdjsonSummary(payload);
+ applyExitCodes(payload);
+}
+
+/**
+ * `skills/list --verify` / `skills/get --verify`: the one-line verdict goes to
+ * **stderr**, after the report, in both `--format text` and `--format json` —
+ * mirrors the one-shot CLI (`consumeMethodOutcome`), so a reader piping stdout
+ * into `jq` still sees it and a `--format json` caller isn't left without one
+ * just because the report itself is already structured.
+ */
+async function writeNdjsonSummary(payload: ConnectionWriteKind): Promise {
+ if (payload.kind === "ndjson" && payload.summary) {
+ // Human-facing stderr line in both formats; may embed server-derived
+ // names, so sanitize (see sanitize.ts).
+ await awaitableError(`${sanitizeText(payload.summary)}\n`);
+ }
+}
+
+function jsonPayload(payload: ConnectionWriteKind): 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,
+ ...(payload.source && { source: payload.source }),
+ };
+ case "servers/show":
+ return payload.source
+ ? { ...payload.server, source: payload.source }
+ : payload.server;
+ case "connections/list":
+ return { connections: payload.connections };
+ case "connection":
+ return payload.authUrl !== undefined
+ ? { ...(payload.connection as JsonObject), authUrl: payload.authUrl }
+ : payload.connection;
+ case "elicitation-pending":
+ // The key doubles as the discriminator: a caller can tell "input
+ // required" from a final tool result by `elicitationPending` alone.
+ return { elicitationPending: payload.elicitation };
+ case "disconnect":
+ return { name: payload.name };
+ case "daemon/status":
+ return payload.status;
+ case "daemon/stop":
+ return payload.result;
+ case "auth/list":
+ return payload.list;
+ case "auth/clear":
+ return payload.result;
+ case "auth/ema-status":
+ return payload.status;
+ case "auth/ema-login":
+ return payload.result;
+ case "auth/ema-logout":
+ return payload.result;
+ case "generic":
+ return payload.data;
+ }
+}
+
+function humanPayload(payload: ConnectionWriteKind, style: Style): string {
+ switch (payload.kind) {
+ case "rpc": {
+ if (asAppInfoProbe(payload.result)) {
+ return formatAppInfoHuman(payload.result, style);
+ }
+ const formatted = formatRpcResultHuman(
+ payload.method,
+ payload.result,
+ style,
+ );
+ return formatted ?? JSON.stringify(payload.result, null, 2);
+ }
+ case "ndjson":
+ return payload.variant === "skill-verify"
+ ? formatSkillVerifyListHuman(payload.lines, style)
+ : formatAppInfoListHuman(payload.lines, style);
+ case "stream-event":
+ return formatStreamEventHuman(payload.data, style);
+ case "servers/list":
+ return formatServersListHuman(payload.servers, style, payload.source);
+ case "servers/show":
+ return formatServerShowHuman(payload.server, style, payload.source);
+ case "connections/list":
+ return formatConnectionsListHuman(payload.connections, style);
+ case "connection": {
+ const info = formatConnectionInfoHuman(
+ payload.connection as JsonObject,
+ style,
+ );
+ if (payload.authUrl === undefined) return info;
+ const name = String((payload.connection as JsonObject).name ?? "");
+ return [
+ info,
+ "",
+ "Sign-in required. The user needs to open this link in a browser to authenticate:",
+ // The URL comes from server-controlled OAuth metadata: only
+ // allowlisted schemes become clickable OSC 8 links (same gate as
+ // every other server-supplied link — see sanitize.ts).
+ ` ${isSafeLinkTarget(payload.authUrl) ? style.link(payload.authUrl) : payload.authUrl}`,
+ style.dim(
+ `The connection completes automatically after sign-in — check with \`connections/show @${name}\`, or just run the next command.`,
+ ),
+ ].join("\n");
+ }
+ case "elicitation-pending":
+ return formatElicitationPendingHuman(
+ payload.elicitation as unknown as JsonObject,
+ style,
+ );
+ case "disconnect":
+ return `${style.bold("Disconnected")} ${`\`${style.bold(`@${payload.name}`)}\``}`;
+ case "daemon/status": {
+ const s = payload.status;
+ if (s.running === false) {
+ return String(s.message ?? "Daemon is not running.");
+ }
+ const connections = Array.isArray(s.connections)
+ ? (s.connections as unknown[])
+ : [];
+ return [
+ `${style.bold("Daemon")} pid ${String(s.pid)}` +
+ (s.stopping === true ? ` ${style.yellow("(shutting down)")}` : ""),
+ style.dim(`Socket: ${String(s.socketPath ?? "")}`),
+ formatConnectionsListHuman(connections, style),
+ ].join("\n");
+ }
+ case "daemon/stop":
+ if (payload.result.stopping === false) {
+ return String(payload.result.message ?? "Daemon was not running.");
+ }
+ return style.green("Daemon stopping.");
+ case "auth/list":
+ return formatAuthListHuman(payload.list, style);
+ case "auth/clear":
+ if (payload.result.all === true) {
+ return style.green(
+ `Cleared ${String(payload.result.cleared ?? 0)} stored auth entr${
+ payload.result.cleared === 1 ? "y" : "ies"
+ }.`,
+ );
+ }
+ return `${style.green("Cleared")} \`${style.bold(String(payload.result.url ?? ""))}\``;
+ case "auth/ema-status":
+ return formatEmaStatusHuman(payload.status, style);
+ case "auth/ema-login":
+ if (payload.result.alreadyLoggedIn) {
+ return `${style.green("Already signed in")} to \`${style.bold(payload.result.issuer)}\` ${style.dim("(use auth/ema-login --relogin for a fresh connection)")}`;
+ }
+ return `${style.green("Signed in")} to \`${style.bold(payload.result.issuer)}\``;
+ case "auth/ema-logout":
+ return `${style.green("Signed out")} of \`${style.bold(payload.result.issuer)}\` ${style.dim("(EMA server tokens cleared)")}`;
+ case "generic": {
+ if (payload.title) {
+ return `${style.bold(payload.title)}\n${JSON.stringify(payload.data, null, 2)}`;
+ }
+ return JSON.stringify(payload.data, null, 2);
+ }
+ }
+}
+
+function asAppInfoProbe(result: JsonObject): CliAppInfo | undefined {
+ if (
+ typeof result.hasApp !== "boolean" ||
+ typeof result.toolName !== "string" ||
+ result.content !== undefined ||
+ result.tools !== undefined
+ ) {
+ return undefined;
+ }
+ // Narrowed by the structural checks above; CliAppInfo adds optional fields.
+ // `JsonObject`'s index signature doesn't structurally overlap with
+ // `CliAppInfo`'s concrete shape, so `as` needs the `unknown` bridge.
+ return result as unknown as CliAppInfo;
+}
+
+function applyExitCodes(payload: ConnectionWriteKind): void {
+ if (payload.kind === "ndjson" && payload.exitCode) {
+ // Report already written above; thrown last so it routes through the
+ // connection CLI's single exit path, same as the one-shot CLI's
+ // `consumeMethodOutcome` (Copilot).
+ throw new CliExitCodeError(payload.exitCode, payload.summary ?? "", {
+ code:
+ payload.exitCode === EXIT_CODES.SKILL_INCOMPLETE
+ ? "skills_incomplete"
+ : "skills_nonconformant",
+ });
+ }
+ if (payload.kind === "rpc") {
+ // Only `--app-info` probes (result is the info object) map to NO_APP.
+ // Auto-collected `payload.appInfo` from tools/call+json must not.
+ const info = asAppInfoProbe(payload.result);
+ if (info) {
+ if (!info.hasApp) {
+ throw new CliExitCodeError(
+ EXIT_CODES.NO_APP,
+ // toolName echoes server-influenced text into a terminal-bound
+ // error message; sanitize like the success path does.
+ `Tool '${sanitizeText(info.toolName)}' has no MCP App UI resource (_meta.ui.resourceUri).`,
+ );
+ }
+ return;
+ }
+ if (payload.result.isError === true) {
+ throw new CliExitCodeError(
+ EXIT_CODES.TOOL_ERROR,
+ `Tool '${sanitizeText(payload.toolName ?? "tool")}' returned isError:true.`,
+ { code: "tool_is_error" },
+ );
+ }
+ }
+}
diff --git a/clients/daemon-cli/src/connection/format-human.ts b/clients/daemon-cli/src/connection/format-human.ts
new file mode 100644
index 0000000000..6e167baad8
--- /dev/null
+++ b/clients/daemon-cli/src/connection/format-human.ts
@@ -0,0 +1,953 @@
+/**
+ * Human-readable (markdown-ish) formatters for the connection CLI.
+ * Styling (color / bold / dim / OSC 8 links) is parameterized via {@link Style}.
+ */
+
+import { PLAIN, type Style } from "@inspector/cli/style.js";
+import { isSafeLinkTarget } from "./sanitize.js";
+import { parseFormSchema } from "./form-schema.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;
+ // Only allowlisted schemes become clickable OSC 8 links (see sanitize.ts);
+ // file:/custom-handler URIs from a server render as plain colored text.
+ if (isSafeLinkTarget(uri)) return style.link(uri);
+ return style.cyan(uri);
+}
+
+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));
+ }
+ }
+
+ // Always render structuredContent once. The duplicate-text filter above
+ // may have removed its JSON copy from the content blocks, so gating this
+ // on `visible.length === 0` would drop the structured payload whenever
+ // any other content block is present alongside the duplicate.
+ if (hasStructuredContent) {
+ if (lines.length > 0) lines.push("");
+ lines.push(heading(style, "Structured content:"));
+ lines.push(JSON.stringify(sc, null, 2));
+ }
+
+ const meta = result._meta as JsonObject | undefined;
+ if (meta && Object.keys(meta).length > 0) {
+ if (lines.length > 0) lines.push("");
+ lines.push(style.dim("Metadata:"));
+ lines.push(style.dim(JSON.stringify(meta, null, 2)));
+ }
+
+ if (lines.length === 0) return style.dim("(no content)");
+ return lines.join("\n");
+}
+
+/** Format resources/read contents. */
+export function formatResourceReadHuman(
+ result: JsonObject,
+ style: Style = PLAIN,
+): string {
+ const contents = asArray(result.contents);
+ if (contents.length === 0) return style.dim("(empty resource)");
+ const lines: string[] = [
+ heading(style, `Resource contents (${contents.length}):`),
+ ];
+ for (const c of contents) {
+ lines.push("");
+ lines.push(`URI: ${formatUri(style, String(c.uri ?? ""))}`);
+ if (c.mimeType) lines.push(style.dim(`MIME: ${String(c.mimeType)}`));
+ if (typeof c.text === "string") {
+ lines.push("````");
+ lines.push(c.text);
+ lines.push("````");
+ } else if (typeof c.blob === "string") {
+ lines.push(style.dim(`[Blob: ${c.blob.length} chars base64]`));
+ }
+ }
+ return lines.join("\n");
+}
+
+/** Format prompts/get. */
+export function formatPromptResultHuman(
+ result: JsonObject,
+ style: Style = PLAIN,
+): string {
+ const description =
+ typeof result.description === "string" ? result.description : undefined;
+ const messages = asArray(result.messages);
+ const lines: string[] = [];
+ if (description) {
+ lines.push(style.dim(description));
+ lines.push("");
+ }
+ lines.push(heading(style, `Messages (${messages.length}):`));
+ for (const msg of messages) {
+ const role = String(msg.role ?? "?");
+ lines.push("");
+ lines.push(style.cyan(`[${role}]`));
+ const content = msg.content;
+ if (typeof content === "string") {
+ lines.push("````");
+ lines.push(content);
+ lines.push("````");
+ } else if (content && typeof content === "object") {
+ if (Array.isArray(content)) {
+ for (const block of content as JsonObject[]) {
+ lines.push(...formatContentBlock(block, style));
+ }
+ } else {
+ lines.push(...formatContentBlock(content as JsonObject, style));
+ }
+ }
+ }
+ if (messages.length === 0 && !description) return style.dim("(empty prompt)");
+ return lines.join("\n");
+}
+
+/** Format prompts/complete. */
+export function formatCompletionsHuman(
+ result: JsonObject,
+ style: Style = PLAIN,
+): string {
+ const values = asArray(result.values);
+ const lines = [heading(style, `Completions (${values.length}):`)];
+ for (const v of values) lines.push(`* ${v}`);
+ if (values.length === 0) lines.push(style.dim("(none)"));
+ if (result.hasMore === true) lines.push(style.dim("(more available)"));
+ return lines.join("\n");
+}
+
+/** Format tasks/list. */
+export function formatTasksHuman(
+ tasks: unknown[],
+ style: Style = PLAIN,
+): string {
+ const lines = [heading(style, `Tasks (${tasks.length}):`)];
+ for (const raw of tasks) {
+ const t = raw as JsonObject;
+ const id = String(t.taskId ?? t.id ?? "?");
+ const status = String(t.status ?? "?");
+ const msg =
+ typeof t.statusMessage === "string"
+ ? style.dim(` — ${t.statusMessage}`)
+ : "";
+ lines.push(`* ${code(style, id)} ${status}${msg}`);
+ }
+ if (tasks.length === 0) lines.push(style.dim("(none)"));
+ return lines.join("\n");
+}
+
+/** Format tasks/get. */
+export function formatTaskHuman(task: unknown, style: Style = PLAIN): string {
+ const t = (task ?? {}) as JsonObject;
+ const lines = [
+ `${heading(style, "Task:")} ${code(style, String(t.taskId ?? t.id ?? "?"))}`,
+ `Status: ${String(t.status ?? "?")}`,
+ ];
+ if (typeof t.statusMessage === "string") {
+ lines.push(`Message: ${t.statusMessage}`);
+ }
+ if (t.createdAt) lines.push(style.dim(`Created: ${String(t.createdAt)}`));
+ if (t.lastUpdatedAt)
+ lines.push(style.dim(`Updated: ${String(t.lastUpdatedAt)}`));
+ return lines.join("\n");
+}
+
+/** Format initialize / server probe. */
+export function formatInitializeHuman(
+ result: JsonObject,
+ style: Style = PLAIN,
+): string {
+ const info = (result.serverInfo ?? {}) as JsonObject;
+ const lines = [
+ `${heading(style, "Server:")} ${style.bold(String(info.name ?? "(unknown)"))}${
+ info.version ? style.dim(` v${String(info.version)}`) : ""
+ }`,
+ ];
+ if (result.protocolVersion) {
+ lines.push(`Protocol: ${String(result.protocolVersion)}`);
+ }
+ if (typeof result.instructions === "string" && result.instructions.trim()) {
+ lines.push("");
+ lines.push(heading(style, "Instructions:"));
+ lines.push(result.instructions.trim());
+ }
+ const caps = result.capabilities;
+ if (caps && typeof caps === "object") {
+ const keys = Object.keys(caps as JsonObject);
+ if (keys.length > 0) {
+ lines.push("");
+ lines.push(`${heading(style, "Capabilities:")} ${keys.join(", ")}`);
+ }
+ }
+ return lines.join("\n");
+}
+
+/** Format roots/list or roots/set. */
+export function formatRootsHuman(
+ roots: unknown[],
+ style: Style = PLAIN,
+): string {
+ const lines = [heading(style, `Roots (${roots.length}):`)];
+ for (const raw of roots) {
+ const r = raw as JsonObject;
+ const name = typeof r.name === "string" ? style.dim(` (${r.name})`) : "";
+ lines.push(`* ${formatUri(style, String(r.uri ?? "?"))}${name}`);
+ }
+ if (roots.length === 0) lines.push(style.dim("(none)"));
+ return lines.join("\n");
+}
+
+/** Format auth/list. */
+export function formatAuthListHuman(
+ list: {
+ oauthStatePath?: string;
+ servers?: unknown[];
+ },
+ style: Style = PLAIN,
+): string {
+ const servers = Array.isArray(list.servers) ? list.servers : [];
+ const lines = [
+ heading(style, `Stored auth (${servers.length}):`),
+ style.dim(String(list.oauthStatePath ?? "")),
+ ];
+ for (const raw of servers) {
+ const s = raw as JsonObject;
+ const flags: string[] = [];
+ if (s.hasTokens === true) flags.push("tokens");
+ if (s.hasRefreshToken === true) flags.push("refresh");
+ const flagText =
+ flags.length > 0
+ ? style.dim(` (${flags.join(", ")})`)
+ : style.dim(" (no tokens)");
+ lines.push(`* ${code(style, String(s.url))}${flagText}`);
+ }
+ if (servers.length === 0) lines.push(style.dim("(none)"));
+ return lines.join("\n");
+}
+
+/** Format auth/ema-status. */
+export function formatEmaStatusHuman(
+ status: {
+ clientConfigPath?: string;
+ configured?: boolean;
+ enabled?: boolean;
+ issuer?: string;
+ clientId?: string;
+ loginState?: string;
+ },
+ style: Style = PLAIN,
+): string {
+ const lines = [heading(style, "EMA (enterprise-managed auth):")];
+ lines.push(style.dim(String(status.clientConfigPath ?? "")));
+ if (status.configured !== true) {
+ lines.push(
+ "IdP: " +
+ style.dim(
+ "(not configured — set enterpriseManagedAuth in client.json or the web Inspector's Client Settings)",
+ ),
+ );
+ return lines.join("\n");
+ }
+ const client = status.clientId
+ ? style.dim(` (client: ${status.clientId})`)
+ : "";
+ lines.push(`IdP: ${code(style, String(status.issuer ?? "?"))}${client}`);
+ lines.push(`Enabled: ${status.enabled === true ? "yes" : style.dim("no")}`);
+ const loginState = String(status.loginState ?? "none");
+ const stateText =
+ loginState === "logged_in"
+ ? style.green(loginState)
+ : style.dim(loginState);
+ lines.push(`IdP session: ${stateText}`);
+ return lines.join("\n");
+}
+
+/** Format servers/list. */
+export function formatServersListHuman(
+ servers: unknown[],
+ style: Style = PLAIN,
+ source?: { kind: "catalog" | "config"; path: string },
+): string {
+ const lines = [heading(style, `Servers (${servers.length}):`)];
+ // Say where the entries came from — shells with different --catalog /
+ // MCP_CATALOG_PATH / --config see different lists from the same daemon.
+ if (source) {
+ lines.push(`Source: ${style.dim(`${source.kind} ${source.path}`)}`);
+ }
+ for (const raw of servers) {
+ const s = raw as JsonObject;
+ const connectionName =
+ typeof s.connection === "string" && s.connection.length > 0
+ ? s.connection
+ : undefined;
+ const connectionMark = connectionName
+ ? ` ${style.green(`@${connectionName}`)}${s.isMru === true ? style.green(" (MRU)") : ""}`
+ : "";
+ lines.push(
+ `* ${code(style, String(s.name))} ${style.dim(`[${String(s.type)}]`)} ${style.dim(String(s.detail ?? ""))}${connectionMark}`,
+ );
+ }
+ 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,
+ source?: { kind: "catalog" | "config"; path: string },
+): 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)"),
+ // Same provenance line as servers/list — which file the entry came from.
+ ...(source
+ ? [`Source: ${style.dim(`${source.kind} ${source.path}`)}`]
+ : []),
+ JSON.stringify(body, null, 2),
+ ].join("\n");
+}
+
+/** Format connections/list. */
+/**
+ * A parked elicitation (`kind: "elicitation-pending"`): show the caller —
+ * typically an agent relaying to a human — what the server is asking and
+ * exactly how to answer it. Guidance lives here in human output only; the
+ * JSON payload stays data-only (the mcpdo skill carries the procedure).
+ */
+export function formatElicitationPendingHuman(
+ elicitation: JsonObject,
+ style: Style = PLAIN,
+): string {
+ const id = String(elicitation.elicitationId ?? "");
+ const mode = elicitation.mode === "url" ? "url" : "form";
+ const message = String(elicitation.message ?? "");
+ const lines = [
+ `${heading(style, "Input required")} — ${code(style, String(elicitation.method ?? ""))}${
+ typeof elicitation.toolName === "string"
+ ? ` (tool ${code(style, elicitation.toolName)})`
+ : ""
+ } on ${code(style, `@${String(elicitation.connection ?? "")}`)} is waiting on the user:`,
+ ` ${message}`,
+ ];
+ if (mode === "url") {
+ const url = typeof elicitation.url === "string" ? elicitation.url : "";
+ lines.push(
+ "",
+ "The user needs to open this link and complete it:",
+ // Only allowlisted schemes render as a clickable OSC 8 link; a server
+ // supplying file:/custom-handler URLs gets plain text (see sanitize.ts).
+ ` ${isSafeLinkTarget(url) ? style.link(url, url) : url}`,
+ "",
+ `When they're done, run: ${code(style, `elicitation/respond ${id} --done`)}`,
+ style.dim(`To give up instead: elicitation/respond ${id} --cancel`),
+ );
+ } else {
+ const fields = parseFormSchema(
+ elicitation.requestedSchema as Record | undefined,
+ );
+ if (fields && fields.length > 0) {
+ lines.push("", heading(style, `Fields (${fields.length}):`));
+ for (const field of fields) {
+ const kind =
+ field.kind === "number" && field.integer ? "integer" : field.kind;
+ const flags = field.required ? `${kind}, required` : kind;
+ const choices =
+ field.kind === "enum" || field.kind === "multiselect"
+ ? ` [${field.choices.map((c) => c.value).join(", ")}]`
+ : "";
+ lines.push(
+ ` ${style.bold(field.name)} (${flags})${choices}${descSuffix(style, field.description)}`,
+ );
+ }
+ }
+ lines.push(
+ "",
+ `Answer with: ${code(style, `elicitation/respond ${id} field:=value ...`)}`,
+ style.dim(`Or: elicitation/respond ${id} --decline | --cancel`),
+ );
+ }
+ const expiresAt = Number(elicitation.expiresAt ?? 0);
+ if (Number.isFinite(expiresAt) && expiresAt > Date.now()) {
+ const minutes = Math.max(1, Math.round((expiresAt - Date.now()) / 60_000));
+ lines.push(
+ style.dim(
+ `Unanswered, this expires (auto-cancels) in about ${minutes} minute${minutes === 1 ? "" : "s"}.`,
+ ),
+ );
+ }
+ return lines.join("\n");
+}
+
+export function formatConnectionsListHuman(
+ connections: unknown[],
+ style: Style = PLAIN,
+): string {
+ const lines = [heading(style, `Connections (${connections.length}):`)];
+ for (const raw of connections) {
+ const s = raw as JsonObject;
+ const mru = s.isMru === true ? style.green(" (MRU)") : "";
+ const era =
+ s.protocolEra !== undefined
+ ? style.dim(` [${String(s.protocolEra)}]`)
+ : "";
+ const pending =
+ s.pendingAuth === true
+ ? s.pendingAuthSignedIn === true
+ ? style.yellow(" (signed in — completing on next use)")
+ : style.yellow(" (sign-in pending)")
+ : "";
+ lines.push(
+ `* ${code(style, `@${String(s.name)}`)}${mru}${style.dim(` — ${String(s.serverIdentity ?? "")}`)}${era}${pending}`,
+ );
+ }
+ if (connections.length === 0) lines.push(style.dim("(none — connect first)"));
+ return lines.join("\n");
+}
+
+/** Format a single connection info (connect / connections/use / connections/show). */
+export function formatConnectionInfoHuman(
+ connection: JsonObject,
+ style: Style = PLAIN,
+): string {
+ const mru = connection.isMru === true ? style.green(" (MRU)") : "";
+ const lines = [
+ `${heading(style, "Connection")} ${code(style, `@${String(connection.name)}`)}${mru}`,
+ `Server: ${style.dim(String(connection.serverIdentity ?? ""))}`,
+ ];
+
+ // Connection details. `protocolEra` is now on every `ConnectionInfo` (#2298
+ // follow-up), so it renders for plain `connect`/`connections/use` results too;
+ // `protocolVersion` and everything below it are `connections/show`-only.
+ const era = connection.protocolEra;
+ const protocolVersion = connection.protocolVersion;
+ if (era !== undefined || protocolVersion !== undefined) {
+ const versionSuffix =
+ protocolVersion !== undefined ? ` (${String(protocolVersion)})` : "";
+ lines.push(
+ `Era: ${style.dim(`${String(era ?? "unknown")}${versionSuffix}`)}`,
+ );
+ }
+ // Authorization snapshot (connect-time; omitted for stdio / no-auth
+ // servers — see `ConnectionInfo.auth`).
+ const auth = connection.auth as JsonObject | undefined;
+ if (auth !== undefined) {
+ const method = auth.method === "ema" ? "EMA" : "OAuth";
+ const parts = [auth.authorized === true ? "authorized" : "not authorized"];
+ if (typeof auth.scope === "string" && auth.scope !== "") {
+ parts.push(`scope: ${auth.scope}`);
+ }
+ if (typeof auth.clientId === "string" && auth.clientId !== "") {
+ parts.push(`client: ${auth.clientId}`);
+ }
+ if (typeof auth.idpSession === "string") {
+ parts.push(`IdP session: ${auth.idpSession}`);
+ }
+ lines.push(`Auth: ${method} ${style.dim(`(${parts.join("; ")})`)}`);
+ }
+ // Sign-in pending (non-TTY connect handed OAuth to the detached helper):
+ // the connection completes automatically on first use after sign-in. Once
+ // the tokens are on disk the read-only echoes flag it, so a poller knows
+ // the user's part is done.
+ if (connection.pendingAuth === true) {
+ lines.push(
+ connection.pendingAuthSignedIn === true
+ ? `Sign-in: ${style.green("completed")} ${style.dim("(connection finishes on next use)")}`
+ : `Sign-in: ${style.yellow("pending")} ${style.dim("(completes automatically after the user signs in)")}`,
+ );
+ }
+ // Live transport state (`connections/show` only). Dormant is informational:
+ // the next op transparently re-dials with stored credentials.
+ if (typeof connection.transport === "string") {
+ const detail =
+ connection.transport === "dormant"
+ ? "dormant (reconnects on next use)"
+ : connection.transport;
+ lines.push(`Transport: ${style.dim(detail)}`);
+ }
+ const serverInfo = connection.serverInfo as JsonObject | undefined;
+ if (serverInfo?.name !== undefined) {
+ const version =
+ serverInfo.version !== undefined ? ` v${String(serverInfo.version)}` : "";
+ lines.push(
+ `Server info: ${style.dim(`${String(serverInfo.name)}${version}`)}`,
+ );
+ }
+ const capabilities = connection.capabilities as JsonObject | undefined;
+ if (capabilities !== undefined) {
+ const keys = Object.keys(capabilities);
+ lines.push(
+ `Capabilities: ${style.dim(keys.length > 0 ? keys.join(", ") : "(none)")}`,
+ );
+ }
+ const supportedVersions = connection.supportedVersions;
+ if (Array.isArray(supportedVersions) && supportedVersions.length > 0) {
+ lines.push(
+ `Supported versions: ${style.dim(supportedVersions.join(", "))}`,
+ );
+ }
+ if (
+ typeof connection.instructions === "string" &&
+ connection.instructions !== ""
+ ) {
+ lines.push(`Instructions: ${style.dim(connection.instructions)}`);
+ }
+
+ return lines.join("\n");
+}
+
+/** Format tools/list --app-info lines. */
+export function formatAppInfoListHuman(
+ lines: unknown[],
+ style: Style = PLAIN,
+): string {
+ const out = [heading(style, `App info (${lines.length} tools):`)];
+ for (const raw of lines) {
+ const info = raw as JsonObject;
+ const name = String(info.toolName ?? "?");
+ if (info.hasApp === true) {
+ const uri = String(info.resourceUri ?? "ui://?");
+ out.push(
+ `* ${code(style, name)} — ${style.green("app")} (${formatUri(style, uri)})`,
+ );
+ } else {
+ const err =
+ typeof info.resourceError === "string"
+ ? style.dim(` — ${info.resourceError}`)
+ : style.dim(" — no app");
+ out.push(`* ${code(style, name)}${err}`);
+ }
+ }
+ return out.join("\n");
+}
+
+/**
+ * Format `skills/list --verify` / `skills/get --verify` NDJSON lines.
+ * Each line is a {@link SkillVerifyReport}; the caller already computed the
+ * one-line stderr summary (`summarizeSkillVerification`) shared with the
+ * one-shot CLI, so this only renders the per-skill breakdown.
+ */
+export function formatSkillVerifyListHuman(
+ lines: unknown[],
+ style: Style = PLAIN,
+): string {
+ const out = [heading(style, `Skill verification (${lines.length}):`)];
+ for (const raw of lines) {
+ const report = raw as JsonObject;
+ const name = String(report.name ?? "?");
+ const uri = String(report.uri ?? "");
+ const outcome = report.outcome as string | undefined;
+ const conformance = asArray(report.conformance);
+ const frontmatter = asArray(report.frontmatter);
+ const files = asArray(report.files);
+ const errorCount = [...conformance, ...frontmatter].filter(
+ (issue) => issue.severity === "error",
+ ).length;
+ const mismatchCount = files.filter(
+ (file) => file.status === "mismatch" || file.status === "read-error",
+ ).length;
+ const verdict =
+ outcome === "verified"
+ ? style.green("verified")
+ : outcome === "incomplete"
+ ? style.dim("incomplete")
+ : style.red("failed");
+ const detail =
+ outcome === "verified"
+ ? ""
+ : outcome === "incomplete"
+ ? style.dim(
+ ` — ${String(report.incomplete ?? "read bounds cut the walk short")}`,
+ )
+ : style.dim(
+ ` — ${errorCount} issue(s), ${mismatchCount} file mismatch(es)`,
+ );
+ out.push(
+ `* ${code(style, name)} (${formatUri(style, uri)}) — ${verdict}${detail}`,
+ );
+ }
+ return out.join("\n");
+}
+
+/** Format a single app-info probe. */
+export function formatAppInfoHuman(
+ info: JsonObject,
+ style: Style = PLAIN,
+): string {
+ const name = String(info.toolName ?? "?");
+ if (info.hasApp === true) {
+ const lines = [
+ `Tool ${code(style, name)} ${style.green("has an MCP App")}`,
+ `Resource: ${formatUri(style, String(info.resourceUri ?? ""))}`,
+ ];
+ if (info.csp) lines.push(style.dim(`CSP: ${JSON.stringify(info.csp)}`));
+ return lines.join("\n");
+ }
+ const err =
+ typeof info.resourceError === "string"
+ ? info.resourceError
+ : "No MCP App UI resource (_meta.ui.resourceUri).";
+ return `Tool ${code(style, name)} ${style.red("has no MCP App")}\n${style.dim(err)}`;
+}
+
+/** Format a stream event for human display. */
+export function formatStreamEventHuman(
+ data: unknown,
+ style: Style = PLAIN,
+): string {
+ if (!data || typeof data !== "object") return String(data);
+ const ev = data as JsonObject;
+ if (ev.type === "subscribed") {
+ return `${heading(style, "Subscribed:")} ${formatUri(style, String(ev.uri ?? ""))}`;
+ }
+ if (ev.type === "resources/updated") {
+ return `${heading(style, "Resource updated:")} ${formatUri(style, String(ev.uri ?? ""))}`;
+ }
+ // logging/tail MessageEntry-shaped
+ if (ev.direction === "notification" && ev.message) {
+ const msg = ev.message as JsonObject;
+ const params = (msg.params ?? {}) as JsonObject;
+ const level = String(params.level ?? "info");
+ const logger = params.logger ? style.dim(` ${String(params.logger)}:`) : "";
+ // Servers may log structured `data`; String() would render it as
+ // "[object Object]", so non-strings get JSON instead.
+ const raw = params.data ?? params.message ?? params;
+ const text = typeof raw === "string" ? raw : JSON.stringify(raw);
+ return `[${colorLevel(style, level)}]${logger} ${text}`;
+ }
+ return JSON.stringify(ev, null, 2);
+}
+
+/**
+ * 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/daemon-cli/src/connection/mcp.ts b/clients/daemon-cli/src/connection/mcp.ts
new file mode 100644
index 0000000000..6170895485
--- /dev/null
+++ b/clients/daemon-cli/src/connection/mcp.ts
@@ -0,0 +1,1517 @@
+import { Command, type Command as CommandType } from "commander";
+import { existsSync, readFileSync } from "node:fs";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+import type { JsonValue } from "@inspector/core/mcp/index.js";
+import type {
+ ElicitCapabilityMode,
+ InspectorServerSettings,
+ ServerProtocolEra,
+} from "@inspector/core/mcp/types.js";
+import {
+ DEFAULT_MAX_FETCH_REQUESTS,
+ DEFAULT_TASK_TTL_MS,
+} from "@inspector/core/mcp/types.js";
+import {
+ loadServerEntries,
+ parseHeaderPair,
+ parseKeyValuePair as parseEnvPair,
+ selectServerEntry,
+} from "@inspector/core/mcp/node/index.js";
+import { type LoggingLevel } from "@modelcontextprotocol/client";
+import { getDefaultEnvironment } from "@modelcontextprotocol/client/stdio";
+import type { MCPServerConfig } from "@inspector/core/mcp/types.js";
+import { LoggingLevelSchema } from "@modelcontextprotocol/core";
+import { CliExitCodeError, EXIT_CODES } from "@inspector/cli/error-handler.js";
+import { callDaemon, ensureDaemon } from "../daemon/index.js";
+import type {
+ ConnectionInfo,
+ ConnectionShowResult,
+ ElicitationRespondParams,
+ ElicitationRespondResult,
+} from "../daemon/protocol.js";
+import {
+ annotateServerEntriesWithConnections,
+ listServerEntries,
+ resolveServerListSource,
+ type ServerListSource,
+ showServerEntry,
+ summarizeServerConfig,
+} from "@inspector/cli/handlers/servers-list.js";
+import { type OutputFormat } from "@inspector/cli/handlers/format-output.js";
+import {
+ DEFAULT_CONNECT_TIMEOUT_MS,
+ withConnectTimeout,
+} from "@inspector/cli/handlers/connect-timeout.js";
+import {
+ CONNECTION_RPC_METHODS,
+ type MethodArgs,
+} from "@inspector/cli/handlers/method-types.js";
+import { authorizeInFrontend } from "./authorize.js";
+import {
+ AUTH_HELPER_COMMAND,
+ obtainPendingAuthUrl,
+ runAuthHelper,
+} from "./auth-helper.js";
+import { isCliAutoOpenForced } from "@inspector/cli/cli-oauth-navigation.js";
+import { emaLogin, emaLogout, getEmaStatus } from "./ema.js";
+import {
+ assertJsonRoundTrips,
+ parseToolCallPositionals,
+ resolveToolCallArgs,
+} from "./parse-tool-args.js";
+import { resolveCommandPath } from "./resolve-command.js";
+import {
+ dispatchConnectionRpc,
+ hoistAtConnection,
+ requireExplicitConnection,
+ stripAt,
+ writeRpcOutcome,
+} from "./dispatch.js";
+import { writeConnectionOutput } from "./format-connection.js";
+import {
+ createPrivateBinding,
+ formatPrivateEnvExports,
+} from "./private-env.js";
+import {
+ clearAllStoredAuth,
+ clearStoredAuth,
+ clearStoredAuthForRelogin,
+ listStoredAuth,
+} from "./stored-auth.js";
+import { styleFromOpts } from "@inspector/cli/style.js";
+import { awaitableLog } from "@inspector/cli/utils/awaitable-log.js";
+import { createInterface } from "node:readline/promises";
+
+function isDaemonUnreachable(error: unknown): boolean {
+ return (
+ error instanceof CliExitCodeError &&
+ error.envelope?.code === "daemon_unreachable"
+ );
+}
+
+/**
+ * `servers/show` with no name falls back to the MRU connection's entry name,
+ * under the same non-interactive gate as MRU connection targeting: an agent
+ * shell must name the entry explicitly. When there is no MRU (no daemon, or
+ * nothing connected), say so — a bare Commander "missing required argument"
+ * doesn't tell the user why a name is needed.
+ */
+async function resolveMruEntryName(): Promise {
+ if (requireExplicitConnection()) {
+ throw new CliExitCodeError(
+ EXIT_CODES.USAGE,
+ "servers/show requires an entry name in non-interactive mode. Pass one (see mcpdo servers/list).",
+ { code: "server_name_required" },
+ );
+ }
+ let connections: ConnectionInfo[] = [];
+ try {
+ const result = await callDaemon<{ connections: ConnectionInfo[] }>(
+ "connections/list",
+ {},
+ );
+ connections = result.connections;
+ } catch (error) {
+ if (!isDaemonUnreachable(error)) throw error;
+ }
+ const mru = connections.find((c) => c.isMru);
+ if (!mru) {
+ throw new CliExitCodeError(
+ EXIT_CODES.USAGE,
+ "No entry name given and there is no most-recently-used connection to infer one from. Pass a catalog entry name (see mcpdo servers/list).",
+ { code: "server_name_required" },
+ );
+ }
+ return mru.name;
+}
+
+/**
+ * The core "not found" error is source-agnostic by design; here we know both
+ * where the list came from and whether the user typed the name. An explicit
+ * name gets the source appended; an MRU-inferred name gets a full explanation,
+ * because "Server 'X' not found" is baffling when the user never typed X —
+ * connections are daemon-global while the catalog is per-shell, so the MRU
+ * connection's entry may simply not exist in this shell's catalog.
+ */
+function describeServerShowNotFound(
+ error: unknown,
+ entryName: string,
+ inferredFromMru: boolean,
+ source: ServerListSource | null,
+): unknown {
+ if (
+ !(error instanceof Error) ||
+ !error.message.startsWith(`Server '${entryName}' not found`)
+ ) {
+ return error;
+ }
+ const where = source
+ ? `${source.kind} ${source.path}`
+ : "the resolved server list";
+ if (inferredFromMru) {
+ return new CliExitCodeError(
+ EXIT_CODES.USAGE,
+ `The most-recently-used connection '${entryName}' has no entry in ${where}. Connections and catalog entries are separate — it may have been connected ad-hoc or from a different catalog. Pass an entry name (see mcpdo servers/list).`,
+ { code: "server_not_found" },
+ );
+ }
+ return new CliExitCodeError(EXIT_CODES.USAGE, `${error.message} (${where})`, {
+ code: "server_not_found",
+ });
+}
+
+/** Commander help/version exits — text already written; not real failures. */
+function isCommanderDisplayOnly(error: unknown): boolean {
+ if (error == null || typeof error !== "object") return false;
+ const code = (error as { code?: unknown }).code;
+ return (
+ code === "commander.help" ||
+ code === "commander.helpDisplayed" ||
+ code === "commander.version"
+ );
+}
+
+type GlobalOpts = {
+ format?: OutputFormat;
+ plain?: boolean;
+ connection?: 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);
+
+/**
+ * `--conn` is a documented shorthand for `--connection`. Expanding it at the
+ * argv level keeps a single option registration (one help entry, one
+ * GlobalOpts field) instead of two options merged at every consumption site.
+ * Expansion stops at the first `--`: everything after the separator belongs
+ * to the child process (`connect … -- `) and must pass through
+ * verbatim.
+ */
+export function expandConnAlias(argv: string[]): string[] {
+ const sep = argv.indexOf("--");
+ const end = sep === -1 ? argv.length : sep;
+ return argv.map((arg, i) =>
+ i >= end
+ ? arg
+ : arg === "--conn"
+ ? "--connection"
+ : arg.startsWith("--conn=")
+ ? `--connection=${arg.slice("--conn=".length)}`
+ : arg,
+ );
+}
+
+/**
+ * Connection-first CLI entry (`mcpdo`). Talks to the implicit connection daemon over
+ * IPC for connect/disconnect/connections and MCP RPCs; `servers/list` and
+ * `servers/show` are local (no daemon).
+ */
+/**
+ * Pin a stdio config's cwd, command, and environment to the CALLER's shell
+ * before it crosses the socket to the daemon.
+ *
+ * The daemon is a persistent detached process: it chdir()s at startup and
+ * keeps the environment of whichever mcpdo invocation first spawned it. Left
+ * unpinned, a relative cwd or bare command name — and every default-inherited
+ * env var the SDK transport fills in (PATH, HOME, SHELL, ...) — would resolve
+ * against that stale context instead of the shell that ran `connect`, which
+ * is what `mcpdo connect node ./server.js` means to the user. A cwd/env
+ * configured in the catalog entry (or flags) still wins; this only pins the
+ * defaults and resolves relative values.
+ */
+export function pinStdioConfigToCaller(
+ config: T,
+): T {
+ // `type` is optional on stdio configs (stdio is the implicit default), so
+ // narrow by excluding the URL transports rather than matching "stdio".
+ if (config.type === "sse" || config.type === "streamable-http") return config;
+ const resolved = resolveCommandPath(config.command);
+ return {
+ ...config,
+ cwd: path.resolve(config.cwd ?? process.cwd()),
+ ...(resolved !== config.command ? { command: resolved } : {}),
+ // The SDK transport spawns with {...getDefaultEnvironment(), ...env}
+ // evaluated in the DAEMON process; snapshotting the same default set
+ // here makes those fallbacks the caller's.
+ env: { ...getDefaultEnvironment(), ...config.env },
+ };
+}
+
+export async function runMcp(argv?: string[]): Promise {
+ const raw = argv ?? process.argv;
+ const { argv: rewritten, connectionFromAt } = hoistAtConnection(
+ expandConnAlias(raw),
+ );
+
+ const program = new Command();
+ program.exitOverride((err) => {
+ // Help/version already printed. Always throw so Commander does not
+ // process.exit (which would tear down in-process tests); runMcp treats
+ // these as success. Bare `mcpdo` uses code `commander.help` with exitCode 1
+ // — must not reach handleError as an ErrorEnvelope.
+ if (isCommanderDisplayOnly(err)) throw err;
+ if (err.exitCode !== 0) throw err;
+ });
+
+ program
+ .name("mcpdo")
+ .description(
+ "MCP Inspector connection CLI — connect once, run many commands against a named connection.\n\n" +
+ "Agent skill for mcpdo: install with `npx skills add modelcontextprotocol/inspector --skill mcpdo`, or see `agent-help` below.",
+ )
+ .helpOption("-h, --help", "Display help for command")
+ .helpCommand("help [command]", "Display help for command")
+ .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(
+ "--connection ",
+ "Connection name (without required @). Overrides MRU / positional @name. `--conn` is a supported shorthand.",
+ )
+ .option(
+ "--catalog ",
+ "Writable catalog file (default: ~/.mcp-inspector/mcp.json or MCP_CATALOG_PATH)",
+ )
+ .option(
+ "--config ",
+ "Read-only connection config file (never written or seeded)",
+ )
+ .option(
+ "--stored-auth-only",
+ "Never start interactive OAuth; use the shared store if present, otherwise fail.",
+ );
+
+ if (connectionFromAt) {
+ program.setOptionValue("connection", connectionFromAt);
+ }
+
+ program
+ .command("servers/list")
+ .description(
+ "List catalog/config server entries (marks live connections when the daemon is running; no MCP connection)",
+ )
+ .action(async () => {
+ const opts = program.opts();
+ const envCatalog = process.env.MCP_CATALOG_PATH;
+ const serverOptions = {
+ catalogPath: opts.catalog?.trim() || envCatalog,
+ configPath: opts.config?.trim() || undefined,
+ };
+ const entries = await listServerEntries(serverOptions);
+ const source = resolveServerListSource(serverOptions);
+ let connections: ConnectionInfo[] = [];
+ try {
+ const result = await callDaemon<{ connections: ConnectionInfo[] }>(
+ "connections/list",
+ {},
+ );
+ connections = result.connections;
+ } catch (error) {
+ if (!isDaemonUnreachable(error)) throw error;
+ }
+ await writeConnectionOutput(outOpts(opts), {
+ kind: "servers/list",
+ servers: annotateServerEntriesWithConnections(entries, connections),
+ ...(source && { source }),
+ });
+ });
+
+ program
+ .command("servers/show")
+ .description(
+ "Show one catalog/config entry in detail (no MCP connection; secrets redacted)",
+ )
+ .argument(
+ "[name]",
+ "Catalog entry name (defaults to the MRU connection's entry on an interactive TTY)",
+ )
+ .action(async (name: string | undefined) => {
+ const opts = program.opts();
+ const envCatalog = process.env.MCP_CATALOG_PATH;
+ const serverOptions = {
+ catalogPath: opts.catalog?.trim() || envCatalog,
+ configPath: opts.config?.trim() || undefined,
+ };
+ const explicitName = stripAt(name?.trim() || undefined);
+ const entryName = explicitName ?? (await resolveMruEntryName());
+ const source = resolveServerListSource(serverOptions);
+ let entry;
+ try {
+ entry = await showServerEntry(entryName, serverOptions);
+ } catch (error) {
+ throw describeServerShowNotFound(
+ error,
+ entryName,
+ explicitName === undefined,
+ source,
+ );
+ }
+ await writeConnectionOutput(outOpts(opts), {
+ kind: "servers/show",
+ server: entry,
+ ...(source && { source }),
+ });
+ });
+
+ registerConnect(program);
+ registerConnectionAdmin(program);
+ registerAuthCommands(program);
+ registerRpcCommands(program);
+ registerElicitationCommands(program);
+ // Keep infra commands last in --help (just before Commander's built-in help).
+ registerDaemonCommands(program);
+ registerPrivateCommand(program);
+ registerAgentHelpCommand(program);
+
+ try {
+ await program.parseAsync(rewritten);
+ } catch (error) {
+ if (isCommanderDisplayOnly(error)) return;
+ throw error;
+ }
+}
+
+function registerConnect(program: CommandType): void {
+ program
+ .command("connect")
+ .description(
+ "Connect a catalog entry or ad-hoc target as a named connection",
+ )
+ .argument(
+ "[target...]",
+ "Catalog entry name, or command/URL (use -- for command args). A " +
+ "single bare word is a catalog name; a URL, a path (contains / or " +
+ "starts with . or ~), multiple tokens, or --transport force an " +
+ "ad-hoc target.",
+ )
+ .option("--server ", "Server name from catalog/config")
+ .option(
+ "-e ",
+ "Environment variables for the server (KEY=VALUE)",
+ parseEnvPair,
+ {},
+ )
+ .option("--cwd ", "Working directory for stdio server process")
+ .option(
+ "--transport ",
+ "Transport type (sse, http, or stdio)",
+ (value: string) => {
+ const valid = ["sse", "http", "stdio"];
+ if (!valid.includes(value)) {
+ throw new Error(`Invalid transport type: ${value}`);
+ }
+ return value as "sse" | "http" | "stdio";
+ },
+ )
+ .option("--server-url ", "Server URL for SSE/HTTP transport")
+ .option(
+ "--header ",
+ 'HTTP headers as "HeaderName: Value" pairs',
+ parseHeaderPair,
+ {},
+ )
+ .option(
+ "--connect-timeout ",
+ `Connection timeout in ms (default ${DEFAULT_CONNECT_TIMEOUT_MS} for ad-hoc)`,
+ (v: string) => {
+ const n = Number(v);
+ if (!Number.isFinite(n) || n < 0) {
+ throw new Error(`--connect-timeout must be a non-negative number.`);
+ }
+ return n;
+ },
+ )
+ .option(
+ "--era ",
+ "Protocol era to negotiate: legacy (default), auto, or modern. " +
+ "Overrides the catalog/config entry's protocolEra; the only way to " +
+ "set it for an ad-hoc target, which has no config entry of its own.",
+ (value: string) => {
+ const valid: ServerProtocolEra[] = ["legacy", "auto", "modern"];
+ if (!valid.includes(value as ServerProtocolEra)) {
+ throw new Error(
+ `Invalid --era: ${value}. Use legacy, auto, or modern.`,
+ );
+ }
+ return value as ServerProtocolEra;
+ },
+ )
+ .option(
+ "--relogin",
+ "Ignore stored OAuth for this connect (HTTP/SSE URL keys only); interactive login runs only if the server requires auth. No-op for stdio / servers with no stored entry",
+ )
+ .option(
+ "--elicit ",
+ "Elicitation capability to advertise: off, url, form, or both (default). " +
+ "Overrides the catalog/config entry's elicitCapability; the only way to " +
+ "set it for an ad-hoc target, which has no config entry of its own. Use " +
+ "off when the caller of mcpdo can't handle an elicitation request, so the " +
+ "server sees no elicitation capability and can fall back on its own.",
+ (value: string) => {
+ const valid: ElicitCapabilityMode[] = ["off", "url", "form", "both"];
+ if (!valid.includes(value as ElicitCapabilityMode)) {
+ throw new Error(
+ `Invalid --elicit: ${value}. Use off, url, form, or both.`,
+ );
+ }
+ return value as ElicitCapabilityMode;
+ },
+ )
+ .option(
+ "--ema",
+ "Treat the server as enterprise-managed (EMA): mint tokens from the " +
+ "signed-in enterprise IdP session instead of standard OAuth. " +
+ "Overrides the catalog/config entry's oauth.enterpriseManaged. " +
+ "Requires install-level IdP config (see auth/ema-status) and " +
+ "per-server OAuth client id/secret from the catalog entry, so it " +
+ "cannot be used with an ad-hoc target.",
+ )
+ .action(async (target: string[], cmdOpts) => {
+ const opts = program.opts();
+ const { name: positionalConnection, rest } =
+ splitConnectionTarget(target);
+ const connectionName =
+ stripAt(opts.connection) ??
+ positionalConnection ??
+ cmdOpts.server?.trim() ??
+ rest[0];
+
+ if (!connectionName) {
+ 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]!) || looksLikePath(rest[0]!)));
+
+ // EMA needs the resource server's OAuth client id/secret, which only a
+ // catalog/config entry can carry (oauth.clientId / oauth.clientSecret).
+ // An ad-hoc target has no entry and this CLI deliberately offers no
+ // secret-bearing flags, so the flow would only fail later with an
+ // opaque error — reject up front with actionable guidance instead.
+ if (cmdOpts.ema === true && adHoc) {
+ throw new CliExitCodeError(
+ EXIT_CODES.USAGE,
+ "--ema cannot be used with an ad-hoc target: EMA requires per-server " +
+ "OAuth client id/secret from a catalog entry. Add the server to a " +
+ "catalog with oauth.clientId and oauth.clientSecret (and " +
+ "oauth.enterpriseManaged), then connect by entry name.",
+ { code: "usage" },
+ );
+ }
+
+ const envCatalog = adHoc ? undefined : process.env.MCP_CATALOG_PATH;
+ const serverOptions = {
+ catalogPath: opts.catalog?.trim() || envCatalog,
+ 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,
+ // Resolve --cwd against the CALLER's working directory. The daemon
+ // that spawns the stdio server inherits an unrelated cwd (see
+ // daemon/run.ts), so a relative --cwd must be pinned here.
+ cwd: cmdOpts.cwd ? path.resolve(cmdOpts.cwd as string) : undefined,
+ env: cmdOpts.e as Record | undefined,
+ headers: cmdOpts.header as Record | undefined,
+ };
+
+ const selectName = adHoc
+ ? undefined
+ : ((cmdOpts.server as string | undefined)?.trim() ?? rest[0]);
+
+ const entries = await loadServerEntries(serverOptions);
+ const selected = selectServerEntry(entries, selectName);
+ // Pin cwd/command/env to THIS shell before the config crosses to the
+ // persistent daemon, whose own cwd and environment are stale (they
+ // belong to whichever invocation first spawned it).
+ const serverConfig = pinStdioConfigToCaller(selected.config);
+ const serverSettings = withEmaOverride(
+ withElicitOverride(
+ withEraOverride(
+ withConnectTimeout(
+ selected.settings,
+ (cmdOpts.connectTimeout as number | undefined) ??
+ (adHoc ? DEFAULT_CONNECT_TIMEOUT_MS : undefined),
+ ),
+ cmdOpts.era as ServerProtocolEra | undefined,
+ ),
+ cmdOpts.elicit as ElicitCapabilityMode | undefined,
+ ),
+ cmdOpts.ema === true ? true : undefined,
+ );
+ const { detail } = summarizeServerConfig(serverConfig);
+ const name = stripAt(connectionName)!;
+
+ if (relogin && "url" in serverConfig && serverConfig.url) {
+ await clearStoredAuthForRelogin(serverConfig.url);
+ }
+
+ const { socketPath } = await ensureDaemon();
+ const connectParams = {
+ name,
+ serverConfig,
+ serverSettings,
+ serverIdentity: detail,
+ };
+
+ let result: ConnectionInfo;
+ try {
+ result = await callDaemon("connect", connectParams, {
+ socketPath,
+ // The daemon enforces the configured connect timeout (which may be
+ // 0 = unlimited or exceed 60s); no fixed local deadline.
+ timeoutMs: 0,
+ });
+ } catch (error) {
+ if (
+ !(error instanceof CliExitCodeError) ||
+ error.envelope?.code !== "auth_required"
+ ) {
+ throw error;
+ }
+ if (opts.storedAuthOnly) {
+ throw error;
+ }
+ // Agent path: no TTY anywhere means the blocking interactive flow is
+ // hostile — the URL sits invisible in a buffered pipe and a timeout
+ // kill would tear down the callback listener the link points at.
+ // Hand the flow to a detached helper, register the connection as
+ // pending intent, and exit with the link so the caller can relay it.
+ // `MCP_AUTO_OPEN_ENABLED=true` (forced auto-open) keeps the blocking
+ // flow: that's an explicit unattended-automation opt-in.
+ const humanPresent =
+ process.stdin.isTTY === true || process.stderr.isTTY === true;
+ if (!humanPresent && !isCliAutoOpenForced()) {
+ const authUrl = await obtainPendingAuthUrl(
+ serverConfig,
+ serverSettings,
+ );
+ // The dial re-attempt is cheap (it fails auth_required again) but
+ // makes the daemon register the pending entry, so
+ // `connections/show @name` polls sign-in state (completing the
+ // connection itself once tokens land) and any real op completes it
+ // via revive.
+ const { socketPath: pendingSocketPath } = await ensureDaemon();
+ const pending = await callDaemon(
+ "connect",
+ { ...connectParams, pendingOnAuthRequired: true },
+ { socketPath: pendingSocketPath, timeoutMs: 0 },
+ );
+ await writeConnectionOutput(outOpts(opts), {
+ kind: "connection",
+ connection: pending,
+ ...(pending.pendingAuth === true && { authUrl }),
+ });
+ return;
+ }
+ await authorizeInFrontend(serverConfig, serverSettings, {
+ storedAuthOnly: false,
+ });
+ // Interactive OAuth can run well past the daemon's idle timeout
+ // (60s, armed while it holds zero connections) — a slow human login
+ // (SSO, MFA) can leave the daemon we ensured above already exited.
+ // Re-ensure so the retry lands on a live daemon instead of a stale
+ // socket; ensureDaemon() is a no-op when the existing one still
+ // answers pings.
+ const { socketPath: freshSocketPath } = await ensureDaemon();
+ result = await callDaemon("connect", connectParams, {
+ socketPath: freshSocketPath,
+ timeoutMs: 0,
+ });
+ }
+ await writeConnectionOutput(outOpts(opts), {
+ kind: "connection",
+ connection: result,
+ });
+ });
+}
+
+function registerAuthCommands(program: CommandType): void {
+ // Internal detached sign-in helper for the non-TTY connect path (see
+ // auth-helper.ts). Hidden: params arrive as JSON on stdin, never argv.
+ program
+ .command(AUTH_HELPER_COMMAND, { hidden: true })
+ .description("Internal: complete an OAuth sign-in (params JSON on stdin)")
+ .action(async () => {
+ await runAuthHelper();
+ });
+
+ program
+ .command("auth/list")
+ .description(
+ "List server URLs in the shared OAuth store (keys for auth/clear)",
+ )
+ .action(async () => {
+ const opts = program.opts();
+ const list = await listStoredAuth();
+ await writeConnectionOutput(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 writeConnectionOutput(outOpts(opts), {
+ kind: "auth/clear",
+ result: { all: true, cleared: result.cleared },
+ });
+ return;
+ }
+ const result = await clearStoredAuth(key!);
+ await writeConnectionOutput(outOpts(opts), {
+ kind: "auth/clear",
+ result: { url: result.url },
+ });
+ });
+
+ program
+ .command("auth/ema-status")
+ .description(
+ "Show enterprise-managed auth (EMA) configuration and IdP login state",
+ )
+ .action(async () => {
+ const opts = program.opts();
+ const status = await getEmaStatus();
+ await writeConnectionOutput(outOpts(opts), {
+ kind: "auth/ema-status",
+ status,
+ });
+ });
+
+ program
+ .command("auth/ema-login")
+ .description(
+ "Sign in to the enterprise IdP (EMA); subsequent connects to EMA servers mint tokens silently from this connection",
+ )
+ .option(
+ "--relogin",
+ "Clear the existing IdP session (and EMA server tokens) and sign in fresh",
+ )
+ .action(async (cmdOpts) => {
+ const opts = program.opts();
+ const result = await emaLogin({ relogin: cmdOpts.relogin === true });
+ await writeConnectionOutput(outOpts(opts), {
+ kind: "auth/ema-login",
+ result,
+ });
+ });
+
+ program
+ .command("auth/ema-logout")
+ .description(
+ "Sign out of the enterprise IdP and clear EMA-minted server tokens",
+ )
+ .action(async () => {
+ const opts = program.opts();
+ const result = await emaLogout();
+ await writeConnectionOutput(outOpts(opts), {
+ kind: "auth/ema-logout",
+ result,
+ });
+ });
+}
+
+function registerConnectionAdmin(program: CommandType): void {
+ program
+ .command("disconnect")
+ .description("Disconnect a connection (MRU when omitted on a TTY)")
+ .argument("[connection]", "Optional @name / name to disconnect")
+ .action(async (connectionArg: string | undefined) => {
+ const opts = program.opts();
+ const name = stripAt(opts.connection) ?? stripAt(connectionArg);
+ const { socketPath } = await ensureDaemon();
+ const result = await callDaemon<{ name: string }>(
+ "disconnect",
+ {
+ name,
+ requireExplicit: requireExplicitConnection(),
+ },
+ { socketPath },
+ );
+ await writeConnectionOutput(outOpts(opts), {
+ kind: "disconnect",
+ name: result.name,
+ });
+ });
+
+ program
+ .command("connections/list")
+ .description("List open connections (marks MRU); does not start the daemon")
+ .action(async () => {
+ const opts = program.opts();
+ try {
+ const result = await callDaemon<{ connections: ConnectionInfo[] }>(
+ "connections/list",
+ {},
+ );
+ await writeConnectionOutput(outOpts(opts), {
+ kind: "connections/list",
+ connections: result.connections,
+ });
+ } catch (error) {
+ if (isDaemonUnreachable(error)) {
+ await writeConnectionOutput(outOpts(opts), {
+ kind: "connections/list",
+ connections: [],
+ });
+ return;
+ }
+ throw error;
+ }
+ });
+
+ program
+ .command("connections/use")
+ .description("Set the MRU connection without an MCP RPC")
+ .argument("", "Connection @name / name")
+ .action(async (connectionArg: string) => {
+ const opts = program.opts();
+ const name = stripAt(opts.connection) ?? stripAt(connectionArg);
+ if (!name) {
+ throw new CliExitCodeError(
+ EXIT_CODES.USAGE,
+ "connections/use requires a connection name",
+ { code: "usage" },
+ );
+ }
+ const { socketPath } = await ensureDaemon();
+ const result = await callDaemon(
+ "connections/use",
+ { name },
+ { socketPath },
+ );
+ await writeConnectionOutput(outOpts(opts), {
+ kind: "connection",
+ connection: result,
+ });
+ });
+
+ program
+ .command("connections/show")
+ .description(
+ "Show connection + connection details: server info, capabilities, negotiated protocol era (defaults to MRU)",
+ )
+ .argument("[connection]", "Connection @name / name (defaults to MRU)")
+ .action(async (connectionArg: string | undefined) => {
+ const opts = program.opts();
+ const name = stripAt(opts.connection) ?? stripAt(connectionArg);
+ const { socketPath } = await ensureDaemon();
+ const result = await callDaemon(
+ "connections/show",
+ { name, requireExplicit: requireExplicitConnection() },
+ { socketPath },
+ );
+ await writeConnectionOutput(outOpts(opts), {
+ kind: "connection",
+ connection: result,
+ });
+ });
+}
+
+function registerDaemonCommands(program: CommandType): void {
+ const daemon = program.command("daemon").description("Daemon control");
+
+ daemon
+ .command("status")
+ .description("Show daemon pid, socket, and connections (does not start it)")
+ .action(async () => {
+ const opts = program.opts();
+ try {
+ const result = await callDaemon("daemon/status", {});
+ await writeConnectionOutput(outOpts(opts), {
+ kind: "daemon/status",
+ status: result as Record,
+ });
+ } catch (error) {
+ if (isDaemonUnreachable(error)) {
+ await writeConnectionOutput(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 connections")
+ .action(async () => {
+ const opts = program.opts();
+ try {
+ const result = await callDaemon("daemon/stop", {});
+ await writeConnectionOutput(outOpts(opts), {
+ kind: "daemon/stop",
+ result: result as Record,
+ });
+ } catch (error) {
+ if (isDaemonUnreachable(error)) {
+ await writeConnectionOutput(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 "$(mcpdo private)"). ' +
+ "Later mcpdo commands in that shell use an isolated, token-gated daemon.",
+ )
+ .action(async () => {
+ const binding = createPrivateBinding();
+ await awaitableLog(formatPrivateEnvExports(binding));
+ });
+}
+
+/**
+ * Locates the repo-root `skills/mcpdo/SKILL.md` relative to this module.
+ * Tries both the built (bundled single-file, `clients/daemon-cli/build/`) and
+ * source (`clients/daemon-cli/src/connection/`) layouts, since the two sit at
+ * different depths from the repo root.
+ */
+function resolveAgentSkillPath(): string | undefined {
+ const here = path.dirname(fileURLToPath(import.meta.url));
+ const candidates = [
+ path.resolve(here, "../../../skills/mcpdo/SKILL.md"),
+ path.resolve(here, "../../../../skills/mcpdo/SKILL.md"),
+ ];
+ return candidates.find((candidate) => existsSync(candidate));
+}
+
+/**
+ * The always-on awareness snippet for a project's CLAUDE.md / AGENTS.md.
+ * Skills are pull-only (an agent sees just the description until something
+ * triggers a load), so task-shaped prompts that never mention MCP won't
+ * activate the skill; a line in an always-in-context instructions file is
+ * the reliable mechanism for standing awareness.
+ */
+const AGENT_INSTRUCTIONS_SNIPPET =
+ "mcpdo manages connections to additional MCP servers. Treat the tools, " +
+ "resources, and prompts on its open connections (`mcpdo connections/list`) " +
+ "as part of your available toolset — check them before deciding a task " +
+ "can't be done, and include them when asked what tools or MCP servers " +
+ "you have. Run `mcpdo agent-help` for the usage guide.\n";
+
+/** Returns SKILL.md content with the YAML frontmatter block removed. */
+function stripFrontmatter(content: string): string {
+ if (!content.startsWith("---\n")) return content;
+ const end = content.indexOf("\n---\n", 4);
+ if (end === -1) return content;
+ return content.slice(end + 5).replace(/^\n+/, "");
+}
+
+function registerAgentHelpCommand(program: CommandType): void {
+ program
+ .command("agent-help")
+ .description(
+ "Print an agent-oriented usage guide, comparable to the mcpdo SKILL. " +
+ "Agents should read this before using other mcpdo commands.",
+ )
+ .option(
+ "--skill",
+ "Print the full usage guide — the mcpdo SKILL.md body, usable as if " +
+ "the skill had been loaded (default when no option is given)",
+ )
+ .option(
+ "--instructions",
+ "Print a short snippet to append to a project's CLAUDE.md/AGENTS.md " +
+ "so agents treat mcpdo connections as part of their toolset on " +
+ "every turn (skills and this guide load only on demand). " +
+ "Pipeable: mcpdo agent-help --instructions >> AGENTS.md",
+ )
+ .option(
+ "--skill-path",
+ "Print the path of the installable SKILL.md file — this guide plus " +
+ "its skill frontmatter — for skill runtimes " +
+ "(e.g. copy into ~/.claude/skills/mcpdo/)",
+ )
+ .action(
+ async (o: {
+ skill?: boolean;
+ instructions?: boolean;
+ skillPath?: boolean;
+ }) => {
+ const picked = [o.skill, o.instructions, o.skillPath].filter(
+ (v) => v === true,
+ ).length;
+ if (picked > 1) {
+ throw new CliExitCodeError(
+ EXIT_CODES.USAGE,
+ "--skill, --instructions, and --skill-path are mutually exclusive.",
+ { code: "agent_help_flag_conflict" },
+ );
+ }
+ if (o.instructions === true) {
+ await awaitableLog(AGENT_INSTRUCTIONS_SNIPPET);
+ return;
+ }
+ const skillPath = resolveAgentSkillPath();
+ if (!skillPath) {
+ throw new CliExitCodeError(
+ EXIT_CODES.USAGE,
+ "Could not locate skills/mcpdo/SKILL.md relative to this install.",
+ { code: "agent_help_not_found" },
+ );
+ }
+ if (o.skillPath === true) {
+ await awaitableLog(skillPath + "\n");
+ return;
+ }
+ await awaitableLog(stripFrontmatter(readFileSync(skillPath, "utf8")));
+ },
+ );
+}
+
+function registerRpcCommands(program: CommandType): void {
+ for (const method of CONNECTION_RPC_METHODS) {
+ const cmd = program
+ .command(method)
+ .description(`MCP ${method} against the current connection`);
+
+ cmd.option(
+ "--metadata ",
+ "General metadata as key=value pairs",
+ parseKeyValue,
+ {},
+ );
+
+ switch (method) {
+ case "tools/list":
+ cmd.option("--app-info", "Emit one NDJSON app-info line per tool");
+ cmd.action(async (o) => {
+ await runRpc(program, method, {
+ appInfo: o.appInfo === true,
+ metadata: o.metadata,
+ });
+ });
+ break;
+ case "tools/call":
+ cmd
+ .argument("[toolName]", "Tool name")
+ .argument(
+ "[toolArgs...]",
+ "Arguments as key:=value pairs or a JSON object",
+ )
+ .option("--tool-name ", "Tool name")
+ .option(
+ "--tool-arg ",
+ "Tool argument as key=value pair (alternative to key:=value positionals)",
+ parseKeyValue,
+ {},
+ )
+ .option(
+ "--tool-args-json ",
+ "Tool arguments as a JSON object (alternative to inline JSON positional)",
+ )
+ .option(
+ "--tool-metadata ",
+ "Tool-specific metadata",
+ parseKeyValue,
+ {},
+ )
+ .option("--task", "Task-augmented tool call (callToolStream)")
+ .option("--app-info", "Probe MCP App metadata only");
+ cmd.action(
+ async (
+ toolNamePos: string | undefined,
+ toolArgsPos: string[] | undefined,
+ o,
+ ) => {
+ const { toolName, toolArg } = resolveToolCallArgs({
+ toolNameFlag: o.toolName as string | undefined,
+ toolNamePos,
+ toolArgsPos,
+ toolArgFlag: (o.toolArg ?? {}) as Record,
+ toolArgsJson: o.toolArgsJson as string | undefined,
+ });
+ await runRpc(program, method, {
+ toolName,
+ toolArg,
+ toolMeta: o.toolMetadata,
+ metadata: o.metadata,
+ task: o.task === true,
+ appInfo: o.appInfo === true,
+ });
+ },
+ );
+ break;
+ case "resources/read":
+ case "resources/subscribe":
+ case "resources/unsubscribe":
+ cmd
+ .argument("[uri]", "Resource URI")
+ .option("--uri ", "Resource URI");
+ cmd.action(async (uriPos: string | undefined, o) => {
+ await runRpc(program, method, {
+ uri: (o.uri as string | undefined) ?? uriPos,
+ metadata: o.metadata,
+ });
+ });
+ break;
+ case "skills/list":
+ cmd.option(
+ "--verify",
+ "Run the SEP-2640 conformance and digest checks over every skill returned",
+ );
+ cmd.action(async (o) => {
+ await runRpc(program, method, {
+ verify: o.verify === true,
+ metadata: o.metadata,
+ });
+ });
+ break;
+ case "skills/get":
+ cmd
+ .argument("[uri]", "Skill URI")
+ .option("--uri ", "Skill URI")
+ .option(
+ "--verify",
+ "Run the SEP-2640 conformance and digest checks over this skill",
+ );
+ cmd.action(async (uriPos: string | undefined, o) => {
+ await runRpc(program, method, {
+ uri: (o.uri as string | undefined) ?? uriPos,
+ verify: o.verify === true,
+ metadata: o.metadata,
+ });
+ });
+ break;
+ case "prompts/get":
+ cmd
+ .argument("[promptName]", "Prompt name")
+ .option("--prompt-name ", "Prompt name")
+ .option(
+ "--prompt-args ",
+ "Prompt arguments",
+ parseKeyValue,
+ {},
+ );
+ cmd.action(async (promptPos: string | undefined, o) => {
+ await runRpc(program, method, {
+ promptName: (o.promptName as string | undefined) ?? promptPos,
+ promptArgs: (o.promptArgs ?? {}) as Record,
+ metadata: o.metadata,
+ });
+ });
+ break;
+ case "prompts/complete":
+ cmd
+ .option("--complete-ref-type ", "ref/prompt or ref/resource")
+ .option("--complete-ref [", "Prompt name or resource URI")
+ .option("--complete-arg-name ", "Argument name")
+ .option("--complete-arg-value ", "Partial value", "");
+ cmd.action(async (o) => {
+ const refType = o.completeRefType as string | undefined;
+ if (refType !== "ref/prompt" && refType !== "ref/resource") {
+ throw new CliExitCodeError(
+ EXIT_CODES.USAGE,
+ "prompts/complete requires --complete-ref-type ref/prompt|ref/resource",
+ { code: "usage" },
+ );
+ }
+ await runRpc(program, method, {
+ completeRefType: refType,
+ completeRef: o.completeRef as string | undefined,
+ completeArgName: o.completeArgName as string | undefined,
+ completeArgValue: (o.completeArgValue as string | undefined) ?? "",
+ metadata: o.metadata,
+ });
+ });
+ break;
+ case "logging/setLevel":
+ cmd
+ .argument("[level]", "Logging level")
+ .option("--log-level ", "Logging level");
+ cmd.action(async (levelPos: string | undefined, o) => {
+ const level = (o.logLevel as string | undefined) ?? levelPos;
+ if (level && !validLogLevels.includes(level as LoggingLevel)) {
+ throw new Error(
+ `Invalid log level: ${level}. Valid: ${validLogLevels.join(", ")}`,
+ );
+ }
+ await runRpc(program, method, {
+ logLevel: level as LoggingLevel | undefined,
+ metadata: o.metadata,
+ });
+ });
+ break;
+ case "tasks/get":
+ case "tasks/cancel":
+ case "tasks/result":
+ cmd.argument("[taskId]", "Task id").option("--task-id ", "Task id");
+ cmd.action(async (taskPos: string | undefined, o) => {
+ await runRpc(program, method, {
+ taskId: (o.taskId as string | undefined) ?? taskPos,
+ metadata: o.metadata,
+ });
+ });
+ break;
+ case "tasks/update":
+ // Modern-only (SEP-2663): resumes a task paused on `input_required`.
+ // `--input-responses` mirrors `roots/set`'s JSON-blob convention
+ // rather than trying to model arbitrary per-request shapes as flags.
+ cmd
+ .argument("[taskId]", "Task id")
+ .option("--task-id ", "Task id")
+ .option(
+ "--input-responses ",
+ "JSON object keyed by the server's inputRequests id",
+ );
+ cmd.action(async (taskPos: string | undefined, o) => {
+ await runRpc(program, method, {
+ taskId: (o.taskId as string | undefined) ?? taskPos,
+ inputResponsesJson: o.inputResponses as string | undefined,
+ metadata: o.metadata,
+ });
+ });
+ break;
+ case "roots/set":
+ cmd.option("--roots-json ", "JSON array of {uri, name?}");
+ cmd.action(async (o) => {
+ await runRpc(program, method, {
+ rootsJson: o.rootsJson as string | undefined,
+ metadata: o.metadata,
+ });
+ });
+ break;
+ default:
+ cmd.action(async (o) => {
+ await runRpc(program, method, {
+ metadata: o.metadata,
+ });
+ });
+ break;
+ }
+ }
+}
+
+/**
+ * `elicitation/respond` — answers an elicitation the daemon parked for a
+ * non-interactive caller (`elicitationPending` output). One respond per
+ * round: the result is either the resumed call's final output or the next
+ * pending round.
+ */
+function registerElicitationCommands(program: CommandType): void {
+ program
+ .command("elicitation/respond")
+ .description(
+ "Answer a pending server elicitation (from elicitationPending output): form answers as key:=value pairs / JSON, --done for URL mode, or --decline / --cancel",
+ )
+ .argument("]