inspector/clients/tui
Repository files (latest commit first)
Filename Latest commit message Latest commit date
cliffhall d90a955164 fix(proxy): route through undici's own fetch and keep undici external
Corporate-proxy users could not connect to any remote MCP server from v2.
Two independent defects, both reported in #2067.

1. The web and TUI bundles inlined undici. It was declared in the root and
   `clients/cli` manifests but named in no client's tsup `external` list, and
   tsup auto-externalizes only what the *nearest* manifest declares — so
   esbuild inlined 1.05MB of it into `clients/{web,tui}/build/`. undici is
   CommonJS, so the emitted chunk's `require("assert")` hit esbuild's ESM
   `__require` shim and threw `Dynamic require of "assert" is not supported`.
   The rejected `import()` surfaced as "the `undici` package is not available",
   telling users to install a package that was already present — and could not
   have helped, because the specifier had been rewritten to a relative chunk at
   build time.

2. `withProxyDispatcher` injected a *userland* undici `EnvHttpProxyAgent` into
   Node's *built-in* fetch. That couples two different copies of undici at the
   dispatcher handler interface, which is not stable across majors: Node 22
   embeds undici 6, Node 24 embeds 7, Node 26 embeds 8. A userland undici 8
   agent handed a built-in undici 7 handler is rejected with
   `fetch failed: invalid onRequestStart method`, and the request never leaves
   the process. So fixing the bundling alone would only have converted the
   web/TUI failure into the CLI's.

Both halves of the pair now come from userland undici — its `fetch` and its
`EnvHttpProxyAgent` — which keeps the handler inside one copy and works
unchanged from the Node 22.19 engine floor through Node 26. Deleting the
wrapper in favor of Node's native `NODE_USE_ENV_PROXY` was not an option:
measured, it is unsupported on 22.19 (the floor) and only lands mid-22.x.

Because undici's `Response` is a different class from `globalThis.Response`,
the proxied fetch re-wraps each response as a genuine global `Response`,
streaming preserved. Without that, `res instanceof Response` is false for
callers that test it — including the SDK's OAuth error formatter, which would
degrade every message to `Raw body: [object Response]`.

The proxy now sits at the bottom of the fetch stack (`environment.fetch` for
the Node clients, and the `createTransportNode` default for the web backend,
its one caller that supplies no `fetchFn`) rather than wrapping the transport's
fetch. Proxying means *substituting* the fetch, not decorating one, so wrapping
a supplied `fetchFn` would discard it; installing it underneath lets
InspectorClient's own wrappers compose over it. A side effect worth having:
OAuth discovery and token requests go through `environment.fetch` too, and were
never proxied before.

Guarding it:

- `undici` is added to all three tsup `external` lists and removed from
  `clients/cli/package.json`, so it is root-declared only, per the placement
  rule in AGENTS.md. The two installs had already drifted (root 8.5.0 vs cli
  8.9.0).
- New `npm run verify:bundle-externals` fails if any package a client declares
  external was inlined into that client's bundle anyway. It reads the *built
  output*, not the config — those two disagreed for four releases — and derives
  what to check from each client's own `external` array, so a newly
  externalized package is covered without editing the guard. Verified against
  the real pre-fix artifact, which it flags.
- The proxy tests now drive a real forwarding proxy rather than a mock. That
  matters: defect 2 was a runtime interface mismatch, which no mock can catch
  because no mock ever dispatches.

AGENTS.md's must-not-bundle rule (added by #2082) is extended with why undici
slipped past it, and notes the `node -e` trap — probing the bundled chunk with
`node -e` falsely succeeds, because it exposes a global `require` that
satisfies esbuild's guard.

Closes #2067

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JTHVxSu8AUgHRLvo1ntZ8H
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-23 21:12:16 -04:00
..
__tests__ fix(proxy): route through undici's own fetch and keep undici external 2026-08-23 21:12:16 -04:00
src fix(proxy): route through undici's own fetch and keep undici external 2026-08-23 21:12:16 -04:00
dev.ts feat: port CLI, TUI, and launcher from v1.5 into v2 (#1452) 2026-06-14 20:27:49 -04:00
eslint.config.js chore(lint): fail lint on warnings (--max-warnings 0) 2026-08-23 10:47:29 -04:00
index.ts feat(web): enforce localhost-only binding; expand loopback origin allow-list (#1796) 2026-07-26 22:15:10 -04:00
package-lock.json chore(deps): bump nanoid to 3.3.18 to clear the high-severity advisory 2026-08-18 23:12:10 -04:00
package.json chore(lint): fail lint on warnings (--max-warnings 0) 2026-08-23 10:47:29 -04:00
README.md fix(tui): widen the root react range so an external ink can't split React 2026-08-12 00:21:43 -04:00
test-config.json feat: port CLI, TUI, and launcher from v1.5 into v2 (#1452) 2026-06-14 20:27:49 -04:00
tsconfig.json test(cli,tui): typecheck the __tests__ dirs (#1791) (#1799) 2026-07-27 12:05:35 -04:00
tsconfig.test.json test(cli,tui): typecheck the __tests__ dirs (#1791) (#1799) 2026-07-27 12:05:35 -04:00
tsup.config.ts fix(proxy): route through undici's own fetch and keep undici external 2026-08-23 21:12:16 -04:00
tui.tsx feat(web): enforce localhost-only binding; expand loopback origin allow-list (#1796) 2026-07-26 22:15:10 -04:00
vitest.config.ts test(tui): extend coverage gate to Ink components + App.tsx (closes #1501) (#1538) 2026-06-29 08:19:12 -04:00

MCP Inspector TUI Client

The Terminal User Interface (TUI) client brings the interactive exploration capabilities of the Web Client directly to your terminal. It is built using Ink to provide a rich, React-like component experience in a command-line environment.

MCP Inspector TUI Screenshot

Running the TUI

You can run the TUI client via npx:

npx @modelcontextprotocol/inspector --tui node build/index.js

With Configuration Files

The TUI can load all servers from an MCP catalog/config file. With no source flag it uses the default writable catalog ~/.mcp-inspector/mcp.json (seeded empty if missing):

npx @modelcontextprotocol/inspector --tui --catalog mcp.json   # writable catalog (seeded empty if missing)
npx @modelcontextprotocol/inspector --tui --config mcp.json    # read-only session (errors if absent)

(It does not use --server; all servers in the file are available in the TUI.)

Options

MCP server (which server(s) to connect to)

Options that specify the MCP server(s) (catalog/config file, ad-hoc command/URL, env vars, headers) are shared by the Web, CLI, and TUI and are documented in MCP server configuration: --catalog (writable catalog, seeded empty if missing; default ~/.mcp-inspector/mcp.json or MCP_CATALOG_PATH), --config (read-only session, errors if absent), -e, --cwd, --header, --transport, --server-url, and the positional [target...]. --catalog and --config are mutually exclusive, and neither combines with an ad-hoc target.

TUI-specific (OAuth for HTTP servers)

The TUI supports OAuth for SSE and Streamable HTTP servers. Per-server OAuth fields in mcp.json (static client id/secret, scopes, enterprise-managed flag) are applied automatically when loaded from --catalog or --config. Install-wide settings (CIMD, enterprise IdP) come from ~/.mcp-inspector/storage/client.json — the same file the web Client Settings dialog writes. You can point at a different file with --client-config or MCP_CLIENT_CONFIG_PATH.

OAuth callback URL

The TUI starts a small loopback HTTP server to receive the authorization redirect after you sign in in the browser. Defaults:

Surface Default callback
Web http://localhost:6274/oauth/callback (main app server)
TUI http://127.0.0.1:6276/oauth/callback (dedicated runner port; avoids colliding with web on 6274)

Why a fixed default port? Enterprise-managed auth (EMA), CIMD, and many static OAuth apps require pre-registered redirect URIs. A predictable default (http://127.0.0.1:6276/oauth/callback) lets you register once on the IdP and reuse it across TUI sessions. Dynamic registration (DCR) can use ephemeral ports instead — see below.

Trade-off: only one TUI OAuth flow at a time can listen on the default port. A second instance starting OAuth while another is in progress may fail with EADDRINUSE. Override the listener with --callback-url or MCP_OAUTH_CALLBACK_URL (e.g. a different fixed port per instance, or http://127.0.0.1:0/oauth/callback when the authorization server accepts dynamically registered redirect URIs).

OAuth redirect URIs must match exactly what you register on the authorization server — localhost and 127.0.0.1 are different URIs. Register the TUI default on your OAuth app / IdP when using pre-registered (static), CIMD, or enterprise-managed clients. Override the listener with --callback-url or MCP_OAUTH_CALLBACK_URL; use http://127.0.0.1:0/oauth/callback for an OS-assigned ephemeral port when the authorization server registers redirect URIs dynamically (DCR).

Flags

Option Env Description
--client-config <path> MCP_CLIENT_CONFIG_PATH Install-level client config (default: ~/.mcp-inspector/storage/client.json).
--client-id <id> — OAuth client ID (static client); overrides client.json.
--client-secret <secret> — OAuth client secret (confidential clients); overrides client.json.
--client-metadata-url <url> — Client ID Metadata Document URL (CIMD); overrides client.json.
--callback-url <url> MCP_OAUTH_CALLBACK_URL OAuth redirect/callback listener (default: http://127.0.0.1:6276/oauth/callback). Must bind a loopback host (localhost / 127.0.0.0/8 / [::1]); a non-loopback host hard-errors, since the listener receives the authorization code over plaintext http.

Authenticating in the TUI

  1. Select an HTTP/SSE server and press C to connect.
  2. If authorization is required, the TUI starts OAuth automatically (browser opens for sign-in).
  3. After the callback completes, connect finishes without a second C.
  4. Use the Auth tab to inspect OAuth state (same fields as web Connection Info) or Clear OAuth state (disconnects when connected).

See also EMA / enterprise-managed auth and OAuth smoke testing for staging servers and verification steps.

Features

The TUI provides terminal-native tabs and panes for interacting with your MCP server:

  • Resources: Browse and read resources exposed by the server.
  • Prompts: List and test prompts.
  • Tools: View available tools and execute them with form-like inputs.
  • Protocol: View JSON-RPC request/response/notification history (matches the web Protocol monitor).
  • Network: View HTTP fetch traffic for SSE / Streamable HTTP servers (matches the web Network monitor).
  • Console: View stdio stderr from the connected server process (matches the web Console monitor).

Navigation

  • Use the Arrow Keys (Left/Right) or Tab to switch between the main tabs (Resources, Tools, Prompts, etc.).
  • Use the Arrow Keys (Up/Down) to scroll through lists of items.
  • Press Enter to select an item, execute a tool, or fetch a resource.
  • Press Escape or Ctrl+C to exit the application.

Development

Like the other clients, the TUI self-validates from its own folder:

npm run validate       # format:check && lint && build && test:coverage
npm test               # run all tests
npm run test:coverage  # run tests under the per-file coverage gate

The repo-root validate:tui just delegates here. eslint.config.js registers react-hooks for the classic rules only (rules-of-hooks + exhaustive-deps); the stricter react-hooks@7 rules are not enforced on the interim component surface (#1501).

Tests live in __tests__/. The coverage gate covers all of src/**, React surface included — the Ink components mount through ink-testing-library (with the ink-scroll-view / ink-form passthrough doubles in __tests__/helpers/), App.tsx mounts against a mock of the @inspector/core surface, and keypresses are driven through stdin. The former interim exclusion of the components and App.tsx was lifted in #1501; the only exclusion left in vitest.config.ts is src/tui-servers.ts, a pure re-export of core's server resolver with no runtime statements of its own (its logic is measured in core/ via the web suite, and tui-servers.test.ts still exercises it behaviorally — it is excluded only so it doesn't surface as a misleading 0/0 row).

Bundling: React-rendering dependencies must be inlined (#1952)

tsup.config.ts splits the TUI's dependencies into bundled (noExternal) and externalized (external). For anything that renders React components, that choice decides which React instance it gets at runtime, and getting it wrong crashes the TUI in a way nothing in this repo can see.

An external package's import "react" resolves from wherever npm placed that package — and npm places it beside a React satisfying its peer range, which is looser than ours in every case here:

Package Its react peer Placed beside a different React when…
ink-form, ink-scroll-view ">=18" the consumer has React 18
ink ">=19" the consumer pins React 19.0 (our ^19.2.4 then nests)

Either way the bundle renders through one React while the external package calls hooks on another, whose dispatcher is null — so opening a tool test form (or any scroll view, or in ink's case simply starting the TUI) dies with TypeError: Cannot read properties of null (reading 'useState').

ink-form and ink-scroll-view are therefore inlined, which removes npm from the decision: an inlined package's import "react" is emitted into build/index.js, so it resolves from the build directory exactly like the bundle's own. Inlining also pins transitive deps to what this install resolved, notably ink-select-input@6 via the overrides entry — npm ignores a dependency's overrides, so a consumer install would otherwise pull the React-18-era v5 that ink-form asks for.

Why ink itself is the exception

ink is external for cost, not because its ">=19" peer makes it safe — it does not, and an earlier revision of this section wrongly claimed it did. Bundling ink works (it was built and verified against both consumer repros) but adds ~1.4 MB, since react-reconciler and yoga-layout come with it, plus a createRequire banner: inlined CJS calls require at runtime (react-reconciler for "react", signal-exit@3 for "assert") and esbuild's interop shim throws Dynamic require of "x" is not supported without a real require in scope. A 602 KB bundle beat a 2 MB one.

What makes the exemption tolerable is a separate lever: the root manifest declares react: "^19.0.0" — open to the whole major, rather than pinned at the version we happen to develop against. That lets npm satisfy our React and a consumer's pinned React 19.x with a single copy, so an external ink resolves ours. Verified against a consumer pinning react@19.0.0 alongside ink@6.8.0 — the case that splits under a narrower range:

bundle react   node_modules/react/index.js
ink -> react   node_modules/react/index.js   # same copy

Narrow that range and the exemption turns straight back into #1952, one level up — breaking TUI startup rather than just its forms. tsupConfig.test.ts pins the root range to ink's own peer floor so it can't drift silently. The residual after all this is a React 20-era consumer that also depends on ink; that gets revisited when the Inspector moves to React 20.

__tests__/tsupConfig.test.ts enforces the whole split: every dependency declaring a react peer must be in noExternal unless it is listed as external by design, each exempt package must also be a root dependency (external means consumers install it), and the root react range must stay open to the major. Add a React-rendering dependency, and that test tells you to bundle it.

The ink-form label patch

Bundling ink-form also makes it patchable, which one label needs: the hint under an incomplete form reads "you have not competed yet". It is upstream's string, hardcoded in ink-form/lib/SubmitButton.js with no prop to override, and ink-form was last published in 2024 — so tsup.config.ts corrects it with an esbuild onLoad hook as the file enters the bundle. It is reported upstream as lukasbach/ink-form#14; if a release ever carries the fix, drop the patch.

The hook throws when the string isn't found rather than passing the source through. A silent no-op would let an ink-form upgrade retire the patch without anyone noticing it had stopped applying — or leave a patch aimed at a string that no longer exists. If the build fails there, check whether upstream fixed the typo and delete the patch instead of re-targeting it.