Merge pull request #1993 from modelcontextprotocol/v2/chore/milestone-merge-v2.2.0
Some checks failed
Run install, format, lint, build, and test on every push / build (push) Has been cancelled
Run install, format, lint, build, and test on every push / publish (push) Has been cancelled
Run install, format, lint, build, and test on every push / publish-github-container-registry (push) Has been cancelled
Some checks failed
Run install, format, lint, build, and test on every push / build (push) Has been cancelled
Run install, format, lint, build, and test on every push / publish (push) Has been cancelled
Run install, format, lint, build, and test on every push / publish-github-container-registry (push) Has been cancelled
chore: merge v2/main for the v2.2.0 milestone release
This commit is contained in:
commit
672f9f41c5
119 changed files with 8840 additions and 2824 deletions
13
.github/copilot-instructions.md
vendored
13
.github/copilot-instructions.md
vendored
|
|
@ -72,6 +72,14 @@ Both exist and do different jobs. Theme files (`src/theme/<Component>.ts`) custo
|
|||
- `src/types/` is only for ambient `.d.ts` module stubs — not a home for new domain types.
|
||||
- ⚠️ The coverage `include` is a **whitelist** naming `components` / `hooks` / `theme` / `lib` / `utils` / `server` (plus the `core/*` runtime). A module placed outside those directories silently falls out of the ≥90% gate. Flag new top-level files or new grab-bag directories.
|
||||
|
||||
## Dependency placement
|
||||
|
||||
- **The MCP SDK packages (`@modelcontextprotocol/client`, `core`, `server`, `server-legacy`, `ext-apps`) belong in the root `package.json` only.** Adding one to a `clients/*/package.json` is a review finding: Node resolution walks up, so the root install already serves every client, and a per-client entry installs a second copy that drifts (it produced two versions of `ext-apps`, and of the transitive v1 SDK, at once — #1970) and reintroduces the duplicate-copy failure `vitest.shared.mts` has a `dedupe` workaround for.
|
||||
- **The v1 `@modelcontextprotocol/sdk` is not a dependency of this repo** and must not become one. It is a peer of `ext-apps`, present in lock files only.
|
||||
- Dependencies reached only through **root-owned code with no manifest** (`test-servers/src`, `core/`) are declared at the root and aliased to the **repo root** in `vitest.shared.mts` — as `express` and `yaml` are — not to `<client>/node_modules` like the other pins there.
|
||||
- **A dependency that renders React components must be bundled into the client that uses it, and is then not a root dependency.** An externalized package resolves its own `react` from wherever npm placed it in the *consumer's* tree, beside a React satisfying *its* peer range — looser than ours, which is all it takes to split React. `ink-form`/`ink-scroll-view` declare `">=18"`, so a consumer's React 18 satisfies them, the TUI ends up with two React instances, and it crashes on the first hook (#1952). Both are inlined via `noExternal` in `clients/tui/tsup.config.ts`. **`ink` is the one exemption, justified by cost (~1.4MB) — never by a peer range**: flag any claim that `">=19"` keeps npm from misplacing it, which is false and was in this repo once. What keeps it safe is the **root `react` range staying open to the whole major (`^19.0.0`)** so npm can dedupe with a consumer's pinned React 19; treat narrowing that range as reopening the bug. `clients/tui/__tests__/tsupConfig.test.ts` enforces the split, the root-declaration of exempt packages, and that range.
|
||||
- **Which section is a separate question from which manifest.** A package `core/` imports at runtime must be in root **`dependencies`**: client builds externalize npm packages, so a published install resolves them from the root manifest and devDependencies are absent there. Only test/build-only packages (`express`) belong in `devDependencies`. Flag a runtime `core/` import added to `devDependencies` — it passes every local check and breaks the published package.
|
||||
|
||||
## Tests and the coverage gate
|
||||
|
||||
- **All new or modified code needs tests.** The per-file gate is **≥ 90% on all four dimensions** — lines, statements, functions, **and branches** — enforced in CI for `clients/{web,cli,tui,launcher}` and the gated `core/` runtime.
|
||||
|
|
@ -85,15 +93,18 @@ Both exist and do different jobs. Theme files (`src/theme/<Component>.ts`) custo
|
|||
|
||||
### Rendering components in tests
|
||||
|
||||
- **Always render through `renderWithMantine`** from `src/test/renderWithMantine.tsx`. Never hand-roll a bare `MantineProvider` — that reintroduces a real failure class where a `Transition`/`Modal` timer fires after happy-dom tears down `window` and fails the entire run.
|
||||
- **Always render through `renderWithMantine`** from `src/test/renderWithMantine.tsx`. A hand-rolled bare `MantineProvider` skips the project theme and the helper's options, and drifts from every other test. (It does *not* reintroduce the timer-leak class — an older version of this rule said so; the leaked-timer net in `setup.ts` is global and covers every unit test regardless of how it renders.)
|
||||
- For a forced color scheme, pass the option — `renderWithMantine(ui, { colorScheme: "dark" })` — rather than a hand-rolled `defaultColorScheme` provider.
|
||||
- Only when asserting _mid-flight_ transition state, use `renderWithMantineTransitions`, passing `settleMs` derived from the component's real animation duration. Do **not** combine it with `vi.useFakeTimers()`, and use the `unmount()` it returns if the test unmounts the tree itself.
|
||||
|
||||
## Gates and PR hygiene
|
||||
|
||||
- `npm run format` before committing; **`npm run ci` before pushing** (`validate` → `coverage` → `verify:build-gate` → `smoke` → Storybook). `npm run validate` is the fast inner-loop check and is **not** a substitute — it runs `test`, not `test:coverage`, so it does zero coverage gating.
|
||||
- **A dependency bump must land in every install that declares it.** v2 is not a workspace — the root and each `clients/*` have their own `node_modules`, and a client's test project compiles `core/` and `test-servers/src` (which resolve from the **root**) alongside the client's own sources. Bumping a shared dependency in one manifest only puts two versions of it in one `tsc` program; for a recursive-generic surface like zod that exhausts the tsc heap (#1896). `verify:dep-lockstep` fails the build on this, so a PR bumping a package that the shared sources import should update the root **and every client that already lists it** — not every client unconditionally, since a package absent from an install can't skew and adding it there would be a spurious dependency.
|
||||
- **A test or smoke must not touch real user state.** The web smokes run against a throwaway catalog via the shared `scripts/lib/prod-web-server.mjs` helper, never the developer's `~/.mcp-inspector/mcp.json` (#1977); the cli/tui smokes drive a temp `--catalog`. A new smoke spawning its own server, or teardown that removes a work dir without first awaiting `stopChild` (the #1801 race — `child-cleanup.mjs` exports both halves and both are required), should be flagged.
|
||||
- **Every PR references an issue**, first body line `Closes #<ISSUE_NUMBER>`.
|
||||
- **Every PR carries exactly one version label**, `v1` or `v2`, matching its base branch.
|
||||
- **Commits carry a `Signed-off-by:` trailer.** The DCO check is a hard merge gate and fails on a single unsigned commit; it matches the trailer against the author *or* committer, and skips only merge and bot commits. Use `git commit -s` — note `format.signOff` does *not* sign `git commit` (only `format-patch`). Repairing pushed commits means `git rebase HEAD~<n> --signoff` + `git push --force-with-lease`; remediation commits are not enabled on this repo.
|
||||
- Update the relevant `README.md` / `AGENTS.md` when a change adds, removes, renames, or repurposes a file or folder, changes the structure or tech stack, or introduces a command, dependency, or architectural pattern.
|
||||
|
||||
## What to prioritize in review
|
||||
|
|
|
|||
55
.github/workflows/claude.yml
vendored
55
.github/workflows/claude.yml
vendored
|
|
@ -35,13 +35,18 @@ jobs:
|
|||
id-token: write
|
||||
actions: read
|
||||
steps:
|
||||
# Actions are pinned to full commit SHAs rather than mutable major tags:
|
||||
# this job holds ANTHROPIC_API_KEY and grants the agent Bash, so a
|
||||
# force-moved tag would be an unreviewed code change inside a
|
||||
# secret-holding job. The trailing `# vX.Y.Z` comment is the form
|
||||
# Dependabot reads, so pinning costs us no upgrade automation. (#1882)
|
||||
- name: Get PR details
|
||||
if: |
|
||||
(github.event_name == 'issue_comment' && github.event.issue.pull_request) ||
|
||||
github.event_name == 'pull_request_review_comment' ||
|
||||
github.event_name == 'pull_request_review'
|
||||
id: pr
|
||||
uses: actions/github-script@v8
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
script: |
|
||||
let prNumber;
|
||||
|
|
@ -57,26 +62,58 @@ jobs:
|
|||
pull_number: prNumber
|
||||
});
|
||||
|
||||
core.setOutput('sha', pr.data.head.sha);
|
||||
core.setOutput('repo', pr.data.head.repo.full_name);
|
||||
// A fork PR's head lives in a different repository — or in none at
|
||||
// all, if the fork was deleted after the PR was opened (`head.repo`
|
||||
// is then null). Neither is ours to check out, so both count as a
|
||||
// fork here and the steps below decline rather than guess.
|
||||
const headRepo = pr.data.head.repo?.full_name ?? null;
|
||||
const isFork = headRepo !== `${context.repo.owner}/${context.repo.repo}`;
|
||||
|
||||
core.setOutput('sha', pr.data.head.sha);
|
||||
core.setOutput('is_fork', String(isFork));
|
||||
|
||||
# A fork PR's head is untrusted code, and checking it out would put it in
|
||||
# reach of a tool-enabled agent run holding ANTHROPIC_API_KEY. Reviewing
|
||||
# the base tree instead would only trade that for a confident review of
|
||||
# the wrong tree, so decline visibly and leave the reason in the run.
|
||||
- name: Decline fork PR
|
||||
if: steps.pr.outcome == 'success' && steps.pr.outputs.is_fork == 'true'
|
||||
run: |
|
||||
{
|
||||
echo "### Claude Code declined this pull request"
|
||||
echo
|
||||
echo "The head branch lives in a fork, so its code is not checked out and Claude is not run."
|
||||
echo "Push the branch to this repository and re-trigger if a review is needed."
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
# No `repository:` — a same-repo head is all that reaches this step, and
|
||||
# checkout defaults to `github.repository`, which no PR can influence.
|
||||
- name: Checkout PR branch
|
||||
if: steps.pr.outcome == 'success'
|
||||
uses: actions/checkout@v6
|
||||
if: steps.pr.outcome == 'success' && steps.pr.outputs.is_fork == 'false'
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: ${{ steps.pr.outputs.sha }}
|
||||
repository: ${{ steps.pr.outputs.repo }}
|
||||
fetch-depth: 0
|
||||
|
||||
# `skipped` means the trigger was an issue or a non-PR comment, so there
|
||||
# is no head to check out and the base tree is the right one. The lookup
|
||||
# having *failed* is deliberately not included: this condition carries no
|
||||
# status-check function, so GitHub applies an implicit `success()` and
|
||||
# skips the step after a failed prior step anyway. Spelling out `skipped`
|
||||
# keeps that from having to be re-derived by the next reader.
|
||||
- name: Checkout repository
|
||||
if: steps.pr.outcome != 'success'
|
||||
uses: actions/checkout@v6
|
||||
if: steps.pr.outcome == 'skipped'
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Run Claude Code
|
||||
# Runs only against a tree this workflow actually checked out: the base
|
||||
# repo (non-PR trigger) or a same-repo PR head. A fork PR reaches here
|
||||
# with `is_fork == 'true'` and both disjuncts false, so it is skipped.
|
||||
if: steps.pr.outcome == 'skipped' || steps.pr.outputs.is_fork == 'false'
|
||||
id: claude
|
||||
uses: anthropics/claude-code-action@v1
|
||||
uses: anthropics/claude-code-action@5ef2e550a465a721f4f45e4a7d3c340c873e1dcc # v1.0.190
|
||||
with:
|
||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
|
||||
|
|
|
|||
8
.github/workflows/main.yml
vendored
8
.github/workflows/main.yml
vendored
|
|
@ -93,9 +93,11 @@ jobs:
|
|||
# CLI/web). Not part of any client's `validate`: it needs the
|
||||
# cli/tui/launcher bundles, which `validate` above already built
|
||||
# (smoke:web builds clients/web/dist on demand — #1486). smoke:web:browser
|
||||
# boots the prod web bundle in headless chromium (#1615). smoke:tui
|
||||
# self-skips here — the Ink TUI needs a real TTY (raw mode) that headless
|
||||
# CI lacks, so its boot/render check is local-only.
|
||||
# boots the prod web bundle in headless chromium (#1615); smoke:web:app
|
||||
# goes further and drives connect → open app → widget ready against a
|
||||
# composable MCP App server (#1859). Both reuse the chromium installed
|
||||
# above. smoke:tui self-skips here — the Ink TUI needs a real TTY (raw
|
||||
# mode) that headless CI lacks, so its boot/render check is local-only.
|
||||
run: npm run smoke
|
||||
|
||||
- name: Run Storybook play-function tests
|
||||
|
|
|
|||
453
AGENTS.md
453
AGENTS.md
File diff suppressed because one or more lines are too long
|
|
@ -39,6 +39,15 @@ EXPOSE 6274
|
|||
# (core/storage/store-io.ts), so set it explicitly to the node user's writable
|
||||
# home and work from there.
|
||||
ENV HOME=/home/node
|
||||
|
||||
# Create the runtime-state dir up front, owned by `node`. Docker seeds a named
|
||||
# volume's ownership from the image's directory at the mount point — and when
|
||||
# that directory does not exist it creates it `root:root`, which the non-root
|
||||
# `node` user then cannot write. Without this, `-v inspector-data:/home/node/.mcp-inspector`
|
||||
# (the recipe for keeping your saved servers across `docker run --rm`) makes
|
||||
# every "add server" fail with `EACCES ... open '/home/node/.mcp-inspector/mcp.json.tmp-*'`.
|
||||
RUN mkdir -p /home/node/.mcp-inspector && chown -R node:node /home/node/.mcp-inspector
|
||||
|
||||
USER node
|
||||
WORKDIR /home/node
|
||||
|
||||
|
|
|
|||
85
README.md
85
README.md
|
|
@ -14,6 +14,8 @@ npx @modelcontextprotocol/inspector --cli # CLI
|
|||
npx @modelcontextprotocol/inspector --tui # TUI
|
||||
```
|
||||
|
||||
> **Upgrading from v1?** Read the [v1 → v2 migration guide](./docs/v1-to-v2-migration.md) — CLI flags, the new `--config` vs. `--catalog` split, the Node engine bump, and what no longer ships.
|
||||
|
||||
> **Repo status.** This is the **v2** line of the Inspector. Active development happens on **`v2/main`** (the develop branch — all v2 PRs target it), which is merged into **`main`** at milestone releases; `main` is the default branch and holds the latest released v2, published to the npm `latest` tag. The legacy **v1** line lives on **`v1/main`** — security fixes only, published straight from that branch to the npm `v1-latest` tag (`npx @modelcontextprotocol/inspector@v1-latest`). See [`AGENTS.md`](./AGENTS.md) for branch/board conventions.
|
||||
|
||||
## Project layout
|
||||
|
|
@ -37,8 +39,8 @@ inspector/
|
|||
│ ├── react/ # React hooks over the state stores
|
||||
│ └── storage/ # File I/O helpers for the OAuth persist backends
|
||||
├── test-servers/ # Composable MCP test servers + fixtures used by integration tests
|
||||
├── scripts/ # Root build/verify tooling (install cascade, smokes, verify-build-gate, verify-format-coverage, pack:verify)
|
||||
├── docs/ # Task-oriented guides (server configuration, MCP App review, launcher/config plan)
|
||||
├── scripts/ # Root build/verify tooling (install cascade, smokes, verify-build-gate, verify-format-coverage, verify-dep-lockstep, pack:verify)
|
||||
├── docs/ # Task-oriented guides (v1→v2 migration, server configuration, MCP App review, launcher/config plan)
|
||||
├── specification/ # Design/build specifications
|
||||
├── AGENTS.md # Contribution rules for agents AND humans (see below)
|
||||
└── README.md # You are here
|
||||
|
|
@ -49,6 +51,7 @@ Each client has its own README with client-specific detail:
|
|||
|
||||
Task-oriented guides live under [`docs/`](./docs):
|
||||
|
||||
- [Migrating from v1 to v2](./docs/v1-to-v2-migration.md) — the v1 → v2 map: CLI flag mapping, `--config` vs. `--catalog` semantics with before/after examples, the Node engine bump (`>=22.7.5` → `>=22.19.0`), env-var renames, and the sub-packages that no longer ship.
|
||||
- [MCP server configuration](./docs/mcp-server-configuration.md) — which server(s) the Inspector connects to: `--catalog` vs. `--config`, ad-hoc targets, the `--` separator, the file format and its Inspector-specific per-server fields. Shared by all three clients; the cli and tui READMEs delegate their server-options sections to it.
|
||||
- [Reviewing an MCP App](./docs/mcp-app-review.md) — the CLI-first → one-shot-web recipe for automated App-tool review: `--app-info` probe → deep-link navigate → rendered widget, plus OAuth handoff and proxy support.
|
||||
- [Launcher and config consolidation](./docs/launcher-config-consolidation-plan.md) — why the launcher runs a client in-process rather than spawning it, and how the shared config processor fits in.
|
||||
|
|
@ -66,6 +69,8 @@ npm install # root install; postinstall cascades into every client
|
|||
|
||||
The cascade (`scripts/install-clients.mjs`) is dev-only — it exits early when the package is installed as a dependency, and the published tarball ships only each client's `build/`, so end users are unaffected. Set `INSPECTOR_SKIP_CLIENT_INSTALL=1` to skip it.
|
||||
|
||||
**Where a dependency is declared.** The MCP SDK packages (`@modelcontextprotocol/client`, `core`, `server`, `server-legacy`, `ext-apps`) live in the **root** `package.json` only — never in a client's. Node resolution walks up, so the root install is on every client's chain, and the root manifest is already what the published tarball resolves against. Declaring them per client installs a second copy that can drift from the root's, which is how two versions of `ext-apps` (and of the transitive v1 `@modelcontextprotocol/sdk`) ended up in the tree before [#1970](https://github.com/modelcontextprotocol/inspector/issues/1970) — and a second copy of `client`/`core` is the failure `vitest.shared.mts` carries a `dedupe` workaround for. The same root-only placement holds for anything reached solely through root-owned code with no manifest of its own (`test-servers/src`, `core/`), and `vitest.shared.mts` aliases those to the repo root — `express` and `yaml`, both reached through `test-servers/src`, are the two today. **Whether such a package is a `dependency` or a `devDependency` follows from who consumes it at runtime, not from where it is declared:** anything `core/` imports at runtime must be a root **`dependency`**, because the client builds externalize npm packages and a published install resolves them from the root manifest, where devDependencies are absent. `express` is test-only and is a devDependency; `yaml` currently sits in `dependencies`.
|
||||
|
||||
## Running during development
|
||||
|
||||
For day-to-day web iteration, run Vite directly from the web client (fast HMR, no launcher build needed):
|
||||
|
|
@ -132,16 +137,27 @@ Each config below is a ready-made server for exercising one feature by hand. Loa
|
|||
|
||||
| Config | Demonstrates | Issue |
|
||||
| ----------------------------------------- | -------------------------------------------------- | ---------------------------------------------------------------------- |
|
||||
| `mcp-app-http.json` **(legacy era)** | An MCP App (UI resource + app tool) in the Apps tab | [#1859](https://github.com/modelcontextprotocol/inspector/issues/1859) |
|
||||
| `modern-mrtr-http.json` | A single MRTR round-trip | — |
|
||||
| `mrtr-showcase-http.json` | Every MRTR preset in one server | — |
|
||||
| `modern-network-http.json` | Network tab: `Mcp-*` headers + error taxonomy | [#1628](https://github.com/modelcontextprotocol/inspector/issues/1628) |
|
||||
| `xmcpheader-modern-http.json` | Tools tab: `x-mcp-header` mirroring and exclusions | [#1632](https://github.com/modelcontextprotocol/inspector/issues/1632) |
|
||||
| `pagination-http.json` | Page-by-page list fetching | [#1721](https://github.com/modelcontextprotocol/inspector/issues/1721) |
|
||||
| `structured-output-http.json` | Tools tab: a result's `structuredContent` section | [#1908](https://github.com/modelcontextprotocol/inspector/issues/1908) |
|
||||
| `duplicate-tool-names-http.json` | A `tools/list` that repeats a tool name | [#1957](https://github.com/modelcontextprotocol/inspector/issues/1957) |
|
||||
| `advertised-extensions-http.json` | Tool registration gated on advertised extensions | [#1739](https://github.com/modelcontextprotocol/inspector/issues/1739) |
|
||||
| `logging-{legacy,modern}-http.json` | Logging, both eras | [#1629](https://github.com/modelcontextprotocol/inspector/issues/1629) |
|
||||
| `subscriptions-{legacy,modern}-http.json` | Resource subscriptions, both eras | [#1630](https://github.com/modelcontextprotocol/inspector/issues/1630) |
|
||||
| `tasks-{legacy,modern}-http.json` | Tasks, both eras | [#1631](https://github.com/modelcontextprotocol/inspector/issues/1631) |
|
||||
|
||||
#### MCP Apps
|
||||
|
||||
`mcp-app-http.json` serves the `mcp_app_demo` tool (`_meta.ui.resourceUri`) alongside its `mcp_app_demo_widget` UI resource, so the **Apps** tab has a real App to render. It is a plain streamable-HTTP server — connect with the **default (legacy)** protocol era, not Modern.
|
||||
|
||||
Open the Apps tab, select `mcp_app_demo`, give it a title and click **Open App**: the widget renders inside the sandbox iframe and exercises the host-side UI protocol surface — host-context render, `size-changed`, `ui/message`, and a log line into the **App logs** panel. Because the widget is served through the sandbox proxy page, this config is also what reproduces [#1859](https://github.com/modelcontextprotocol/inspector/issues/1859) (a missing `clients/web/static/sandbox_proxy.html` surfaces here as a "Sandbox not loaded" message in place of the widget) — a failure that only ever appeared in an installed package, never in the repo.
|
||||
|
||||
For the scripted version of the same flow (`--app-info` probe → deep link → rendered widget), see [Reviewing an MCP App](./docs/mcp-app-review.md).
|
||||
|
||||
#### MRTR
|
||||
|
||||
`modern-mrtr-http.json` serves the `mrtr_confirm` tool (preset `mrtr_confirm`, `createMrtrTool`) over the modern leg. Its handler returns `inputRequired(...)` embedding a form elicitation, so invoking it produces a real round-trip: `input_required` → the client fulfils the embedded elicitation and retries with a new id → `complete`.
|
||||
|
|
@ -197,6 +213,20 @@ Under SDK v2 a `tools/call` rejecting with `-32602` renders as a distinct error
|
|||
|
||||
Turn on **"Fetch Lists One Page at a Time"** (Server Settings — the `paginatedLists` setting, or the **Paginated** switch in a list sidebar) and the lists load page 1 only (4 items) with a **Load next page** control and an _N pages loaded_ status. Each click fetches the next 4 and appends them; Refresh resets to page 1. With the switch off (the default), the same lists auto-aggregate all three pages on connect.
|
||||
|
||||
#### Structured output
|
||||
|
||||
`structured-output-http.json` serves `list_items` (nested `structuredContent` — objects inside arrays inside an object, the shape from [#1908](https://github.com/modelcontextprotocol/inspector/issues/1908)), `get_temp` (a flat three-key payload), and `echo` (no `outputSchema` at all). It is a plain streamable-HTTP server — connect with the **default (legacy)** protocol era.
|
||||
|
||||
Run `list_items` from the Tools tab: the result panel shows the `content[]` text summary ("Found 2 items.") **and** a collapsible **Structured Output** section rendering the schema-validated payload as pretty-printed, copyable JSON. That section is what v2 was dropping — a tool declaring an `outputSchema` returns its real data there, and the text block usually only summarizes it. Run `echo` to confirm the section is absent when a result carries no `structuredContent`.
|
||||
|
||||
#### Duplicate tool names
|
||||
|
||||
`duplicate-tool-names-http.json` serves `get_weather`, `get_temp`, `echo`, and `add`, then repeats `get_weather` and `echo` at the end of `tools/list` with the same `name` and a `(duplicate)` title (`duplicateToolNames`). No preset can produce this shape — the SDK's `registerTool` rejects a repeated name — but a real server can and does, and the Inspector has to render it faithfully.
|
||||
|
||||
Connect (default legacy era), open the Tools tab, and type `get` into **Search tools**: the list must narrow to exactly the three `get_*` rows. On the broken build it kept a stale `echo` row, because the sidebar keyed rows by `tool.name` alone and the colliding keys orphaned a child during reconciliation ([#1957](https://github.com/modelcontextprotocol/inspector/issues/1957)).
|
||||
|
||||
The duplicated copies are appended rather than placed beside their twin on purpose. React matches a leading run of same-key children first, so a head-adjacent duplicate happens to line up and the defect hides; separating the pair is what makes it observable — and it is also the realistic shape, two tool sources concatenated.
|
||||
|
||||
#### Advertised extensions
|
||||
|
||||
`advertised-extensions-http.json` serves `echo` (always) and a `get_weather` tool **gated on the `io.modelcontextprotocol/tasks` extension** (`extensionGatedTools`): the tool is registered but starts disabled, and the server enables it on `notifications/initialized` only when the client declared that extension in its `capabilities.extensions`.
|
||||
|
|
@ -252,17 +282,18 @@ Individual clients: `build:web`, `build:cli`, `build:tui`, `build:launcher`. The
|
|||
|
||||
Each client self-validates from its own folder; the root scripts chain them. There is **no** aggregate root `test` script — use `validate` (fast) or `coverage` (the gate).
|
||||
|
||||
| Script | What it does |
|
||||
| --------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `npm run validate` | Runs `verify:format-coverage` (asserts every tracked source file is format-gated) first, then `validate:core` (the shared `core/` `format:check` + `lint` gate), then per client: `format:check` + `lint` + **`typecheck`** (cli/tui only) + `build` + fast unit tests. The quick inner-loop check. |
|
||||
| `npm run coverage` | The **per-file ≥90% gate** (lines/statements/functions/branches) under v8 instrumentation, per client. CI-enforced. For web this also runs the integration project and covers the shared `core/` runtime (including `core/json` and `core/client`). |
|
||||
| `npm run smoke` | End-to-end smokes through the built launcher (`--help` dispatch + prod cli/tui/web), plus a headless-Chromium boot smoke that runs the prod web bundle and asserts a clean first render (no uncaught error — sync exception or unhandled rejection, how a Node built-in reaching the browser bundle manifests). |
|
||||
| `npm run verify:build-gate` | Runs a real `vite build` with a Node built-in forced into the browser graph and asserts the build **fails** via the #1769 gate (which turns Vite's browser-externalization warning into a hard error). Guards against the warning phrasing drifting in a Vite bump and silently disabling the gate. Part of `npm run ci`. |
|
||||
| `npm run verify:format-coverage` | Parses the `format:check` globs out of every `package.json` (only those reachable from `validate`), enumerates all tracked source files, and **fails** listing any not covered by a glob — the durable guard for the "every first-party source file is format-gated" invariant (#1792). Runs first in `validate`. |
|
||||
| `npm run test:scripts` | Table-driven unit tests (`node --test`) for the guard's own pure parsers (`scripts/lib/npm-scripts.mjs` + the exported helpers of `verify-typecheck-coverage.mjs`), one case per rule they encode. Runs in `validate` — and `verify:typecheck-coverage` guards *this* gate in turn (reachable from `validate`, non-empty test set, every test file matched by the `test:scripts` glob), since `node --test` silently skips a file its glob misses and still exits 0. |
|
||||
| `npm run verify:typecheck-coverage` | The typecheck-coverage analog of the above (#1791): for each Node client (auto-discovered from disk — enrolled via its `typecheck` script's projects, or for a `tsc -b` client like `clients/web` via its `tsconfig.json` `references`) it runs those projects with `tsc --listFilesOnly`, unions them, and **fails** listing any tracked `.ts`/`.tsx`/`.mts`/`.cts` under the client that lands in no project (so a new top-level config/helper can't silently go untypechecked). It also requires, deny-by-default, the first-party TS no client owns (`test-servers/src`, the root `vitest.shared.mts`, all of `core/`, and any new top-level location) to land in some client project's tsc pass — so a `core` `*.tsx` web's projects don't reach is caught too. Also asserts the gate is wired (each client's typecheck pass — its `typecheck` script, or web's `tsc -b` — is reachable from its `validate`, and the root chain runs each client's `validate`). Runs in `validate`. |
|
||||
| `npm run ci` | **Mandatory pre-push command.** `validate` → `coverage` → `verify:build-gate` → `smoke` → Storybook. A true superset of GitHub CI. |
|
||||
| `npm run pack:verify` | Publish smoke — see [Publishing](#publishing). |
|
||||
| Script | What it does |
|
||||
| ----------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `npm run validate` | Runs the three durable guards first — `verify:format-coverage` (every tracked source file is format-gated), `verify:typecheck-coverage` (every one lands in a tsconfig project), `verify:dep-lockstep` (no dependency the shared sources directly import skews across installs) — then `test:scripts` (the guards' own parser unit tests), then `validate:core` (the shared `core/` `format:check` + `lint` gate), then per client: `format:check` + `lint` + **`typecheck`** (cli/tui/launcher; web typechecks via `tsc -b` inside its `build`) + `build` + fast unit tests. The quick inner-loop check. |
|
||||
| `npm run coverage` | The **per-file ≥90% gate** (lines/statements/functions/branches) under v8 instrumentation, per client. CI-enforced. For web this also runs the integration project and covers the shared `core/` runtime (including `core/json` and `core/client`). |
|
||||
| `npm run smoke` | End-to-end smokes through the built launcher (`--help` dispatch + prod cli/tui/web), plus two headless-Chromium smokes: a boot smoke that runs the prod web bundle and asserts a clean first render (no uncaught error — sync exception or unhandled rejection, how a Node built-in reaching the browser bundle manifests), and an **MCP Apps** smoke (`smoke:web:app`) that drives connect → open app → `data-app-status="ready"` against a composable App server, covering the sandbox proxy and UI-protocol bridge. |
|
||||
| `npm run verify:build-gate` | Runs a real `vite build` with a Node built-in forced into the browser graph and asserts the build **fails** via the #1769 gate (which turns Vite's browser-externalization warning into a hard error). Guards against the warning phrasing drifting in a Vite bump and silently disabling the gate. Part of `npm run ci`. |
|
||||
| `npm run verify:format-coverage` | Parses the `format:check` globs out of every `package.json` (only those reachable from `validate`), enumerates all tracked source files, and **fails** listing any not covered by a glob — the durable guard for the "every first-party source file is format-gated" invariant (#1792). Runs first in `validate`. |
|
||||
| `npm run test:scripts` | Table-driven unit tests (`node --test`) for the guard's own pure parsers (`scripts/lib/npm-scripts.mjs` + the exported helpers of `verify-typecheck-coverage.mjs` and `verify-dep-lockstep.mjs`), one case per rule they encode. Runs in `validate` — and `verify:typecheck-coverage` guards *this* gate in turn (reachable from `validate`, non-empty test set, every test file matched by the `test:scripts` glob), since `node --test` silently skips a file its glob misses and still exits 0. |
|
||||
| `npm run verify:typecheck-coverage` | The typecheck-coverage analog of the above (#1791): for each Node client (auto-discovered from disk — enrolled via its `typecheck` script's projects, or for a `tsc -b` client like `clients/web` via its `tsconfig.json` `references`) it runs those projects with `tsc --listFilesOnly`, unions them, and **fails** listing any tracked `.ts`/`.tsx`/`.mts`/`.cts` under the client that lands in no project (so a new top-level config/helper can't silently go untypechecked). It also requires, deny-by-default, the first-party TS no client owns (`test-servers/src`, the root `vitest.shared.mts`, all of `core/`, and any new top-level location) to land in some client project's tsc pass — so a `core` `*.tsx` web's projects don't reach is caught too. Also asserts the gate is wired (each client's typecheck pass — its `typecheck` script, or web's `tsc -b` — is reachable from its `validate`, and the root chain runs each client's `validate`). Runs in `validate`. |
|
||||
| `npm run verify:dep-lockstep` | Guards the "one version per install-crossing dependency" invariant (#1896). v2 is not a workspace, so a client's test project compiles the shared first-party TypeScript — `core/`, `test-servers/src`, and the root-owned `vitest.shared.mts`, all of which resolve their dependencies from the **root** install — alongside the client's own sources, putting the same package in one `tsc` program twice. At the same version that's harmless; skewed, TypeScript must relate two structurally-distinct copies of every type, which for a recursive-generic surface is exponential (zod `4.3.6` vs `4.4.3` exhausted the 4GB tsc heap in `clients/web`). Derives its candidate set from what those shared sources import, compares the committed lockfiles' top-level entries, and **fails deny-by-default** on any skew not in the annotated `TOLERATED_SKEW` allowlist — and an allowlisted package is tolerated only *within a major version*. Runs in `validate`. |
|
||||
| `npm run ci` | **Mandatory pre-push command.** `validate` → `coverage` → `verify:build-gate` → `smoke` → Storybook. A true superset of GitHub CI. |
|
||||
| `npm run pack:verify` | Publish smoke — see [Publishing](#publishing). |
|
||||
|
||||
Per-client scripts exist too (`validate:web`, `coverage:cli`, `smoke:tui`, …), plus root `validate:core` / `format:core` for the shared `core/` package, `format:scripts` for the root `scripts/` tooling, and `format:shared` / `lint:shared` for the root "shared" surface (`test-servers/src/**`, `vitest.shared.mts`, the root `eslint.config.js`). Run `npm run format` before committing — the root `format` fixes `core/`, the root `scripts/`, the shared surface, and every client; `validate` runs the non-fixing `format:check` and fails CI on any unformatted file.
|
||||
|
||||
|
|
@ -278,6 +309,8 @@ The root `package.json` `"files"` allowlist is the source of truth for the tarba
|
|||
|
||||
- **No source maps.** The client bundlers set `sourcemap: false` (`clients/{cli,tui}/tsup.config.ts`, `clients/web/tsup.runner.config.ts`); Vite and the launcher's `tsc` already emit none. Maps are ~half the unpacked size and aren't needed at runtime — debug via `npm run dev` on the source.
|
||||
- **`clients/web/build` ships via `clients/web/.npmignore`.** `clients/web/.gitignore` lists `build/`, and npm's packlist honors that nested `.gitignore` over the root `"files"` allowlist — so the prod web-server runner was silently missing from the tarball while `clients/web/dist` slipped through (its `.gitignore` only lists `dist-ssr`). `clients/web/.npmignore` overrides the `.gitignore` for publishing so both `build/` (runner) and `dist/` (SPA) ship. The other clients don't need this — none ship a nested `.gitignore`.
|
||||
- **`clients/web/static` ships the MCP Apps sandbox proxy.** `clients/web/static/sandbox_proxy.html` is a committed source file (not a build artifact), read from disk at runtime by `clients/web/server/sandbox-controller.ts` as `<runner dir>/../static/sandbox_proxy.html`. It was missing from the root `"files"` allowlist entirely, so every published build failed the Apps tab with **"Sandbox not loaded"** ([#1859](https://github.com/modelcontextprotocol/inspector/issues/1859)) while working fine in the repo. Because the path is resolved _relative to_ `clients/web/build`, the directory must ship at that exact location — `pack:verify` asserts both the tarball entry and the installed-on-disk path.
|
||||
- **A dependency that renders React is bundled, not externalized.** An externalized package resolves its own `react` from wherever npm placed **it** in the consumer's tree, which is not necessarily where the bundle resolves ours — npm places a package beside a React satisfying *its* peer range, and those ranges are looser than ours. `ink-form` and `ink-scroll-view` declare `">=18"`, so a project holding React 18 satisfies them and gets them hoisted while the Inspector's React 19 nests underneath: two React copies, and the TUI dies with `TypeError: Cannot read properties of null (reading 'useState')` the moment a tool test form or a scroll view mounts ([#1952](https://github.com/modelcontextprotocol/inspector/issues/1952)). Both are therefore inlined by `clients/tui/tsup.config.ts` and are **not** root dependencies: the tarball ships their code inside `clients/tui/build/index.js` rather than having consumers install them. Bundling also pins their transitive deps to what this repo's install resolved (notably `ink-select-input@6` via `overrides`, which npm ignores for a package installed as a dependency). **`ink` is the one exception, on cost:** bundling it works but adds ~1.4 MB (`react-reconciler` and `yoga-layout` come along, plus a `createRequire` banner for the inlined CJS), so it stays external — *not* because its `">=19"` peer makes it safe, which it does not. What keeps that tolerable is the root `react` range: `"^19.0.0"` is deliberately open to the whole major so npm can dedupe our React with whatever React 19 a consumer pins, leaving an external `ink` on the same copy the bundle uses. **Narrowing that range reopens the bug for the renderer itself** — `clients/tui/__tests__/tsupConfig.test.ts` pins it to `ink`'s peer floor, and guards the rest of the split; see the [TUI README](./clients/tui/README.md#bundling-react-rendering-dependencies-must-be-inlined-1952).
|
||||
- **A single version number, read from the root `package.json`.** The Inspector ships as one package with one version, so only the **root** `package.json` carries a `version` — the four `clients/*/package.json`s deliberately have none. Every Node client (CLI, TUI, and the web backend) resolves the version through the shared `readInspectorVersion()` reader in `core/node/version.ts`, which walks up to the root manifest (always present in the tarball). No client `package.json` is read at runtime, so none needs to ship. The web **browser** can't read the filesystem; it gets its version from the backend via `GET /api/config` (see [#1639](https://github.com/modelcontextprotocol/inspector/issues/1639)).
|
||||
|
||||
### `npm run pack:verify` — publish smoke against the real tarball
|
||||
|
|
@ -309,14 +342,34 @@ A container image is published to GHCR (`ghcr.io/modelcontextprotocol/inspector`
|
|||
|
||||
```bash
|
||||
# run the web UI (reads the auth token from the container logs)
|
||||
docker run --rm -p 6274:6274 ghcr.io/modelcontextprotocol/inspector
|
||||
docker run --rm -p 127.0.0.1:6274:6274 ghcr.io/modelcontextprotocol/inspector
|
||||
|
||||
# or build the image locally
|
||||
docker build -t mcp-inspector .
|
||||
docker run --rm -p 6274:6274 mcp-inspector
|
||||
docker run --rm -p 127.0.0.1:6274:6274 mcp-inspector
|
||||
```
|
||||
|
||||
The image defaults to `--web` bound to `0.0.0.0:6274` with browser auto-open disabled; override the args to run another mode (`docker run --rm ghcr.io/modelcontextprotocol/inspector --cli …`). Pass `-e MCP_INSPECTOR_API_TOKEN=…` to set a known token (otherwise one is generated and printed in the logs), or `-e DANGEROUSLY_OMIT_AUTH=true` to disable auth. Binding `0.0.0.0` (all network interfaces) is refused by default outside a container — it exposes the process-spawning backend to the local network — so the image opts in explicitly with `DANGEROUSLY_BIND_ALL_INTERFACES=true` (already set in the `Dockerfile`); a bare `HOST=0.0.0.0` without that flag exits with an error. If you **remap the published port** (`-p 8080:6274`), the browser's origin (`http://localhost:8080`) no longer matches the in-container port, so set `-e ALLOWED_ORIGINS=http://localhost:8080,http://127.0.0.1:8080` (or run `-e CLIENT_PORT=8080 -p 8080:8080`) or connects will 403. `ALLOWED_ORIGINS` **replaces** the default list rather than merging, so list every loopback form you'll browse from (see the [web README](./clients/web/README.md#host-binding--the-origin-allow-list)). The image runs as the non-root `node` user and has a `HEALTHCHECK` that probes the web UI — it assumes the default `--web` mode, so add `--no-healthcheck` when running `--cli`/`--tui` (which have no web server).
|
||||
**Keep the `127.0.0.1:` prefix on the published port.** A bare `-p 6274:6274` publishes on **every host interface**, putting the Inspector on your local network. The container's `HOST=0.0.0.0` is a separate concern — it governs the _container's_ interfaces, not the host's — so the `DANGEROUSLY_BIND_ALL_INTERFACES` opt-in that guards a wildcard bind outside a container does not cover this. It matters more here than for an ordinary web app: the backend spawns processes on request, `GET /` embeds the API token into the served HTML, and a request arriving with **no** `Origin` header skips the origin allow-list entirely — so for any non-browser client the API token is the only guard. Publishing wider needs a real access-control boundary in front of the Inspector — a reverse proxy that authenticates, an SSH tunnel, a private network. Setting your own `MCP_INSPECTOR_API_TOKEN` does **not** substitute: `GET /` discloses whatever token is in use, so a custom one is harvested exactly as easily as a generated one.
|
||||
|
||||
**Keeping the servers you add.** The Inspector saves your server list to `$HOME/.mcp-inspector/mcp.json`, which in the image is `/home/node/.mcp-inspector/mcp.json` — inside the container's writable layer, so `--rm` discards it and every run starts with an empty list. Mount a volume there to keep it:
|
||||
|
||||
```bash
|
||||
docker run --rm -p 127.0.0.1:6274:6274 \
|
||||
-v mcp-inspector-data:/home/node/.mcp-inspector \
|
||||
ghcr.io/modelcontextprotocol/inspector
|
||||
```
|
||||
|
||||
The same volume also persists OAuth tokens and stored state, so an authorized server stays authorized across runs. Use `-e MCP_CATALOG_PATH=/some/other/path.json` to put the catalog somewhere else — mount a volume covering whatever directory you point it at. If you **bind-mount a host directory** instead of a named volume (`-v "$PWD/inspector-data:/home/node/.mcp-inspector"`), the directory keeps its host ownership, so on Linux add `--user "$(id -u):$(id -g)"` or `chown` it to uid `1000` — otherwise the non-root `node` user can't write and adding a server fails with `EACCES`.
|
||||
|
||||
**Upgrading from an image before this fix?** Earlier images did not create `/home/node/.mcp-inspector`, so Docker created the volume's mount point as `root` and the non-root `node` user couldn't write to it. An **empty** volume repairs itself on the first run of a current image (Docker applies the image directory's ownership to an empty volume), but one that already has files in it keeps its old `root` ownership and still fails with `EACCES`. Fix it once:
|
||||
|
||||
```bash
|
||||
docker run --rm -u 0 --entrypoint chown \
|
||||
-v mcp-inspector-data:/data ghcr.io/modelcontextprotocol/inspector \
|
||||
-R node:node /data
|
||||
```
|
||||
|
||||
The image defaults to `--web` bound to `0.0.0.0:6274` with browser auto-open disabled; override the args to run another mode (`docker run --rm ghcr.io/modelcontextprotocol/inspector --cli …`). Pass `-e MCP_INSPECTOR_API_TOKEN=…` to set a known token (otherwise one is generated and printed in the logs), or `-e DANGEROUSLY_OMIT_AUTH=true` to disable auth. Binding `0.0.0.0` (all network interfaces) is refused by default outside a container — it exposes the process-spawning backend to the local network — so the image opts in explicitly with `DANGEROUSLY_BIND_ALL_INTERFACES=true` (already set in the `Dockerfile`); a bare `HOST=0.0.0.0` without that flag exits with an error. If you **remap the published port** (`-p 127.0.0.1:8080:6274`), the browser's origin (`http://localhost:8080`) no longer matches the in-container port, so set `-e ALLOWED_ORIGINS=http://localhost:8080,http://127.0.0.1:8080` (or run `-e CLIENT_PORT=8080 -p 127.0.0.1:8080:8080`) or connects will 403. `ALLOWED_ORIGINS` **replaces** the default list rather than merging, so list every loopback form you'll browse from (see the [web README](./clients/web/README.md#host-binding--the-origin-allow-list)). The image runs as the non-root `node` user and has a `HEALTHCHECK` that probes the web UI — it assumes the default `--web` mode, so add `--no-healthcheck` when running `--cli`/`--tui` (which have no web server).
|
||||
|
||||
## Contributing — `AGENTS.md` and `CLAUDE.md`
|
||||
|
||||
|
|
|
|||
|
|
@ -12,6 +12,8 @@ npx @modelcontextprotocol/inspector --cli node build/index.js
|
|||
|
||||
Supports tools, resources, and prompts (plus `--method servers/list` / `servers/show` for catalog entries without connecting).
|
||||
|
||||
> Coming from the v1 CLI? See the [v1 → v2 migration guide](../../docs/v1-to-v2-migration.md) — every v1 flag still exists, but exit codes, argument ordering, and the `--` separator changed.
|
||||
|
||||
### Examples
|
||||
|
||||
**Basic usage**
|
||||
|
|
|
|||
1024
clients/cli/package-lock.json
generated
1024
clients/cli/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -32,10 +32,6 @@
|
|||
"format:check": "prettier --check src __tests__ \"*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/client": "2.0.0-beta.5",
|
||||
"@modelcontextprotocol/core": "2.0.0-beta.5",
|
||||
"@modelcontextprotocol/server": "2.0.0-beta.5",
|
||||
"@modelcontextprotocol/server-legacy": "2.0.0-beta.5",
|
||||
"@napi-rs/keyring": "^1.3.0",
|
||||
"ajv": "^8.17.1",
|
||||
"atomically": "^2.1.1",
|
||||
|
|
@ -43,7 +39,7 @@
|
|||
"open": "^10.2.0",
|
||||
"pino": "^9.14.0",
|
||||
"undici": "^8.5.0",
|
||||
"zod": "^4.3.6"
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
|
|
|
|||
|
|
@ -102,7 +102,93 @@ The repo-root `validate:tui` just delegates here. `eslint.config.js` registers
|
|||
stricter react-hooks@7 rules are not enforced on the interim component surface
|
||||
(#1501).
|
||||
|
||||
Tests live in `__tests__/`. The coverage gate currently covers the TUI's
|
||||
non-React logic (server resolution, logger, tab metadata, and the `utils/`
|
||||
form/URL helpers); the Ink components and `App.tsx` are an interim exclusion in
|
||||
`vitest.config.ts` pending a renderer-based follow-up.
|
||||
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](https://github.com/lukasbach/ink-form/issues/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.
|
||||
|
|
|
|||
262
clients/tui/__tests__/tsupConfig.test.ts
Normal file
262
clients/tui/__tests__/tsupConfig.test.ts
Normal file
|
|
@ -0,0 +1,262 @@
|
|||
/**
|
||||
* Bundling invariant for the TUI's React-rendering dependencies (#1952).
|
||||
*
|
||||
* The published TUI is a single ESM bundle whose bare `import "react"` resolves
|
||||
* from `clients/tui/build/`. Any package left *external* resolves its own
|
||||
* `react` from wherever npm placed **it** instead — and npm places a package
|
||||
* next to a version satisfying its declared peer range. `ink-form` and
|
||||
* `ink-scroll-view` accept `react: ">=18"`, so a consumer project holding React
|
||||
* 18 satisfies them, they hoist to that project's root, and the Inspector's
|
||||
* React 19 nests beneath it. Two React copies later, the first hook either of
|
||||
* them calls reads a null dispatcher and the TUI dies with
|
||||
* "Cannot read properties of null (reading 'useState')" — the moment a tool
|
||||
* test form or a scroll view mounts.
|
||||
*
|
||||
* Inlining them removes npm from the decision entirely: their `import "react"`
|
||||
* is emitted into the bundle, so it resolves exactly where the bundle's does.
|
||||
*
|
||||
* This test is the durable guard, because the failure is invisible in the repo
|
||||
* (a dev install has one React) and in every smoke (same) — it only appears
|
||||
* once the package is installed *under* another project that renders React.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import type { Options } from "tsup";
|
||||
import tsupConfig, {
|
||||
INK_FORM_INCOMPLETE_HINT,
|
||||
INK_FORM_SUBMIT_BUTTON,
|
||||
fixInkFormIncompleteHint,
|
||||
inkFormLabelPatch,
|
||||
} from "../tsup.config.js";
|
||||
|
||||
const clientDir = path.resolve(
|
||||
path.dirname(fileURLToPath(import.meta.url)),
|
||||
"..",
|
||||
);
|
||||
|
||||
/**
|
||||
* `defineConfig()` is typed as a union — a single options object, an array of
|
||||
* them, or a function returning either. The TUI config is the first form, so
|
||||
* narrow to it at runtime rather than assuming: a future config that grows a
|
||||
* second build should fail loudly here instead of silently checking nothing.
|
||||
*/
|
||||
function singleConfig(config: typeof tsupConfig): Options {
|
||||
if (typeof config !== "object" || Array.isArray(config)) {
|
||||
throw new Error(
|
||||
"clients/tui/tsup.config.ts is expected to export one options object",
|
||||
);
|
||||
}
|
||||
return config;
|
||||
}
|
||||
|
||||
/** The string entries of an `external` / `noExternal` list (ours are all strings but the type allows RegExp). */
|
||||
function stringEntries(list: (string | RegExp)[] | undefined): string[] {
|
||||
return (list ?? []).filter((entry) => typeof entry === "string");
|
||||
}
|
||||
|
||||
/**
|
||||
* React-rendering dependencies left external by an explicit trade-off.
|
||||
*
|
||||
* `ink` is here for its **cost**, not because its peer range makes it safe.
|
||||
* Bundling it works (verified against both consumer repros) but adds ~1.4MB —
|
||||
* react-reconciler and yoga-layout come with it — plus a `createRequire` banner
|
||||
* for the inlined CJS. The smaller tarball won.
|
||||
*
|
||||
* The exemption is only tolerable because the root manifest keeps `react` open
|
||||
* to the whole major (`^19.0.0`), which lets npm dedupe our React with whatever
|
||||
* React 19 a consumer pins — so an external `ink` resolves the same copy the
|
||||
* bundle does. The test below pins that relationship: narrowing the root range
|
||||
* silently reopens #1952 for the renderer itself.
|
||||
*/
|
||||
const EXTERNAL_BY_DESIGN = new Set(["ink"]);
|
||||
|
||||
function readPackageJson(name: string): {
|
||||
peerDependencies?: Record<string, string>;
|
||||
} {
|
||||
// Resolve through the client's own node_modules rather than `require.resolve`
|
||||
// on the package name: several of these packages export only their entry
|
||||
// point, so `<name>/package.json` is not a resolvable subpath.
|
||||
const manifest = path.join(clientDir, "node_modules", name, "package.json");
|
||||
return JSON.parse(readFileSync(manifest, "utf8"));
|
||||
}
|
||||
|
||||
/** Dependencies that declare a `react` peer — i.e. that render React themselves. */
|
||||
function reactRenderingDependencies(): string[] {
|
||||
const { dependencies } = JSON.parse(
|
||||
readFileSync(path.join(clientDir, "package.json"), "utf8"),
|
||||
) as { dependencies: Record<string, string> };
|
||||
|
||||
return Object.keys(dependencies).filter(
|
||||
(name) => readPackageJson(name).peerDependencies?.react !== undefined,
|
||||
);
|
||||
}
|
||||
|
||||
/** The real `ink-form` module the label patch targets. */
|
||||
const submitButtonPath = path.join(
|
||||
clientDir,
|
||||
"node_modules",
|
||||
"ink-form",
|
||||
"lib",
|
||||
"SubmitButton.js",
|
||||
);
|
||||
|
||||
const config = singleConfig(tsupConfig);
|
||||
const noExternal = stringEntries(config.noExternal);
|
||||
const external = stringEntries(config.external);
|
||||
|
||||
describe("tui tsup config", () => {
|
||||
it("finds the React-rendering dependencies to check", () => {
|
||||
// A guard on the guard: if this resolves to nothing (a rename, a moved
|
||||
// node_modules), every assertion below would pass vacuously.
|
||||
expect(reactRenderingDependencies().length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("bundles every React-rendering dependency that is not exempt", () => {
|
||||
const shouldInline = reactRenderingDependencies().filter(
|
||||
(name) => !EXTERNAL_BY_DESIGN.has(name),
|
||||
);
|
||||
|
||||
expect(shouldInline.length).toBeGreaterThan(0);
|
||||
for (const name of shouldInline) {
|
||||
expect(
|
||||
noExternal,
|
||||
`${name} renders React, so it must be bundled`,
|
||||
).toContain(name);
|
||||
expect(external, `${name} must not be external`).not.toContain(name);
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps the root react range open to the whole major, so npm can dedupe", () => {
|
||||
// This is what keeps the one exemption tolerable. `ink` is external, so it
|
||||
// resolves whatever React npm placed beside it — and npm can only place it
|
||||
// beside *ours* if our range admits the consumer's React too. Pinning the
|
||||
// root range above the major floor (say `^19.2.4`) means a consumer holding
|
||||
// React 19.0 gets a second copy: `ink` renders through theirs, the bundle
|
||||
// through ours, and the TUI dies at startup on a null dispatcher.
|
||||
//
|
||||
// So the root range must start at the same floor `ink`'s own peer does.
|
||||
const rootReact = (
|
||||
JSON.parse(
|
||||
readFileSync(path.join(clientDir, "..", "..", "package.json"), "utf8"),
|
||||
) as { dependencies: Record<string, string> }
|
||||
).dependencies.react;
|
||||
const inkPeerReact = readPackageJson("ink").peerDependencies?.react ?? "";
|
||||
|
||||
const floor = /^>=(\d+)\.0\.0$/.exec(inkPeerReact)?.[1];
|
||||
expect(floor, `unexpected ink peer range: ${inkPeerReact}`).toBeDefined();
|
||||
expect(
|
||||
rootReact,
|
||||
`root react must be ^${floor}.0.0 so npm can dedupe with any React ${floor} a consumer pins`,
|
||||
).toBe(`^${floor}.0.0`);
|
||||
});
|
||||
|
||||
it("keeps each exempt package external, and declared for consumers", () => {
|
||||
// An external package is not shipped in the bundle, so the root manifest
|
||||
// has to install it — the mirror image of the inlined ones, which must NOT
|
||||
// be root dependencies. Getting this backwards breaks the published TUI at
|
||||
// startup with an unresolved import.
|
||||
const rootDependencies = JSON.parse(
|
||||
readFileSync(path.join(clientDir, "..", "..", "package.json"), "utf8"),
|
||||
) as { dependencies: Record<string, string> };
|
||||
|
||||
for (const name of EXTERNAL_BY_DESIGN) {
|
||||
expect(external, `${name} is external by design`).toContain(name);
|
||||
expect(
|
||||
Object.keys(rootDependencies.dependencies),
|
||||
`${name} is external, so consumers must install it`,
|
||||
).toContain(name);
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps react itself external, as the single shared instance", () => {
|
||||
expect(external).toContain("react");
|
||||
expect(noExternal).not.toContain("react");
|
||||
});
|
||||
|
||||
it("corrects ink-form's misspelled incomplete-form hint", () => {
|
||||
// Read the real dependency, so an `ink-form` upgrade that fixes or rewords
|
||||
// the label fails here — the patch must then be removed, not left silently
|
||||
// matching nothing.
|
||||
const patched = fixInkFormIncompleteHint(
|
||||
readFileSync(submitButtonPath, "utf8"),
|
||||
submitButtonPath,
|
||||
);
|
||||
expect(patched).toContain(INK_FORM_INCOMPLETE_HINT.fixed);
|
||||
expect(patched).not.toContain(INK_FORM_INCOMPLETE_HINT.typo);
|
||||
});
|
||||
|
||||
it("routes the real module through the plugin, not just the helper", async () => {
|
||||
// The helper is only reached if the plugin's onLoad filter matches. Drive
|
||||
// the plugin as esbuild would — register, then invoke — so a filter that
|
||||
// stops matching the real module's path fails here instead of no-opping
|
||||
// through a green build (the exact silence this patch exists to prevent).
|
||||
type OnLoadCallback = (args: {
|
||||
path: string;
|
||||
}) => Promise<{ contents: string }>;
|
||||
const registered: { filter: RegExp; callback: OnLoadCallback }[] = [];
|
||||
|
||||
const build = {
|
||||
onLoad: (options: { filter: RegExp }, callback: OnLoadCallback) =>
|
||||
registered.push({ filter: options.filter, callback }),
|
||||
};
|
||||
// esbuild's `PluginBuild` carries far more than this patch touches, and the
|
||||
// stub above deliberately implements only the one hook it registers — so
|
||||
// the double cast is bridging a real structural gap, not hiding a mismatch.
|
||||
// A hook the patch called but the stub lacks fails as undefined here rather
|
||||
// than passing silently.
|
||||
inkFormLabelPatch.setup(
|
||||
build as unknown as Parameters<typeof inkFormLabelPatch.setup>[0],
|
||||
);
|
||||
|
||||
expect(registered).toHaveLength(1);
|
||||
const [{ filter, callback }] = registered;
|
||||
expect(filter.test(submitButtonPath)).toBe(true);
|
||||
|
||||
const { contents } = await callback({ path: submitButtonPath });
|
||||
expect(contents).toContain(INK_FORM_INCOMPLETE_HINT.fixed);
|
||||
expect(contents).not.toContain(INK_FORM_INCOMPLETE_HINT.typo);
|
||||
});
|
||||
|
||||
it("scopes the patch to ink-form's SubmitButton and nothing else", () => {
|
||||
expect(INK_FORM_SUBMIT_BUTTON.test(submitButtonPath)).toBe(true);
|
||||
// A Windows-style path must match too — the filter runs against whatever
|
||||
// esbuild resolved, and its separator is the platform's.
|
||||
expect(
|
||||
INK_FORM_SUBMIT_BUTTON.test(
|
||||
"C:\\repo\\node_modules\\ink-form\\lib\\SubmitButton.js",
|
||||
),
|
||||
).toBe(true);
|
||||
for (const other of [
|
||||
"/repo/node_modules/ink-form/lib/Form.js",
|
||||
"/repo/node_modules/ink-select-input/build/SubmitButton.js",
|
||||
"/repo/src/SubmitButton.jsx",
|
||||
]) {
|
||||
expect(INK_FORM_SUBMIT_BUTTON.test(other), other).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("fails loudly rather than silently skipping a label it cannot find", () => {
|
||||
expect(() =>
|
||||
fixInkFormIncompleteHint("no such label here", "SubmitButton.js"),
|
||||
).toThrow(/no longer contains the ink-form label/);
|
||||
});
|
||||
|
||||
it("only rewrites the misspelling", () => {
|
||||
// The two strings must differ by exactly the fix, or the patch is silently
|
||||
// changing copy nobody reviewed.
|
||||
expect(INK_FORM_INCOMPLETE_HINT.fixed).toBe(
|
||||
INK_FORM_INCOMPLETE_HINT.typo.replace("competed", "completed"),
|
||||
);
|
||||
});
|
||||
|
||||
it("verifies the deps it exempts are still declared", () => {
|
||||
// If `ink` ever leaves the dependency list, the exemption above is stale
|
||||
// and would silently excuse a future package that took its name.
|
||||
for (const name of EXTERNAL_BY_DESIGN) {
|
||||
expect(reactRenderingDependencies()).toContain(name);
|
||||
}
|
||||
});
|
||||
});
|
||||
79
clients/tui/package-lock.json
generated
79
clients/tui/package-lock.json
generated
|
|
@ -8,8 +8,6 @@
|
|||
"name": "@modelcontextprotocol/inspector-tui",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/client": "2.0.0-beta.5",
|
||||
"@modelcontextprotocol/core": "2.0.0-beta.5",
|
||||
"@napi-rs/keyring": "^1.3.0",
|
||||
"ajv": "^8.17.1",
|
||||
"atomically": "^2.1.1",
|
||||
|
|
@ -20,7 +18,7 @@
|
|||
"open": "^10.2.0",
|
||||
"pino": "^9.14.0",
|
||||
"react": "^19.2.4",
|
||||
"zod": "^4.3.6"
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"bin": {
|
||||
"mcp-inspector-tui": "build/index.js"
|
||||
|
|
@ -1054,36 +1052,6 @@
|
|||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||
}
|
||||
},
|
||||
"node_modules/@modelcontextprotocol/client": {
|
||||
"version": "2.0.0-beta.5",
|
||||
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/client/-/client-2.0.0-beta.5.tgz",
|
||||
"integrity": "sha512-YuuNm5f2TMoFQRje1UqVP8TJRjijCXMz4ckvoVpx1cUXuBEmykWQ2d8R536pek6UKcXT41T5nWc4qR1JFIbEmg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/core": "2.0.0-beta.5",
|
||||
"cross-spawn": "^7.0.5",
|
||||
"eventsource": "^3.0.2",
|
||||
"eventsource-parser": "^3.0.0",
|
||||
"jose": "^6.1.3",
|
||||
"pkce-challenge": "^5.0.0",
|
||||
"zod": "^4.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/@modelcontextprotocol/core": {
|
||||
"version": "2.0.0-beta.5",
|
||||
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/core/-/core-2.0.0-beta.5.tgz",
|
||||
"integrity": "sha512-HKbY9XTbsDy1Y6r2I55TGE3JEapM0vg96e1MUmBIF9LGjos5gjhcIrTz1yvBPLg2aFKHjwhUAQfRdrCEnPxNew==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"zod": "^4.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/keyring": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/keyring/-/keyring-1.3.0.tgz",
|
||||
|
|
@ -2830,6 +2798,7 @@
|
|||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"path-key": "^3.1.0",
|
||||
|
|
@ -3247,27 +3216,6 @@
|
|||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/eventsource": {
|
||||
"version": "3.0.7",
|
||||
"resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz",
|
||||
"integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"eventsource-parser": "^3.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/eventsource-parser": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.0.tgz",
|
||||
"integrity": "sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/expect-type": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz",
|
||||
|
|
@ -3793,6 +3741,7 @@
|
|||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
|
||||
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/istanbul-lib-coverage": {
|
||||
|
|
@ -3834,15 +3783,6 @@
|
|||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/jose": {
|
||||
"version": "6.2.3",
|
||||
"resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz",
|
||||
"integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/panva"
|
||||
}
|
||||
},
|
||||
"node_modules/joycon": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz",
|
||||
|
|
@ -4529,6 +4469,7 @@
|
|||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
|
||||
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
|
|
@ -4608,15 +4549,6 @@
|
|||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/pkce-challenge": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz",
|
||||
"integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pkg-types": {
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz",
|
||||
|
|
@ -4964,6 +4896,7 @@
|
|||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
||||
"integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"shebang-regex": "^3.0.0"
|
||||
|
|
@ -4976,6 +4909,7 @@
|
|||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
|
||||
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
|
|
@ -6211,6 +6145,7 @@
|
|||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"isexe": "^2.0.0"
|
||||
|
|
|
|||
|
|
@ -27,8 +27,6 @@
|
|||
"format:check": "prettier --check src __tests__ \"*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/client": "2.0.0-beta.5",
|
||||
"@modelcontextprotocol/core": "2.0.0-beta.5",
|
||||
"@napi-rs/keyring": "^1.3.0",
|
||||
"ajv": "^8.17.1",
|
||||
"atomically": "^2.1.1",
|
||||
|
|
@ -39,7 +37,7 @@
|
|||
"open": "^10.2.0",
|
||||
"pino": "^9.14.0",
|
||||
"react": "^19.2.4",
|
||||
"zod": "^4.3.6"
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"overrides": {
|
||||
"ink-select-input": "^6.2.0"
|
||||
|
|
|
|||
|
|
@ -1,10 +1,76 @@
|
|||
import { defineConfig } from "tsup";
|
||||
import { defineConfig, type Options } from "tsup";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const repoRoot = path.resolve(dirname, "../..");
|
||||
|
||||
/**
|
||||
* `ink-form` hardcodes a misspelled hint under an incomplete form — "you have
|
||||
* not competed yet" — with no prop to override it. It is upstream's string
|
||||
* (`ink-form/lib/SubmitButton.js`), last published in 2024, but it renders in
|
||||
* the Inspector's tool/prompt/resource test forms, so we correct it on the way
|
||||
* into the bundle. Reported upstream as lukasbach/ink-form#14; drop this patch
|
||||
* if a release ever carries the fix.
|
||||
*
|
||||
* This is only possible because `ink-form` is inlined (see `noExternal` below).
|
||||
*/
|
||||
export const INK_FORM_INCOMPLETE_HINT = {
|
||||
typo: "There are still required inputs you have not competed yet.",
|
||||
fixed: "There are still required inputs you have not completed yet.",
|
||||
};
|
||||
|
||||
/**
|
||||
* Applies the correction above, throwing if the string is no longer there.
|
||||
*
|
||||
* Failing loudly is the point: a silent no-op would let an `ink-form` upgrade
|
||||
* (or a fixed upstream, or a reworded label) quietly retire this patch with
|
||||
* nobody noticing it had stopped applying — or, worse, leave a patch here for a
|
||||
* string that no longer exists. If this throws, check whether upstream fixed
|
||||
* the typo; if so, delete this patch rather than re-targeting it.
|
||||
*/
|
||||
export function fixInkFormIncompleteHint(source: string, file: string): string {
|
||||
if (!source.includes(INK_FORM_INCOMPLETE_HINT.typo)) {
|
||||
throw new Error(
|
||||
`${file} no longer contains the ink-form label this build patches ` +
|
||||
`(${JSON.stringify(INK_FORM_INCOMPLETE_HINT.typo)}). If upstream fixed ` +
|
||||
`the typo, remove fixInkFormIncompleteHint from tsup.config.ts.`,
|
||||
);
|
||||
}
|
||||
return source.replaceAll(
|
||||
INK_FORM_INCOMPLETE_HINT.typo,
|
||||
INK_FORM_INCOMPLETE_HINT.fixed,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Which module the patch above is applied to.
|
||||
*
|
||||
* Exported so the tests can check it against the *resolved* path of the real
|
||||
* `ink-form` module — a filter that stops matching is the one way this patch
|
||||
* can silently no-op, since `fixInkFormIncompleteHint` would then never run.
|
||||
*/
|
||||
export const INK_FORM_SUBMIT_BUTTON = /ink-form[\\/]lib[\\/]SubmitButton\.js$/;
|
||||
|
||||
// The plugin type is derived from tsup rather than imported from `esbuild`:
|
||||
// `esbuild` is tsup's transitive dependency, not a declared one of this client,
|
||||
// so a direct import typechecks only while npm happens to hoist it.
|
||||
type EsbuildPlugin = NonNullable<Options["esbuildPlugins"]>[number];
|
||||
|
||||
export const inkFormLabelPatch: EsbuildPlugin = {
|
||||
name: "ink-form-label-patch",
|
||||
setup(build) {
|
||||
build.onLoad(
|
||||
{ filter: INK_FORM_SUBMIT_BUTTON },
|
||||
async ({ path: file }) => ({
|
||||
contents: fixInkFormIncompleteHint(await readFile(file, "utf8"), file),
|
||||
loader: "js",
|
||||
}),
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export default defineConfig({
|
||||
entry: ["index.ts"],
|
||||
format: ["esm"],
|
||||
|
|
@ -15,12 +81,39 @@ export default defineConfig({
|
|||
sourcemap: false,
|
||||
target: "node22",
|
||||
platform: "node",
|
||||
noExternal: [/^@inspector\/core/],
|
||||
// Every package here renders React components, so it MUST share the one React
|
||||
// instance the bundle imports. Bundling is what guarantees that: an inlined
|
||||
// package's `import "react"` is emitted into build/index.js, so it resolves
|
||||
// from *this* directory exactly like the bundle's own, and no consumer install
|
||||
// layout can point it elsewhere (#1952).
|
||||
//
|
||||
// Left external, npm is free to place a package beside a *different* React,
|
||||
// because it places one beside a version satisfying that package's own peer
|
||||
// range — looser than ours in every case here. `ink-form` and
|
||||
// `ink-scroll-view` accept ">=18", so a consumer's React 18 satisfies them
|
||||
// while the Inspector's React 19 nests underneath: two React copies, and the
|
||||
// first hook they call reads a null dispatcher ("Cannot read properties of
|
||||
// null (reading 'useState')") the moment a tool test form or a scroll view
|
||||
// mounts. That is the reported crash, and inlining them is its fix.
|
||||
//
|
||||
// `__tests__/tsupConfig.test.ts` guards this list.
|
||||
noExternal: [/^@inspector\/core/, "ink-form", "ink-scroll-view"],
|
||||
external: [
|
||||
// `react` is deliberately external — the single instance every inlined
|
||||
// package above resolves to, from this build directory.
|
||||
"react",
|
||||
// `ink` is external by a deliberate trade-off, NOT because a ">=19" peer
|
||||
// makes it safe — it does not, and that claim was wrong here once already.
|
||||
// Bundling it works (verified) but costs ~1.4MB, since react-reconciler and
|
||||
// yoga-layout come with it, plus a `createRequire` banner for the inlined
|
||||
// CJS. The smaller tarball won.
|
||||
//
|
||||
// What makes that tolerable is the root manifest's `react: ^19.0.0`: being
|
||||
// open to the whole major lets npm satisfy our React and a consumer's
|
||||
// pinned one with a single copy, so an external `ink` resolves *ours*. Narrow
|
||||
// that range and this exemption turns back into the #1952 crash, one level
|
||||
// up — `__tests__/tsupConfig.test.ts` guards it.
|
||||
"ink",
|
||||
"ink-form",
|
||||
"ink-scroll-view",
|
||||
"open",
|
||||
"commander",
|
||||
"pino",
|
||||
|
|
@ -28,6 +121,7 @@ export default defineConfig({
|
|||
"@modelcontextprotocol/core",
|
||||
"@napi-rs/keyring",
|
||||
],
|
||||
esbuildPlugins: [inkFormLabelPatch],
|
||||
esbuildOptions(options) {
|
||||
options.alias = {
|
||||
"@inspector/core": path.join(repoRoot, "core"),
|
||||
|
|
|
|||
|
|
@ -1,7 +0,0 @@
|
|||
import * as a11yAddonAnnotations from "@storybook/addon-a11y/preview";
|
||||
import { setProjectAnnotations } from "@storybook/react-vite";
|
||||
import * as projectAnnotations from "./preview";
|
||||
|
||||
// This is an important step to apply the right configuration when testing your stories.
|
||||
// More info at: https://storybook.js.org/docs/api/portable-stories/portable-stories-vitest#setprojectannotations
|
||||
setProjectAnnotations([a11yAddonAnnotations, projectAnnotations]);
|
||||
|
|
@ -172,7 +172,7 @@ The guard blocks only the **wildcard** all-interfaces addresses. Binding a **spe
|
|||
|
||||
- **Bind a specific address.** `HOST=192.168.1.50` (a LAN IP) or a public IP works directly: the default origin allow-list follows the bind host, so `allowedOrigins` becomes `http://<that-host>:PORT` and a browser hitting that address is accepted with no extra config. (The host is canonicalized the way a browser is — so `HOST=127.1` advertises `http://127.0.0.1:PORT`, an IPv6 bind host is bracketed as `http://[2001:db8::1]:PORT`, and the port is dropped when it's the http default `:80` — matching what the browser sends.)
|
||||
- **Behind TLS or a reverse proxy**, the browser's `Origin` becomes the public origin (e.g. `https://inspector.example.com`, often without a port), which won't match the auto-derived `http://<bind-host>:PORT`. Set `ALLOWED_ORIGINS` to the real public origin(s): `ALLOWED_ORIGINS=https://inspector.example.com`.
|
||||
- **Using the `0.0.0.0` wildcard** (opt-in via `DANGEROUSLY_BIND_ALL_INTERFACES=true`, as the Docker image does): a wildcard bind also serves loopback, so the default allow-list is the loopback trio plus the canonical wildcard origins (`http://0.0.0.0:PORT`, `http://[::]:PORT`), and **local access works out of the box** — `docker run -p 6274:6274` browsed at `http://localhost:6274` connects with no extra config. Reaching it at a **non-loopback** address (a LAN IP, a public hostname) still needs `ALLOWED_ORIGINS` — but since that **replaces** the default, keep the loopback forms in the list if you also browse locally: `ALLOWED_ORIGINS=http://localhost:PORT,http://127.0.0.1:PORT,http://192.168.1.50:PORT,https://inspector.example.com`.
|
||||
- **Using the `0.0.0.0` wildcard** (opt-in via `DANGEROUSLY_BIND_ALL_INTERFACES=true`, as the Docker image does): a wildcard bind also serves loopback, so the default allow-list is the loopback trio plus the canonical wildcard origins (`http://0.0.0.0:PORT`, `http://[::]:PORT`), and **local access works out of the box** — `docker run -p 127.0.0.1:6274:6274` browsed at `http://localhost:6274` connects with no extra config. Reaching it at a **non-loopback** address (a LAN IP, a public hostname) still needs `ALLOWED_ORIGINS` — but since that **replaces** the default, keep the loopback forms in the list if you also browse locally: `ALLOWED_ORIGINS=http://localhost:PORT,http://127.0.0.1:PORT,http://192.168.1.50:PORT,https://inspector.example.com`.
|
||||
|
||||
The bind-host guard and the `ALLOWED_ORIGINS` allow-list apply to both the prod server and `--dev`. Note that in **`--dev`** the Vite dev server _additionally_ enforces its own `server.allowedHosts` Host-header check, whose default accepts loopback and IP-literal hosts. The host you **bind** is auto-allowed (Vite adds the resolved `server.host` — which this config sets from `HOST` — to the allow-list), so `HOST=<hostname>` works out of the box under `--dev` too. What needs an explicit `server.allowedHosts` entry is reaching the dev server at a **different** name than the one bound — e.g. a wildcard bind reached by hostname, or a reverse-proxy domain. For those, prefer the prod server (`mcp-inspector --web`) or add the host to `server.allowedHosts`.
|
||||
|
||||
|
|
|
|||
1077
clients/web/package-lock.json
generated
1077
clients/web/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -41,11 +41,6 @@
|
|||
"@mantine/form": "^8.3.17",
|
||||
"@mantine/hooks": "^8.3.17",
|
||||
"@mantine/notifications": "^8.3.17",
|
||||
"@modelcontextprotocol/client": "2.0.0-beta.5",
|
||||
"@modelcontextprotocol/core": "2.0.0-beta.5",
|
||||
"@modelcontextprotocol/ext-apps": "^1.7.4",
|
||||
"@modelcontextprotocol/server": "2.0.0-beta.5",
|
||||
"@modelcontextprotocol/server-legacy": "2.0.0-beta.5",
|
||||
"@napi-rs/keyring": "^1.3.0",
|
||||
"ajv": "^8.17.1",
|
||||
"atomically": "^2.1.1",
|
||||
|
|
@ -61,7 +56,7 @@
|
|||
"react-markdown": "^10.1.0",
|
||||
"react-syntax-highlighter": "^16.1.1",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"zod": "~4.3.6"
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@chromatic-com/storybook": "^5.2.1",
|
||||
|
|
@ -87,7 +82,6 @@
|
|||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
"eslint-plugin-storybook": "^10.5.5",
|
||||
"express": "^5.2.1",
|
||||
"globals": "^17.4.0",
|
||||
"happy-dom": "^20.9.0",
|
||||
"playwright": "^1.58.2",
|
||||
|
|
|
|||
|
|
@ -28,7 +28,9 @@ const NODE_ONLY_OPTIMIZE_DEPS_EXCLUDE = [
|
|||
"which",
|
||||
// `@napi-rs/keyring` is loaded only inside
|
||||
// `core/auth/node/secret-store.ts` from the Hono `/api/servers`
|
||||
// handlers. It's a native-binding package (no browser code path) so
|
||||
// handlers — and lazily even there, via a cached dynamic import, so
|
||||
// an unsupported platform degrades instead of crashing at startup
|
||||
// (#1905). It's a native-binding package (no browser code path) so
|
||||
// excluding it keeps Vite's dep scanner from chasing into the
|
||||
// platform-specific binaries during dev startup.
|
||||
"@napi-rs/keyring",
|
||||
|
|
|
|||
|
|
@ -994,24 +994,33 @@ function App() {
|
|||
} = useInspectorClient(inspectorClient);
|
||||
const {
|
||||
tools: managedTools,
|
||||
error: toolsLoadError,
|
||||
listChanged: toolsListChanged,
|
||||
refresh: refreshTools,
|
||||
clearListChanged: clearToolsListChanged,
|
||||
} = useManagedTools(inspectorClient, managedToolsState);
|
||||
const {
|
||||
prompts: managedPrompts,
|
||||
error: promptsLoadError,
|
||||
listChanged: promptsListChanged,
|
||||
refresh: refreshPrompts,
|
||||
clearListChanged: clearPromptsListChanged,
|
||||
} = useManagedPrompts(inspectorClient, managedPromptsState);
|
||||
const {
|
||||
resources: managedResources,
|
||||
error: resourcesLoadError,
|
||||
listChanged: resourcesListChanged,
|
||||
refresh: refreshResources,
|
||||
clearListChanged: clearResourcesListChanged,
|
||||
} = useManagedResources(inspectorClient, managedResourcesState);
|
||||
const { resourceTemplates, refresh: refreshResourceTemplates } =
|
||||
useManagedResourceTemplates(inspectorClient, managedResourceTemplatesState);
|
||||
const {
|
||||
resourceTemplates,
|
||||
error: resourceTemplatesLoadError,
|
||||
refresh: refreshResourceTemplates,
|
||||
} = useManagedResourceTemplates(
|
||||
inspectorClient,
|
||||
managedResourceTemplatesState,
|
||||
);
|
||||
// Paged (paginated) list sources. When `paginatedLists` is on the managed
|
||||
// states skip their all-page walk and these drive the sidebar instead (#1721).
|
||||
const {
|
||||
|
|
@ -4373,6 +4382,9 @@ function App() {
|
|||
toolsListChanged={toolsListChanged}
|
||||
promptsListChanged={promptsListChanged}
|
||||
resourcesListChanged={resourcesListChanged}
|
||||
toolsLoadError={toolsLoadError}
|
||||
promptsLoadError={promptsLoadError}
|
||||
resourcesLoadError={resourcesLoadError ?? resourceTemplatesLoadError}
|
||||
subscriptions={subscriptions}
|
||||
subscriptionStreamState={subscriptionStreamState}
|
||||
logs={logs}
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue