Skip to content

feat(daemon-cli): mcpdo — experimental connection CLI client (#1432) - #1783

Merged
cliffhall merged 94 commits into
v2/mainfrom
v2/mcpi-client
Oct 7, 2026
Merged

cliffhall merged 94 commits into
v2/mainfrom
v2/mcpi-client

Conversation

@BobDickinson

@BobDickinson BobDickinson commented Jul 25, 2026 •

Copy link
Copy Markdown
Contributor

Closes #1432

Summary

Adds clients/mcpdo, an experimental connection CLI published as the mcpdo bin: connect to an MCP server once, then run many commands against that named connection (ssh-agent style). Connections are held by an implicit local Unix-socket daemon that mcpdo starts on demand and talks to over token-authenticated NDJSON.

mcpdo connect test-stdio --config path/to/mcp.json
mcpdo tools/list
mcpdo tools/call echo message:=hi
mcpdo --conn other tools/list      # or --connection
mcpdo logging/tail                 # long-lived stream; Ctrl-C to stop
mcpdo connections/list && mcpdo daemon status
eval "$(mcpdo private)"            # optional per-shell private daemon
  • Daemon security model: per-daemon bearer token always required (generated at startup, published 0600 as daemon.token, or supplied via env for private mode), 0700 socket dirs under $TMPDIR/mcp-conn-<uid>/, socket-path length validated up front, O_EXCL pid lock with dead-pid reclaim (no takeover of a live daemon), 1 MiB NDJSON request-line cap, daemon stderr to a 0600 daemon.log.
  • Output safety: terminal-bound text (results, elicitation prompts, daemon errors) is control-character sanitized; OSC 8 URIs validated; --format json stays verbatim.
  • Stdio correctness: connect always sends an absolute cwd (defaults to the caller's), bare command names are resolved against the caller's PATH client-side, the default-inherited environment (PATH/HOME/SHELL…) is snapshotted from the caller's shell rather than the daemon's, and the daemon chdirs away from its spawn directory.
  • Auth: shared oauth.json with the other Inspector clients; connect-time OAuth on this CLI (--relogin, --stored-auth-only); elicitation bridging for form/URL prompts (non-interactive callers — --format json or no TTY — get the elicitation parked and answer it via elicitation/respond; URL mode never auto-accepts).
  • Era support: negotiates legacy/modern via core InspectorClient; --era legacy|auto|modern on connect.
  • Reuses clients/cli handlers / error-handler / OAuth helpers via a temporary build-time @inspector/cli alias — chore(mcpdo): replace the temporary @inspector/cli source alias with a real shared surface #2461 tracks promoting that surface to a shared area.
  • Wired into monorepo validate / build / coverage / verify:bundle-externals; documented in AGENTS.md, clients/mcpdo/README.md, and specification/v2_cli_v2.md. Adds a top-level skills/mcpdo end-user skill (teaches an agent to drive mcpdo), distinct from the .claude/skills/ repo procedures.

Packaging

Published by this PR (maintainer-approved): root bin.mcpdo → clients/mcpdo/build/mcp-bin.js; files adds clients/mcpdo/build and skills/mcpdo. Adds ~199 KB compressed (~770 KB unpacked, 16%) to the tarball. The daemon is inert unless mcpdo is invoked. The bin was renamed from mcpi to mcpdo to avoid the existing unrelated mcpi npm package.

Naming

Reviewer-visible rename since the last review: the client moved from clients/mcpi to clients/mcpdo, the bin is mcpdo, and user-facing vocabulary moved from "session" to "connection" (connections/list|show|use, --connection/--conn) — modern MCP is session-less and the daemon-held thing's lifecycle is the connection's.

Test plan

  • npm run coverage:mcpdo — 417 tests, per-file coverage gate ≥90 on all four dimensions (only the two true bootstraps src/mcp-bin.ts / src/daemon/run.ts excluded)
  • npm run verify:bundle-externals (mcpdo enrolled, 4 bundles)
  • npm run local:gate from repo root — green on macOS
  • Manual: connect/tools/resources/logging-tail against test-servers over stdio + HTTP, OAuth + EMA connects, shared and mcpdo private daemons

@BobDickinson BobDickinson added the v2 Issues and PRs for v2 label Jul 25, 2026
Base automatically changed from v2/cli-improvements to v2/main July 26, 2026 20:55
@cliffhall cliffhall linked an issue Aug 17, 2026 that may be closed by this pull request
BobDickinson added a commit that referenced this pull request Sep 14, 2026
Adds a per-connection override for the elicitation capability mcpi
advertises to a server, mirroring the existing --era mechanism:

- InspectorServerSettings.elicitCapability ("off"|"url"|"form"|"both",
  default "both") persists on disk as elicitCapability, omitted when it
  equals the default, and round-trips through serverList.ts the same
  way protocolEra does.
- mcpi connect gains --elicit <mode>, validated the same way as --era,
  with a withElicitOverride() helper mirroring withEraOverride() (incl.
  synthesizing bare-defaults settings for ad-hoc targets).
- createSessionClient() now derives the InspectorClient elicit option
  from serverSettings.elicitCapability via elicitCapabilityToClientOption()
  instead of the old Phase-1 hardcoded { url: true, form: true }.

This lets a caller that cannot handle an interactive elicitation prompt
(a script, an agent) opt out entirely so the server sees no elicitation
capability and can fall back to its own alternative, instead of every
elicitation request being auto-declined.

Also updates clients/mcpi/README.md with an "Elicitation support"
section (previously undocumented, despite already-shipped URL/form
prompt rendering) and the --elicit flag, and refreshes the stale
"Sampling / elicitation CLI: Still TUI/web" to-do row in
specification/v2_cli_v2.md.

Manually verified end-to-end against the modern-mrtr-http test server:
--elicit off makes the server itself reject the mid-round input request
("capabilities do not declare the required capability"); --elicit both
(default) succeeds and reaches the interactive/auto-decline prompt path
as before.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@BobDickinson

Copy link
Copy Markdown
Contributor Author

mcpi work summary — PR #1783 (2026-09-13 → 2026-09-14)

Branch: v2/mcpi-client in /Users/bob/Documents/GitHub/inspector-trees/v2-mcpi-client
Repo: modelcontextprotocol/inspector
PR: #1783 — all pushed; CI (build, coverage) green as of 7fd4af59.

Organized by what actually changed functionally, not commit order.


1. Modern protocol-era support (era + skills primitives)

Gave mcpi first-class awareness of MCP's protocol eras (legacy vs. modern/
task-capable) and filled in missing skills primitives:

  • --era override on connect (f2fc1a2c): force which protocol era an
    ad-hoc session negotiates as, instead of only auto-detecting.
  • sessions/show replaces initialize (9550b32c): the session-info RPC
    now reports era details directly (protocol version, task support, etc.)
    instead of the old bare initialize response.
  • protocolEra surfaced everywhere (380dd3e7): every session listing
    (sessions/list, not just sessions/show) now reports era at a glance.
  • tasks/update (22d5c9f4): implemented to resume paused "modern"
    (task-capable) MCP tasks, with success/error-path tests.
  • skills/list and skills/get (40b4f441): implemented the RPCs
    (previously stubbed/missing), supporting positional and --uri argument
    forms, plus a --verify flag on skills/list.
  • Docs (2a61f427): documented --era, sessions/show, and
    tasks/update end-to-end.

2. Elicitation features (legacy URL-mode and modern/MRTR form-mode)

Built out MCP's elicitation flow, covering both eras' mechanisms:

  • URL-mode (legacy elicitation) (ac7e4bf1): when a server elicits via a
    URL, mcpi prompts to confirm/open it and waits for completion.
  • Form-mode (modern/MRTR structured elicitation) (258d789f): when a
    server elicits structured form data (JSON-schema-driven, per the newer
    request-response/MRTR-style pattern), mcpi walks the user through each
    field interactively with a review step before submitting.
  • --elicit capability override (98a41510): lets a caller declare
    elicitation support explicitly, for ad-hoc/non-standard clients.

3. Making mcpi agent-friendly

Everything else — reframing and hardening mcpi so an AI agent driving it
non-interactively gets the same guarantees a human at a terminal gets:

  • Packaging (29193232): bundled mcpi into the published
    @modelcontextprotocol/inspector npm package so it actually ships.
  • mcpi agent-help + skills/mcpi/SKILL.md (9ecd2647): a discoverable,
    self-contained reference for agents on how to drive mcpi non-interactively.
  • OAuth without a TTY (0096e2c7): OAuth's URL-prompt-and-wait flow no
    longer requires an interactive terminal; message reframed for an
    agent-attended flow ("The user needs to navigate to this link to
    authenticate: <url>"). Added clean SIGINT/SIGTERM cancellation so a user
    (or agent) can break out of the ~15-minute OAuth wait if they decide not to
    auth or auth fails, instead of it being a hard, uninterruptible block. Also
    addressed the daemon idle-timeout interacting with long OAuth waits.
    Live-tested with a real, non-TTY OAuth flow.
  • Non-TTY elicitation (7cf45384): removed the TTY gate on elicitation
    entirely — both URL-mode and form-mode now work non-interactively, since
    the underlying readline-based prompting was never actually TTY-dependent,
    just gated by policy. Closed the one real risk this exposed (stdin EOF/close
    could hang readline.question() forever) by racing every prompt against a
    "stdin closed" signal. Live end-to-end tested against a real MCP test
    server, including a piped-EOF instant-decline case and a live-FIFO
    simulated-agent-relayed-answer case.
  • Final non-TTY audit + SIGINT cleanup (7fd4af59): audited all
    remaining isTTY gates; confirmed auth/clear --all and
    requireExplicitSession()'s explicit-session requirement are intentional
    (see MRU note below), fixed a stale doc comment, and extended clean
    SIGINT/SIGTERM cancellation from the two streaming RPCs to the general
    rpc path so Ctrl-C during any blocking call (e.g. tools/call, an
    elicitation wait) cancels cleanly instead of killing the process.

Key design note (MRU): the daemon is a single shared process, so MRU
("most recently used" session) state is global, not per-terminal.
requireExplicitSession() gates on stdin, not stdout, so a human piping
output (mcpi tools/list | jq) still gets MRU convenience; a truly
non-interactive caller (agent/script/CI) must pass --session/@name
explicitly, since there's no live human to catch a wrong guess.
MCP_ALLOW_DEFAULT_SESSION=1 opts back into MRU for scripts that want it.


Non-functional maintenance (excluded from the above as "not changes")

These kept the branch buildable/green but didn't change behavior:

  • e79dea7f, fd64afff — restored build:dev tooling/build config after a
    v2/main merge broke it.
  • 54d00b91 — brought mcpi's validate scripts into parity with the rest of
    the repo's guards.
  • aabc19fa, 12353bea — closed CI coverage/build gaps (including one
    caused by the agent-help commit itself shipping without tests) — pure
    test-coverage backfill, no functional change.

@cliffhall cliffhall left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Mergeability verdict: ❌ Not mergeable — changes requested

CI is green and the feature works end to end. I connected, listed and called tools, and checked sessions and daemon status against a stdio test server. The test suite is large and mostly good. Four things block the merge:

  1. Private-mode daemons can be taken over, and live sessions orphaned (security, reproduced).
  2. Server-controlled terminal escape sequences reach the user's terminal raw (security, reproduced).
  3. Stdio connect resolves relative commands against the daemon's cwd, not the caller's, so it can run a different file than the one the user named (reproduced).
  4. Repo-rule violations: dependency placement, coverage-gate exclusions, no DCO signoff on any of the 30 commits, and a test that fails on stock macOS so local:gate cannot go green on a Mac.

There is also a scope question maintainers need to decide explicitly, not by drift: this PR now publishes a global mcpi bin and a background daemon to every installer of @modelcontextprotocol/inspector, while the PR description still says it does not.

Everything below was checked in a clean worktree of 22c97b09 (npm install && npm run build, then validate:guards, coverage:mcpi and verify:bundle-externals) on macOS 15, with an isolated MCP_STORAGE_DIR / MCP_INSPECTOR_DAEMON_DIR.


1. Security

Most of the risk comes from what mcpi adds on top of the one-shot CLI: a detached, long-lived process that accepts connect requests carrying an arbitrary serverConfig (including a stdio command) over a Unix socket, and then spawns that command. The one-shot CLI's exposure ends when the process exits. The daemon's does not: it stays up for as long as any session is open, because the idle timer only arms at zero sessions.

The stated trust model is same-UID filesystem trust (shared mode), plus an IPC token in private mode. The findings below are measured against that model.

1a. 🔴 A wrong or missing token replaces a live private daemon (blocker)

ensureDaemon() (clients/mcpi/src/daemon/ensure.ts) treats "socket reachable but ping failed" as "stale socket". It unlinks the socket and spawns a new daemon. ping also fails on daemon_auth_failed, so any caller holding the wrong token, or no token, deletes the live daemon's socket and installs its own daemon in its place.

Reproduced:

# user starts a private daemon
MCP_INSPECTOR_DAEMON_TOKEN=goodtoken mcpi connect --session a node server.js   → daemon 90433

# any same-UID process without the token
mcpi connect --session evil node server.js                                      → spawns daemon 90441 (NO token)

# the legitimate user, still presenting goodtoken
MCP_INSPECTOR_DAEMON_TOKEN=goodtoken mcpi sessions/list
Sessions (1):
* `@evil` (MRU) — node server.js [legacy]

Three consequences:

  • Private mode's guarantee is void. The user's token-bearing client is silently served by an unauthenticated daemon that someone else started. The replacement's @evil session is now the user's MRU default, so the user's next bare mcpi tools/call … goes to a server the other party chose. The token exists to separate same-UID callers; this is the one thing it currently fails to do.
  • Orphaned processes. The original daemon, 90433 above, and its stdio server keep running with no reachable socket. They never exit, because idle reaping is only armed at zero sessions. The same happens with a wrong token (MCP_INSPECTOR_DAEMON_TOKEN=wrong also produced a second daemon). A typo therefore leaks processes.
  • Lock-out. With a wrong token, the legitimate user then gets daemon_auth_failed against the replacement.

Fix: on daemon_auth_failed (or any structured error reply), ensureDaemon must fail loudly and leave the socket alone. Only a socket that refuses connections (ECONNREFUSED / ENOENT) is stale. daemon.lock is written but never used as a lock. Make it one: store pid plus start time, use an O_EXCL create or proper-lockfile, which is already a root dependency, and check liveness with process.kill(pid, 0) before unlinking anything.

1b. 🔴 Terminal escape injection from server-controlled text (blocker)

The human formatter (clients/mcpi/src/session/format-human.ts, the default --format text) writes server-supplied strings straight to the terminal: tool results, descriptions, resource text, elicitation messages, and URIs embedded in OSC 8 hyperlinks (clients/cli/src/style.ts). I found no control-character stripping anywhere under clients/mcpi/src.

Reproduced with the echo test tool (od -c of stdout):

E c h o :   h i 033 ] 5 2 ; c ; c H d u Z W Q = \a 033 ] 0 ; S P O O F E D - T I T L E \a

That is an OSC 52 clipboard write and an OSC 0 title change, both delivered intact from the server. Depending on the terminal, the same channel allows clipboard poisoning (the next paste into a shell), hiding or overwriting earlier output (CSI cursor moves and erases), and spoofing link targets. A URI containing \a also breaks out of the OSC 8 wrapper. The one-shot CLI emits JSON, where these bytes are escaped, so this is new exposure introduced by this PR. It matters more because the skill in this PR targets agents reading the output.

Fix: sanitize every server-derived string before styling it. Replace C0/C1 controls other than \n and \t, plus DEL, with visible escapes such as \x1b → ␛ or \u001b. Validate or percent-encode URIs before putting them in OSC 8. --format json is already safe.

1c. 🟠 Stdio commands resolve against the daemon's cwd, not the caller's

The daemon is spawned without cwd, so it inherits the directory of whichever mcpi invocation first started it. connect does not default --cwd to the caller's process.cwd() (clients/mcpi/src/session/mcp.ts, serverOptions.cwd). A relative stdio target is therefore resolved in someone else's directory:

(daemon started from the repo root; lsof cwd → /…/mcp-inspector-pr1783)
cd test-servers/build && mcpi connect --session rel node ./test-server-stdio.js
{"error":{"code":"error","message":"Connection closed"}}

Here it failed with an opaque error, but only because the file did not exist in the daemon's cwd. If a file with the same name exists there, mcpi silently runs that one. mcpi connect node ./server.js in project B would execute project A's ./server.js. That is a correctness bug with a real security edge. PATH has the same staleness problem: every later session inherits the first shell's PATH, whether that came from nvm, a venv or anything else.

Fix: the front end should always send an absolute cwd, defaulting to process.cwd(), and should resolve relative command paths before sending. It should also consider forwarding the caller's PATH. The daemon should chdir to its own directory, or to /, at startup so it never pins an arbitrary working directory.

1d. 🟠 The daemon dies silently, and the socket-path limit is unchecked

ensure.ts spawns the daemon with stdio: "ignore", so every startup failure is invisible. The client waits 10s and reports a generic daemon_start_timeout. I hit this at once: my scratch MCP_INSPECTOR_DAEMON_DIR produced a 140-byte socket path, and listen() fails above macOS's 104-byte sun_path limit (108 on Linux). The daemon exited, left a stale daemon.lock behind (mode 0644, because the chmod never ran), and the user saw only a timeout.

The private-mode layout, $HOME/.mcp-inspector/private/<uuid>/daemon.sock, uses 72 fixed bytes, leaving ~32 bytes for $HOME on macOS. This is also why a test fails locally (see §2c).

Fix: validate the socket path length up front with a clear error, and shorten the private layout, e.g. a short id, or $TMPDIR/mcpi-<uid>/<short> with a 0700 dir. Send the daemon's stderr to daemon.log in the daemon dir with mode 0600, and have daemon_start_timeout include its tail.

1e. 🟡 Hardening (not blocking on their own)

  • Shared-mode directory permissions. ensureDaemonDir() creates ~/.mcp-inspector with the default umask (0755 here). The socket is chmod 0600 only after listen(), which leaves a short window. Create the dir 0700 and bind inside it. Then the socket's own mode never matters, and BSD's inconsistent enforcement of socket permissions stops mattering too.
  • An exec service outside agent sandboxes. This deserves a paragraph because the PR ships a skill aimed at agents. In shared mode, any same-UID process that can connect() to ~/.mcp-inspector/daemon.sock can have the daemon spawn any command. Same-UID is nominally the same privilege, but agent sandboxes (Claude Code's sandbox, Codex, containers that bind-mount $HOME, Flatpak) often restrict exec and filesystem access and not Unix-socket connects. A daemon started outside a sandbox, by the human, becomes an unsandboxed exec endpoint for any agent inside one. My suggestion: always require a token, including in shared mode, stored in a 0600 file in the 0700 daemon dir. That gives no extra protection against a plain same-UID process, which can read the file anyway. It does give sandbox policies a file-read denial to rely on, which is the control they actually have. It also retires the tokenless path that 1a exploits.
  • No request-size limit on the NDJSON reader (readline over the socket). A single unbounded line grows daemon memory without limit. Cap the line length.
  • Non-TTY elicitation answered by an agent (7cf4538). This is a real product decision, not a bug. Form-mode elicitation is meant to put a question to the user, and this makes it routine for an agent to answer on the user's behalf. That may be fine for an inspector, but please record it as a decision (spec doc plus README), and keep URL-mode elicitation requiring an explicit human action. skills/mcpi/SKILL.md also still says "running non-interactively (no TTY, scripted, or --format json) auto-declines", which that commit made false. Only --format json declines now.
  • core/auth/node/runner-interactive-oauth.ts now installs process-wide SIGINT/SIGTERM listeners for the length of the OAuth wait. They are correctly removed in finally, but this is shared core/ and also runs under the TUI, which owns Ctrl-C through Ink. Please confirm that TUI Ctrl-C during an OAuth wait still behaves as intended, or scope the handler to callers that opt in.

Good things worth keeping: timingSafeEqual token comparison, 0600 socket and lock, 0700 private dirs, randomBytes(32) tokens, the POSIX-safe single-quoting in mcpi private, idle self-reaping, and stdio children exiting when their stdin closes. I SIGKILLed the daemon, and its test server exited with no orphans.


2. Repo-rule compliance (AGENTS.md)

2a. 🔴 Dependency placement

  • clients/mcpi/package.json re-declares root-owned runtime dependencies: @modelcontextprotocol/{client,core,server,server-legacy}, ajv, atomically, @napi-rs/keyring, pino, undici, zod, commander and open. This breaks "a client declares only what that client alone consumes … clients/cli and clients/launcher therefore declare no runtime dependencies". It re-creates exactly the second copy that #1896 exists to prevent (the 3,387-line clients/mcpi/package-lock.json). server/server-legacy are not runtime dependencies of a client at all.
  • The re-declaration is also load-bearing, which is why this matters beyond neatness. tsup auto-externalizes what the client's manifest declares, and mcpi's external list omits undici, zod, ajv and atomically. Deleting the manifest entries today would inline undici and reproduce #2067 (Dynamic require of "assert" is not supported). Fix: delete the client runtime deps and name every root runtime dependency that core/ reaches in clients/mcpi/tsup.config.ts external, mirroring clients/cli/tsup.config.ts and its comments.
  • AGENTS.md still says "must also be named in all three bundler external lists (clients/{cli,tui}/tsup.config.ts, clients/web/tsup.runner.config.ts)". With a fourth bundler this rule changes, so update AGENTS.md in the same change, per the maintenance rule.

2b. 🔴 Coverage gate: whole files waved out

clients/mcpi/vitest.config.ts excludes src/daemon/ipc-glue.ts and src/daemon/stream-client.ts from the ≥90 per-file gate as "hard-to-stabilize accept/stream races". The rule is explicit: "A genuinely-unreachable branch is annotated at the source, never waved through by lowering the gate", and a race is fixed with an awaited condition, never with headroom or exclusion (#1596). ipc-glue.ts is the socket accept loop and the elicitation line-consumer, the most security-relevant code in the client. It is the file that most needs gating. Only the true bootstraps (mcp-bin.ts, daemon/run.ts) qualify for exclusion, as with clients/cli's src/index.ts. The AGENTS.md edit that documents these exclusions should be dropped along with them.

2c. 🔴 A test fails locally, so local:gate cannot pass on macOS

coverage:mcpi → daemon-private.test.ts > ensureDaemon spawns a token-gated daemon from env fails on stock macOS: 1 failed | 220 passed, with Timed out waiting for session daemon. The test sets HOME to os.tmpdir()/…, which on macOS is /var/folders/…/T/. The resulting socket path is 142 bytes (§1d). CI passes only because Linux's /tmp is short. local:gate is the mandatory pre-push command, and the PR's own test plan still has npm run ci unchecked. Fixing §1d fixes this too.

2d. 🔴 DCO

None of the 30 commits carries Signed-off-by (git log --format='%(trailers:key=Signed-off-by)' is empty for every one). AGENTS.md: "sign off every commit (git commit -s — the DCO check is a hard merge gate with no partial credit)". A rebase with --signoff fixes it. It is probably best done together with the rebase in §2f.

2e. 🟠 Docs and structure

  • The PR description is stale and contradicts the code. It says "Not in the published tarball (files allowlist unchanged)" and "No root bin.mcpi". 2919323 adds "mcpi": "./clients/mcpi/build/mcp-bin.js" to root bin, and adds clients/mcpi/build and skills/mcpi to files. It also still says "Depends on #1782" (merged) and "Retarget … after #1782 merges". Please rewrite it; reviewers and the release notes will read it.
  • The new top-level skills/ directory is missing from the AGENTS.md / README Project Structure trees. Its relationship to .claude/skills/ needs one line (end-user skill shipped in the tarball vs. repo procedures). Otherwise the next agent will try to run verify:skills rules against it, or move it.
  • Branch name v2/mcpi-client lacks the type/issue segment (v2/feat/1432-mcpi-client). This is minor and not worth a new branch now.

2f. 🟠 Freshness and size

The branch is 124 commits behind v2/main. That includes #2374's Skills registry and -32021 changes and the SDK-v2 client-extension work, which touch the same skills/list / skills/get / era surfaces mcpi wraps. mergeStateStatus says CLEAN, but green CI on a stale base proves little for this surface. Please rebase with --signoff and re-run local:gate. At 16.8k added lines, with two merge commits and features accreted over two months (elicitation, EMA, tasks/update, era, packaging), this is hard to review as a whole. At minimum, the packaging change (2919323) should be split into its own PR so it gets its own decision (see §3).

2g. 🟡 Architecture: the @inspector/cli reach-in

The build-time alias from clients/mcpi into clients/cli/src (handlers, error-handler, OAuth navigation) makes one client's private source another client's API. clients/cli refactors can now break mcpi with no signal in the cli's own gate. fd64aff and aabc19f are both exactly this kind of breakage. The code comments say "temporary"; please file the tracking issue now (move handlers/, error-handler, and cli-oauth-navigation into core/, or a shared Node-runner area) and link it from the tsup comment. Temporary without an issue tends to become permanent.


3. Should it ship in the published package? Should it be containerized?

Shipping. Publishing adds a second global bin and a long-lived background daemon to every npm i -g @modelcontextprotocol/inspector install, under a name maintainers haven't signed off on. (mcpi is also an existing, unrelated npm package, a Minecraft-Pi API, which is harmless for a bin but will confuse search and npx mcpi.) The issue and spec still call this experimental. I'd keep it out of the tarball until the security items above are fixed and a maintainer signs off on the bin name, then publish it in a dedicated PR. That was the original plan in this PR's description, and I think it was right.

Containerizing the daemon: should not, and mostly could not usefully. The daemon's whole job needs host resources: the OS keychain (@napi-rs/keyring), the shared oauth.json store, the user's browser for OAuth, a loopback OAuth callback port, and above all local stdio servers that exist to touch the user's files and tools. Putting the daemon in a container breaks keyring and OAuth, turns every stdio server into a mount-and-PATH configuration problem, and on macOS and Windows adds a Linux VM dependency (Docker Desktop, Podman). It also secures the wrong thing. The daemon itself is small, trusted first-party code. The risky parts are (a) the socket as an exec endpoint, which §1a and §1e fix in code, and (b) the MCP servers it runs, which are untrusted third-party code. That is the same risk every MCP host takes, and containers are the right tool for it.

What I'd recommend instead:

  1. Fix the socket boundary in code (§1a, §1e). That is the risk the daemon adds.
  2. Make server isolation opt-in, per session. Document the recipe that already works today with no code: mcpi connect docker run -i --rm --network none -v "$PWD:/work:ro" <image>. Then consider a first-class --sandbox on connect that wraps the stdio command: docker/podman run -i everywhere, with lighter native options later (bwrap on Linux, sandbox-exec profiles on macOS). That puts isolation where the untrusted code is, lets users choose it per server, and costs nothing when unused.
  3. Treat HTTP/SSE targets as needing no process isolation. Their risk is the terminal-output and elicitation surface (§1b, §1e), which sanitization covers.

Summary of requested changes

# Change Severity
1a ensureDaemon: never unlink or replace on auth failure; turn daemon.lock into a real pid lock 🔴 security
1b Sanitize control characters in all server-derived text output; validate OSC 8 URIs 🔴 security
1c Send an absolute cwd (default process.cwd()) with stdio connects; chdir the daemon away 🟠 security/correctness
1d Socket-path length check, shorter private layout, daemon stderr to a 0600 log 🟠
1e 0700 daemon dir; token always required (file-backed); cap NDJSON line length; fix SKILL.md elicitation wording; confirm TUI Ctrl-C 🟡
2a Drop client runtime deps; complete the external list; update AGENTS.md's "three lists" rule 🔴 rules
2b Gate ipc-glue.ts / stream-client.ts (fix races, v8 ignore only truly unreachable lines) 🔴 rules
2c Make daemon-private.test.ts pass on macOS (falls out of 1d) 🔴 rules
2d --signoff every commit 🔴 rules
2e/2f Rebase on v2/main, rewrite the PR description, document skills/, split out packaging 🟠
2g File the tracking issue for the @inspector/cli reach-in 🟡

Happy to re-review once the 🔴 items are in. The session model itself works well and I'd like to see it land.

BobDickinson and others added 2 commits September 22, 2026 22:25
Small, mcpi-motivated additions to shared code, kept separate so the
client itself is reviewable on its own:

- clients/cli handlers: expose method metadata (method-types) and a
  reusable run-method entry point for out-of-process callers; unit
  tests for the mocked run-method paths
- clients/cli/src/cli-oauth-navigation.ts: allow callers to supply
  their own browser-open/navigation hooks
- core/auth/node/runner-interactive-oauth.ts: SIGINT/SIGTERM-aware
  wait so Ctrl-C during an interactive OAuth flow cleans up the
  callback server (removed in finally); test in clients/web test tree
- core/mcp/serverList.ts, core/mcp/types.ts: server-list helpers and
  types shared by cli and mcpi

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Bob Dickinson <bob.dickinson@gmail.com>
Add clients/mcpi, an experimental session-oriented CLI: connect once,
then run many MCP commands against a named session held open by an
implicit local Unix-socket daemon (ssh-agent style). Not part of the
published package; runs from a repo checkout (npm link).

Highlights:

- Session daemon (auto-spawned, idle self-reaping) with NDJSON IPC,
  token-gated private mode (`mcpi private`), MRU session selection
- Full command surface via shared clients/cli handlers: tools,
  resources, prompts, skills, tasks, completions, logging, sampling,
  elicitation (interactive form prompts and agent-answerable modes)
- OAuth support including stored-token reuse, interactive browser
  flows, and enterprise-managed auth (EMA): --ema connect flag,
  auth/ema-status|login|logout, per-session Auth reporting with
  disk-truth reads in sessions/show
- Era detection/reporting (legacy vs 2025-11-25) per session
- Human and JSON output formats; agent-focused skills/mcpi/SKILL.md
- Spec: specification/v2_cli_v2.md; docs in clients/mcpi/README.md
- Tests: 221 unit/integration tests, per-file coverage gates wired
  into the repo quality gate (coverage:mcpi, validate:mcpi)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Bob Dickinson <bob.dickinson@gmail.com>
BobDickinson and others added 7 commits September 23, 2026 11:03
1a — no daemon takeover: a socket that accepts connections is owned by a
live daemon; any ping failure (auth, timeout, protocol) now fails loudly
instead of unlinking the socket and respawning over it. daemon.lock is a
real O_EXCL pid lock with dead-pid reclaim, closing the probe/unlink/bind
race between two starting daemons.

1b — terminal escape sanitization: every server-controlled string is
sanitized before reaching the terminal in text mode (new
session/sanitize.ts: C0/C1 controls except \n\t become visible
stand-ins). Wired into the human formatter, the ndjson stderr summary,
elicitation prompts (message/url/schema — never protocol ids), and
daemon-client error messages. --format json stays verbatim (JSON already
escapes controls).

1c — stdio cwd correctness: --cwd is resolved to an absolute path at the
caller; stdio connects with no cwd default to the client's cwd
(catalog/--cwd still win); the daemon chdirs to its own dir on startup so
its inherited cwd is inert.

1d — no silent daemon death: socket paths are validated against sun_path
limits up front with an actionable error; private daemon dirs moved to
the short $TMPDIR/mcpi-<uid>/<id>/ layout (0700, fits the macOS limit);
daemon stderr goes to a 0600 daemon.log whose tail is quoted in
start-timeout errors.

1e — hardening: daemon dir created 0700; a token is now always required —
generated when the environment doesn't supply one and published to a
0600 daemon.token beside the socket for clients to read, retiring the
unauthenticated request path; NDJSON request lines are capped at 1 MiB;
SKILL.md/README/spec updated to record the elicitation decision (only
--format json auto-declines; URL mode never auto-accepts); the OAuth
runner's process-wide SIGINT/SIGTERM handlers are now opt-in
(handleSignals) so the TUI keeps Ctrl-C ownership under Ink, with CLI and
mcpi opting in.

Signed-off-by: Bob Dickinson <bob.dickinson@gmail.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
clients/mcpi declared root-owned runtime dependencies, re-creating the
second copy the dependency-placement rule (#1896) exists to prevent, and
the re-declaration was load-bearing: tsup auto-externalized from the
client manifest, so the external list was incomplete.

- clients/mcpi/package.json now declares no runtime dependencies (same
  steady state as clients/cli and clients/launcher); the 3,387-line
  lockfile shrinks to devDeps only.
- clients/mcpi/tsup.config.ts names every root runtime dependency that
  core/ (or the bundled one-shot CLI source) reaches, mirroring
  clients/cli/tsup.config.ts; verify:bundle-externals passes against the
  built output.
- A scoped override pins sucrase's nested commander to ^13: with no
  top-level commander declared, npm otherwise hoists sucrase's
  commander@4 into clients/mcpi/node_modules where it shadows the root
  commander@13 on the walk-up (helpCommand crash at startup).
- AGENTS.md's "three lists" rule is now four (clients/{cli,mcpi,tui}
  tsup configs + web's runner config); the no-runtime-deps steady state
  names mcpi; sdk-watch's checklist string updated to match.

Signed-off-by: Bob Dickinson <bob.dickinson@gmail.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…review 2b)

Remove the "hard-to-stabilize accept/stream races" coverage exclusions
for src/daemon/ipc-glue.ts and src/daemon/stream-client.ts; only true
bootstraps (src/mcp-bin.ts, src/daemon/run.ts) stay outside the gate.

New __tests__/daemon-ipc-glue.test.ts exercises the per-connection
wiring deterministically with an in-memory Duplex (no accept races):
the elicitation channel round trip, non-answer lines, double-pending
rejection, disconnect/destroyed-socket rejection, the mid-handle
destroyed guard, and single-shot stream cleanup on socket error.
daemon-stream.test.ts gains default socket-path/timeout + explicit
token coverage and a post-end frame-ignore case.

Writing those tests surfaced a real bug: readline re-emits socket
errors on the interface, so a client RST would have crashed the daemon
with an unhandled 'error' event. acceptDaemonConnection now attaches an
rl error listener; the socket error handler keeps owning teardown.

Both files clear >=90 on all four dimensions (ipc-glue 99/95/95/100,
stream-client 96/92/93/97); mcpi suite 250/250.

Signed-off-by: Bob Dickinson <bob.dickinson@gmail.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…es (review 2e, 2g)

AGENTS.md and README gain the skills/ entry in the project tree
(distinct from .claude/skills/); the temporary @inspector/cli alias
notes in AGENTS.md, clients/mcpi/README.md and tsup.config.ts now link
the tracking issue #2461.

Signed-off-by: Bob Dickinson <bob.dickinson@gmail.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…dio servers (review §3)

The daemon token gates who can command the daemon, not what a spawned
server can do. Record the zero-code recipe — wrapping the stdio command
in `docker run -i` — as the way to isolate an untrusted server, per the
review recommendation.

Signed-off-by: Bob Dickinson <bob.dickinson@gmail.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
… (review 1c follow-up)

The daemon inherits the environment of whichever mcpi invocation first
spawned it, so a bare command name like `node` was looked up in that
stale PATH — a different nvm version or venv could supply a different
binary than the caller's shell would. The connect front end now
resolves bare names (no path separator) to an absolute path using the
caller's PATH before the config crosses the IPC boundary, so the
daemon spawns exactly the caller's binary and no environment is
forwarded. Unresolvable names pass through unchanged so the daemon's
spawn error stays the user-visible failure; commands with a separator
still resolve against the pinned session cwd.

Signed-off-by: Bob Dickinson <bob.dickinson@gmail.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…bundle into the package

Maintainer-approved decisions on the #1783 review thread:

- Bin name: `mcpdo` (conflict-free on npm; `mcpi` collides with an
  unrelated package). Root `bin` now installs it and `files` ships
  `clients/daemon-cli/build` and `skills/mcpdo`, so
  `npm i -g @modelcontextprotocol/inspector` provides the experimental
  client (~200 KB compressed addition).
- Internal name: `clients/daemon-cli` (role-based, like cli/tui/web/
  launcher), insulated from future bin renames. Root scripts are now
  build:/validate:/coverage:daemon-cli.
- Vocabulary: the daemon holds named live connections, not resumable
  sessions, so the session wording over-promised and collided with MCP
  transport terminology. Commands are now `connections/list|show|use`;
  `connect`/`disconnect` stay top-level lifecycle verbs. The global flag
  is `--connection <name>` with `--conn` as a documented shorthand
  (argv-level alias, one option registration). Env opt-in renamed to
  MCP_ALLOW_DEFAULT_CONNECTION; daemon dirs move to
  $TMPDIR/mcp-conn-<uid>/. IdP *session* wording is kept where it names
  the enterprise IdP login session (a different concept).
- Shared cli helpers consumed only by mcpdo follow suit
  (annotateServerEntriesWithConnections, CONNECTION_RPC_METHODS, and the
  servers/list `connection` annotation field).

Signed-off-by: Bob Dickinson <bob.dickinson@gmail.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@BobDickinson BobDickinson changed the title feat(mcpi): experimental session CLI client (#1432) feat(daemon-cli): mcpdo — experimental connection CLI client (#1432) Sep 23, 2026
@BobDickinson

Copy link
Copy Markdown
Contributor Author

Thanks for the thorough review — reproducing 1a–1c made these easy to fix with confidence. Everything below is on the branch; local:gate is green on macOS (the §2c failure is gone). Two headline changes since your review, both maintainer-approved: the client is renamed (mcpi → mcpdo, clients/mcpi → clients/daemon-cli, "session" → "connection" vocabulary), and packaging is folded back into this PR (details under §3).

§1 Security

  • 1a — fixed. ensureDaemon now fails loudly on any structured error reply (including daemon_auth_failed) and never unlinks the socket; only ECONNREFUSED/ENOENT is treated as stale. daemon.lock is a real O_EXCL pid+starttime lock with a kill(pid, 0) liveness check before any reclaim. Your repro sequence now errors instead of replacing the daemon.
  • 1b — fixed. All server-derived terminal-bound text goes through a sanitizer (C0/C1 + DEL → visible escapes, \n/\t preserved); OSC 8 URIs are validated before wrapping. Covered by tests including your OSC 52/OSC 0 payloads. --format json unchanged.
  • 1c — fixed. connect always sends an absolute cwd, defaulting to the caller's process.cwd(); the daemon chdirs to its own directory at startup. For PATH we went a step further than forwarding: bare command names are resolved client-side against the caller's PATH (which-style) and sent absolute, so no environment crosses the socket at all.
  • 1d — fixed. Socket path length is validated up front against the platform sun_path limit with a clear error; the layout is shortened to $TMPDIR/mcp-conn-<uid>/<id>/ (0700); daemon stderr goes to a 0600 daemon.log and start-timeout errors include its tail.
  • 1e — all taken. Daemon dir created 0700 before bind; token always required in every mode (0600 daemon.token in the 0700 dir — adopted your reasoning: it gives sandbox policies a file-read denial to enforce and retires the tokenless path from 1a); 1 MiB NDJSON request-line cap; SKILL.md elicitation wording corrected and the non-TTY-agent-may-answer decision is recorded in specification/v2_cli_v2.md (URL mode still requires an explicit answer); the OAuth SIGINT/SIGTERM handlers were a regression introduced by this PR's own first commit — they're now opt-in (handleSignals), so TUI Ctrl-C behavior is unchanged.

§2 Repo rules

  • 2a — fixed. Client runtime deps removed; every root runtime dependency core/ reaches is named in clients/daemon-cli/tsup.config.ts external, mirroring clients/cli; AGENTS.md's "three lists" rule updated to four.
  • 2b — fixed. ipc-glue.ts and stream-client.ts are in the ≥90 per-file gate; the accept/stream races were fixed with awaited conditions, and only genuinely-unreachable lines carry annotated v8 ignore. Only the two true bootstraps remain excluded. The AGENTS.md exclusion note is gone.
  • 2c — fixed (fell out of 1d). local:gate green on stock macOS.
  • 2d — fixed. Rebased; every commit carries Signed-off-by.
  • 2e — done. PR description rewritten to match the code (including packaging); skills/ documented in the AGENTS.md and README structure trees with the .claude/skills/ distinction. Agreed on leaving the branch name.
  • 2f — done, with one deviation. Rebased onto current v2/main and re-ran local:gate. Packaging was initially split to a separate branch as you suggested, then folded back after an explicit maintainer decision to ship it in this PR — see §3.
  • 2g — done. Tracking issue chore(mcpdo): replace the temporary @inspector/cli source alias with a real shared surface #2461 filed for promoting the @inspector/cli reach-in surface to a shared area; linked from the tsup comment.

§3 Decisions

  • Shipping: maintainer-approved to bundle in this PR, under the new conflict-free bin name mcpdo (your mcpi-collision point drove the rename). Data point: the addition is ~199 KB compressed / ~770 KB unpacked (~16% of the tarball), and the daemon is inert unless the bin is invoked. Without bundling there was no reasonable install story for the experimental client (build-from-source + npm link).
  • Containerizing: agree — no. Your opt-in per-connection isolation recipe (docker run -i --rm --network none …) is now documented in clients/daemon-cli/README.md; a first-class --sandbox flag is deferred as a possible follow-up.

Ready for re-review whenever you are.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

The request-size security limit is bypassable, and several correctness and required lint-enforcement issues remain.

Get a fresh assessment by requesting another Copilot review.

Review effort: Balanced
Findings: 2 High severity · 4 Medium severity · 4 Low severity

Open (10)
What changed in this PR

Adds mcpdo, an experimental connection-oriented MCP CLI backed by a token-authenticated Unix-socket daemon.

Changes:

  • Implements persistent MCP connections, commands, OAuth, elicitation, streaming, and safe output formatting.
  • Adds extensive tests and integrates the client into build, validation, coverage, and packaging.
  • Documents the new client and ships an agent-facing mcpdo skill.
File Description
AGENTS.md Documents the new client and repository rules.
README.md Adds mcpdo to the project overview.
clients/​cli/​__tests__/​method-types.test.ts Updates shared method-list tests.
clients/​cli/​__tests__/​run-method-mocks.test.ts Updates reusable handler mocks.
clients/​cli/​__tests__/​servers-list.test.ts Tests reusable server-list behavior.
clients/​cli/​src/​cli-oauth-navigation.ts Exposes shared OAuth navigation.
clients/​cli/​src/​cliOAuth.ts Supports reusable OAuth flows.
clients/​cli/​src/​handlers/​consume-outcome.ts Updates shared outcome handling.
clients/​cli/​src/​handlers/​method-types.ts Defines connection-compatible methods.
clients/​cli/​src/​handlers/​run-method.ts Exposes shared MCP method execution.
clients/​cli/​src/​handlers/​servers-list.ts Generalizes server catalog loading.
clients/​cli/​src/​style.ts Exposes CLI styling helpers.
clients/​daemon-cli/​README.md Documents installation and usage.
clients/​daemon-cli/​__tests__/​agent-help.test.ts Tests agent-help output.
clients/​daemon-cli/​__tests__/​authorize.test.ts Tests authorization behavior.
clients/​daemon-cli/​__tests__/​connection-stored-auth.test.ts Tests stored-auth commands.
clients/​daemon-cli/​__tests__/​daemon-connections.test.ts Tests connection lifecycle.
clients/​daemon-cli/​__tests__/​daemon-coverage.test.ts Covers daemon edge cases.
clients/​daemon-cli/​__tests__/​daemon-ipc-glue.test.ts Tests IPC framing and handling.
clients/​daemon-cli/​__tests__/​daemon-paths.test.ts Tests daemon filesystem paths.
clients/​daemon-cli/​__tests__/​daemon-private.test.ts Tests private-daemon authentication.
clients/​daemon-cli/​__tests__/​daemon-stream.test.ts Tests streaming IPC.
clients/​daemon-cli/​__tests__/​dispatch.test.ts Tests RPC and stream dispatch.
clients/​daemon-cli/​__tests__/​elicitation-bridge.test.ts Tests daemon elicitation bridging.
clients/​daemon-cli/​__tests__/​elicitation-client.test.ts Tests elicitation client transport.
clients/​daemon-cli/​__tests__/​elicitation-prompt.test.ts Tests interactive elicitation.
clients/​daemon-cli/​__tests__/​ema-commands.test.ts Tests EMA commands.
clients/​daemon-cli/​__tests__/​ema.test.ts Tests EMA authentication logic.
clients/​daemon-cli/​__tests__/​form-prompt.test.ts Tests form prompting and validation.
clients/​daemon-cli/​__tests__/​form-schema.test.ts Tests elicitation schema parsing.
clients/​daemon-cli/​__tests__/​format-connection.test.ts Tests connection output formatting.
clients/​daemon-cli/​__tests__/​helpers/​mcp-runner.ts Adds daemon CLI test harness.
clients/​daemon-cli/​__tests__/​hoist-connection.test.ts Tests connection argument rewriting.
clients/​daemon-cli/​__tests__/​mcp-auth-coverage.test.ts Covers MCP authentication branches.
clients/​daemon-cli/​__tests__/​mcp-connection.test.ts Tests CLI connection workflows.
clients/​daemon-cli/​__tests__/​mcp-coverage.test.ts Covers command-routing edge cases.
clients/​daemon-cli/​__tests__/​parse-tool-args.test.ts Tests tool argument parsing.
clients/​daemon-cli/​__tests__/​resolve-command.test.ts Tests executable resolution.
clients/​daemon-cli/​__tests__/​sanitize.test.ts Tests terminal sanitization.
clients/​daemon-cli/​eslint.config.js Configures daemon-client linting.
clients/​daemon-cli/​package-lock.json Locks daemon-client dependencies.
clients/​daemon-cli/​package.json Defines scripts and package metadata.
clients/​daemon-cli/​src/​connection/​authorize.ts Implements connect-time OAuth.
clients/​daemon-cli/​src/​connection/​dispatch.ts Dispatches daemon RPCs and streams.
clients/​daemon-cli/​src/​connection/​elicitation-prompt.ts Implements elicitation prompts.
clients/​daemon-cli/​src/​connection/​ema.ts Implements enterprise-managed auth.
clients/​daemon-cli/​src/​connection/​form-prompt.ts Collects form elicitation input.
clients/​daemon-cli/​src/​connection/​form-schema.ts Parses elicitation schemas.
clients/​daemon-cli/​src/​connection/​format-connection.ts Formats command output.
clients/​daemon-cli/​src/​connection/​format-human.ts Provides human-readable formatting.
clients/​daemon-cli/​src/​connection/​mcp.ts Defines the mcpdo command surface.
clients/​daemon-cli/​src/​connection/​parse-tool-args.ts Parses tool-call arguments.
clients/​daemon-cli/​src/​connection/​private-env.ts Creates private-daemon shell exports.
clients/​daemon-cli/​src/​connection/​resolve-command.ts Resolves caller-side executables.
clients/​daemon-cli/​src/​connection/​sanitize.ts Sanitizes terminal-bound data.
clients/​daemon-cli/​src/​connection/​stored-auth.ts Manages persisted authentication.
clients/​daemon-cli/​src/​daemon/​auth.ts Implements daemon token authentication.
clients/​daemon-cli/​src/​daemon/​client.ts Implements request-response IPC.
clients/​daemon-cli/​src/​daemon/​connections.ts Manages persistent MCP connections.
clients/​daemon-cli/​src/​daemon/​elicitation-bridge.ts Bridges elicitation over IPC.
clients/​daemon-cli/​src/​daemon/​ensure.ts Starts and discovers the daemon.
clients/​daemon-cli/​src/​daemon/​framing.ts Encodes and parses IPC frames.
clients/​daemon-cli/​src/​daemon/​index.ts Exports daemon APIs.
clients/​daemon-cli/​src/​daemon/​ipc-glue.ts Accepts and processes socket clients.
clients/​daemon-cli/​src/​daemon/​paths.ts Defines daemon paths and limits.
clients/​daemon-cli/​src/​daemon/​protocol.ts Defines the IPC protocol.
clients/​daemon-cli/​src/​daemon/​run.ts Boots the daemon process.
clients/​daemon-cli/​src/​daemon/​server.ts Implements daemon lifecycle and routing.
clients/​daemon-cli/​src/​daemon/​stream-client.ts Implements streaming IPC clients.
clients/​daemon-cli/​src/​mcp-bin.ts Boots the mcpdo executable.
clients/​daemon-cli/​tsconfig.json Configures source type-checking.
clients/​daemon-cli/​tsconfig.test.json Configures test type-checking.
clients/​daemon-cli/​tsup.config.ts Builds CLI and daemon bundles.
clients/​daemon-cli/​vitest.config.ts Configures tests and coverage.
clients/​tui/​package-lock.json Refreshes the TUI dependency lock.
clients/​web/​package-lock.json Refreshes the web dependency lock.
clients/​web/​src/​test/​core/​auth/​runner-interactive-oauth.test.ts Tests OAuth signal cancellation.
core/​auth/​node/​runner-interactive-oauth.ts Adds optional signal handling.
core/​mcp/​serverList.ts Persists elicitation capability settings.
core/​mcp/​types.ts Defines elicitation capability types.
package.json Wires build, validation, packaging, and bin entry.
scripts/​install-clients.mjs Adds daemon-client installation.
scripts/​lib/​workflow-gate.test.mjs Updates gate coverage assertions.
scripts/​sdk-watch.mjs Includes daemon bundle externals guidance.
scripts/​verify-bundle-externals.mjs Verifies the new multi-entry bundle.
scripts/​verify-format-coverage.mjs Enrolls daemon-client formatting.
scripts/​verify-test-timeouts.mjs Enrolls daemon-client timeouts.
scripts/​verify-test-timeouts.test.mjs Updates timeout guard tests.
skills/​mcpdo/​SKILL.md Adds agent-facing usage guidance.
specification/​v2_catalog_launch_config.md Links the as-built CLI specification.
specification/​v2_cli_tui_launcher.md Documents the additional client surface.
specification/​v2_cli_v2.md Specifies the implemented connection CLI.
Files not reviewed (3)
  • clients/daemon-cli/package-lock.json: Generated file
  • clients/tui/package-lock.json: Generated file
  • clients/web/package-lock.json: Generated file

💡 Add a code-review agent skill for context-aware, tailored reviews. Learn more in the docs.

Comment thread clients/mcpdo/src/daemon/ensure.ts
Comment thread clients/daemon-cli/src/daemon/ipc-glue.ts Outdated
Comment thread clients/daemon-cli/eslint.config.js
Comment thread clients/mcpdo/src/connection/elicitation-prompt.ts
Comment thread clients/daemon-cli/src/connection/form-prompt.ts
Comment thread clients/daemon-cli/src/connection/resolve-command.ts
Comment thread AGENTS.md
Comment thread clients/daemon-cli/package.json Outdated
Comment thread skills/mcpdo/SKILL.md Outdated
Comment thread specification/v2_cli_v2.md Outdated
- ensure: a losing concurrent starter re-reads the winner's published
  daemon.token instead of polling its own dead token into a bogus
  daemon_start_timeout (explicit/private tokens still fail loud); test
- ipc-glue: enforce the 1 MiB line cap per newline-delimited segment so a
  terminated oversized line can't reset the counter past the check, and
  ignore lines after rejection; unit + e2e regression tests
- elicitation: parse the form schema raw and sanitize server-controlled
  strings at render points only, so responses carry the server's own
  keys/values
- form-prompt: reject non-finite numbers ("Infinity" is not a valid JSON
  number)
- resolve-command: honor an empty PATH entry as the current directory
  (POSIX) and return absolute paths for relative entries
- lint: add the type-aware no-floating-promises pass and --max-warnings 0,
  matching clients/cli
- docs: AGENTS.md external-lists brace path mcpdo -> daemon-cli; SKILL.md
  connect example uses --config; spec no longer advertises unregistered
  `initialize`

Signed-off-by: Bob Dickinson <bob.dickinson@gmail.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

Unvalidated terminal hyperlinks, an unsafe private-daemon parent directory, and potentially truncated stream output must be corrected before approval.

Get a fresh assessment by requesting another Copilot review.

Review effort: Balanced
Findings: 4 High severity

Open (4)
Resolved since last review (10)
Files not reviewed (3)
  • clients/daemon-cli/package-lock.json: Generated file
  • clients/tui/package-lock.json: Generated file
  • clients/web/package-lock.json: Generated file
Previously missed (3)

In code that hasn't changed since last review

Medium severity Add elicitCapability persistence and fallback coverage

core/​mcp/​serverList.ts:74

The new persisted setting adds valid/invalid read branches and default-omission write behavior, but the existing comprehensive serverList.test.ts suite has no elicitCapability case. Add coverage for all accepted literals, an unknown hand-edited value falling back to the default, and round-trip/default omission so this shared catalog behavior cannot regress unnoticed.

Medium severity Verify the published mcpdo executable and artifacts

package.json:22

This publishes a second executable, but scripts/pack-and-verify.mjs still checks only the installed mcp-inspector bin and web/launcher artifacts. A missing clients/daemon-cli/build, broken bin.mcpdo target, or omitted skills/mcpdo directory would therefore pass the repository's published-tarball verification. Enroll the new files and run the installed mcpdo --help in that check.

Low severity Rename the tracking entry to Connection CLI umbrella

specification/​v2_catalog_launch_config.md:515

The PR explicitly renames the user-facing concept from “session” to “connection,” but this updated row still calls #1432 the “Session CLI umbrella.” Use “Connection CLI umbrella” so the tracking table matches the as-built terminology.

Comment thread clients/daemon-cli/src/connection/dispatch.ts
Comment thread clients/daemon-cli/src/connection/elicitation-prompt.ts Outdated
Comment thread clients/daemon-cli/src/connection/format-human.ts Outdated
Comment thread clients/mcpdo/src/daemon/paths.ts
- dispatch: chain stream writes and await the chain before returning, so
  mcp-bin's process.exit can't truncate a pending stdout write on piped or
  backpressured output; write errors stay non-fatal as before
- sanitize: isSafeLinkTarget scheme allowlist (https/http) for OSC 8
  hyperlinks; format-human and URL-mode elicitation render every other
  scheme (file:, custom protocol handlers) as plain text
- paths: fail closed unless the predictable $TMPDIR/mcp-conn-<uid> root is
  a real directory owned by the current user, and tighten a loose mode
  fatally instead of best-effort — a shared-/tmp user can no longer plant
  the root (dir or symlink) and keep write control over socket/token paths

Signed-off-by: Bob Dickinson <bob.dickinson@gmail.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🔵 Needs a closer look

The security-sensitive daemon and authentication surface requires final human review, and the executable still has an output-truncation defect.

Review effort: Balanced
Findings: None

Resolved since last review (1)
Previously missed (1)

In code that hasn't changed since last review

Low severity Remove unjustified double cast and type the formatter input

clients/​daemon-cli/​src/​connection/​format-connection.ts:297

This unannotated as unknown as bypasses structural type checking even though the payload is already an ElicitationPendingInfo. The repository’s TypeScript rule requires every unavoidable double cast to explain why it is safe and why no narrower option exists; here the safer fix is to type formatElicitationPendingHuman for ElicitationPendingInfo (or a narrow formatter-facing shape) and remove the cast.

Removes the unjustified double cast at the call site by giving the
formatter the payload's real type instead of JsonObject.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Bob Dickinson <bob.dickinson@gmail.com>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🔵 Needs a closer look

The security-sensitive daemon, IPC, authentication, and packaging changes span 133 files and require final human validation.

Review effort: Balanced
Findings: None

@BobDickinson

Copy link
Copy Markdown
Contributor Author

Copilot review loop closed — round 45 (review 5407415375) came back clean: no inline comments, no findings, no suppressed comments, and no defect named in the headline. Per the pr-flow exit rules, one clean round ends the loop.

Summary of the rounds since the maintainer review closeout:

  • Round 40 (e458ef38): bound the fetched OpenID configuration to the queried issuer, and restricted end_session_endpoint URLs (which carry id_token_hint) to https or http-on-loopback.
  • Round 41 (06e8a40d): three doc nits — stale TODO(#1432), a mislabeled comment, and a JSDoc orphaned from its function.
  • Round 42 (ebab9eb3): tightened the issuer check from trailing-slash-tolerant to the exact string match OIDC Discovery §4.3 requires; mismatch falls back to SDK discovery.
  • Round 43 (a438cf6d): fixed the mcpdo daemon/stop → mcpdo daemon stop user-facing messages in server.ts (the earlier sed had silently not taken). Declined the tasks/update inline finding in-thread — its premise was false (tasks/* is not in ONE_SHOT_METHODS, and --task-id is wired in mcpdo's parser).
  • Round 44 (9fc2e38a): removed an unjustified as unknown as JsonObject double cast by typing formatElicitationPendingHuman with ElicitationPendingInfo.

The one finding still listed Open is the declined round-43 tasks/update item; the in-thread reply stands.

@cliffhall

Copy link
Copy Markdown
Member

Smoke-test re-run of mcpdo: head 9fc2e38a

Verdict: ❌ Not mergeable yet. Very close: one must-fix remains, a narrow leftover of F1.

Every item from the previous smoke test that 064cfe5 addressed is fixed. I re-drove each one against the built bin, and the PR head (which now contains all of v2/main) passes local:gate with no local changes. What's still open:

# Severity Finding
R1 🔴 Must-fix A task that asks for input more than once hangs, and on the agent path it wedges the connection. Round 1 now works (that was F1). Round 2 is never delivered. TTY: the call hangs after the first answer, and Ctrl-C does nothing, because the terminal is left in raw mode (-isig). Park mode: elicitation/respond to round 1 never returns. After that, the connection keeps reporting the answered id as pending, but respond and --cancel both reject that id with elicitation_not_found, so only disconnect recovers. The README's 10-minute auto-cancel doesn't rescue it either: a stuck connection was still stuck after 12 minutes. Reproduces with the repo's own modern_loop_task fixture. Core allows 10 rounds. Non-task MRTR multi-round works fine in the same mode.
R2 🟡 Should-fix F1b is only partly fixed: a cancelled non-tool request still holds the connection. The abort now cancels in-flight tool calls, which works well with both Ctrl-C and SIGTERM, and the server sees the cancel. But cancelToolCall() is the only abort hook. A resources/read (or any other non-tool request) that the server never answers still blocks every later command on that connection until disconnect. The code comment assumes those "settle on their own", and that's the assumption an inspector can't make.
R3–R6 ⚪ Nits Listed at the end.

Status of the previous findings:

# Was Now
B1 Merged tree failed verify:bundle-externals (ext-tasks inlined) ✅ Fixed. verify:bundle-externals OK — 4 bundles
F1 input_required task hung forever and wedged the connection ✅ Fixed for the single-round case: TTY prompt, park + respond, --decline and Ctrl-C all complete. ⚠️ Multi-round is still broken (R1).
F1b A cancelled call kept running in the daemon ✅ Fixed for tool calls. ⚠️ Not fixed for non-tool requests (R2).
F2 roots/set never reached the server ✅ Fixed (legacy roots/list and modern MRTR, configured roots and roots/set)
F3 OAuth lost tokens on keychain-less hosts ✅ Fixed. Falls back to the file store and survives a daemon restart.
F4 A global option before @name broke the command ✅ Fixed in every ordering I tried
N1 Usage errors printed twice ✅ Fixed. One JSON envelope with code: "usage".
N2 Lock message named daemon/stop ✅ Fixed
N3 No --version ✅ Fixed
N4 skills/list printed raw JSON; "failed — 0 issue(s)" ✅ Fixed. Human format, plus an unverifiable verdict.
N5 No milestone Still unset (left to the maintainer, as noted)

How this was tested

  • PR head 9fc2e38a, freshly installed and built in a clean worktree. It is 0 commits behind v2/main and mergeStateStatus is CLEAN. CI build and coverage are green.
  • npm run local:gate passes on the head as-is (4m16s):
    • Test suites: web 8,811 · cli 455 · daemon-cli 443 (32 files) · tui 442 · launcher 8 · scripts 938.
    • verify:bundle-externals OK, every smoke including the new smoke:mcpdo, Firefox, and Storybook 528.
    • As last time, HTTP(S)_PROXY had to be unset in this sandbox. That's environmental.
  • npm run pack:verify passes. The installed mcpdo bin passes --help, servers/list and connect → list → disconnect.
  • DCO: all 78 non-merge commits in the PR carry Signed-off-by. I checked this by hand because the DCO app is currently suspended on this repo, so the PR has no DCO check (tracked in ci: replace the suspended DCO app with a required signoff check we own #2566).
  • Manual smoke: Linux, Node 22, real composable test servers over stdio and streamable HTTP in both eras, plus two small fixtures of my own: a stdio list_roots config and a raw stdio server that never answers resources/read. Every session ran in a real pty, driven by an expect-style script that waits for each prompt before typing. Ctrl-C was sent as a real ^C byte, exactly as a user's keyboard would send it. Captures were rendered with xterm.js. All 22 earlier scenarios were re-run as a regression sweep, along with 9 new ones.

🔴 R1: a task that asks for input more than once

09e-task-multiround

What the capture shows:

  1. TTY: round 1 is prompted and answered. Round 2 never appears. Ctrl-C was pressed twice and had no effect. 25s later the terminal is still -isig -icanon -echo, so the ^C byte is read as input instead of raising SIGINT. The user has to kill mcpdo from another terminal. Once the front-end dies, the connection does recover.
  2. Park mode (--format json / no TTY, the agent path): round 1 parks correctly. Answering it never returns (timeout kills it at 20s). The connection then reports the same, already-answered id as pending, while respond and respond --cancel both say that id doesn't exist. Every other call on the connection is refused, so disconnect is the only way out.
  3. Contrast: mrtr_loop (non-task MRTR) on the same daemon in park mode advances round by round and trips the 10-round cap exactly as it should.

Where to look: the bridge delivers round 1 to the subscribing call, but no round-2 frame (TTY) or new park (respond path) ever appears, and the park store keeps reporting the round-1 id. Two possible causes:

  • The bridge/park path doesn't handle a second task-input-required event for the same task. The fixture reuses the input key confirm every round.
  • The terminal is never restored to cooked mode between "submitted" and "next prompt". Raw mode alone would make Ctrl-C dead during any long wait after a prompt, not just this one.

Suggested fix:

  • Treat each task input round like an MRTR round: a new frame in TTY mode, and a new elicitationPending returned from respond in park mode.
  • Restore the tty as soon as a prompt is submitted.
  • A regression test that drives modern_loop_task to the round cap would pin both.

🟡 R2: a cancelled non-tool request still wedges the connection

10b-abort-nontool

The server here is a 30-line raw stdio fixture that answers initialize / tools/* / resources/list but never resources/read. After the read is cancelled, tools/call ping, which worked a moment earlier, and tools/list both queue behind it until disconnect. With the default requestTimeout: 0, nothing ever settles it.

Suggested fix: abort the underlying request for every method, not just tool calls. An AbortSignal passed through to the SDK request would cover this. Alternatively, release the queue slot when the caller is gone, even if the request is still outstanding.

For comparison, the tool-call half of the fix works well:

10-abort

Ctrl-C (TTY) and SIGTERM (no TTY) each cancel a 60s slow_task. The server logs [slow_task] cancelled after 2s both times, and the next call on the connection returns in about 0.15s.


✅ Re-verified findings

F1: single-round input_required task (TTY, park, --decline, Ctrl-C)

09-task-input-tty 09b-task-input-park 09d-task-ctrlc
  • TTY: the task's form is prompted inline with a review step, and the task completes with the answer.
  • Park: the elicitation is parked with origin: "task-input-required". Other calls are refused with a pointer to it, respond completes the task, and the connection is free afterwards. --decline also completes cleanly.
  • Ctrl-C at the prompt: it is treated as a cancel. The task ends with action: "cancel" and the next command runs in 0.16s.

F2: roots reach the server

15-roots
  • Legacy: roots from the catalog entry are advertised, and the server's list_roots tool, which sends a real server→client roots/list, sees them. After roots/set, it sees the new set.
  • Modern MRTR: mrtr_roots reports 2, then 3 roots after roots/set, and an entry with no roots still advertises the capability (0 roots).

F3: OAuth with the default secret store, keychain-less host

19-oauth-default-store 21-oauth-tty-default

This ran with MCP_INSPECTOR_SECRET_STORE and MCP_INSPECTOR_SECRET_KEY both unset.

  • The store falls back to File (unencrypted) at $MCP_STORAGE_DIR/secrets.json (0600).
  • Non-TTY: pendingAuth → sign in → "signed in — completing on next use" → the call succeeds.
  • TTY: "Authorization complete." followed by a working call.
  • Tokens survive daemon stop followed by connect --stored-auth-only.

F4: argv ordering

05-argv
  • These all work: --format json @srv, --plain @srv, --format=json @srv, @srv --format json, --format json --plain @srv and --conn srv --format json.
  • An @-prefixed tool value after the subcommand passes through untouched (message:=@not-a-connection echoes back).
  • tools/list @srv is rejected as an extra argument, which matches "before the subcommand".

N1–N4

24-nits

The N2 message (Use mcpdo daemon stop) appears in the security capture below.


✅ Regression sweep: all 22 earlier scenarios re-run, no regressions

Every error in these captures is the intended negative case: tool not found, a parked call refusing other work, URL-mode decline refused, a --elicit off server refusing, a wrong daemon token, an over-long socket path, and so on.

Expand captures 01-help 02-catalog-connect 03-tools 04-resources-prompts 05-args-cwd-env 06-logging-stream 07-subs-pagination 08-tasks 11-elicit-park 12-elicit-tty 13-elicit-url 14-elicit-url-tty 16-mru-private 17-security 18-oauth 20-oauth-tty 22-era-lifecycle 23-ema-agenthelp

Nits (non-blocking)

  • R3. The README's tasks/update recipe can't be reached from mcpdo for modern tasks. Even with --task, a modern input task parks rather than returning a task id, because core polls it to terminal state. The elicitationPending payload carries no taskId, and tasks/list is refused while it's parked. So tasks/update <taskId> has no id to work from. elicitation/respond is the path that works, and the README (§ tasks, and "modern non-task MRTR" under Elicitation support) should say so now that task elicitations are delivered.
    09c-task-tasks-update
  • R4. An explicit MCP_INSPECTOR_SECRET_STORE=memory still fails late and misleadingly. That is by design (the override wins), but the failure is "stored credentials could not be refreshed" (exit 3) after a successful sign-in. A one-line warning at connect, such as "memory store can't work across mcpdo's processes", would save someone a debugging session.
    19b-oauth-explicit-memory
  • R5. The five-line secret-store fallback warning repeats on every command that touches the store (connect, auth/list, …), on stderr. Agents will see it constantly. Consider printing it once per daemon, or only on connect.
  • R6. Review hygiene: the review decision is still CHANGES_REQUESTED and there's no milestone.

🤖 Generated with Claude Code

@cliffhall cliffhall left a comment •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we refactor/rename clients/daemon-cli to clients/mcpdo since that's the name of the client now. Seems confusing to have two names for it in the codebase.

R1: PromptReader now restores cooked mode while parked and on dispose, so
Ctrl-C (SIGINT) stays live between prompt rounds and in the user's next
shell instead of being left dead by a persistent raw-mode reader.

R2: thread an ambient per-connection abort signal from the daemon RPC
layer through InspectorClient.getRequestOptions, so an aborted mcpdo RPC
cancels the in-flight MCP request rather than leaking it. Adds
combineAbortSignals and setAmbientRequestSignal; the daemon sets/clears
it around runMethod.

R3: README task-resume recipe rewritten — a modern (SEP-2663)
input_required task is answered with elicitation/respond, not
tasks/update (core polls it to terminal; the input round surfaces as an
elicitation). Elicitation-support intro now names modern task input
rounds alongside legacy elicitation/create and non-task MRTR rounds.

R4: at connect, surface an mcpdo-specific warning when the secret store
is memory — it lives in one process, so the daemon and OAuth helper
cannot share it and sign-in later fails; advises file or keyring.

R5: quiet the per-process secret-storage fallback/caveat banner in the
mcpdo front-end (which spawns a fresh process per command) and re-emit it
once, deterministically, at connect via setSecretStorageWarningsQuiet +
a force bypass on warnAboutSecretStorage. The daemon stays unquiet so the
warning still lands once in its stderr log; web/cli/tui are unaffected.

Also fixes the modern-tasks test server to return proper SEP-2663 steps.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Bob Dickinson <bob.dickinson@gmail.com>
@BobDickinson

Copy link
Copy Markdown
Contributor Author

Thanks for the thorough re-drive, @cliffhall — every finding reproduced from your captures. Fixed in c0e4cccb, each with a regression test written first against the repo's own fixtures.

🔴 R1 — multi-round task input (TTY hang + Ctrl-C dead; park elicitation_not_found)

Both halves were real and both are fixed.

  • TTY / Ctrl-C. The PromptReader left the terminal in raw mode between prompt rounds, so the ^C byte was read as input instead of raising SIGINT. It now restores cooked mode while parked (and on dispose), and re-enters raw only while a question is actually pending. Ctrl-C is live in the gap between rounds and in the user's next shell. Covered by new prompt-reader TTY cooked/raw tests.
  • Round 2 never delivered / park path. Each task input round is now surfaced like an MRTR round — a fresh frame in TTY mode and a fresh park (new elicitationPending) in --format json. The modern_loop_task fixture also wasn't returning proper SEP-2663 steps, so it's fixed to drive real multi-round input; the regression test walks it round by round.

⚠️ One residual I'm flagging rather than fixing here: against a malformed server that repeats an already-answered input key and never terminates, core polls unbounded — the SDK's maxInputRounds=10 cap counts only distinct rounds, so a repeated key never trips it. The well-behaved modern_loop_task path (distinct rounds) hits the cap correctly, as you saw with mrtr_loop. The unbounded-distinct-key case is a core-level gap; happy to track it separately if you'd like.

🟡 R2 — cancelled non-tool request still wedges the connection

Fixed by threading an ambient per-connection AbortSignal from the daemon RPC layer through InspectorClient.getRequestOptions (the ~22-caller choke point), so an aborted RPC cancels the in-flight SDK request for every method, not just tools/call. The daemon sets/clears the signal around runMethod. New repro test drives a slow resources/read and asserts the request's extra.signal fires on abort.

⚠️ Residual flagged: the ext-tasks task-session driver (cancelRequestorTask/updateRequestorTask, the SEP-2663 session path) doesn't route through getRequestOptions, so the ambient signal doesn't reach it yet — a narrower follow-up than the non-tool request path this fixes.

⚪ R3 — README tasks/update recipe

Rewritten. A modern (SEP-2663) input_required task parks rather than returning a task id (core polls it to terminal), so there's no id for tasks/update to work from — it's answered with elicitation/respond. The §tasks recipe and the Elicitation support intro now say so and name modern task input rounds alongside legacy elicitation/create and non-task MRTR.

⚪ R4 — explicit MCP_INSPECTOR_SECRET_STORE=memory fails late

Added a connect-time warning: when the resolved store is memory, the front-end now prints that it can't persist across mcpdo's separate processes (daemon + OAuth helper), so sign-in will fail later, and advises file or keyring. The explicit override is still honored.

⚪ R5 — fallback warning repeats on every command

The front-end spawns a fresh process per command, each re-emitting the banner. It's now quieted process-wide in the front-end and re-emitted once, deterministically, on connect (via a new setSecretStorageWarningsQuiet + a force bypass on warnAboutSecretStorage). The persistent daemon stays unquiet so the warning still lands once in its stderr log; web/cli/tui are unaffected.

R6 / rename

Review hygiene + milestone are maintainer calls, left as noted. On your clients/daemon-cli → clients/mcpdo request (the CHANGES_REQUESTED review): agreed, that's the right call now that mcpdo is the product name — it's queued as the next change on this branch (including the mcpdod daemon process/socket/lock rebrand), kept separate from this fix commit so the review response stays reviewable.

npm run local:gate is green on c0e4cccb.

Cliff's review (5408193681) asked to rename `clients/daemon-cli` to
`clients/mcpdo`, since `mcpdo` is the product/bin name and two names for
one client was confusing. The other clients are named for what they are
(web, cli, tui, launcher), so mcpdo fits that convention.

Dir + package:
- git mv clients/daemon-cli -> clients/mcpdo
- package renamed @modelcontextprotocol/daemon-cli -> @modelcontextprotocol/mcpdo
  (bin stays `mcpdo`); lockfile regenerated
- updated every path/name reference: root package.json scripts,
  scripts/*.mjs (install cascade, smokes, pack-verify, dep/sdk sweeps,
  verify:* guards and their tests), AGENTS.md, READMEs, specs, and the
  local-dev/testing skills

Daemon rebrand -> mcpdod (Unix d-suffix convention):
- run.ts sets process.title = "mcpdod", so the detached daemon shows as
  `mcpdod` in ps/pgrep and is killable with `pkill mcpdod` instead of a
  bare `node .../mcpdod.js`
- runtime files renamed as a cohort in paths.ts: daemon.{sock,lock,token,log}
  -> mcpdod.{sock,lock,token,log}, with every test and doc reference
- built bundle build/daemon.js -> build/mcpdod.js (tsup entry key, the
  resolver candidate list in ensure.ts, pack-and-verify, specs)
- env vars unchanged (MCP_INSPECTOR_DAEMON_DIR / _TOKEN)

The client is unreleased/experimental, so there is no on-disk migration
concern: an old daemon from a previous build is just an orphan to kill once.

npm run local:gate is green (including smoke:mcpdo, which spawns the real
daemon via the new bundle and socket names).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Bob Dickinson <bob.dickinson@gmail.com>
@BobDickinson

Copy link
Copy Markdown
Contributor Author

@cliffhall — the clients/daemon-cli → clients/mcpdo rename is done in 49e56870.

  • Package @modelcontextprotocol/daemon-cli → @modelcontextprotocol/mcpdo (bin stays mcpdo); every path/name reference updated across root scripts, the verify:* guards + tests, the install/smoke/pack/sweep scripts, AGENTS.md, the READMEs, the specs, and the local-dev/testing skills.
  • Took the chance to rebrand the daemon to mcpdod (Unix d-suffix): it now sets process.title = "mcpdod", so a detached daemon shows as mcpdod in ps/pgrep and is killable with pkill mcpdod instead of a bare node …. Its runtime files follow suit — mcpdod.{sock,lock,token,log} — and the built bundle is build/mcpdod.js. Env vars (MCP_INSPECTOR_DAEMON_DIR/_TOKEN) are unchanged.

npm run local:gate is green, including smoke:mcpdo, which spawns the real daemon via the new bundle and socket names.

BobDickinson and others added 4 commits October 5, 2026 11:16
Add 'mcpdo disconnect <name> -c/--clear-auth', which tears down a
connection and clears its stored OAuth tokens in one step so the next
plain connect re-triggers sign-in. The daemon's disconnect now surfaces
the server URL; the front-end clears stored auth for it only after the
connection is gone, closing the window where a live client keeps working
on in-memory tokens. Also add a -r short alias to the existing --relogin
option on connect and the EMA login command.

Updates the README, the shipped agent skill, and adds tests for the
daemon serverUrl return, the front-end flag behavior, and output.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Bob Dickinson <bob.dickinson@gmail.com>
auth/list now annotates each stored OAuth URL with the catalog and
connection names it is 'known as' and marks entries currently held by a
live connection, so the raw store URLs correlate to the servers in use.
auth/clear accepts one of those friendly names as an alternate to the
store URL; a name that maps to no URL (stdio) or to more than one URL is
rejected with guidance. Adds the pure auth-names module (buildAuthNameIndex,
resolveFriendlyName) with unit and command-level tests.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Bob Dickinson <bob.dickinson@gmail.com>
The friendly-name routing added in the previous commit only treated
^https?:// arguments as direct store keys, so clearing an EMA IdP login
by its exact 'ema-idp:<issuer>' key (the string auth/list prints) was
wrongly rejected as an unknown name — a regression. Route any exact
store key through the direct clear path.

Also stop showing IdP login records as unnamed servers: auth/list now
renders them as 'enterprise IdP login — <issuer>' (and tags the JSON
with idp/issuer), and auth/clear accepts that bare issuer URL, mapping
it back to the prefixed store key. Adds a core parseIdpOAuthStorageKey
inverse of idpOAuthStorageKey (with the shared ema-idp: prefix constant)
so the prefix is not duplicated, plus tests across core and mcpdo.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Bob Dickinson <bob.dickinson@gmail.com>
Render EMA IdP login records leading with their bare issuer URL and put
the 'enterprise IdP login' marker in the trailing annotation slot,
parallel to 'known as:', so every row leads with the identifier used to
clear it and the rare enterprise entry no longer dominates the line.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Bob Dickinson <bob.dickinson@gmail.com>
@cliffhall

Copy link
Copy Markdown
Member

@BobDickinson there are merge conflicts in the method-types.ts file

Fix the OAuth/EMA pending-auth flow so the agent shows the sign-in URL as
the last line of its reply and then waits, instead of withholding it until
the session ends. Replace the opaque parkElicitations flag with an honest
interactive flag threaded through dispatch -> protocol -> server ->
connections, driving both elicitation parking (opt-in) and message audience
(agent-facing by default). The real lever is the SKILL.md guidance: relay
the URL and end the turn rather than poll/sleep.

Also route the ema-logout IdP end-session URL through the same
isSafeLinkTarget/style.link gate as every other server-supplied URL, so it
renders as a clickable OSC 8 link on its own line rather than plain text.
It is still never auto-launched.

Fix a race in the consent-clicker eval test: it waited on the /cb callback
hit but asserted clickedCount(), which increments after clickConsent
returns; wait on clickedCount() instead.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Bob Dickinson <bob.dickinson@gmail.com>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🔵 Needs a closer look

The EMA clear paths leave reusable IdP sessions behind, and the root lockfile retains a nonexistent mcpdo bin path.

Review effort: Balanced
Findings: None

BobDickinson and others added 2 commits October 6, 2026 15:15
The daemon-cli -> mcpdo rename updated the root package.json bin entry to
clients/mcpdo/build/mcp-bin.js but left package-lock.json pointing at the
old clients/daemon-cli path, which no longer exists. Regenerating the
lockfile with --package-lock-only produces exactly this one-line change.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Bob Dickinson <bob.dickinson@gmail.com>
Signed-off-by: Bob Dickinson <bob.dickinson@gmail.com>

# Conflicts:
#	clients/cli/src/handlers/method-types.ts
@BobDickinson

Copy link
Copy Markdown
Contributor Author

Response to Copilot review (round of 2026-10-06)

The review posted no inline comments but its overview headline named two concerns. Addressing both:

1. "The root lockfile retains a nonexistent mcpdo bin path" — fixed (ff51b5a0).
The daemon-cli → mcpdo rename updated the root package.json bin to clients/mcpdo/build/mcp-bin.js but left package-lock.json pointing at the old clients/daemon-cli/... path, which no longer exists. Corrected; regenerating with npm install --package-lock-only produces exactly this one-line change.

2. "The EMA clear paths leave reusable IdP sessions behind" — working as designed, declining.
Clearing EMA server tokens intentionally does not end the shared IdP browser SSO session: that cookie is shared across applications, and ending it is the user's choice, not a side effect of a per-server token clear. This is precisely why ema-logout now surfaces the RP-initiated end-session URL (rendered as a clickable OSC 8 link by this PR) for the user to optionally navigate to, rather than auto-ending the session. No defect here.

Also in this push: merged current v2/main (resolved one comment/field conflict in clients/cli/src/handlers/method-types.ts, keeping the base's new --quiet/--output/--output-format fields alongside this branch's tasks/update method).

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🔵 Needs a closer look

Park expiry can allow a still-running RPC’s later elicitation to be misrouted to a new command on the same connection.

Review effort: Balanced
Findings: None

@Littlewindmillcc Littlewindmillcc left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ok

A parked elicitation that expires or is torn down forgets the park and
unwires its bridge subscriber, but the server-side call keeps running:
a tool call that survives its errored elicitation could emit a second
one, which the bridge (unable to attribute an elicitation to a specific
call) would misroute to whatever new rpc has since started on the
connection. Cancel the whole call via cancelToolCall() on expiry and on
connection/daemon teardown — the same lever runRpcOnClient uses on caller
disconnect — so no second elicitation is produced. Wired as a cancelCall
callback mirroring unwire, keeping the registry decoupled from the client
surface.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Bob Dickinson <bob.dickinson@gmail.com>
@BobDickinson

Copy link
Copy Markdown
Contributor Author

Round 47 (park-expiry misroute) — fixed in e7e19b6.

The finding is accurate and in-scope (the mcpdo parking is this PR's own code). Trace: on expiry/teardown, `ParkingElicitationChannel.close()` → the bridge's `handleOne` catch calls `message.cancel()`, which cancels only that one elicitation, not the server call. The abandoned tool call keeps running; with this park's subscriber unwired, a second elicitation from it would be dispatched to `subscribers[0]` — now a newer rpc — i.e. misrouted.

Fix: on expiry and on connection/daemon teardown, cancel the whole call via `client.cancelToolCall()` (sends `notifications/cancelled`), so the server stops and emits no second prompt. This is the same lever `runRpcOnClient` already uses on caller disconnect. It is wired as a `cancelCall` callback mirroring the existing `unwire`, keeping the registry decoupled from the client surface.

Why not the ambient request signal: `setAmbientRequestSignal` is a LIFO save/restore slot, and a parked call's lifetime is not LIFO (call A can settle while a later call B holds the slot), so using it here would clobber B's signal. `cancelToolCall()` is client-level with no such hazard. It is a no-op when the parked method is not a tool call (no active controller) — the case where no second elicitation realistically arises.

Tests: added an end-to-end assertion that expiry triggers `cancelToolCall` through the real server, plus registry-level coverage on both the expiry and teardown paths. Full `local:gate` green (the one failure is the pre-existing #2318 web-integration flake, which passes in isolation and is unrelated to these mcpdo-only changes).

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🔵 Needs a closer look

Cancellation gaps can wedge daemon connections or cross-cancel unrelated stream and RPC requests.

Review effort: Balanced
Findings: None

Previously missed (1)

In code that hasn't changed since last review

Low severity PR description references outdated client paths and commands

clients/​mcpdo/​README.md:5

The implementation and this README consistently use clients/mcpdo and scripts such as coverage:mcpdo, but the PR description still says the client lives at clients/daemon-cli, points the published bin there, and lists coverage:daemon-cli/daemon-cli enrollment. Those paths and commands do not exist in this diff; update the PR description and test-plan names to match the final rename so reviewers and release verification use the actual package.

Littlewindmillcc

This comment was marked as spam.

@BobDickinson

Copy link
Copy Markdown
Contributor Author

Round 48 — no inline findings. Two items:

Headline ("cancellation gaps … cross-cancel unrelated stream and RPC requests") — declined, not reachable. The expiry/teardown cancel added in e7e19b6 targets exactly the abandoned parked call, not unrelated work:

  • The daemon serializes rpcs per client (rpcQueues) and assertNoParkedCall refuses any new rpc while a call is parked, so when expiry fires the abandoned parked call is the only in-flight call on that client — there is no concurrent rpc to cross-cancel.
  • cancelToolCall() aborts only activeToolCallAbortController (set solely during a tool call). Long-lived streams (logging/tail, etc.) are separate producers wired through startStream, not tool-call controllers, so they are untouched.
  • Cancelling frees the call (and its per-connection queue slot), which is the opposite of wedging the connection — the un-cancelled path was the one that could leave an abandoned call running for the full 10-min TTL.

Previously-missed (LOW, PR-description paths) — fixed. Updated the PR description to the final rename: clients/daemon-cli → clients/mcpdo, bin → clients/mcpdo/build/mcp-bin.js, coverage:daemon-cli → coverage:mcpdo, and the enrollment note. No code change.

Review loop closed. This round had no in-scope code finding (the only code finding was declined as non-reachable; the description fix is not code and needs no re-review), so per the pr-flow stop rule I am not requesting another round — it would only re-review unchanged code.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

v2 Issues and PRs for v2

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Inspector mcpdo client

4 participants