Merge origin/v2/main: move the #1939 npx fix into the shared tsc-program lib
Upstream #1965 moved the tsc --listFilesOnly machinery into scripts/lib/tsc-program.mjs (shared with the new verify:dep-lockstep), still spawning `npx` — which is ENOENT on Windows. Resolve the conflict by taking upstream's factoring and porting the #1939 fix into the lib: rawProjectListing and verify-typecheck-coverage's --showConfig pass now spawn process.execPath with the cached, hard-failing tscEntry resolver (exported from tsc-program.mjs). Fixture copies in both main tests pick up the new lib import graph. verify:dep-lockstep now runs on Windows too. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
commit
7164806be3
110 changed files with 11123 additions and 1634 deletions
1
.gitattributes
vendored
1
.gitattributes
vendored
|
|
@ -1 +1,2 @@
|
|||
* text=auto eol=lf
|
||||
package-lock.json linguist-generated=true
|
||||
|
|
|
|||
2
.github/copilot-instructions.md
vendored
2
.github/copilot-instructions.md
vendored
|
|
@ -100,7 +100,7 @@ Both exist and do different jobs. Theme files (`src/theme/<Component>.ts`) custo
|
|||
## 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 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 — it derives its candidates from what each `tsc` program actually resolves (`tsc --listFilesOnly`, keeping packages that reach one program from two installs), so a package reached only through another package's `.d.ts` counts too (#1965) — so a PR bumping a package the shared sources pull in 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.
|
||||
|
|
|
|||
16
.github/workflows/main.yml
vendored
16
.github/workflows/main.yml
vendored
|
|
@ -23,10 +23,10 @@ jobs:
|
|||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: '22.x'
|
||||
cache: 'npm'
|
||||
|
|
@ -79,7 +79,7 @@ jobs:
|
|||
# smoke reuse the cache instead of downloading its own copy. The Storybook
|
||||
# step below reuses the same install.
|
||||
- name: Cache Playwright browsers
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@v6
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
key: playwright-${{ runner.os }}-${{ hashFiles('clients/web/package-lock.json') }}
|
||||
|
|
@ -129,10 +129,10 @@ jobs:
|
|||
id-token: write
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: '22.x'
|
||||
cache: 'npm'
|
||||
|
|
@ -217,7 +217,7 @@ jobs:
|
|||
id-token: write
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Log in to the Container registry
|
||||
uses: docker/login-action@v4
|
||||
|
|
@ -241,14 +241,14 @@ jobs:
|
|||
latest=true
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
uses: docker/setup-qemu-action@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Build and push Docker image
|
||||
id: push
|
||||
uses: docker/build-push-action@v6
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
|
|
|
|||
45
AGENTS.md
45
AGENTS.md
File diff suppressed because one or more lines are too long
|
|
@ -31,8 +31,12 @@ RUN npm install -g /tmp/inspector.tgz && rm /tmp/inspector.tgz
|
|||
ENV HOST=0.0.0.0 \
|
||||
DANGEROUSLY_BIND_ALL_INTERFACES=true \
|
||||
CLIENT_PORT=6274 \
|
||||
MCP_SANDBOX_PORT=6275 \
|
||||
MCP_AUTO_OPEN_ENABLED=false
|
||||
EXPOSE 6274
|
||||
# 6275 is the MCP Apps sandbox, a second listener the browser reaches directly.
|
||||
# It is only needed for the Apps tab, so it is EXPOSEd but publishing it is
|
||||
# optional — see the Docker section of the root README.
|
||||
EXPOSE 6274 6275
|
||||
|
||||
# Run as the non-root `node` user the base image ships. The inspector resolves
|
||||
# its runtime-state dir (default catalog, OAuth token storage) from `HOME`
|
||||
|
|
|
|||
59
README.md
59
README.md
|
|
@ -32,7 +32,8 @@ inspector/
|
|||
├── core/ # Shared code consumed via the `@inspector/core` alias (no package.json)
|
||||
│ ├── auth/ # OAuth: providers, discovery, storage, mid-session recovery (browser/node/remote backends)
|
||||
│ ├── client/ # Install-level client config (`client.json`): browser-safe parse/validate + Node load/save, remote backend, secrets
|
||||
│ ├── json/ # JSON + parameter/argument conversion utilities
|
||||
│ ├── json/ # JSON + parameter/argument conversion utilities, and the nullable-union
|
||||
│ │ # schema collapse shared by the web and TUI form builders
|
||||
│ ├── logging/ # Silent pino logger singleton
|
||||
│ ├── mcp/ # InspectorClient runtime, state stores, transports, config import
|
||||
│ ├── node/ # Node-only shared helpers: version reader, hostUrl (host normalize/canonicalize + all-interfaces/loopback detection)
|
||||
|
|
@ -145,6 +146,7 @@ Each config below is a ready-made server for exercising one feature by hand. Loa
|
|||
| `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) |
|
||||
| `nullable-fields-http.json` | Tools tab: nullable (`anyOf` + `null`) arguments | [#1928](https://github.com/modelcontextprotocol/inspector/issues/1928) |
|
||||
| `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) |
|
||||
|
|
@ -227,6 +229,14 @@ Connect (default legacy era), open the Tools tab, and type `get` into **Search t
|
|||
|
||||
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.
|
||||
|
||||
#### Nullable arguments
|
||||
|
||||
`nullable-fields-http.json` serves `record_shipment`, whose four arguments are each declared with Zod's `.nullish()` — "optional **and** explicitly nullable". That compiles to `anyOf: [<branch>, { "type": "null" }]`, so the real type (and, for the enum, its `enum` list) sits on a branch rather than at the top level. `get_temp` sits alongside it with a plain, non-nullable `units` enum for comparison. Plain streamable-HTTP — connect with the **default (legacy)** protocol era.
|
||||
|
||||
Open the Tools tab and select `record_shipment`: `direction` must render as a **Select** (`envio` / `recebimento`) with a clear button that sets it back to `null`, `reference` as a text input, `quantity` as a number input, and `express` as a checkbox. On the broken build every one of them fell through to the raw-JSON textarea, which re-escaped its own contents on each keystroke until the value was unusable ([#1928](https://github.com/modelcontextprotocol/inspector/issues/1928)). The tool echoes the arguments it received, so the result panel shows exactly what was sent.
|
||||
|
||||
The **TUI** had the same gap and is worth checking against the same server (`--tui`, then test `record_shipment`): `direction` is a select, `quantity` an integer field, `express` a boolean. Both clients now share one collapse step — `normalizeNullableUnion` in [`core/json/nullableUnion.ts`](./core/json/nullableUnion.ts) — precisely so they cannot drift on which schemas they can render.
|
||||
|
||||
#### 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`.
|
||||
|
|
@ -284,14 +294,14 @@ Each client self-validates from its own folder; the root scripts chain them. The
|
|||
|
||||
| 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 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 reaching one `tsc` program from two installs skews across them) — 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 test:scripts` | Table-driven unit tests (`node --test`) for the guard's own pure parsers (`scripts/lib/npm-scripts.mjs`, `scripts/lib/tsc-program.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 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 actually enters each program** (#1965) — every client tsconfig project listed with `tsc --listFilesOnly` via the shared `scripts/lib/tsc-program.mjs`, each resolved `node_modules` file mapped to its owning install, keeping the packages that reach one program from two installs (a package whose declarations arrive only through another package's `.d.ts`, as `@modelcontextprotocol/sdk`'s do, is invisible to a scan of first-party imports). Prices each copy from the lockfile entry for the exact install path the program resolved, compares only the installs that met in one program, and **fails deny-by-default** on any disagreement not in the annotated `TOLERATED_SKEW` allowlist — empty today — with an allowlisted package 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). |
|
||||
|
||||
|
|
@ -326,16 +336,42 @@ Publishing is automated by two release-gated jobs in [`.github/workflows/main.ym
|
|||
|
||||
A v2 release is cut from **`main`**, after the milestone's work has been merged there from `v2/main` — not from `v2/main` itself. (The v1 line releases independently from `v1/main` to the `v1-latest` tag and never touches `main`; see [Repo status](#mcp-inspector).)
|
||||
|
||||
Because there is **one version number** (only the root `package.json` has one — the clients carry none, so there is nothing to keep in sync and no `check-version` step), the release flow is just:
|
||||
Because there is **one version number** (only the root `package.json` has one — the clients carry none, so there is nothing to keep in sync and no `check-version` step), the release flow is three steps.
|
||||
|
||||
**1. Bump on `v2/main`, before the milestone merge.** The bump is part of the milestone's work, so it belongs on the develop branch and flows into `main` with everything else:
|
||||
|
||||
Substitute the real issue number and release below — the commands are written to be copy-pasteable as-is (a `2.2.0` → `2.3.0` minor bump):
|
||||
|
||||
```bash
|
||||
npm version <major|minor|patch> # bumps the root package.json + tags
|
||||
git push --follow-tags
|
||||
git checkout -b v2/chore/2010-bump-2-3-0 v2/main
|
||||
npm version minor --no-git-tag-version # or major / patch; bump only, no tag
|
||||
# PR → v2/main
|
||||
```
|
||||
|
||||
⚠️ **`--no-git-tag-version` is load-bearing.** A bare `npm version` also tags, and the tag would land on a `v2/main` commit — but the release must be cut from `main`, so the tag has to point at the merge commit there (step 3). Tagging here creates a tag on a commit that is never released.
|
||||
|
||||
**2. Merge `v2/main` → `main`** through the usual milestone-merge branch. It now carries the bump, so the release lands on `main` with the version already correct.
|
||||
|
||||
Between steps 1 and 2 the two branches **do** differ, and that is expected, not drift: `v2/main` reads the version being built while `main` still reads the one currently released. What this ordering removes is *post-release* drift — once the milestone merge lands they agree again, and `v2/main` is never left **behind** `main`. If you see `v2/main` ahead of `main`, a release is in flight; if you see it behind, something went wrong.
|
||||
|
||||
**3. Tag the `main` commit and draft the Release:**
|
||||
|
||||
```bash
|
||||
git fetch origin main
|
||||
git tag 2.3.0 origin/main && git push origin 2.3.0
|
||||
# then draft & publish a GitHub Release for that tag → triggers `publish`
|
||||
```
|
||||
|
||||
⚠️ **Tag `origin/main`, not your local `HEAD`.** `git checkout main && git pull` resolves through whatever merge-or-rebase strategy you have configured, so a divergent local `main` can quietly produce or replay local commits. Tagging `HEAD` there tags a commit that is not on `origin/main`, and `git push origin <tag>` pushes only the tag — leaving a release whose commit was never published. Naming `origin/main` explicitly makes the tagged commit exactly what the remote branch points at, regardless of local state.
|
||||
|
||||
⚠️ **No `v` prefix.** This repo's release tags are bare `x.y.z` — `2.2.0`, `2.1.0`, `2.0.0` — so tag `2.3.0`, not `v2.3.0`. Note npm's own `tag-version-prefix` defaults to `v` and the repo sets no `.npmrc`, so a bare `npm version` would have produced a `v`-prefixed tag that does not match the convention. Tagging by hand (step 3) is what keeps it right. The workflow's assert step strips a leading `v` before comparing, so a `v`-prefixed tag would still publish — it would just be inconsistent with every previous release.
|
||||
|
||||
The release's target commit selects which workflow runs, so this only publishes when a release is cut from a commit carrying this (v2) workflow.
|
||||
|
||||
**Why the bump goes on `v2/main` first ([#2010](https://github.com/modelcontextprotocol/inspector/issues/2010)).** It used to happen on the milestone-merge branch, which is cut from `main` — so the bump existed only *downstream* of `v2/main` and nothing carried it back. `v2/main` sat at `2.0.0` through both the 2.1.0 and 2.2.0 releases. That is not cosmetic: a branch cut from a milestone-merge branch silently carries the bump into an unrelated PR (this happened on [#2009](https://github.com/modelcontextprotocol/inspector/issues/2009), where a container bugfix arrived with a `2.0.0 → 2.2.0` diff), and anything reading the version in development — `readInspectorVersion()`, `--version`, `GET /api/config` — reported a version two releases old.
|
||||
|
||||
Do **not** "fix" a future drift by merging `main` back into `v2/main`. `main` carries the entire pre-v2 v1 history (retained through `ec5d8e13 chore: replace main's tree with v2` — ~230 commits `v2/main` does not have), so a back-merge grafts all of it into the develop branch's log permanently in order to deliver a two-file change. Bumping first means there is nothing to back-merge.
|
||||
|
||||
### Docker
|
||||
|
||||
A container image is published to GHCR (`ghcr.io/modelcontextprotocol/inspector`, `linux/amd64` + `linux/arm64`) by the release workflow. The [`Dockerfile`](Dockerfile) is a two-stage build: the first stage installs and `npm pack`s the publishable tarball; the second stage `npm install -g`s that tarball, so the image ships the exact same artifact as npm, with a clean `mcp-inspector` bin.
|
||||
|
|
@ -349,6 +385,15 @@ docker build -t mcp-inspector .
|
|||
docker run --rm -p 127.0.0.1:6274:6274 mcp-inspector
|
||||
```
|
||||
|
||||
**Using the Apps tab? Publish `6275` too.** The MCP Apps sandbox is a second listener the browser reaches directly, on `MCP_SANDBOX_PORT` (default `6275`). Nothing else needs it, so the single-port commands above are fine for ordinary inspection — but the Apps tab renders a blank widget without it:
|
||||
|
||||
```bash
|
||||
docker run --rm -p 127.0.0.1:6274:6274 -p 127.0.0.1:6275:6275 \
|
||||
ghcr.io/modelcontextprotocol/inspector
|
||||
```
|
||||
|
||||
Publish it on the **same port number** inside and out. The sandbox URL is handed to the browser via `/api/config` as `http://localhost:<container port>/sandbox`, so remapping it (`-p 9000:6275`) advertises a port the browser can't reach; use `-e MCP_SANDBOX_PORT=9000 -p 127.0.0.1:9000:9000` instead.
|
||||
|
||||
**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:
|
||||
|
|
|
|||
|
|
@ -258,18 +258,29 @@ describe("runMethod", () => {
|
|||
);
|
||||
expect(stdout.trim().split("\n")).toHaveLength(2);
|
||||
|
||||
// Invoke only the SIGINT handler consumeMethodOutcome registers, rather
|
||||
// than broadcasting `process.emit("SIGINT")` process-wide: `atomically`
|
||||
// (loaded via core's store-io) pulls in `when-exit`, whose module-level
|
||||
// `process.once("SIGINT")` handler re-raises the signal with
|
||||
// `process.kill(process.pid, "SIGINT")` — on Windows an unconditional
|
||||
// TerminateProcess that kills the vitest worker fork mid-run (#1941).
|
||||
const listenersBefore = new Set(process.listeners("SIGINT"));
|
||||
const streamPromise = consumeMethodOutcome(
|
||||
{
|
||||
kind: "stream",
|
||||
label: "t",
|
||||
start: (write) => {
|
||||
write({ hi: true });
|
||||
queueMicrotask(() => process.emit("SIGINT"));
|
||||
return () => {};
|
||||
},
|
||||
},
|
||||
{},
|
||||
);
|
||||
const added = process
|
||||
.listeners("SIGINT")
|
||||
.filter((listener) => !listenersBefore.has(listener));
|
||||
expect(added).toHaveLength(1);
|
||||
for (const listener of added) listener("SIGINT");
|
||||
await streamPromise;
|
||||
expect(stdout).toContain('"hi":true');
|
||||
} finally {
|
||||
|
|
|
|||
|
|
@ -507,6 +507,9 @@ function renderApp(servers: Record<string, TuiServer>) {
|
|||
async function mount(servers: Record<string, TuiServer>) {
|
||||
const r = renderApp(servers);
|
||||
await tick();
|
||||
// The mount commit's effect flush can still be queued behind this tick, and
|
||||
// this write is the one that absorbs the dropped first keypress.
|
||||
await settleInputHandlers();
|
||||
r.stdin.write("x");
|
||||
await tick();
|
||||
return r;
|
||||
|
|
@ -532,6 +535,11 @@ const ENTER = "\r";
|
|||
*/
|
||||
async function press(r: RenderResult, keys: string[]) {
|
||||
for (const k of keys) {
|
||||
// Same hazard `waitUntil` guards against, at the other end: a caller can
|
||||
// reach here on a turn that still has React's passive-effect flush queued
|
||||
// (e.g. straight after a plain `tick`, or after an earlier key committed a
|
||||
// render), so settle before every write rather than only after a poll.
|
||||
await settleInputHandlers();
|
||||
r.stdin.write(k);
|
||||
await tick();
|
||||
await tick();
|
||||
|
|
@ -552,9 +560,29 @@ async function press(r: RenderResult, keys: string[]) {
|
|||
*/
|
||||
const POLL_TRIES = 100;
|
||||
|
||||
/**
|
||||
* One check-phase turn, queued BEHIND React's already-scheduled passive-effect
|
||||
* flush. A frame observed by a poll predicate is written during React's
|
||||
* COMMIT, but ink re-arms its useInput listeners in the passive-effect flush
|
||||
* React schedules (via setImmediate in Node) during that same commit. Node's
|
||||
* event loop runs the timers phase before the check phase, so a 25ms poll
|
||||
* tick can observe the new frame and let the test write the next keypress
|
||||
* BEFORE that flush has run — the key is then dispatched to the previous
|
||||
* commit's stale useInput closures (where e.g. pendingStepUp is still null)
|
||||
* and silently swallowed (#1942). Yielding one setImmediate turn after the
|
||||
* predicate passes sequences the next stdin write after the flush (FIFO
|
||||
* within the check queue), so "frame visible" once again implies "input
|
||||
* handlers armed".
|
||||
*/
|
||||
const settleInputHandlers = () =>
|
||||
new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
async function waitUntil(predicate: () => boolean, tries = POLL_TRIES) {
|
||||
for (let i = 0; i < tries; i++) {
|
||||
if (predicate()) return;
|
||||
if (predicate()) {
|
||||
await settleInputHandlers();
|
||||
return;
|
||||
}
|
||||
await tick();
|
||||
}
|
||||
}
|
||||
|
|
@ -626,6 +654,23 @@ afterEach(() => {
|
|||
while (mounted.length) mounted.pop()?.unmount();
|
||||
});
|
||||
|
||||
// Pins the synchronization contract the OAuth step-up assertions depend on
|
||||
// (#1942). The `setImmediate` sentinel below stands in for React's pending
|
||||
// passive-effect flush — the turn where ink re-arms `useInput`. If `waitUntil`
|
||||
// ever returns without yielding a check-phase turn, the sentinel has not run
|
||||
// and this fails, instead of the regression resurfacing as a differently-named
|
||||
// flaky OAuth test under coverage instrumentation.
|
||||
describe("test helpers", () => {
|
||||
it("waitUntil settles input handlers before resolving", async () => {
|
||||
let flushed = false;
|
||||
setImmediate(() => {
|
||||
flushed = true;
|
||||
});
|
||||
await waitUntil(() => true);
|
||||
expect(flushed).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("App (foundation)", () => {
|
||||
it("renders the server list with the MCP Servers header", async () => {
|
||||
const r = renderApp(stdioServer());
|
||||
|
|
|
|||
|
|
@ -213,4 +213,173 @@ describe("schemaToForm", () => {
|
|||
);
|
||||
expect(form.sections[0]!.fields[0]).toMatchObject({ initialValue: "hi" });
|
||||
});
|
||||
|
||||
// #2015 (the TUI twin of #1928): an argument declared with Zod's `.nullish()`
|
||||
// has no top-level `type`/`enum` — they sit on the surviving `anyOf` branch —
|
||||
// so before normalization every one of these degraded to a plain text field.
|
||||
describe("nullable unions", () => {
|
||||
it("renders a select for an anyOf string-enum|null argument", () => {
|
||||
const form = schemaToForm(
|
||||
{
|
||||
properties: {
|
||||
direction: {
|
||||
anyOf: [
|
||||
{ type: "string", enum: ["envio", "recebimento"] },
|
||||
{ type: "null" },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
"nullableEnum",
|
||||
);
|
||||
expect(form.sections[0]!.fields[0]).toMatchObject({
|
||||
name: "direction",
|
||||
type: "select",
|
||||
options: [
|
||||
{ label: "envio", value: "envio" },
|
||||
{ label: "recebimento", value: "recebimento" },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("maps the remaining nullable scalar branches to their typed fields", () => {
|
||||
const form = schemaToForm(
|
||||
{
|
||||
properties: {
|
||||
reference: { anyOf: [{ type: "string" }, { type: "null" }] },
|
||||
quantity: {
|
||||
anyOf: [
|
||||
{ type: "integer", minimum: 1, maximum: 9 },
|
||||
{ type: "null" },
|
||||
],
|
||||
},
|
||||
ratio: { anyOf: [{ type: "number" }, { type: "null" }] },
|
||||
express: { anyOf: [{ type: "boolean" }, { type: "null" }] },
|
||||
},
|
||||
},
|
||||
"nullableScalars",
|
||||
);
|
||||
const fields = form.sections[0]!.fields;
|
||||
expect(fields.map((f) => ({ name: f.name, type: f.type }))).toEqual([
|
||||
{ name: "reference", type: "string" },
|
||||
{ name: "quantity", type: "integer" },
|
||||
{ name: "ratio", type: "float" },
|
||||
{ name: "express", type: "boolean" },
|
||||
]);
|
||||
// The branch's own constraints are hoisted along with its type.
|
||||
expect(fields[1]).toMatchObject({ min: 1, max: 9 });
|
||||
});
|
||||
|
||||
it("renders a select for a nullable array-of-enum argument", () => {
|
||||
const form = schemaToForm(
|
||||
{
|
||||
properties: {
|
||||
tags: {
|
||||
anyOf: [
|
||||
{ type: "array", items: { enum: ["a", "b"] } },
|
||||
{ type: "null" },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
"nullableArrayEnum",
|
||||
);
|
||||
expect(form.sections[0]!.fields[0]).toMatchObject({
|
||||
type: "select",
|
||||
options: [
|
||||
{ label: "a", value: "a" },
|
||||
{ label: "b", value: "b" },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
// With the null left in the list, the all-strings check would reject the
|
||||
// enum and this would degrade to a plain text field.
|
||||
it("renders a select for a type: [string, null] enum, null stripped", () => {
|
||||
const form = schemaToForm(
|
||||
{
|
||||
properties: {
|
||||
direction: {
|
||||
type: ["string", "null"],
|
||||
enum: ["envio", "recebimento", null],
|
||||
},
|
||||
},
|
||||
},
|
||||
"typeArrayNullableEnum",
|
||||
);
|
||||
expect(form.sections[0]!.fields[0]).toMatchObject({
|
||||
type: "select",
|
||||
options: [
|
||||
{ label: "envio", value: "envio" },
|
||||
{ label: "recebimento", value: "recebimento" },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("collapses the type: [T, null] encoding too", () => {
|
||||
const form = schemaToForm(
|
||||
{
|
||||
properties: {
|
||||
note: { type: ["string", "null"] },
|
||||
flag: { type: ["boolean", "null"] },
|
||||
},
|
||||
},
|
||||
"typeArrayNull",
|
||||
);
|
||||
expect(
|
||||
form.sections[0]!.fields.map((f) => ({ name: f.name, type: f.type })),
|
||||
).toEqual([
|
||||
{ name: "note", type: "string" },
|
||||
{ name: "flag", type: "boolean" },
|
||||
]);
|
||||
});
|
||||
|
||||
// ink-form hands a select's stringified value straight back, so a numeric
|
||||
// enum routed to a select would submit "1" for 1. The typed field loses the
|
||||
// enum constraint but keeps the value's type — the safer loss.
|
||||
it("routes a nullable numeric enum to its typed field, not a select", () => {
|
||||
const form = schemaToForm(
|
||||
{
|
||||
properties: {
|
||||
level: {
|
||||
anyOf: [{ type: "integer", enum: [1, 2] }, { type: "null" }],
|
||||
},
|
||||
ratio: {
|
||||
anyOf: [{ type: "number", enum: [0.5, 1.5] }, { type: "null" }],
|
||||
},
|
||||
},
|
||||
},
|
||||
"numericEnum",
|
||||
);
|
||||
expect(
|
||||
form.sections[0]!.fields.map((f) => ({ name: f.name, type: f.type })),
|
||||
).toEqual([
|
||||
{ name: "level", type: "integer" },
|
||||
{ name: "ratio", type: "float" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("routes a plain numeric enum to its typed field too", () => {
|
||||
const form = schemaToForm(
|
||||
{ properties: { level: { type: "integer", enum: [1, 2] } } },
|
||||
"plainNumericEnum",
|
||||
);
|
||||
expect(form.sections[0]!.fields[0]).toMatchObject({ type: "integer" });
|
||||
});
|
||||
|
||||
it("leaves a union of two real types as a plain string field", () => {
|
||||
const form = schemaToForm(
|
||||
{
|
||||
properties: {
|
||||
mixed: { anyOf: [{ type: "string" }, { type: "number" }] },
|
||||
},
|
||||
},
|
||||
"realUnion",
|
||||
);
|
||||
expect(form.sections[0]!.fields[0]).toMatchObject({
|
||||
name: "mixed",
|
||||
type: "string",
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -3,10 +3,19 @@
|
|||
*/
|
||||
|
||||
import type { FormStructure, FormSection, FormField } from "ink-form";
|
||||
import {
|
||||
isStringEnum,
|
||||
normalizeNullableUnion,
|
||||
} from "@inspector/core/json/nullableUnion.js";
|
||||
|
||||
/** Minimal JSON Schema property shape used when building tool parameter forms */
|
||||
interface JsonSchemaProperty {
|
||||
type?: string;
|
||||
/**
|
||||
* An array here is the `["string", "null"]` nullable encoding, which
|
||||
* {@link normalizeNullableUnion} collapses to a single name before the
|
||||
* dispatch below reads it.
|
||||
*/
|
||||
type?: string | string[];
|
||||
title?: string;
|
||||
enum?: unknown[];
|
||||
/** Non-standard legacy support: titles for enum values */
|
||||
|
|
@ -15,6 +24,8 @@ interface JsonSchemaProperty {
|
|||
minimum?: number;
|
||||
maximum?: number;
|
||||
default?: unknown;
|
||||
/** Present on a nullable union; see {@link normalizeNullableUnion}. */
|
||||
anyOf?: readonly unknown[];
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -68,9 +79,16 @@ export function schemaToForm(
|
|||
// `properties` values are `unknown` (the SDK schema admits anything), so
|
||||
// guard before treating a value as a schema object — a malformed server
|
||||
// schema with e.g. `properties: { foo: null }` must not throw on `.title`.
|
||||
const property = (
|
||||
typeof prop === "object" && prop !== null ? prop : {}
|
||||
) as JsonSchemaProperty;
|
||||
// Flatten a nullable union (`anyOf: [X, {type:"null"}]`, `type: [X,"null"]`)
|
||||
// before dispatching. Every branch below reads a single `type` string, and
|
||||
// for an enum the `enum` keyword sits on the union's surviving branch — so
|
||||
// without this an argument declared with Zod's `.nullish()` loses its
|
||||
// select and degrades to a plain text field (#2015, the TUI twin of #1928).
|
||||
const property = normalizeNullableUnion(
|
||||
(typeof prop === "object" && prop !== null
|
||||
? prop
|
||||
: {}) as JsonSchemaProperty,
|
||||
);
|
||||
const baseField = {
|
||||
name: key,
|
||||
label: property.title || key,
|
||||
|
|
@ -90,12 +108,17 @@ export function schemaToForm(
|
|||
...baseField,
|
||||
options: toSelectOptions(property.items.enum, property.items.enumNames),
|
||||
} as FormField;
|
||||
} else if (property.enum) {
|
||||
// Single select
|
||||
} else if (isStringEnum(property.enum)) {
|
||||
// Single select. Gated on the members being strings because
|
||||
// `toSelectOptions` stringifies them and ink-form hands the string
|
||||
// straight back: a numeric `enum: [1, 2]` would submit `"1"` and violate
|
||||
// the schema. A typed non-string enum falls through to its typed field
|
||||
// below, which loses the enum constraint but keeps the value's type —
|
||||
// the safer of the two losses.
|
||||
field = {
|
||||
type: "select",
|
||||
...baseField,
|
||||
options: toSelectOptions(property.enum, property.enumNames),
|
||||
options: toSelectOptions(property.enum ?? [], property.enumNames),
|
||||
} as FormField;
|
||||
} else {
|
||||
// Map JSON Schema types to ink-form types
|
||||
|
|
|
|||
|
|
@ -162,7 +162,9 @@ The dev/prod backend guards every `/api/*` route with `x-mcp-remote-auth: Bearer
|
|||
|
||||
## Host binding & the origin allow-list
|
||||
|
||||
Both the prod backend (`server/web-server-config.ts`) and the dev Vite server (`vite.config.ts`) resolve their bind host through one shared guard, `server/resolve-bind-host.ts`. It binds `localhost` by default and **refuses an all-interfaces host** (`0.0.0.0`, `::`, empty, or any equivalent spelling — `0`, `0x0`, `0.0`, `::0`, `::ffff:0.0.0.0`, … are all folded to the wildcard and refused) — which would expose the process-spawning backend to the whole network, the exposure DNS-rebinding attacks target — unless `DANGEROUSLY_BIND_ALL_INTERFACES=true` is set. The Docker image sets that flag (a container must bind `0.0.0.0` to be reachable through `-p`); a bare `HOST=0.0.0.0` anywhere else exits with an actionable error.
|
||||
Both the prod backend (`server/web-server-config.ts`) and the dev Vite server (`vite.config.ts`) resolve their bind host through one shared guard, `server/resolve-bind-host.ts`. It binds **`127.0.0.1`** by default and **refuses an all-interfaces host** (`0.0.0.0`, `::`, empty, or any equivalent spelling — `0`, `0x0`, `0.0`, `::0`, `::ffff:0.0.0.0`, … are all folded to the wildcard and refused) — which would expose the process-spawning backend to the whole network, the exposure DNS-rebinding attacks target — unless `DANGEROUSLY_BIND_ALL_INTERFACES=true` is set. The Docker image sets that flag (a container must bind `0.0.0.0` to be reachable through `-p`); a bare `HOST=0.0.0.0` anywhere else exits with an actionable error.
|
||||
|
||||
**The default is the IPv4 loopback _address_, deliberately not the name `localhost`** (#1951). `server.listen(port, host)` resolves a name through `dns.lookup` and binds the **single** address it gets back; since Node 17 the default result order is `verbatim`, so on a glibc Linux host — whose `/etc/hosts` maps `localhost` to both `127.0.0.1` and `::1` — that first address is `::1`. `HOST=localhost` there binds IPv6 loopback **only** and refuses every IPv4 client. On a desktop this is invisible (the browser resolves `localhost` the same way and lands on `::1` too); it breaks wherever the connection is made by something that pins `127.0.0.1` — a VS Code dev container's port forwarder, `ssh -L 6274:127.0.0.1:6274`, a container healthcheck. Binding the address takes the resolver out of the decision. Browsing to `http://localhost:PORT` still works, since browsers fall back across address families and the loopback allow-list covers all three forms. An explicit `HOST=localhost` is still honored as typed — the single-family bind is then your choice.
|
||||
|
||||
The backend's `/api/*` routes also enforce an **origin allow-list** (`allowedOrigins`) as DNS-rebinding protection. When left to default on a loopback host, it expands to all three interchangeable loopback origin forms for the port — `http://localhost:PORT`, `http://127.0.0.1:PORT`, and `http://[::1]:PORT` — because `localhost` resolves to either IPv4 or IPv6 loopback and Node/Vite may bind the IPv6 form, so the browser can legitimately arrive at `http://[::1]:PORT`. Set `ALLOWED_ORIGINS` (comma-separated) to override; entries are canonicalized (`new URL(o).origin`), so a trailing slash / uppercase host / explicit `:80` still match. **Each entry must include the scheme** — `http://localhost:6274`, not `localhost:6274` (a scheme-less value is dropped with a warning). `ALLOWED_ORIGINS` **replaces** the default list (it does not merge), so **list every origin you'll browse from, including the loopback forms** you still want (`http://localhost:PORT`, `http://127.0.0.1:PORT`, `http://[::1]:PORT`) — otherwise local access stops working. A blank `ALLOWED_ORIGINS` does **not** disable the check — it falls back to the default (fail closed); there is no env knob to turn origin validation off.
|
||||
|
||||
|
|
@ -176,7 +178,7 @@ The guard blocks only the **wildcard** all-interfaces addresses. Binding a **spe
|
|||
|
||||
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`.
|
||||
|
||||
**MCP Apps caveats.** The MCP Apps sandbox runs on a **separate** port (`MCP_SANDBOX_PORT`, dynamic by default). For the Apps tab to work off loopback, that sandbox port must be independently reachable from the browser — pin it with `MCP_SANDBOX_PORT` and expose/forward it too (the Docker image `EXPOSE`s only `6274`). (Under a `0.0.0.0` wildcard bind the sandbox URL is advertised as `localhost`, which is reachable — a wildcard bind serves loopback — so only the port needs handling.) Also note the sandbox iframe is gated by a `frame-ancestors` CSP, and **a bracketed IPv6 literal is not a valid CSP host-source** — so MCP Apps requires browsing the app at a name or IPv4 (`localhost`, `127.0.0.1`, a hostname, a LAN IPv4), **not** a bare `http://[::1]:…` address. Finally, the sandbox URL is always `http://` — so **behind TLS** (an `https://` app page) the browser blocks the `http://…/sandbox` iframe as mixed content and MCP Apps can't render; the Apps tab needs a plain-`http` app origin today.
|
||||
**MCP Apps caveats.** The MCP Apps sandbox runs on a **separate** port — `MCP_SANDBOX_PORT`, defaulting to a fixed **`6275`** (#2008; it was OS-assigned before, which meant it changed every run and so could never be named in a `forwardPorts` / `-p` / tunnel config written ahead of time). For the Apps tab to work off loopback, that sandbox port must be independently reachable from the browser — expose/forward `6275` alongside `6274`, or set `MCP_SANDBOX_PORT` to pick another. If the port is already taken the sandbox falls back to an OS-assigned one and warns, so a second Inspector still gets a working Apps tab locally — but the forwarded port is then wrong, which is what the warning tells you. (Under a `0.0.0.0` wildcard bind the sandbox URL is advertised as `localhost`, which is reachable — a wildcard bind serves loopback — so only the port needs handling.) Also note the sandbox iframe is gated by a `frame-ancestors` CSP, and **a bracketed IPv6 literal is not a valid CSP host-source** — so MCP Apps requires browsing the app at a name or IPv4 (`localhost`, `127.0.0.1`, a hostname, a LAN IPv4), **not** a bare `http://[::1]:…` address. Finally, the sandbox URL is always `http://` — so **behind TLS** (an `https://` app page) the browser blocks the `http://…/sandbox` iframe as mixed content and MCP Apps can't render; the Apps tab needs a plain-`http` app origin today.
|
||||
|
||||
In every case, exposing the Inspector beyond loopback also means anyone who can reach it can drive its backend — keep authentication on (do **not** set `DANGEROUSLY_OMIT_AUTH`) and prefer a specific bind address over the wildcard.
|
||||
|
||||
|
|
|
|||
|
|
@ -26,21 +26,46 @@ function isEnabled(value: string | undefined): boolean {
|
|||
return v === "true" || v === "1";
|
||||
}
|
||||
|
||||
/**
|
||||
* The default bind host: the IPv4 loopback **address**, deliberately not the
|
||||
* name `localhost`.
|
||||
*
|
||||
* `server.listen(port, host)` resolves a name through `dns.lookup` and binds the
|
||||
* **single** address it gets back. Since Node 17 the default result order is
|
||||
* `verbatim`, so Node takes whatever the resolver returns first rather than
|
||||
* preferring IPv4 — and on a glibc Linux host with the stock `/etc/hosts`
|
||||
* (`127.0.0.1 localhost` *and* `::1 localhost`) that is `::1`. `HOST=localhost`
|
||||
* therefore binds IPv6 loopback **only**, and every IPv4 client is refused.
|
||||
*
|
||||
* That is invisible on a desktop, where the browser resolves `localhost` the
|
||||
* same way and lands on `::1` too. It breaks wherever the connection is made by
|
||||
* something that pins IPv4 `127.0.0.1` instead — a VS Code dev container's port
|
||||
* forwarder (#1951), an `ssh -L …:127.0.0.1:…` tunnel, a container healthcheck.
|
||||
* Binding the address directly removes the resolver from the decision, and the
|
||||
* loopback origin allow-list already covers `localhost`, `127.0.0.1`, and
|
||||
* `[::1]` alike, so browsing to `http://localhost:PORT` still works (browsers
|
||||
* fall back across address families; a Node `listen` does not).
|
||||
*/
|
||||
export const DEFAULT_BIND_HOST = "127.0.0.1";
|
||||
|
||||
/**
|
||||
* Resolve the bind hostname from `env` (default `process.env`), defaulting to
|
||||
* `localhost`. Refuses an all-interfaces host (`0.0.0.0` / `::` / empty / their
|
||||
* legacy spellings) unless {@link BIND_ALL_INTERFACES_ENV} is explicitly
|
||||
* enabled — the published Docker image sets it, since a container must bind
|
||||
* `0.0.0.0` to be reachable through `-p`. Throws (fail fast, loudly) rather than
|
||||
* silently binding wide open. The returned value is trimmed and de-bracketed
|
||||
* (an IPv6 literal is returned bare, e.g. `HOST=[::1]` → `::1`) so detection,
|
||||
* `listen()`, and the origin list all consume the same value; `formatHostForUrl`
|
||||
* re-adds the brackets wherever a URL is built.
|
||||
* {@link DEFAULT_BIND_HOST}. Refuses an all-interfaces host (`0.0.0.0` / `::` /
|
||||
* empty / their legacy spellings) unless {@link BIND_ALL_INTERFACES_ENV} is
|
||||
* explicitly enabled — the published Docker image sets it, since a container
|
||||
* must bind `0.0.0.0` to be reachable through `-p`. Throws (fail fast, loudly)
|
||||
* rather than silently binding wide open. The returned value is trimmed and
|
||||
* de-bracketed (an IPv6 literal is returned bare, e.g. `HOST=[::1]` → `::1`) so
|
||||
* detection, `listen()`, and the origin list all consume the same value;
|
||||
* `formatHostForUrl` re-adds the brackets wherever a URL is built.
|
||||
*
|
||||
* An explicit `HOST=localhost` is honored as typed — the single-family bind
|
||||
* described on {@link DEFAULT_BIND_HOST} is then the caller's choice, not ours.
|
||||
*/
|
||||
export function resolveBindHostname(
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): string {
|
||||
const host = (env.HOST ?? "localhost").trim();
|
||||
const host = (env.HOST ?? DEFAULT_BIND_HOST).trim();
|
||||
if (isAllInterfacesHost(host) && !isEnabled(env[BIND_ALL_INTERFACES_ENV])) {
|
||||
// Show the resolved address when it differs from the typed spelling — the
|
||||
// guard now catches forms the resolver folds to the wildcard (a fullwidth
|
||||
|
|
|
|||
|
|
@ -11,13 +11,14 @@ import {
|
|||
canonicalUrlHost,
|
||||
isAllInterfacesHost,
|
||||
} from "../../../core/node/hostUrl.ts";
|
||||
import { DEFAULT_BIND_HOST } from "./resolve-bind-host.js";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
export interface SandboxControllerOptions {
|
||||
/** Port to bind (0 = dynamic). */
|
||||
port: number;
|
||||
/** Host to bind (default localhost). */
|
||||
/** Host to bind (default {@link DEFAULT_BIND_HOST}). */
|
||||
host?: string;
|
||||
/**
|
||||
* The backend's origin allow-list (the embedder origins). Used to build the
|
||||
|
|
@ -95,10 +96,28 @@ function parseListenPort(raw: string | undefined): number | undefined {
|
|||
}
|
||||
|
||||
/**
|
||||
* Resolve sandbox port from env: MCP_SANDBOX_PORT → SERVER_PORT → 0 (dynamic).
|
||||
* An invalid value falls through rather than crashing the boot; a set-but-invalid
|
||||
* MCP_SANDBOX_PORT (the dedicated knob) is warned so the fall-through isn't
|
||||
* silent — matching the warn-and-drop precedent for ALLOWED_ORIGINS.
|
||||
* The default sandbox listen port — **fixed**, deliberately not `0`.
|
||||
*
|
||||
* A `0` default lets the OS assign, which is fine on a desktop (the browser
|
||||
* learns the port from `/api/config`) but unusable anywhere the port has to be
|
||||
* declared *before* the process starts: a dev container's `forwardPorts`, a
|
||||
* `docker run -p`, a k8s service, an SSH tunnel. The port changed on every run,
|
||||
* so it could never be named in that config and the Apps tab was unreachable
|
||||
* (#2008). `CLIENT_PORT` has always defaulted to a fixed `6274` for the same
|
||||
* reason; this is its sandbox counterpart.
|
||||
*
|
||||
* `6275` sits next to the web port so the pair is easy to forward together. It
|
||||
* is not a v1 carry-over — v2 has no proxy server on it.
|
||||
*/
|
||||
export const DEFAULT_SANDBOX_PORT = 6275;
|
||||
|
||||
/**
|
||||
* Resolve sandbox port from env: MCP_SANDBOX_PORT → SERVER_PORT →
|
||||
* {@link DEFAULT_SANDBOX_PORT}. An invalid value falls through rather than
|
||||
* crashing the boot; a set-but-invalid MCP_SANDBOX_PORT (the dedicated knob) is
|
||||
* warned so the fall-through isn't silent — matching the warn-and-drop
|
||||
* precedent for ALLOWED_ORIGINS. An explicit `0` is still honored as "let the
|
||||
* OS assign" ({@link parseListenPort} accepts it).
|
||||
*/
|
||||
export function resolveSandboxPort(): number {
|
||||
const fromSandbox = parseListenPort(process.env.MCP_SANDBOX_PORT);
|
||||
|
|
@ -107,13 +126,21 @@ export function resolveSandboxPort(): number {
|
|||
`Ignoring invalid MCP_SANDBOX_PORT="${process.env.MCP_SANDBOX_PORT}" (need an integer 0–65535); falling back.`,
|
||||
);
|
||||
}
|
||||
return fromSandbox ?? parseListenPort(process.env.SERVER_PORT) ?? 0;
|
||||
return (
|
||||
fromSandbox ??
|
||||
parseListenPort(process.env.SERVER_PORT) ??
|
||||
DEFAULT_SANDBOX_PORT
|
||||
);
|
||||
}
|
||||
|
||||
export function createSandboxController(
|
||||
options: SandboxControllerOptions,
|
||||
): SandboxController {
|
||||
const { port, host = "localhost", allowedOrigins } = options;
|
||||
// Defaulted to the same address the web server binds, never the name
|
||||
// `localhost` — a name resolves to one address family and would reintroduce
|
||||
// the #1951 split (web on IPv4, sandbox on IPv6) for any future call site
|
||||
// that omits `host`. Both call sites pass `config.sandboxHost` today.
|
||||
const { port, host = DEFAULT_BIND_HOST, allowedOrigins } = options;
|
||||
let server: Server | null = null;
|
||||
let sandboxUrl: string | null = null;
|
||||
|
||||
|
|
@ -177,8 +204,28 @@ export function createSandboxController(
|
|||
});
|
||||
res.end(sandboxHtml);
|
||||
});
|
||||
// A taken port used to mean "no sandbox at all", which was tolerable
|
||||
// only because the default was dynamic and therefore never collided.
|
||||
// Now that it's fixed (see DEFAULT_SANDBOX_PORT), a second Inspector —
|
||||
// or anything else already on 6275 — would take the Apps tab down, so
|
||||
// retry once on an OS-assigned port. The retry is announced loudly:
|
||||
// whoever pinned MCP_SANDBOX_PORT to forward it needs to know the pin
|
||||
// didn't take, and a silently-moved port is exactly the failure this
|
||||
// default was introduced to fix.
|
||||
let retriedDynamic = false;
|
||||
server.on("error", (err: NodeJS.ErrnoException) => {
|
||||
if (err.code === "EADDRINUSE") {
|
||||
if (!retriedDynamic && port !== 0) {
|
||||
retriedDynamic = true;
|
||||
console.warn(
|
||||
`Sandbox: port ${port} in use; falling back to an OS-assigned port. ` +
|
||||
`The MCP Apps tab will work locally, but the sandbox is no longer ` +
|
||||
`on a predictable port — set MCP_SANDBOX_PORT to a free one if you ` +
|
||||
`need to forward it (container / SSH tunnel / reverse proxy).`,
|
||||
);
|
||||
server!.listen(0, host);
|
||||
return;
|
||||
}
|
||||
console.error(
|
||||
`Sandbox: port ${port || "dynamic"} in use. MCP Apps tab may not work.`,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -598,6 +598,7 @@ const DEFAULT_USE_INSPECTOR_CLIENT: ReturnType<typeof useInspectorClient> = {
|
|||
serverInfo: undefined,
|
||||
instructions: undefined,
|
||||
excludedTools: [],
|
||||
malformedListItems: [],
|
||||
appRendererClient: null,
|
||||
connect: vi.fn().mockResolvedValue(undefined),
|
||||
disconnect: vi.fn().mockResolvedValue(undefined),
|
||||
|
|
|
|||
|
|
@ -63,6 +63,7 @@ import { ManagedRequestorTasksState } from "@inspector/core/mcp/state/managedReq
|
|||
import { ResourceSubscriptionsState } from "@inspector/core/mcp/state/resourceSubscriptionsState.js";
|
||||
import {
|
||||
cleanRoots,
|
||||
oauthAuthorizationParamsFromSettings,
|
||||
serializeMcpConfig,
|
||||
} from "@inspector/core/mcp/serverList.js";
|
||||
import type { ClientConfig } from "@inspector/core/client/types.js";
|
||||
|
|
@ -163,6 +164,7 @@ import {
|
|||
import { buildExportFilename, downloadJsonFile } from "./lib/downloadFile";
|
||||
import { INSPECTOR_SERVERS_TAB } from "./utils/inspectorTabs";
|
||||
import { enrichProtocolEntries } from "./utils/correlateTransportErrors";
|
||||
import { visibleMalformedListItems } from "./utils/malformedListReport";
|
||||
import {
|
||||
parseDeepLink,
|
||||
deepLinkConfigEquals,
|
||||
|
|
@ -990,6 +992,7 @@ function App() {
|
|||
protocolEra,
|
||||
discoverResult,
|
||||
excludedTools,
|
||||
malformedListItems,
|
||||
lastError,
|
||||
} = useInspectorClient(inspectorClient);
|
||||
const {
|
||||
|
|
@ -1055,6 +1058,14 @@ function App() {
|
|||
setPaginatedListsOverride(null);
|
||||
}, [persistedPaginatedLists, activeServerId]);
|
||||
const paginatedLists = paginatedListsOverride ?? persistedPaginatedLists;
|
||||
// The malformed-entry report is written by the aggregate walk's salvage. In
|
||||
// paginated mode the tools/prompts/resources panels render the paged stores
|
||||
// instead, which never write or clear it — so it would linger above a page it
|
||||
// does not describe. See `visibleMalformedListItems` (#1909 + #1721).
|
||||
const shownMalformedListItems = useMemo(
|
||||
() => visibleMalformedListItems(malformedListItems, paginatedLists),
|
||||
[malformedListItems, paginatedLists],
|
||||
);
|
||||
const connected = connectionStatus === "connected";
|
||||
const toolsPagination = usePaginatedList({
|
||||
connected,
|
||||
|
|
@ -2301,11 +2312,15 @@ function App() {
|
|||
.map((m) => [m.key, m.value]),
|
||||
)
|
||||
: undefined;
|
||||
const serverAuthorizationParams = savedSettings
|
||||
? oauthAuthorizationParamsFromSettings(savedSettings)
|
||||
: undefined;
|
||||
const oauthFromServer =
|
||||
savedSettings &&
|
||||
(savedSettings.oauthClientId ||
|
||||
savedSettings.oauthClientSecret ||
|
||||
savedSettings.oauthScopes ||
|
||||
serverAuthorizationParams ||
|
||||
savedSettings.enterpriseManaged)
|
||||
? {
|
||||
...(savedSettings.oauthClientId && {
|
||||
|
|
@ -2317,6 +2332,9 @@ function App() {
|
|||
...(savedSettings.oauthScopes && {
|
||||
scope: savedSettings.oauthScopes,
|
||||
}),
|
||||
...(serverAuthorizationParams && {
|
||||
authorizationParams: serverAuthorizationParams,
|
||||
}),
|
||||
...(savedSettings.enterpriseManaged && {
|
||||
enterpriseManaged: true,
|
||||
}),
|
||||
|
|
@ -4376,6 +4394,7 @@ function App() {
|
|||
latencyMs={latencyMs}
|
||||
tools={tools}
|
||||
excludedTools={excludedTools}
|
||||
malformedListItems={shownMalformedListItems}
|
||||
prompts={prompts}
|
||||
resources={resources}
|
||||
resourceTemplates={resourceTemplates}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,17 @@ describe("CopyButton", () => {
|
|||
expect(screen.getByText("⎘")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("names itself 'Copy' with no label, and qualifies the name when given one", () => {
|
||||
const { unmount } = renderWithMantine(<CopyButton value="hello" />);
|
||||
expect(screen.getByRole("button", { name: "Copy" })).toBeInTheDocument();
|
||||
unmount();
|
||||
|
||||
renderWithMantine(<CopyButton value="hello" label="ID Token" />);
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Copy ID Token" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("triggers a click without throwing when clipboard is unavailable", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithMantine(<CopyButton value="hello" />);
|
||||
|
|
|
|||
|
|
@ -11,6 +11,15 @@ export interface CopyButtonProps {
|
|||
* rows (e.g. beside a Code block). Icon size is unchanged.
|
||||
*/
|
||||
flush?: boolean;
|
||||
/**
|
||||
* Names what is being copied, for the accessible label only ("Copy ID
|
||||
* Token"). Set it when more than one CopyButton can appear in the same
|
||||
* region, so button-by-button screen-reader navigation can tell them apart;
|
||||
* a lone control needs no qualifier. The visible tooltip stays "Copy" either
|
||||
* way, and the accessible name keeps "Copy"/"Copied" as its first word so it
|
||||
* still contains the visible text (WCAG 2.5.3).
|
||||
*/
|
||||
label?: string;
|
||||
}
|
||||
|
||||
const CopyActionIcon = ActionIcon.withProps({
|
||||
|
|
@ -18,7 +27,8 @@ const CopyActionIcon = ActionIcon.withProps({
|
|||
fz: 24,
|
||||
});
|
||||
|
||||
export function CopyButton({ value, flush = false }: CopyButtonProps) {
|
||||
export function CopyButton({ value, flush = false, label }: CopyButtonProps) {
|
||||
const qualifier = label ? ` ${label}` : "";
|
||||
return (
|
||||
<MantineCopyButton value={value}>
|
||||
{({ copied, copy }) => (
|
||||
|
|
@ -26,7 +36,7 @@ export function CopyButton({ value, flush = false }: CopyButtonProps) {
|
|||
<CopyActionIcon
|
||||
color={copied ? "green" : "var(--inspector-text-primary)"}
|
||||
onClick={copy}
|
||||
aria-label={copied ? "Copied" : "Copy"}
|
||||
aria-label={(copied ? "Copied" : "Copy") + qualifier}
|
||||
{...(flush && { p: 0, h: "auto", w: "auto" })}
|
||||
>
|
||||
{copied ? "\u2713" : "\u2398"}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,89 @@
|
|||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { MalformedItemsWarning } from "./MalformedItemsWarning";
|
||||
|
||||
const meta: Meta<typeof MalformedItemsWarning> = {
|
||||
title: "Elements/MalformedItemsWarning",
|
||||
component: MalformedItemsWarning,
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof MalformedItemsWarning>;
|
||||
|
||||
/** The failure this was built for: a PHP server whose empty `annotations`
|
||||
* object reaches the wire as `[]` (#1909). */
|
||||
export const OneDroppedTemplate: Story = {
|
||||
args: {
|
||||
method: "resources/templates/list",
|
||||
what: "resource templates",
|
||||
items: [
|
||||
{
|
||||
method: "resources/templates/list",
|
||||
index: 1,
|
||||
label: "array_annotations",
|
||||
reason: "annotations: Invalid input: expected object, received array",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export const SeveralDropped: Story = {
|
||||
args: {
|
||||
method: "tools/list",
|
||||
what: "tools",
|
||||
items: [
|
||||
{
|
||||
method: "tools/list",
|
||||
index: 0,
|
||||
label: "get_weather",
|
||||
reason: "inputSchema: Invalid input: expected object, received string",
|
||||
},
|
||||
{
|
||||
method: "tools/list",
|
||||
index: 4,
|
||||
label: "add",
|
||||
reason: "name: Invalid input: expected string, received number",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
/** An entry too broken to carry a name is reported by its position. */
|
||||
export const UnlabeledEntry: Story = {
|
||||
args: {
|
||||
method: "prompts/list",
|
||||
what: "prompts",
|
||||
items: [
|
||||
{
|
||||
method: "prompts/list",
|
||||
index: 2,
|
||||
reason: "Invalid input: expected object, received null",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
/** Entries belonging to another list method are ignored — the same set is
|
||||
* passed to every panel, and each selects its own. */
|
||||
export const OtherMethodOnly: Story = {
|
||||
args: {
|
||||
method: "resources/templates/list",
|
||||
what: "resource templates",
|
||||
items: [
|
||||
{
|
||||
method: "tools/list",
|
||||
index: 0,
|
||||
label: "get_weather",
|
||||
reason: "inputSchema: Invalid input: expected object, received string",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
/** The resting state: a conforming server, nothing rendered. */
|
||||
export const Conforming: Story = {
|
||||
args: {
|
||||
method: "resources/templates/list",
|
||||
what: "resource templates",
|
||||
items: [],
|
||||
},
|
||||
};
|
||||
|
|
@ -0,0 +1,97 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import { screen } from "@testing-library/react";
|
||||
import type { MalformedListItem } from "@inspector/core/mcp";
|
||||
import { renderWithMantine } from "../../../test/renderWithMantine";
|
||||
import { MalformedItemsWarning } from "./MalformedItemsWarning";
|
||||
|
||||
const TEMPLATE_ENTRY: MalformedListItem = {
|
||||
method: "resources/templates/list",
|
||||
index: 1,
|
||||
label: "array_annotations",
|
||||
reason: "annotations: Invalid input: expected object, received array",
|
||||
};
|
||||
|
||||
const TOOL_ENTRY: MalformedListItem = {
|
||||
method: "tools/list",
|
||||
index: 0,
|
||||
label: "get_weather",
|
||||
reason: "inputSchema: Invalid input: expected object, received string",
|
||||
};
|
||||
|
||||
describe("MalformedItemsWarning", () => {
|
||||
it("names the dropped entry and why it was dropped", () => {
|
||||
renderWithMantine(
|
||||
<MalformedItemsWarning
|
||||
items={[TEMPLATE_ENTRY]}
|
||||
method="resources/templates/list"
|
||||
what="resource templates"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("1 malformed entry dropped")).toBeInTheDocument();
|
||||
expect(screen.getByText("array_annotations")).toBeInTheDocument();
|
||||
expect(screen.getByText(TEMPLATE_ENTRY.reason)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders nothing when the server was conforming", () => {
|
||||
renderWithMantine(
|
||||
<MalformedItemsWarning
|
||||
items={[]}
|
||||
method="resources/templates/list"
|
||||
what="resource templates"
|
||||
/>,
|
||||
);
|
||||
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("ignores entries belonging to another list method", () => {
|
||||
// Every panel receives the whole set and selects its own — a tools failure
|
||||
// must not surface above the templates list.
|
||||
renderWithMantine(
|
||||
<MalformedItemsWarning
|
||||
items={[TOOL_ENTRY]}
|
||||
method="resources/templates/list"
|
||||
what="resource templates"
|
||||
/>,
|
||||
);
|
||||
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("get_weather")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("pluralizes the count and lists every entry", () => {
|
||||
renderWithMantine(
|
||||
<MalformedItemsWarning
|
||||
items={[
|
||||
TOOL_ENTRY,
|
||||
{ ...TOOL_ENTRY, index: 4, label: "add", reason: "name: bad" },
|
||||
TEMPLATE_ENTRY,
|
||||
]}
|
||||
method="tools/list"
|
||||
what="tools"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("2 malformed entries dropped")).toBeInTheDocument();
|
||||
expect(screen.getByText("get_weather")).toBeInTheDocument();
|
||||
expect(screen.getByText("add")).toBeInTheDocument();
|
||||
// The template entry belongs to the other panel.
|
||||
expect(screen.queryByText("array_annotations")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("falls back to the position when the entry had no usable name", () => {
|
||||
renderWithMantine(
|
||||
<MalformedItemsWarning
|
||||
items={[
|
||||
{
|
||||
method: "prompts/list",
|
||||
index: 2,
|
||||
reason: "Invalid input: expected object, received null",
|
||||
},
|
||||
]}
|
||||
method="prompts/list"
|
||||
what="prompts"
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("entry 2")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,99 @@
|
|||
import { Alert, Code, List, ScrollArea, Text } from "@mantine/core";
|
||||
import type { MalformedListItem } from "@inspector/core/mcp";
|
||||
|
||||
export interface MalformedItemsWarningProps {
|
||||
/**
|
||||
* Every malformed entry the client is currently reporting, across all list
|
||||
* methods — pass the hook's set straight through; the component selects its
|
||||
* own by {@link method}.
|
||||
*/
|
||||
items: MalformedListItem[];
|
||||
/** The list method this panel renders, e.g. `"resources/templates/list"`. */
|
||||
method: string;
|
||||
/** What the entries are, for the alert title — e.g. "resource templates". */
|
||||
what: string;
|
||||
}
|
||||
|
||||
// Yellow, not red: unlike ListLoadError this is not a failure — the list DID
|
||||
// load and is rendered below. It reports what the server sent that the
|
||||
// Inspector could not accept.
|
||||
const WarningAlert = Alert.withProps({
|
||||
color: "yellow",
|
||||
variant: "light",
|
||||
});
|
||||
|
||||
// The entry list stays compact; the reasons are the payload, so they get the
|
||||
// monospace treatment while the labels stay in body type.
|
||||
const EntryList = List.withProps({
|
||||
size: "xs",
|
||||
spacing: 4,
|
||||
withPadding: true,
|
||||
});
|
||||
|
||||
const EntryReason = Code.withProps({
|
||||
variant: "wrapping",
|
||||
});
|
||||
|
||||
// A server can be wrong about many entries at once; cap the height so a badly
|
||||
// broken list can't push the list itself off the sidebar.
|
||||
const EntryScroll = ScrollArea.withProps({
|
||||
mah: 180,
|
||||
type: "auto",
|
||||
});
|
||||
|
||||
const SummaryText = Text.withProps({
|
||||
size: "xs",
|
||||
mb: 4,
|
||||
});
|
||||
|
||||
// The entry's identity, emphasized against its monospaced reason.
|
||||
const EntryLabel = Text.withProps({
|
||||
component: "span",
|
||||
size: "xs",
|
||||
fw: 600,
|
||||
});
|
||||
|
||||
/** `label` when the entry had one, else its position in the response. */
|
||||
function describeEntry(item: MalformedListItem): string {
|
||||
return item.label ?? `entry ${item.index}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* "Some entries were dropped" — the visible half of the #1909 salvage.
|
||||
*
|
||||
* The client keeps the valid entries of a list whose result failed schema
|
||||
* validation, rather than losing the whole list to one bad entry. Dropping them
|
||||
* silently would trade one lie for another, so what was dropped, and why, is
|
||||
* reported here beside the list that rendered without them.
|
||||
*/
|
||||
export function MalformedItemsWarning({
|
||||
items,
|
||||
method,
|
||||
what,
|
||||
}: MalformedItemsWarningProps) {
|
||||
const mine = items.filter((item) => item.method === method);
|
||||
if (mine.length === 0) return null;
|
||||
|
||||
return (
|
||||
<WarningAlert
|
||||
title={`${mine.length} malformed ${
|
||||
mine.length === 1 ? "entry" : "entries"
|
||||
} dropped`}
|
||||
>
|
||||
<SummaryText>
|
||||
The server returned {what} that don't match the MCP schema. The
|
||||
rest of the list is shown below.
|
||||
</SummaryText>
|
||||
<EntryScroll>
|
||||
<EntryList>
|
||||
{mine.map((item) => (
|
||||
<List.Item key={`${item.method}:${item.index}`}>
|
||||
<EntryLabel>{describeEntry(item)}</EntryLabel>{" "}
|
||||
<EntryReason>{item.reason}</EntryReason>
|
||||
</List.Item>
|
||||
))}
|
||||
</EntryList>
|
||||
</EntryScroll>
|
||||
</WarningAlert>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue