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
|
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
|
## 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.
|
- `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.
|
- **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 references an issue**, first body line `Closes #<ISSUE_NUMBER>`.
|
||||||
- **Every PR carries exactly one version label**, `v1` or `v2`, matching its base branch.
|
- **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:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v6
|
uses: actions/setup-node@v7
|
||||||
with:
|
with:
|
||||||
node-version: '22.x'
|
node-version: '22.x'
|
||||||
cache: 'npm'
|
cache: 'npm'
|
||||||
|
|
@ -79,7 +79,7 @@ jobs:
|
||||||
# smoke reuse the cache instead of downloading its own copy. The Storybook
|
# smoke reuse the cache instead of downloading its own copy. The Storybook
|
||||||
# step below reuses the same install.
|
# step below reuses the same install.
|
||||||
- name: Cache Playwright browsers
|
- name: Cache Playwright browsers
|
||||||
uses: actions/cache@v4
|
uses: actions/cache@v6
|
||||||
with:
|
with:
|
||||||
path: ~/.cache/ms-playwright
|
path: ~/.cache/ms-playwright
|
||||||
key: playwright-${{ runner.os }}-${{ hashFiles('clients/web/package-lock.json') }}
|
key: playwright-${{ runner.os }}-${{ hashFiles('clients/web/package-lock.json') }}
|
||||||
|
|
@ -129,10 +129,10 @@ jobs:
|
||||||
id-token: write
|
id-token: write
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v6
|
uses: actions/setup-node@v7
|
||||||
with:
|
with:
|
||||||
node-version: '22.x'
|
node-version: '22.x'
|
||||||
cache: 'npm'
|
cache: 'npm'
|
||||||
|
|
@ -217,7 +217,7 @@ jobs:
|
||||||
id-token: write
|
id-token: write
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
- name: Log in to the Container registry
|
- name: Log in to the Container registry
|
||||||
uses: docker/login-action@v4
|
uses: docker/login-action@v4
|
||||||
|
|
@ -241,14 +241,14 @@ jobs:
|
||||||
latest=true
|
latest=true
|
||||||
|
|
||||||
- name: Set up QEMU
|
- name: Set up QEMU
|
||||||
uses: docker/setup-qemu-action@v3
|
uses: docker/setup-qemu-action@v4
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v4
|
uses: docker/setup-buildx-action@v4
|
||||||
|
|
||||||
- name: Build and push Docker image
|
- name: Build and push Docker image
|
||||||
id: push
|
id: push
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@v7
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
push: true
|
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 \
|
ENV HOST=0.0.0.0 \
|
||||||
DANGEROUSLY_BIND_ALL_INTERFACES=true \
|
DANGEROUSLY_BIND_ALL_INTERFACES=true \
|
||||||
CLIENT_PORT=6274 \
|
CLIENT_PORT=6274 \
|
||||||
|
MCP_SANDBOX_PORT=6275 \
|
||||||
MCP_AUTO_OPEN_ENABLED=false
|
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
|
# 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`
|
# 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)
|
├── core/ # Shared code consumed via the `@inspector/core` alias (no package.json)
|
||||||
│ ├── auth/ # OAuth: providers, discovery, storage, mid-session recovery (browser/node/remote backends)
|
│ ├── 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
|
│ ├── 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
|
│ ├── logging/ # Silent pino logger singleton
|
||||||
│ ├── mcp/ # InspectorClient runtime, state stores, transports, config import
|
│ ├── mcp/ # InspectorClient runtime, state stores, transports, config import
|
||||||
│ ├── node/ # Node-only shared helpers: version reader, hostUrl (host normalize/canonicalize + all-interfaces/loopback detection)
|
│ ├── 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) |
|
| `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) |
|
| `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) |
|
| `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) |
|
| `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) |
|
| `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) |
|
| `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.
|
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
|
||||||
|
|
||||||
`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`.
|
`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 |
|
| 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 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 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: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 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: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 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). |
|
| `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).)
|
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
|
```bash
|
||||||
npm version <major|minor|patch> # bumps the root package.json + tags
|
git checkout -b v2/chore/2010-bump-2-3-0 v2/main
|
||||||
git push --follow-tags
|
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`
|
# 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.
|
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
|
### 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.
|
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
|
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.
|
**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:
|
**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);
|
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(
|
const streamPromise = consumeMethodOutcome(
|
||||||
{
|
{
|
||||||
kind: "stream",
|
kind: "stream",
|
||||||
label: "t",
|
label: "t",
|
||||||
start: (write) => {
|
start: (write) => {
|
||||||
write({ hi: true });
|
write({ hi: true });
|
||||||
queueMicrotask(() => process.emit("SIGINT"));
|
|
||||||
return () => {};
|
return () => {};
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{},
|
{},
|
||||||
);
|
);
|
||||||
|
const added = process
|
||||||
|
.listeners("SIGINT")
|
||||||
|
.filter((listener) => !listenersBefore.has(listener));
|
||||||
|
expect(added).toHaveLength(1);
|
||||||
|
for (const listener of added) listener("SIGINT");
|
||||||
await streamPromise;
|
await streamPromise;
|
||||||
expect(stdout).toContain('"hi":true');
|
expect(stdout).toContain('"hi":true');
|
||||||
} finally {
|
} finally {
|
||||||
|
|
|
||||||
|
|
@ -507,6 +507,9 @@ function renderApp(servers: Record<string, TuiServer>) {
|
||||||
async function mount(servers: Record<string, TuiServer>) {
|
async function mount(servers: Record<string, TuiServer>) {
|
||||||
const r = renderApp(servers);
|
const r = renderApp(servers);
|
||||||
await tick();
|
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");
|
r.stdin.write("x");
|
||||||
await tick();
|
await tick();
|
||||||
return r;
|
return r;
|
||||||
|
|
@ -532,6 +535,11 @@ const ENTER = "\r";
|
||||||
*/
|
*/
|
||||||
async function press(r: RenderResult, keys: string[]) {
|
async function press(r: RenderResult, keys: string[]) {
|
||||||
for (const k of keys) {
|
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);
|
r.stdin.write(k);
|
||||||
await tick();
|
await tick();
|
||||||
await tick();
|
await tick();
|
||||||
|
|
@ -552,9 +560,29 @@ async function press(r: RenderResult, keys: string[]) {
|
||||||
*/
|
*/
|
||||||
const POLL_TRIES = 100;
|
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) {
|
async function waitUntil(predicate: () => boolean, tries = POLL_TRIES) {
|
||||||
for (let i = 0; i < tries; i++) {
|
for (let i = 0; i < tries; i++) {
|
||||||
if (predicate()) return;
|
if (predicate()) {
|
||||||
|
await settleInputHandlers();
|
||||||
|
return;
|
||||||
|
}
|
||||||
await tick();
|
await tick();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -626,6 +654,23 @@ afterEach(() => {
|
||||||
while (mounted.length) mounted.pop()?.unmount();
|
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)", () => {
|
describe("App (foundation)", () => {
|
||||||
it("renders the server list with the MCP Servers header", async () => {
|
it("renders the server list with the MCP Servers header", async () => {
|
||||||
const r = renderApp(stdioServer());
|
const r = renderApp(stdioServer());
|
||||||
|
|
|
||||||
|
|
@ -213,4 +213,173 @@ describe("schemaToForm", () => {
|
||||||
);
|
);
|
||||||
expect(form.sections[0]!.fields[0]).toMatchObject({ initialValue: "hi" });
|
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 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 */
|
/** Minimal JSON Schema property shape used when building tool parameter forms */
|
||||||
interface JsonSchemaProperty {
|
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;
|
title?: string;
|
||||||
enum?: unknown[];
|
enum?: unknown[];
|
||||||
/** Non-standard legacy support: titles for enum values */
|
/** Non-standard legacy support: titles for enum values */
|
||||||
|
|
@ -15,6 +24,8 @@ interface JsonSchemaProperty {
|
||||||
minimum?: number;
|
minimum?: number;
|
||||||
maximum?: number;
|
maximum?: number;
|
||||||
default?: unknown;
|
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
|
// `properties` values are `unknown` (the SDK schema admits anything), so
|
||||||
// guard before treating a value as a schema object — a malformed server
|
// guard before treating a value as a schema object — a malformed server
|
||||||
// schema with e.g. `properties: { foo: null }` must not throw on `.title`.
|
// schema with e.g. `properties: { foo: null }` must not throw on `.title`.
|
||||||
const property = (
|
// Flatten a nullable union (`anyOf: [X, {type:"null"}]`, `type: [X,"null"]`)
|
||||||
typeof prop === "object" && prop !== null ? prop : {}
|
// before dispatching. Every branch below reads a single `type` string, and
|
||||||
) as JsonSchemaProperty;
|
// 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 = {
|
const baseField = {
|
||||||
name: key,
|
name: key,
|
||||||
label: property.title || key,
|
label: property.title || key,
|
||||||
|
|
@ -90,12 +108,17 @@ export function schemaToForm(
|
||||||
...baseField,
|
...baseField,
|
||||||
options: toSelectOptions(property.items.enum, property.items.enumNames),
|
options: toSelectOptions(property.items.enum, property.items.enumNames),
|
||||||
} as FormField;
|
} as FormField;
|
||||||
} else if (property.enum) {
|
} else if (isStringEnum(property.enum)) {
|
||||||
// Single select
|
// 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 = {
|
field = {
|
||||||
type: "select",
|
type: "select",
|
||||||
...baseField,
|
...baseField,
|
||||||
options: toSelectOptions(property.enum, property.enumNames),
|
options: toSelectOptions(property.enum ?? [], property.enumNames),
|
||||||
} as FormField;
|
} as FormField;
|
||||||
} else {
|
} else {
|
||||||
// Map JSON Schema types to ink-form types
|
// 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
|
## 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.
|
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`.
|
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.
|
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";
|
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
|
* Resolve the bind hostname from `env` (default `process.env`), defaulting to
|
||||||
* `localhost`. Refuses an all-interfaces host (`0.0.0.0` / `::` / empty / their
|
* {@link DEFAULT_BIND_HOST}. Refuses an all-interfaces host (`0.0.0.0` / `::` /
|
||||||
* legacy spellings) unless {@link BIND_ALL_INTERFACES_ENV} is explicitly
|
* empty / their legacy spellings) unless {@link BIND_ALL_INTERFACES_ENV} is
|
||||||
* enabled — the published Docker image sets it, since a container must bind
|
* explicitly enabled — the published Docker image sets it, since a container
|
||||||
* `0.0.0.0` to be reachable through `-p`. Throws (fail fast, loudly) rather than
|
* must bind `0.0.0.0` to be reachable through `-p`. Throws (fail fast, loudly)
|
||||||
* silently binding wide open. The returned value is trimmed and de-bracketed
|
* rather than silently binding wide open. The returned value is trimmed and
|
||||||
* (an IPv6 literal is returned bare, e.g. `HOST=[::1]` → `::1`) so detection,
|
* de-bracketed (an IPv6 literal is returned bare, e.g. `HOST=[::1]` → `::1`) so
|
||||||
* `listen()`, and the origin list all consume the same value; `formatHostForUrl`
|
* detection, `listen()`, and the origin list all consume the same value;
|
||||||
* re-adds the brackets wherever a URL is built.
|
* `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(
|
export function resolveBindHostname(
|
||||||
env: NodeJS.ProcessEnv = process.env,
|
env: NodeJS.ProcessEnv = process.env,
|
||||||
): string {
|
): string {
|
||||||
const host = (env.HOST ?? "localhost").trim();
|
const host = (env.HOST ?? DEFAULT_BIND_HOST).trim();
|
||||||
if (isAllInterfacesHost(host) && !isEnabled(env[BIND_ALL_INTERFACES_ENV])) {
|
if (isAllInterfacesHost(host) && !isEnabled(env[BIND_ALL_INTERFACES_ENV])) {
|
||||||
// Show the resolved address when it differs from the typed spelling — the
|
// Show the resolved address when it differs from the typed spelling — the
|
||||||
// guard now catches forms the resolver folds to the wildcard (a fullwidth
|
// guard now catches forms the resolver folds to the wildcard (a fullwidth
|
||||||
|
|
|
||||||
|
|
@ -11,13 +11,14 @@ import {
|
||||||
canonicalUrlHost,
|
canonicalUrlHost,
|
||||||
isAllInterfacesHost,
|
isAllInterfacesHost,
|
||||||
} from "../../../core/node/hostUrl.ts";
|
} from "../../../core/node/hostUrl.ts";
|
||||||
|
import { DEFAULT_BIND_HOST } from "./resolve-bind-host.js";
|
||||||
|
|
||||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
export interface SandboxControllerOptions {
|
export interface SandboxControllerOptions {
|
||||||
/** Port to bind (0 = dynamic). */
|
/** Port to bind (0 = dynamic). */
|
||||||
port: number;
|
port: number;
|
||||||
/** Host to bind (default localhost). */
|
/** Host to bind (default {@link DEFAULT_BIND_HOST}). */
|
||||||
host?: string;
|
host?: string;
|
||||||
/**
|
/**
|
||||||
* The backend's origin allow-list (the embedder origins). Used to build the
|
* 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).
|
* The default sandbox listen port — **fixed**, deliberately not `0`.
|
||||||
* 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
|
* A `0` default lets the OS assign, which is fine on a desktop (the browser
|
||||||
* silent — matching the warn-and-drop precedent for ALLOWED_ORIGINS.
|
* 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 {
|
export function resolveSandboxPort(): number {
|
||||||
const fromSandbox = parseListenPort(process.env.MCP_SANDBOX_PORT);
|
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.`,
|
`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(
|
export function createSandboxController(
|
||||||
options: SandboxControllerOptions,
|
options: SandboxControllerOptions,
|
||||||
): SandboxController {
|
): 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 server: Server | null = null;
|
||||||
let sandboxUrl: string | null = null;
|
let sandboxUrl: string | null = null;
|
||||||
|
|
||||||
|
|
@ -177,8 +204,28 @@ export function createSandboxController(
|
||||||
});
|
});
|
||||||
res.end(sandboxHtml);
|
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) => {
|
server.on("error", (err: NodeJS.ErrnoException) => {
|
||||||
if (err.code === "EADDRINUSE") {
|
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(
|
console.error(
|
||||||
`Sandbox: port ${port || "dynamic"} in use. MCP Apps tab may not work.`,
|
`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,
|
serverInfo: undefined,
|
||||||
instructions: undefined,
|
instructions: undefined,
|
||||||
excludedTools: [],
|
excludedTools: [],
|
||||||
|
malformedListItems: [],
|
||||||
appRendererClient: null,
|
appRendererClient: null,
|
||||||
connect: vi.fn().mockResolvedValue(undefined),
|
connect: vi.fn().mockResolvedValue(undefined),
|
||||||
disconnect: 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 { ResourceSubscriptionsState } from "@inspector/core/mcp/state/resourceSubscriptionsState.js";
|
||||||
import {
|
import {
|
||||||
cleanRoots,
|
cleanRoots,
|
||||||
|
oauthAuthorizationParamsFromSettings,
|
||||||
serializeMcpConfig,
|
serializeMcpConfig,
|
||||||
} from "@inspector/core/mcp/serverList.js";
|
} from "@inspector/core/mcp/serverList.js";
|
||||||
import type { ClientConfig } from "@inspector/core/client/types.js";
|
import type { ClientConfig } from "@inspector/core/client/types.js";
|
||||||
|
|
@ -163,6 +164,7 @@ import {
|
||||||
import { buildExportFilename, downloadJsonFile } from "./lib/downloadFile";
|
import { buildExportFilename, downloadJsonFile } from "./lib/downloadFile";
|
||||||
import { INSPECTOR_SERVERS_TAB } from "./utils/inspectorTabs";
|
import { INSPECTOR_SERVERS_TAB } from "./utils/inspectorTabs";
|
||||||
import { enrichProtocolEntries } from "./utils/correlateTransportErrors";
|
import { enrichProtocolEntries } from "./utils/correlateTransportErrors";
|
||||||
|
import { visibleMalformedListItems } from "./utils/malformedListReport";
|
||||||
import {
|
import {
|
||||||
parseDeepLink,
|
parseDeepLink,
|
||||||
deepLinkConfigEquals,
|
deepLinkConfigEquals,
|
||||||
|
|
@ -990,6 +992,7 @@ function App() {
|
||||||
protocolEra,
|
protocolEra,
|
||||||
discoverResult,
|
discoverResult,
|
||||||
excludedTools,
|
excludedTools,
|
||||||
|
malformedListItems,
|
||||||
lastError,
|
lastError,
|
||||||
} = useInspectorClient(inspectorClient);
|
} = useInspectorClient(inspectorClient);
|
||||||
const {
|
const {
|
||||||
|
|
@ -1055,6 +1058,14 @@ function App() {
|
||||||
setPaginatedListsOverride(null);
|
setPaginatedListsOverride(null);
|
||||||
}, [persistedPaginatedLists, activeServerId]);
|
}, [persistedPaginatedLists, activeServerId]);
|
||||||
const paginatedLists = paginatedListsOverride ?? persistedPaginatedLists;
|
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 connected = connectionStatus === "connected";
|
||||||
const toolsPagination = usePaginatedList({
|
const toolsPagination = usePaginatedList({
|
||||||
connected,
|
connected,
|
||||||
|
|
@ -2301,11 +2312,15 @@ function App() {
|
||||||
.map((m) => [m.key, m.value]),
|
.map((m) => [m.key, m.value]),
|
||||||
)
|
)
|
||||||
: undefined;
|
: undefined;
|
||||||
|
const serverAuthorizationParams = savedSettings
|
||||||
|
? oauthAuthorizationParamsFromSettings(savedSettings)
|
||||||
|
: undefined;
|
||||||
const oauthFromServer =
|
const oauthFromServer =
|
||||||
savedSettings &&
|
savedSettings &&
|
||||||
(savedSettings.oauthClientId ||
|
(savedSettings.oauthClientId ||
|
||||||
savedSettings.oauthClientSecret ||
|
savedSettings.oauthClientSecret ||
|
||||||
savedSettings.oauthScopes ||
|
savedSettings.oauthScopes ||
|
||||||
|
serverAuthorizationParams ||
|
||||||
savedSettings.enterpriseManaged)
|
savedSettings.enterpriseManaged)
|
||||||
? {
|
? {
|
||||||
...(savedSettings.oauthClientId && {
|
...(savedSettings.oauthClientId && {
|
||||||
|
|
@ -2317,6 +2332,9 @@ function App() {
|
||||||
...(savedSettings.oauthScopes && {
|
...(savedSettings.oauthScopes && {
|
||||||
scope: savedSettings.oauthScopes,
|
scope: savedSettings.oauthScopes,
|
||||||
}),
|
}),
|
||||||
|
...(serverAuthorizationParams && {
|
||||||
|
authorizationParams: serverAuthorizationParams,
|
||||||
|
}),
|
||||||
...(savedSettings.enterpriseManaged && {
|
...(savedSettings.enterpriseManaged && {
|
||||||
enterpriseManaged: true,
|
enterpriseManaged: true,
|
||||||
}),
|
}),
|
||||||
|
|
@ -4376,6 +4394,7 @@ function App() {
|
||||||
latencyMs={latencyMs}
|
latencyMs={latencyMs}
|
||||||
tools={tools}
|
tools={tools}
|
||||||
excludedTools={excludedTools}
|
excludedTools={excludedTools}
|
||||||
|
malformedListItems={shownMalformedListItems}
|
||||||
prompts={prompts}
|
prompts={prompts}
|
||||||
resources={resources}
|
resources={resources}
|
||||||
resourceTemplates={resourceTemplates}
|
resourceTemplates={resourceTemplates}
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,17 @@ describe("CopyButton", () => {
|
||||||
expect(screen.getByText("⎘")).toBeInTheDocument();
|
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 () => {
|
it("triggers a click without throwing when clipboard is unavailable", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
renderWithMantine(<CopyButton value="hello" />);
|
renderWithMantine(<CopyButton value="hello" />);
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,15 @@ export interface CopyButtonProps {
|
||||||
* rows (e.g. beside a Code block). Icon size is unchanged.
|
* rows (e.g. beside a Code block). Icon size is unchanged.
|
||||||
*/
|
*/
|
||||||
flush?: boolean;
|
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({
|
const CopyActionIcon = ActionIcon.withProps({
|
||||||
|
|
@ -18,7 +27,8 @@ const CopyActionIcon = ActionIcon.withProps({
|
||||||
fz: 24,
|
fz: 24,
|
||||||
});
|
});
|
||||||
|
|
||||||
export function CopyButton({ value, flush = false }: CopyButtonProps) {
|
export function CopyButton({ value, flush = false, label }: CopyButtonProps) {
|
||||||
|
const qualifier = label ? ` ${label}` : "";
|
||||||
return (
|
return (
|
||||||
<MantineCopyButton value={value}>
|
<MantineCopyButton value={value}>
|
||||||
{({ copied, copy }) => (
|
{({ copied, copy }) => (
|
||||||
|
|
@ -26,7 +36,7 @@ export function CopyButton({ value, flush = false }: CopyButtonProps) {
|
||||||
<CopyActionIcon
|
<CopyActionIcon
|
||||||
color={copied ? "green" : "var(--inspector-text-primary)"}
|
color={copied ? "green" : "var(--inspector-text-primary)"}
|
||||||
onClick={copy}
|
onClick={copy}
|
||||||
aria-label={copied ? "Copied" : "Copy"}
|
aria-label={(copied ? "Copied" : "Copy") + qualifier}
|
||||||
{...(flush && { p: 0, h: "auto", w: "auto" })}
|
{...(flush && { p: 0, h: "auto", w: "auto" })}
|
||||||
>
|
>
|
||||||
{copied ? "\u2713" : "\u2398"}
|
{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