Merge pull request #1601 from modelcontextprotocol/1579-wave-1

Wave 1 rollup: integrated 1579-wave-1 (#1600)
This commit is contained in:
Cliff Hall 2026-07-09 11:17:21 -04:00 • committed by GitHub
commit 822ac251e7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
43 changed files with 3187 additions and 327 deletions

View file

@ -13,7 +13,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: '20.x'
node-version: '22.x'
cache: 'npm'
- name: Install dependencies (root + all clients)
@ -25,18 +25,27 @@ jobs:
- name: Validate (format, lint, build, fast tests)
# Each client self-validates: format:check + lint + build + test (no
# coverage instrumentation — fast). This also builds every client bundle
# (web dist, cli/tui/launcher) that the smokes below need. The per-file
# coverage gate (`npm run coverage`) is intentionally NOT run in CI — run
# it locally before pushing when you want the gate (#1484).
# (web dist, cli/tui/launcher) that the smokes below need. The heavier
# per-file coverage gate runs in its own step below. Unit tests run in
# both steps (fast here, instrumented under coverage); the double unit
# run is an accepted trade-off for keeping this fast build step intact.
# A future optimization could split coverage into per-client parallel
# jobs, but that's a larger restructure and deliberately out of scope.
run: npm run validate
- name: Run web integration tests
# `validate` runs the web UNIT project only (fast); the integration
# project (real stdio/HTTP servers, e2e OAuth, filesystem storage) is
# otherwise only reached via test:coverage. Run it here without coverage
# so CI still exercises those paths even though the gate is local-only.
working-directory: ./clients/web
run: npm run test:integration
- name: Enforce per-file coverage gate (≥90% on all four dimensions)
# CI-ENFORCED coverage gate (#1550): runs `npm run coverage`, which
# chains every client's `test:coverage` (v8-instrumented) and fails the
# job if ANY file drops below 90% on lines, statements, functions, or
# branches. This is the whole point of the step — a PR that regresses
# coverage below the threshold now blocks merge instead of relying on a
# contributor remembering to run the gate locally.
#
# This also covers the web integration project: web's `test:coverage`
# runs `--project=unit --project=integration --coverage`, so the former
# standalone `Run web integration tests` step was removed to avoid
# running the integration suite twice.
run: npm run coverage
- name: Run cross-client smokes
# End-to-end smokes through the built launcher (--help dispatch + prod

View file

@ -176,12 +176,12 @@ gh project item-edit --project-id PVT_kwDOCt2Azc4BJVxt --id "$ITEM_ID" --field-i
- Run CLI tests with `npm run test` from `clients/cli/` (builds test-servers + CLI bin first via `pretest`)
- Run TUI tests with `npm run test` from `clients/tui/`
- The repo root has no aggregate `test` script — each client self-validates, so run `npm run validate` from the root (all clients, fast) or `cd clients/<name> && npm run validate` (one client). Each client still exposes its own `test` / `test:coverage` for quick iteration.
- **`validate` is fast: it runs `test`, not `test:coverage`.** The coverage gate (slower — adds v8 instrumentation, and for web the integration project) is a **separate** top-level `npm run coverage` (and per-client `coverage:web` / `coverage:cli` / `coverage:tui` / `coverage:launcher`, each delegating to that client's `test:coverage`). Run `npm run coverage` when you want the gate. **CI does NOT run `coverage`** — the gate is local-only; CI runs `validate` (fast) plus the web integration suite (`clients/web` `test:integration`, no coverage) so the integration paths are still exercised.
- **`validate` is fast: it runs `test`, not `test:coverage`.** The coverage gate (slower — adds v8 instrumentation, and for web the integration project) is a **separate** top-level `npm run coverage` (and per-client `coverage:web` / `coverage:cli` / `coverage:tui` / `coverage:launcher`, each delegating to that client's `test:coverage`). Run `npm run coverage` when you want to reproduce the gate locally before pushing. **CI runs `coverage`** on every push (#1550): the per-file ≥90 gate is CI-enforced, so a PR that drops any file below 90 on lines/statements/functions/branches fails the job. CI runs `validate` (fast) for format/lint/build/unit tests, then `coverage` for the instrumented gate. Because web's `test:coverage` already runs the integration project, CI has no separate `test:integration` step — the integration paths are exercised inside the coverage gate.
- Each client's `test:coverage` enforces a **uniform per-file gate of ≥ 90 on all four dimensions** — lines, statements, functions, and branches — across `clients/web`, `clients/cli`, `clients/tui`, and `clients/launcher` (CI enforces this gate). This is the result of a codebase-wide audit: the branch floor was first lifted 50 → 70 for web (#1271), then the whole gate raised to 90 with real tests added for every outlier. Genuinely-unreachable branches are **not** waved through by lowering the gate — they are annotated at the source with a justified `/* v8 ignore … -- <reason> */` comment. Acceptable reasons are happy-dom-inherent paths (Mantine portal mount points, `useMediaQuery` fallbacks, `typeof window` SSR guards), React StrictMode effect-replay blocks, and provably-dead defensive guards (e.g. a `?? fallback` for a value the types guarantee non-null, or a `Select.onChange` receiving a value outside the allowed list). New code must clear 90 on every dimension; reach for a justified `v8 ignore` only when a branch is genuinely impossible to exercise.
- The **same per-file gate** is enforced for the CLI and TUI (#1484), not just web:
- **CLI** (`clients/cli`): tests run **in-process** by importing `runCli()` (see `__tests__/helpers/cli-runner.ts`) so `clients/cli/src` is measured under v8 instrumentation. A thin out-of-process layer (`__tests__/e2e.test.ts` + `scripts/smoke-cli.mjs`) still spawns the built binary for the shebang/`process.exit` paths; `src/index.ts` (binary bootstrap) is the only coverage exclusion. `commander` uses `.exitOverride()` so a parse error throws instead of tearing down the test worker.
- **TUI** (`clients/tui`): the gate covers the **non-React logic** only — `logger.ts`, `components/tabsConfig.ts`, and `utils/*` (server resolution lives in `core/` and is measured by the web suite). The Ink components, `App.tsx`, and `hooks/` are an **interim exclusion** in `clients/tui/vitest.config.ts` pending the renderer-based follow-up (#1501). When adding new **non-React** logic under `clients/tui/src`, it falls under the gate automatically — add tests for it.
- Run `npm run test:integration` (also from `clients/web/`) for the InspectorClient + transport + auth integration suite. It runs under a separate `integration` vitest project in node env (no happy-dom) with 30s timeouts. The script builds `test-servers/` first via `tsc -p ../../test-servers --noCheck` so the stdio MCP test server can be spawned as a real subprocess. CI runs it as its own step after unit tests.
- Run `npm run test:integration` (also from `clients/web/`) for the InspectorClient + transport + auth integration suite. It runs under a separate `integration` vitest project in node env (no happy-dom) with 30s timeouts. The script builds `test-servers/` first via `tsc -p ../../test-servers --noCheck` so the stdio MCP test server can be spawned as a real subprocess. CI does not run `test:integration` as its own step — the integration project is covered by the CI `coverage` gate, whose web `test:coverage` runs `--project=unit --project=integration --coverage`.
- Test files live alongside the source as `<Name>.test.tsx` (or `.test.ts` for non-React modules). Integration tests live under `clients/web/src/test/integration/`, mirroring the `core/` source layout (`mcp/`, `mcp/node/`, `mcp/remote/`, `auth/`, `auth/node/`, `storage/`). Any test file under that folder is automatically picked up by the `integration` vitest project (node env, 30s timeouts) via the folder glob in `vite.config.ts` — placement is the manifest, there is no enumeration to keep in sync. Tests outside the folder run in the `unit` project (happy-dom). When adding a new test for, e.g., `core/mcp/remote/foo.ts`, put it at `src/test/integration/mcp/remote/foo.test.ts`.
- Use `renderWithMantine` from `src/test/renderWithMantine.tsx` to render components — it wraps in `MantineProvider` with the project theme
@ -196,8 +196,8 @@ gh project item-edit --project-id PVT_kwDOCt2Azc4BJVxt --id "$ITEM_ID" --field-i
- **`npm run ci` before pushing** — runs the same steps as `.github/workflows/main.yml` (minus `npm install`): `validate` → web `test:integration` → `smoke` → Storybook play-function tests (installs Playwright chromium if needed). Use this when you want CI parity; expect several minutes. **`npm run validate`** remains the fast loop during development (unit tests only — no web integration, smoke, or Storybook).
- ALWAYS do `npm run format` before committing, then **`npm run ci`** (or at minimum `npm run validate`) before pushing. From the repo root, `validate` chains the four per-client validations (`validate:web` → `validate:cli` → `validate:tui` → `validate:launcher`); each delegates to that client's own `npm run validate` = `format:check` + `lint` + `build` + `test` in its own folder (no coverage — fast). Every client is self-validating and the top level just chains them, building each client's bundle along the way (no cross-client build dependencies).
- The one CLI nuance: `clients/cli`'s out-of-process `e2e.test.ts` spawns the built binary, so its `test` **builds first** via `pretest` (`test-servers:build && build`). To avoid building it twice, `clients/cli`'s `validate` folds that in — it is `format:check && lint && test` with **no** separate `build` step (the other clients, whose tests don't spawn their bundle, keep an explicit `build`). `validate:web`/`validate:tui`/`validate:launcher` are the uniform `format:check && lint && build && test`.
- Optionally also run **`npm run coverage`** when you want the local-only per-file ≥90 gate — CI does **not** run `coverage`; it relies on `validate` + web `test:integration` instead.
- **`smoke` is NOT part of `validate`** — it is included in `npm run ci`. It runs `smoke:launcher` (`--help` dispatch) plus the prod `smoke:cli` / `smoke:tui` / `smoke:web`, and contains **no build commands** — it assumes the cli/tui/launcher bundles already exist (a full `validate` builds them; `smoke:web` builds `clients/web/dist` on demand).
- Before pushing, also run **`npm run coverage`** — `validate` is fast and does NOT enforce the per-file gate (or, for web, run the integration project); `coverage` does both. **CI runs `coverage`** too (#1550), so the gate blocks PRs that regress below 90 — running it locally first just catches failures before CI does. CI has no separate `test:integration` step: web's `test:coverage` already runs the integration project.
- **`smoke` is a separate top-level target, NOT part of `validate`.** Run it (or the individual `smoke:*`) after a build/validate: `npm run validate && npm run smoke`. It runs `smoke:launcher` (`--help` dispatch) plus the prod `smoke:cli` / `smoke:tui` / `smoke:web`, and contains **no build commands** — it assumes the cli/tui/launcher bundles already exist (a full `validate` builds them; `smoke:web` builds `clients/web/dist` on demand). CI runs `validate`, then the `coverage` gate (which also covers the web integration project), then `smoke`. Storybook is the only CI step left out (see below).
- `smoke:launcher` (`scripts/smoke-launcher.mjs`) runs the built launcher with `--help`, `--cli --help`, and `--tui --help`, asserting each exits 0 and prints that mode's usage banner (which also proves the launcher resolved and loaded the right client build). It's the cheap dispatch check before the heavier prod smokes below.
- `smoke:web` (`scripts/smoke-web.mjs`) starts `mcp-inspector --web` (prod, no `--dev`) against the built `clients/web/dist` and asserts `GET /` serves the SPA (HTTP 200) with the injected `__INSPECTOR_API_TOKEN__`. Prod `--web` serves from `clients/web/dist`, which ships in the published package but is absent in a fresh checkout — the runner builds it on demand (`build:client` = `vite build`) on first launch, or exits with an actionable error if that build can't run (see `clients/web/server/ensure-web-build.ts` and the launcher README). `--dev` runs Vite directly and never needs `dist`.
- `smoke:cli` (`scripts/smoke-cli.mjs`) drives `mcp-inspector --cli` through the built launcher against the bundled stdio test server via a temp `--catalog`: it asserts `tools/list` returns the server's tools (real connect over stdio), the default writable catalog is seeded empty on first run, a missing read-only `--config` errors without seeding, and `--catalog` + `--config` is rejected. `smoke:tui` (`scripts/smoke-tui.mjs`) launches `mcp-inspector --tui --catalog <temp>` and asserts the Ink app renders its first frame (the "MCP Servers" panel) within a timeout, then SIGTERMs it — a shallow boot/render check, not full interaction. **`smoke:tui` is local-only: it self-skips when `process.env.CI` is set**, because the Ink TUI needs a real TTY (raw mode) that headless CI lacks — so run it (via `npm run smoke`) on your own machine before pushing. Both build `test-servers/build` on demand if it's missing.

View file

@ -80,6 +80,12 @@ npx @modelcontextprotocol/inspector --cli https://my-mcp-server.example.com --tr
When a server is loaded from a `--catalog`/`--config` file, its per-server settings (headers, connection/request timeouts, and OAuth) are applied to the connection — the same resolution the TUI uses. A `--header` flag overrides the file's headers for that run while leaving the file's timeouts and OAuth in place.
### HTTP proxy support
Connections to remote HTTP/SSE servers honor the conventional proxy environment variables: `HTTPS_PROXY` / `HTTP_PROXY` (and their lowercase forms) select the proxy, and `NO_PROXY` exempts hosts. This applies to the Node transport shared by the CLI and the web backend — no inspector-specific flag is needed. When a proxy variable is set, outbound requests are routed through undici's `EnvHttpProxyAgent`.
Proxy routing is powered by the [`undici`](https://www.npmjs.com/package/undici) package (`^8.5.0`, which requires Node `>= 22.19.0` — the inspector's supported floor). It is imported lazily only when a proxy variable is set, so runs without a proxy configured pay no cost.
## Options
### MCP server (which server to connect to)
@ -131,13 +137,13 @@ Register `http://127.0.0.1:6276/oauth/callback` on static or enterprise IdPs tha
#### Flags
| Option | Env | Description |
| ------ | --- | ----------- |
| `--client-config <path>` | `MCP_CLIENT_CONFIG_PATH` | Install-level client config (default: `~/.mcp-inspector/storage/client.json`). |
| `--client-id <id>` | — | OAuth client ID (static client); overrides `client.json`. |
| `--client-secret <secret>` | — | OAuth client secret; overrides `client.json`. |
| `--client-metadata-url <url>` | — | CIMD metadata URL; overrides `client.json`. |
| `--callback-url <url>` | `MCP_OAUTH_CALLBACK_URL` | Redirect URI sent to the authorization server (default: `http://127.0.0.1:6276/oauth/callback`). |
| Option | Env | Description |
| ----------------------------- | ------------------------ | ------------------------------------------------------------------------------------------------ |
| `--client-config <path>` | `MCP_CLIENT_CONFIG_PATH` | Install-level client config (default: `~/.mcp-inspector/storage/client.json`). |
| `--client-id <id>` | — | OAuth client ID (static client); overrides `client.json`. |
| `--client-secret <secret>` | — | OAuth client secret; overrides `client.json`. |
| `--client-metadata-url <url>` | — | CIMD metadata URL; overrides `client.json`. |
| `--callback-url <url>` | `MCP_OAUTH_CALLBACK_URL` | Redirect URI sent to the authorization server (default: `http://127.0.0.1:6276/oauth/callback`). |
**Example** — list tools on an OAuth-protected server using stored tokens and CIMD from the command line:
@ -147,7 +153,33 @@ npx @modelcontextprotocol/inspector --cli --catalog mcp.json --server my-http-se
--method tools/list
```
See [EMA / enterprise-managed auth](../../specification/v2_auth_ema.md) and [OAuth smoke testing](../../specification/v2_auth_smoke_testing.md) (§3 Stytch/CIMD; [§5 mid-session manual validation](v2_auth_smoke_testing.md#5-mid-session-auth--step-up--manual-validation) — CLI **C1–C2**).
See [EMA / enterprise-managed auth](../../specification/v2_auth_ema.md) and [OAuth smoke testing](../../specification/v2_auth_smoke_testing.md) (§3 Stytch/CIMD; [§5 mid-session manual validation](../../specification/v2_auth_smoke_testing.md#5-mid-session-auth--step-up--manual-validation) — CLI **C1–C2**).
## Exit codes & error envelopes
Every non-zero exit maps to a stable failure class, so a programmatic caller
(CI, a script, an agent) can branch on _why_ the CLI failed without scraping
prose from stderr:
| Code | Meaning |
| ---- | ------- |
| `0` | Success. |
| `1` | Usage / unexpected error (the catch-all). |
| `2` | No MCP App found on the tool (`--app-info` probe). |
| `3` | Server requires authentication (401/403, `WWW-Authenticate`, OAuth). |
| `4` | Server unreachable (DNS, connection refused, timeout, `fetch failed`). |
| `5` | Tool error (`tools/call` returned `isError:true`, or the tool was not found). |
On any non-zero exit the CLI also writes a single JSON line to **stderr** — the
`ErrorEnvelope`:
```json
{ "error": { "code": "auth_required", "message": "Unauthorized", "status": 401, "url": "https://api.example/mcp" } }
```
The `code` is a stable identifier for the failure class; `message` is the
human-readable error; `cause`, `status`, and `url` are included when known.
Because it is one line, a caller can parse it with `2>&1 | tail -1 | jq .error`.
## Why use the CLI?

View file

@ -1,5 +1,11 @@
import { describe, it, expect, vi, afterEach } from "vitest";
import { handleError } from "../src/error-handler.js";
import {
CliExitCodeError,
EXIT_CODES,
classifyError,
formatErrorOutput,
handleError,
} from "../src/error-handler.js";
/**
* `handleError` is the binary's last-resort error sink (wired up in
@ -13,33 +19,217 @@ describe("handleError", () => {
vi.restoreAllMocks();
});
it("logs an Error's message and exits with code 1", () => {
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
it("emits a JSON error envelope on stderr and exits with the classified code", () => {
const writeSpy = vi
.spyOn(process.stderr, "write")
.mockImplementation((() => true) as never);
const exitSpy = vi
.spyOn(process, "exit")
.mockImplementation((() => undefined) as never);
handleError(new Error("boom"));
expect(errorSpy).toHaveBeenCalledWith("boom");
expect(exitSpy).toHaveBeenCalledWith(1);
expect(exitSpy).toHaveBeenCalledWith(EXIT_CODES.USAGE);
const written = writeSpy.mock.calls[0]![0] as string;
const parsed = JSON.parse(written) as {
error: { code: string; message: string };
};
expect(parsed.error.code).toBe("error");
expect(parsed.error.message).toBe("boom");
});
it("logs a string error verbatim", () => {
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
vi.spyOn(process, "exit").mockImplementation((() => undefined) as never);
it("uses a CliExitCodeError's exitCode and envelope code", () => {
vi.spyOn(process.stderr, "write").mockImplementation((() => true) as never);
const exitSpy = vi
.spyOn(process, "exit")
.mockImplementation((() => undefined) as never);
handleError("plain failure");
handleError(
new CliExitCodeError(EXIT_CODES.NO_APP, "no app", { code: "no_app" }),
);
expect(errorSpy).toHaveBeenCalledWith("plain failure");
});
it("falls back to 'Unknown error' for non-Error, non-string values", () => {
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
vi.spyOn(process, "exit").mockImplementation((() => undefined) as never);
handleError({ unexpected: true });
expect(errorSpy).toHaveBeenCalledWith("Unknown error");
expect(exitSpy).toHaveBeenCalledWith(EXIT_CODES.NO_APP);
});
});
describe("classifyError", () => {
it("classifies a 401 status as AUTH_REQUIRED", () => {
const err = Object.assign(new Error("Unauthorized"), { status: 401 });
const { exitCode, envelope } = classifyError(err, {
url: "https://x.example/mcp",
});
expect(exitCode).toBe(EXIT_CODES.AUTH_REQUIRED);
expect(envelope.code).toBe("auth_required");
expect(envelope.status).toBe(401);
expect(envelope.url).toBe("https://x.example/mcp");
});
it("classifies a WWW-Authenticate message as AUTH_REQUIRED without a status", () => {
const { exitCode } = classifyError(
new Error("Dynamic client registration failed: WWW-Authenticate Bearer"),
);
expect(exitCode).toBe(EXIT_CODES.AUTH_REQUIRED);
});
it("classifies ENOTFOUND / fetch failed as UNREACHABLE", () => {
const err = new Error("fetch failed");
(err as { cause?: unknown }).cause = new Error(
"getaddrinfo ENOTFOUND no.such.host",
);
const { exitCode, envelope } = classifyError(err);
expect(exitCode).toBe(EXIT_CODES.UNREACHABLE);
expect(envelope.cause).toContain("ENOTFOUND");
});
it("classifies a connection-refused cause as UNREACHABLE", () => {
const err = new Error("connect ECONNREFUSED 127.0.0.1:1");
expect(classifyError(err).exitCode).toBe(EXIT_CODES.UNREACHABLE);
});
it("falls back to USAGE for an unrecognized Error", () => {
expect(classifyError(new Error("something else")).exitCode).toBe(
EXIT_CODES.USAGE,
);
});
it("handles a string error", () => {
const { exitCode, envelope } = classifyError("plain failure");
expect(exitCode).toBe(EXIT_CODES.USAGE);
expect(envelope.message).toBe("plain failure");
});
it("handles a non-Error, non-string value", () => {
const { envelope } = classifyError({ unexpected: true });
expect(envelope.message).toBe("Unknown error");
});
it("reads an HTTP status from a nested response object", () => {
const err = { response: { status: 401 } };
const { exitCode, envelope } = classifyError(err);
expect(exitCode).toBe(EXIT_CODES.AUTH_REQUIRED);
expect(envelope.status).toBe(401);
});
it("preserves a CliExitCodeError's explicit envelope code over the default", () => {
const { envelope } = classifyError(
new CliExitCodeError(EXIT_CODES.TOOL_ERROR, "x", {
code: "tool_not_found",
}),
);
expect(envelope.code).toBe("tool_not_found");
});
it("carries a CliExitCodeError's explicit status and url onto the envelope", () => {
const { envelope } = classifyError(
new CliExitCodeError(EXIT_CODES.AUTH_REQUIRED, "denied", {
status: 401,
url: "https://api.example/mcp",
}),
);
expect(envelope.status).toBe(401);
expect(envelope.url).toBe("https://api.example/mcp");
});
it("falls back to the context url for a CliExitCodeError without its own url", () => {
const { envelope } = classifyError(
new CliExitCodeError(EXIT_CODES.UNREACHABLE, "down"),
{ url: "https://ctx.example/mcp" },
);
expect(envelope.url).toBe("https://ctx.example/mcp");
});
it("derives a default envelope code for a bare CliExitCodeError", () => {
expect(
classifyError(new CliExitCodeError(EXIT_CODES.UNREACHABLE, "x")).envelope
.code,
).toBe("unreachable");
expect(
classifyError(new CliExitCodeError(EXIT_CODES.NO_APP, "x")).envelope.code,
).toBe("no_app");
expect(
classifyError(new CliExitCodeError(EXIT_CODES.AUTH_REQUIRED, "x"))
.envelope.code,
).toBe("auth_required");
expect(
classifyError(new CliExitCodeError(EXIT_CODES.TOOL_ERROR, "x")).envelope
.code,
).toBe("tool_error");
expect(
classifyError(new CliExitCodeError(EXIT_CODES.USAGE, "x")).envelope.code,
).toBe("error");
});
it("terminates on a self-referential cause chain without recursing infinitely", () => {
// A cyclic `error.cause` must not hang the classifier; the depth cap bounds
// the walk and returns a finite string.
const err = new Error("looping");
(err as { cause?: unknown }).cause = err;
const { envelope } = classifyError(err);
expect(envelope.message).toBe("looping");
// Bounded by the depth cap: a finite string, not an infinite loop.
expect(typeof envelope.cause).toBe("string");
expect(envelope.cause).toContain("looping");
});
it("captures a non-Error cause as a string", () => {
const err = new Error("outer");
(err as { cause?: unknown }).cause = { reason: "blocked" };
const { envelope } = classifyError(err);
expect(envelope.cause).toBe("[object Object]");
});
it("flattens a nested Error cause chain into one string", () => {
const inner = new Error("getaddrinfo ENOTFOUND host");
const middle = Object.assign(new Error("connect failed"), { cause: inner });
const outer = Object.assign(new Error("fetch failed"), { cause: middle });
const { envelope } = classifyError(outer);
expect(envelope.cause).toBe("connect failed: getaddrinfo ENOTFOUND host");
});
it("reads a numeric SDK-style .code as an HTTP status", () => {
const err = Object.assign(new Error("forbidden"), { code: 403 });
const { exitCode, envelope } = classifyError(err);
expect(exitCode).toBe(EXIT_CODES.AUTH_REQUIRED);
expect(envelope.status).toBe(403);
});
it("ignores a JSON-RPC McpError numeric .code (outside the HTTP range) as a status", () => {
// MCP SDK's McpError carries a numeric `.code` that is a JSON-RPC error
// code (e.g. -32601 MethodNotFound), NOT an HTTP status. It must not leak
// into `status`, nor get misclassified as AUTH_REQUIRED.
const err = Object.assign(new Error("Method not found"), { code: -32601 });
const { exitCode, envelope } = classifyError(err);
expect(exitCode).toBe(EXIT_CODES.USAGE);
expect(envelope.status).toBeUndefined();
});
it("ignores a non-numeric node error code when reading the status", () => {
const err = Object.assign(new Error("getaddrinfo ENOTFOUND host"), {
code: "ENOTFOUND",
});
const { exitCode, envelope } = classifyError(err);
// The message matches the UNREACHABLE pattern, and the string node code
// must NOT be treated as an HTTP status.
expect(exitCode).toBe(EXIT_CODES.UNREACHABLE);
expect(envelope.status).toBeUndefined();
});
});
describe("formatErrorOutput", () => {
it("emits one JSON line on stderr ending in a newline", () => {
const { stderr, exitCode } = formatErrorOutput(new Error("nope"));
expect(stderr.endsWith("\n")).toBe(true);
expect(stderr.split("\n").length).toBe(2);
const parsed = JSON.parse(stderr) as { error: { message: string } };
expect(parsed.error.message).toBe("nope");
expect(exitCode).toBe(EXIT_CODES.USAGE);
});
it("threads the context url into the envelope", () => {
const { stderr } = formatErrorOutput(new Error("nope"), {
url: "https://ctx.example/mcp",
});
const parsed = JSON.parse(stderr) as { error: { url?: string } };
expect(parsed.error.url).toBe("https://ctx.example/mcp");
});
});

View file

@ -1,4 +1,5 @@
import { runCli as invokeCli } from "../../src/cli.js";
import { formatErrorOutput } from "../../src/error-handler.js";
export interface CliResult {
exitCode: number | null;
@ -52,7 +53,8 @@ function captureWrite(append: (text: string) => void) {
* (`exitCode` / `stdout` / `stderr` / `output`) so every existing test and the
* shared assertion helpers keep working unchanged:
* - stdout/stderr are captured by temporarily patching `process.std*.write`
* (and `console.error`/`console.warn`, which commander and error paths use).
* (which also captures `console.error`/`console.warn`, since those route
* through `process.stderr.write`).
* - A thrown error (bad args, failed connect, unsupported method) maps to
* `exitCode: 1` with the message appended to stderr — mirroring how the real
* binary (`index.ts`) routes errors through `handleError` → `process.exit(1)`.
@ -77,8 +79,6 @@ export async function runCli(
const originalStdoutWrite = process.stdout.write;
const originalStderrWrite = process.stderr.write;
const originalConsoleError = console.error;
const originalConsoleWarn = console.warn;
// Snapshot and apply env overrides; `undefined` records keys that did not
// exist so they can be deleted (not set to the string "undefined") on restore.
@ -93,15 +93,12 @@ export async function runCli(
process.stdout.write = captureWrite((text) => {
stdout += text;
}) as typeof process.stdout.write;
// `console.error` / `console.warn` route through `process.stderr.write`, so
// patching the underlying stream captures them too — no separate console
// override is needed (the previous explicit overrides were redundant).
process.stderr.write = captureWrite((text) => {
stderr += text;
}) as typeof process.stderr.write;
console.error = (...parts: unknown[]) => {
stderr += parts.map(String).join(" ") + "\n";
};
console.warn = (...parts: unknown[]) => {
stderr += parts.map(String).join(" ") + "\n";
};
// `runCli` reads `process.argv.slice(2)`, so prepend two placeholder entries
// ([node, script]) to mirror how the binary is launched.
@ -127,14 +124,15 @@ export async function runCli(
try {
await Promise.race([invokeCli(argv), timeout]);
} catch (error) {
exitCode = 1;
stderr += (error instanceof Error ? error.message : String(error)) + "\n";
// Mirror the binary's `handleError` exactly so tests observe the same exit
// code and stderr (one JSON envelope line) the real CLI would emit.
const out = formatErrorOutput(error);
exitCode = out.exitCode;
stderr += out.stderr;
} finally {
if (timer) clearTimeout(timer);
process.stdout.write = originalStdoutWrite;
process.stderr.write = originalStderrWrite;
console.error = originalConsoleError;
console.warn = originalConsoleWarn;
for (const [key, value] of Object.entries(envBackup)) {
if (value === undefined) delete process.env[key];
else process.env[key] = value;

View file

@ -15,6 +15,7 @@
"atomically": "^2.1.1",
"commander": "^13.1.0",
"pino": "^9.14.0",
"undici": "^8.5.0",
"zod": "^4.3.6"
},
"bin": {
@ -5377,6 +5378,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/undici": {
"version": "8.5.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-8.5.0.tgz",
"integrity": "sha512-xamtWoB1EshgjpmlXd7GGm2VfdDtw1+rD8uhry8pSNW3If6S8E0m2T2+orSKeZXEn/aPJMviCpDBA65WJt8zhg==",
"license": "MIT",
"engines": {
"node": ">=22.19.0"
}
},
"node_modules/undici-types": {
"version": "7.18.2",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",

View file

@ -38,6 +38,7 @@
"atomically": "^2.1.1",
"commander": "^13.1.0",
"pino": "^9.14.0",
"undici": "^8.5.0",
"zod": "^4.3.6"
},
"devDependencies": {

View file

@ -1,20 +1,216 @@
function formatError(error: unknown): string {
let message: string;
/**
* Exit-code map. Non-zero codes let an automated caller (CI, an agent) branch
* on the failure class without regex-scraping stderr:
*
* - 0: success
* - 1: usage / unexpected error (the catch-all; same as before this map)
* - 2: `--app-info` probe found no MCP App on the tool
* - 3: server requires authentication (401 / WWW-Authenticate / OAuth)
* - 4: server unreachable (DNS, connect refused, timeout, fetch failure)
* - 5: tool error (`tools/call` returned `isError:true`, or tool not found)
*/
export const EXIT_CODES = {
OK: 0,
USAGE: 1,
NO_APP: 2,
AUTH_REQUIRED: 3,
UNREACHABLE: 4,
TOOL_ERROR: 5,
} as const;
if (error instanceof Error) {
message = error.message;
} else if (typeof error === "string") {
message = error;
} else {
message = "Unknown error";
/** Machine-readable error envelope written as one JSON line on stderr. */
export interface ErrorEnvelope {
/** Stable identifier for the failure class (e.g. "auth_required"). */
code: string;
/** Human-readable message. */
message: string;
/** The underlying error's `cause` (e.g. undici's ENOTFOUND), if any. */
cause?: string;
/** HTTP status when the failure was an HTTP-level response. */
status?: number;
/** The server URL the failure was against, when known. */
url?: string;
}
/**
* Thrown by the CLI to request a specific non-zero exit code without routing
* through the generic error path. {@link formatErrorOutput} reads `exitCode`
* and `envelope`; the in-process test runner does the same so tests observe
* the real code and stderr.
*/
export class CliExitCodeError extends Error {
constructor(
public readonly exitCode: number,
message: string,
public readonly envelope?: Partial<ErrorEnvelope>,
) {
super(message);
this.name = "CliExitCodeError";
}
}
/** Depth cap for {@link causeOf} — bounds a pathological/self-referential
* `error.cause` chain so a cycle can never recurse infinitely. */
const MAX_CAUSE_DEPTH = 16;
/** Read an `Error.cause` chain into a single readable string, if present. */
function causeOf(error: unknown, depth = 0): string | undefined {
if (depth >= MAX_CAUSE_DEPTH) return undefined;
if (!(error instanceof Error)) return undefined;
const cause = (error as { cause?: unknown }).cause;
if (cause === undefined || cause === null) return undefined;
if (cause instanceof Error) {
const nested = causeOf(cause, depth + 1);
return nested ? `${cause.message}: ${nested}` : cause.message;
}
return String(cause);
}
/** Best-effort HTTP status from common error shapes (undici, MCP SDK, fetch). */
function statusOf(error: unknown): number | undefined {
if (error == null || typeof error !== "object") return undefined;
const e = error as {
status?: unknown;
code?: unknown;
response?: { status?: unknown };
};
if (typeof e.status === "number") return e.status;
if (typeof e.response?.status === "number") return e.response.status;
// SDK SseError / StreamableHTTPError expose `.code` as the HTTP status. Guard
// to the HTTP range (100-599) so a JSON-RPC McpError code (e.g. -32601
// MethodNotFound) is not mistaken for an HTTP status and leaked into the
// envelope or misclassified as AUTH_REQUIRED. String node codes like
// "ENOTFOUND" are already excluded by the numeric check.
if (typeof e.code === "number" && e.code >= 100 && e.code <= 599) {
return e.code;
}
return undefined;
}
const UNREACHABLE_PATTERN =
/ENOTFOUND|ECONNREFUSED|ECONNRESET|EAI_AGAIN|ETIMEDOUT|fetch failed|getaddrinfo|connect timed out|aborted/i;
/**
* Classify an arbitrary error into an exit code and envelope. Used both by the
* binary's {@link handleError} and by callers that want to throw a
* {@link CliExitCodeError} with the right code up front.
*/
export function classifyError(
error: unknown,
context?: { url?: string },
): { exitCode: number; envelope: ErrorEnvelope } {
const message =
error instanceof Error
? error.message
: typeof error === "string"
? error
: "Unknown error";
const cause = causeOf(error);
const status = statusOf(error);
const url = context?.url;
// A pre-classified error carries its own exit code; fill in any envelope
// fields the thrower didn't supply.
if (error instanceof CliExitCodeError) {
return {
exitCode: error.exitCode,
envelope: {
code: error.envelope?.code ?? codeForExit(error.exitCode),
message,
...(cause !== undefined && { cause }),
...(error.envelope?.status !== undefined && {
status: error.envelope.status,
}),
...((error.envelope?.url ?? url) !== undefined && {
url: error.envelope?.url ?? url,
}),
},
};
}
return message;
// 401 / OAuth-required → AUTH_REQUIRED so the caller can kick the auth flow.
if (
status === 401 ||
status === 403 ||
/WWW-Authenticate|Unauthorized|invalid_token|OAuth/i.test(
message + " " + (cause ?? ""),
)
) {
return {
exitCode: EXIT_CODES.AUTH_REQUIRED,
envelope: {
code: "auth_required",
message,
...(cause !== undefined && { cause }),
...(status !== undefined && { status }),
...(url !== undefined && { url }),
},
};
}
// Network-layer failure → UNREACHABLE so the caller can retry via a proxy.
if (UNREACHABLE_PATTERN.test(message + " " + (cause ?? ""))) {
return {
exitCode: EXIT_CODES.UNREACHABLE,
envelope: {
code: "unreachable",
message,
...(cause !== undefined && { cause }),
...(status !== undefined && { status }),
...(url !== undefined && { url }),
},
};
}
return {
exitCode: EXIT_CODES.USAGE,
envelope: {
code: "error",
message,
...(cause !== undefined && { cause }),
...(status !== undefined && { status }),
...(url !== undefined && { url }),
},
};
}
function codeForExit(exitCode: number): string {
switch (exitCode) {
case EXIT_CODES.NO_APP:
return "no_app";
case EXIT_CODES.AUTH_REQUIRED:
return "auth_required";
case EXIT_CODES.UNREACHABLE:
return "unreachable";
case EXIT_CODES.TOOL_ERROR:
return "tool_error";
default:
return "error";
}
}
/**
* Single source of truth for mapping a thrown error to the CLI's exit code and
* stderr text. The binary entry ({@link handleError}) and the in-process test
* runner both call this, so tests observe exactly what the binary would emit.
*
* stderr is one JSON line `{"error":{...}}` so a caller can `2>&1 | tail -1 |
* jq .error`; the message is inside the envelope so nothing is lost vs. the
* previous bare-message behaviour.
*/
export function formatErrorOutput(
error: unknown,
context?: { url?: string },
): { exitCode: number; stderr: string } {
const { exitCode, envelope } = classifyError(error, context);
return {
exitCode,
stderr: JSON.stringify({ error: envelope }) + "\n",
};
}
export function handleError(error: unknown): never {
const errorMessage = formatError(error);
console.error(errorMessage);
process.exit(1);
const { exitCode, stderr } = formatErrorOutput(error);
process.stderr.write(stderr);
process.exit(exitCode);
}

View file

@ -71,3 +71,7 @@ export default defineConfig([
},
])
```
## HTTP proxy support
The web backend connects to remote MCP servers through the shared Node transport (`core/mcp/node/transport.ts`), which honors the conventional proxy environment variables: `HTTPS_PROXY` / `HTTP_PROXY` (and their lowercase forms) select the proxy, and `NO_PROXY` exempts hosts. Routing is powered by [`undici`](https://www.npmjs.com/package/undici)'s `EnvHttpProxyAgent`, imported lazily only when a proxy variable is set, so runs without a proxy configured pay no cost. See the CLI README for more detail.

View file

@ -1605,6 +1605,75 @@ describe("App history pin/replay", () => {
});
});
// The `/oauth/callback` handler must reject a returned `state` that does not
// parse to the expected 64-char-hex authId shape (a forgery indicator) instead
// of silently proceeding. See #1562.
describe("App OAuth callback state validation", () => {
const originalUrl = window.location.href;
beforeEach(() => {
notificationsMock.show.mockClear();
window.sessionStorage.clear();
});
afterEach(() => {
window.history.replaceState({}, "", originalUrl);
});
it("rejects an unparseable state param with a clear error toast", async () => {
window.history.replaceState(
{},
"",
"/oauth/callback?code=abc123&state=not-a-valid-state",
);
renderWithMantine(<App />);
await waitFor(() =>
expect(notificationsMock.show).toHaveBeenCalledWith(
expect.objectContaining({
title: "OAuth callback rejected",
color: "red",
}),
),
);
});
it("does not reject when the state param parses to a valid authId", async () => {
// A well-formed 64-char-hex state passes the shape guard, so the handler
// proceeds to the server-matching step. With the resume snapshot pointing at
// a server id that is not registered, that step surfaces the "could not be
// matched" toast — asserting on that specific downstream toast proves the
// state was accepted (never the "OAuth callback rejected" toast) rather than
// relying on an indirect "some toast fired" check.
writeOAuthResumeSnapshot({
version: 1,
serverId: "server-that-does-not-exist",
activeTab: "Tools",
authKind: "reauth",
tabUi: {},
});
window.history.replaceState(
{},
"",
`/oauth/callback?code=abc123&state=${"a".repeat(64)}`,
);
renderWithMantine(<App />);
await waitFor(() =>
expect(notificationsMock.show).toHaveBeenCalledWith(
expect.objectContaining({
title: "OAuth callback could not be matched",
}),
),
);
expect(notificationsMock.show).not.toHaveBeenCalledWith(
expect.objectContaining({ title: "OAuth callback rejected" }),
);
});
});
describe("App OAuth resume lifecycle", () => {
const storage = new Map<string, string>();
@ -1744,7 +1813,6 @@ describe("App OAuth resume lifecycle", () => {
INSPECTOR_SERVERS_TAB,
),
);
expect(readOAuthResumeSnapshot()).toBeUndefined();
await user.click(screen.getByText("connect"));
await waitFor(() =>

View file

@ -2123,16 +2123,41 @@ function App() {
// session key the pre-redirect page saved the fetch log under, so the
// rebuilt client can restore those `auth` entries. Read it before the
// URL is cleared below.
//
// Defense-in-depth: a `state` that is present but does not parse to our
// expected 64-char-hex authId shape did not originate from
// `generateOAuthState`, so reject the callback instead of silently
// proceeding with an undefined sessionId. This is a shape check, not full
// state-matching — the primary CSRF protection remains PKCE
// (`code_verifier`); this layer catches malformed/forged `state` early.
//
// Intentional asymmetry: a present-but-malformed `state` (including the
// empty string `?state=`) is rejected, but a wholly absent `state`
// (`stateParam === null`) is *not* — it falls through with
// `sessionId = undefined` and is matched via the OAuth resume snapshot
// (`resumeSnapshot.serverId`) instead. Rejecting the null case would turn any provider error redirect
// that omits `state` into a misleading "rejected" toast, hiding the real
// OAuth error surfaced by the `!params.successful` branch below.
const stateParam = new URLSearchParams(window.location.search).get("state");
const sessionId = stateParam
? (parseOAuthState(stateParam)?.authId ?? undefined)
: undefined;
const parsedState = stateParam ? parseOAuthState(stateParam) : null;
const stateRejected = stateParam !== null && parsedState === null;
const sessionId = parsedState?.authId ?? undefined;
const resumeSnapshot = consumeOAuthResumeSnapshot();
// Strip the code/state off the URL immediately so a reload can't replay
// the (now single-use) authorization code through the exchange again.
window.history.replaceState({}, "", "/");
if (stateRejected) {
notifications.show({
title: "OAuth callback rejected",
message:
"OAuth callback carried an unrecognized state parameter that did not originate from this session. Please try connecting again.",
color: "red",
});
return;
}
const applyResumeUiOnce = (
snapshot: NonNullable<typeof resumeSnapshot>,
) => {

View file

@ -0,0 +1,208 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import type { McpUiDisplayMode } from "@modelcontextprotocol/ext-apps/app-bridge";
import {
currentStyles,
currentTheme,
measureContainerDimensions,
snapshotHostContext,
} from "./hostContext";
const COLOR_SCHEME_ATTR = "data-mantine-color-scheme";
/**
* Build a fake CSSStyleDeclaration whose `getPropertyValue` reads from a
* lookup table, so tests control exactly which CSS variables resolve (and to
* what) without depending on happy-dom's custom-property resolution.
*/
function fakeComputedStyle(
values: Record<string, string>,
): CSSStyleDeclaration {
return {
getPropertyValue: (name: string) => values[name] ?? "",
} as unknown as CSSStyleDeclaration;
}
function stubComputedStyle(values: Record<string, string>) {
vi.spyOn(window, "getComputedStyle").mockReturnValue(
fakeComputedStyle(values),
);
}
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
document.documentElement.removeAttribute(COLOR_SCHEME_ATTR);
});
describe("currentTheme", () => {
it("returns the resolved dark scheme written by Mantine", () => {
document.documentElement.setAttribute(COLOR_SCHEME_ATTR, "dark");
expect(currentTheme()).toBe("dark");
});
it("returns the resolved light scheme written by Mantine", () => {
document.documentElement.setAttribute(COLOR_SCHEME_ATTR, "light");
expect(currentTheme()).toBe("light");
});
it("ignores an unrecognized attribute value and falls back", () => {
document.documentElement.setAttribute(COLOR_SCHEME_ATTR, "sepia");
vi.stubGlobal("matchMedia", () => ({ matches: false }));
expect(currentTheme()).toBe("light");
});
it("falls back to matchMedia dark when the attribute is absent", () => {
document.documentElement.removeAttribute(COLOR_SCHEME_ATTR);
vi.stubGlobal("matchMedia", (query: string) => ({
matches: query.includes("dark"),
}));
expect(currentTheme()).toBe("dark");
});
it("falls back to light when matchMedia does not match dark", () => {
document.documentElement.removeAttribute(COLOR_SCHEME_ATTR);
vi.stubGlobal("matchMedia", () => ({ matches: false }));
expect(currentTheme()).toBe("light");
});
it("falls back to light when matchMedia is unavailable", () => {
document.documentElement.removeAttribute(COLOR_SCHEME_ATTR);
vi.stubGlobal("matchMedia", undefined);
expect(currentTheme()).toBe("light");
});
it("returns light in a non-DOM environment (no document, no window)", () => {
vi.stubGlobal("document", undefined);
vi.stubGlobal("window", undefined);
expect(currentTheme()).toBe("light");
});
it("consults matchMedia when document is absent but window is present", () => {
vi.stubGlobal("document", undefined);
vi.stubGlobal("matchMedia", (query: string) => ({
matches: query.includes("dark"),
}));
expect(currentTheme()).toBe("dark");
});
});
describe("currentStyles", () => {
it("maps resolved Mantine tokens to spec style variables", () => {
stubComputedStyle({
"--mantine-color-body": "#101113",
"--mantine-color-text": "#c9c9c9",
"--mantine-radius-md": "0.5rem",
});
const styles = currentStyles();
expect(styles).toEqual({
variables: {
"--color-background-primary": "#101113",
"--color-text-primary": "#c9c9c9",
"--border-radius-md": "0.5rem",
},
});
});
it("trims whitespace and omits variables that resolve to empty", () => {
stubComputedStyle({
"--mantine-color-body": " #ffffff ",
"--mantine-color-text": " ",
});
const styles = currentStyles();
expect(styles?.variables?.["--color-background-primary"]).toBe("#ffffff");
expect(styles?.variables?.["--color-text-primary"]).toBeUndefined();
});
it("returns undefined when nothing resolves", () => {
stubComputedStyle({});
expect(currentStyles()).toBeUndefined();
});
it("returns undefined in a non-DOM environment (no document)", () => {
vi.stubGlobal("document", undefined);
expect(currentStyles()).toBeUndefined();
});
it("returns undefined in a non-DOM environment (no window)", () => {
vi.stubGlobal("window", undefined);
expect(currentStyles()).toBeUndefined();
});
});
describe("measureContainerDimensions", () => {
it("returns whole-pixel width and height for a laid-out element", () => {
const el = document.createElement("div");
vi.spyOn(el, "getBoundingClientRect").mockReturnValue({
width: 320.6,
height: 199.4,
} as DOMRect);
expect(measureContainerDimensions(el)).toEqual({ width: 321, height: 199 });
});
it("returns undefined for a 0x0 (unattached) element", () => {
const el = document.createElement("div");
vi.spyOn(el, "getBoundingClientRect").mockReturnValue({
width: 0,
height: 0,
} as DOMRect);
expect(measureContainerDimensions(el)).toBeUndefined();
});
it("returns undefined when only one dimension is zero", () => {
const el = document.createElement("div");
vi.spyOn(el, "getBoundingClientRect").mockReturnValue({
width: 100,
height: 0,
} as DOMRect);
expect(measureContainerDimensions(el)).toBeUndefined();
});
it("returns undefined when getBoundingClientRect is unavailable", () => {
const el = {} as unknown as HTMLElement;
expect(measureContainerDimensions(el)).toBeUndefined();
});
});
describe("snapshotHostContext", () => {
const MODES: readonly McpUiDisplayMode[] = ["inline", "fullscreen"];
it("seeds theme, inline display mode, and a copied available-modes list", () => {
document.documentElement.setAttribute(COLOR_SCHEME_ATTR, "dark");
stubComputedStyle({});
const context = snapshotHostContext(null, MODES);
expect(context.theme).toBe("dark");
expect(context.displayMode).toBe("inline");
expect(context.availableDisplayModes).toEqual(["inline", "fullscreen"]);
// A copy, not the caller's array.
expect(context.availableDisplayModes).not.toBe(MODES);
expect(context.styles).toBeUndefined();
expect(context.containerDimensions).toBeUndefined();
});
it("includes styles and container dimensions when both resolve", () => {
document.documentElement.setAttribute(COLOR_SCHEME_ATTR, "light");
stubComputedStyle({ "--mantine-color-body": "#ffffff" });
const container = document.createElement("div");
vi.spyOn(container, "getBoundingClientRect").mockReturnValue({
width: 640,
height: 480,
} as DOMRect);
const context = snapshotHostContext(container, MODES);
expect(context.theme).toBe("light");
expect(context.styles).toEqual({
variables: { "--color-background-primary": "#ffffff" },
});
expect(context.containerDimensions).toEqual({ width: 640, height: 480 });
});
it("omits container dimensions when the container has no layout box", () => {
stubComputedStyle({});
const container = document.createElement("div");
vi.spyOn(container, "getBoundingClientRect").mockReturnValue({
width: 0,
height: 0,
} as DOMRect);
const context = snapshotHostContext(container, MODES);
expect(context.containerDimensions).toBeUndefined();
});
});

View file

@ -0,0 +1,156 @@
import type {
McpUiDisplayMode,
McpUiHostContext,
McpUiHostStyles,
McpUiStyles,
McpUiStyleVariableKey,
} from "@modelcontextprotocol/ext-apps/app-bridge";
/**
* Resolve the host theme from the DOM. Mantine writes the resolved color
* scheme to `<html data-mantine-color-scheme>`. Reading it here (rather than
* capturing React state) keeps the bridge factory's identity stable across
* theme toggles — the renderer treats a new factory identity as "rebuild the
* bridge", which would reload a running app's iframe on every theme flip.
*
* The attribute is only ever `"light"` or `"dark"` — Mantine resolves
* `defaultColorScheme="auto"` to the system value before paint and never
* writes `"auto"` here, so no `auto` branch is needed. The matchMedia
* fallback only covers the attribute being absent (e.g. a hydration race).
*/
export function currentTheme(): "light" | "dark" {
if (typeof document !== "undefined") {
const attr = document.documentElement.getAttribute(
"data-mantine-color-scheme",
);
if (attr === "dark" || attr === "light") return attr;
}
if (
typeof window !== "undefined" &&
window.matchMedia?.("(prefers-color-scheme: dark)").matches
) {
return "dark";
}
return "light";
}
/**
* Maps the spec's host-style variable keys ({@link McpUiStyleVariableKey}) to
* the inspector's underlying CSS custom properties. The inspector themes itself
* with Mantine, so each spec token resolves to the matching Mantine design-token
* variable (or an `--inspector-*` token layered on top of one). Only a curated
* subset of the ~90 spec keys is mapped — the ones the inspector has a
* meaningful equivalent for; the rest are omitted, which the spec allows (hosts
* may provide any subset).
*/
const STYLE_VARIABLE_SOURCES: Partial<Record<McpUiStyleVariableKey, string>> = {
"--color-background-primary": "--mantine-color-body",
"--color-background-secondary": "--inspector-surface-card",
"--color-background-tertiary": "--inspector-surface-subtle",
"--color-text-primary": "--mantine-color-text",
"--color-text-secondary": "--inspector-text-secondary",
"--color-text-inverse": "--inspector-text-inverse",
"--color-text-info": "--inspector-log-info",
"--color-text-danger": "--inspector-log-error",
"--color-text-success": "--inspector-status-connected",
"--color-text-warning": "--inspector-log-warning",
"--color-border-primary": "--inspector-border-default",
"--color-border-secondary": "--inspector-border-subtle",
"--font-sans": "--mantine-font-family",
"--font-mono": "--mantine-font-family-monospace",
"--font-text-xs-size": "--mantine-font-size-xs",
"--font-text-sm-size": "--mantine-font-size-sm",
"--font-text-md-size": "--mantine-font-size-md",
"--font-text-lg-size": "--mantine-font-size-lg",
"--border-radius-xs": "--mantine-radius-xs",
"--border-radius-sm": "--mantine-radius-sm",
"--border-radius-md": "--mantine-radius-md",
"--border-radius-lg": "--mantine-radius-lg",
"--border-radius-xl": "--mantine-radius-xl",
"--shadow-sm": "--mantine-shadow-sm",
"--shadow-md": "--mantine-shadow-md",
"--shadow-lg": "--mantine-shadow-lg",
};
const STYLE_VARIABLE_ENTRIES = Object.entries(STYLE_VARIABLE_SOURCES) as [
McpUiStyleVariableKey,
string,
][];
/**
* Resolve the inspector's design tokens into a {@link McpUiHostStyles} for
* hostContext, so style-aware apps can theme themselves from the host instead
* of falling back to their own defaults. Reads the computed value of each
* mapped CSS variable from the document root — which reflects the active
* Mantine color scheme — and keeps only the ones that resolve to a non-empty
* value. Returns undefined when nothing resolves (e.g. a non-DOM/test
* environment) so we never advertise an empty styles object.
*/
export function currentStyles(): McpUiHostStyles | undefined {
if (typeof document === "undefined" || typeof window === "undefined") {
return undefined;
}
const computed = window.getComputedStyle(document.documentElement);
const variables: McpUiStyles = {} as McpUiStyles;
let resolved = false;
for (const [specKey, sourceVar] of STYLE_VARIABLE_ENTRIES) {
const value = computed.getPropertyValue(sourceVar).trim();
if (value) {
variables[specKey] = value;
resolved = true;
}
}
return resolved ? { variables } : undefined;
}
/**
* Spec shape for `hostContext.containerDimensions`. Derived from
* {@link McpUiHostContext} so the seed and live-push paths share one source of
* truth and stay in lockstep with the spec types.
*/
export type ContainerDimensions = NonNullable<
McpUiHostContext["containerDimensions"]
>;
/**
* Measure the host container an app renders into and return its concrete
* `{ width, height }` (whole pixels). Returns undefined when the element has
* no layout box yet (0×0 — e.g. before the iframe is attached, or in a
* non-DOM/test environment) so a meaningless size is never seeded into
* hostContext. The return type is the concrete pair rather than the spec's
* {@link ContainerDimensions} union so callers can compare both fields.
*/
export function measureContainerDimensions(
element: HTMLElement,
): { width: number; height: number } | undefined {
if (typeof element.getBoundingClientRect !== "function") return undefined;
const rect = element.getBoundingClientRect();
const width = Math.round(rect.width);
const height = Math.round(rect.height);
if (width <= 0 || height <= 0) return undefined;
return { width, height };
}
/**
* Read the live host UI state into a {@link McpUiHostContext} for the bridge
* handshake — the single place that decides which fields the inspector seeds.
* Optional fields are omitted (not set undefined) so the SDK's diff stays
* accurate; subsequent live changes are pushed by the renderer's observers as
* partial `host-context-changed` notifications.
*/
export function snapshotHostContext(
container: HTMLElement | null,
availableDisplayModes: readonly McpUiDisplayMode[],
): McpUiHostContext {
const styles = currentStyles();
const containerDimensions = container
? measureContainerDimensions(container)
: undefined;
return {
theme: currentTheme(),
displayMode: "inline",
availableDisplayModes: [...availableDisplayModes],
...(styles ? { styles } : {}),
...(containerDimensions ? { containerDimensions } : {}),
};
}

View file

@ -280,6 +280,18 @@ describe("HistoryEntry", () => {
).toBeInTheDocument();
});
it("toggles the local expand/collapse state when clicking Expand/Collapse in the compact layout", async () => {
const user = userEvent.setup();
renderWithMantine(
<HistoryEntry {...baseProps} entry={successEntry} embedded />,
);
expect(screen.getByRole("button", { name: "Expand" })).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Expand" }));
expect(
screen.getByRole("button", { name: "Collapse" }),
).toBeInTheDocument();
});
it("starts expanded when isListExpanded is true and shows Parameters and Response", () => {
renderWithMantine(
<HistoryEntry

View file

@ -2,9 +2,25 @@ import { useState } from "react";
import { describe, it, expect, vi } from "vitest";
import userEvent from "@testing-library/user-event";
import type { Prompt } from "@modelcontextprotocol/sdk/types.js";
import { renderWithMantine, screen } from "../../../test/renderWithMantine";
import {
renderWithMantine,
screen,
waitFor,
fireEvent,
act,
} from "../../../test/renderWithMantine";
import { PromptArgumentsForm } from "./PromptArgumentsForm";
// Completion requests are debounced (COMPLETION_DEBOUNCE_MS = 300ms). Rather
// than sleeping past that window on the wall clock — which races the debounce
// timer under instrumented/concurrent load (#1596) — every completion test
// below uses `userEvent.setup({ delay: null })` (no per-keystroke real-timer
// scheduling) and waits on the *real* rendered outcome via `findBy`/`waitFor`.
// Those poll until the debounce fires and the response settles, so they
// resolve as soon as the work is done and never depend on machine speed. A
// scoped 3s timeout covers the debounce + async settle under heavy load.
const SETTLE = { timeout: 3000 } as const;
/**
* Wrapper that owns `argumentValues` state so completion tests can
* type multi-character input naturally — the production parent
@ -130,7 +146,7 @@ describe("PromptArgumentsForm", () => {
});
it("invokes onArgumentChange when typing in an argument input", async () => {
const user = userEvent.setup();
const user = userEvent.setup({ delay: null });
const onArgumentChange = vi.fn();
renderWithMantine(
<PromptArgumentsForm
@ -145,7 +161,7 @@ describe("PromptArgumentsForm", () => {
});
it("clears an argument via its Clear button (onArgumentChange with empty value)", async () => {
const user = userEvent.setup();
const user = userEvent.setup({ delay: null });
const onArgumentChange = vi.fn();
renderWithMantine(
<PromptArgumentsForm
@ -162,7 +178,7 @@ describe("PromptArgumentsForm", () => {
});
it("invokes onGetPrompt when Get Prompt is clicked", async () => {
const user = userEvent.setup();
const user = userEvent.setup({ delay: null });
const onGetPrompt = vi.fn();
renderWithMantine(
<PromptArgumentsForm
@ -178,7 +194,7 @@ describe("PromptArgumentsForm", () => {
});
it("disables Get Prompt until every required argument has a value", async () => {
const user = userEvent.setup();
const user = userEvent.setup({ delay: null });
renderWithMantine(
<StatefulForm prompt={promptWithArgs} onGetPrompt={vi.fn()} />,
);
@ -219,7 +235,7 @@ describe("PromptArgumentsForm", () => {
describe("completions", () => {
it("fires a completion immediately on focus before any keystroke", async () => {
const user = userEvent.setup();
const user = userEvent.setup({ delay: null });
const onCompleteArgument = vi
.fn<
(
@ -240,17 +256,16 @@ describe("PromptArgumentsForm", () => {
);
await user.click(screen.getByRole("textbox", { name: /^text/ }));
// No debounce on focus — the call fires synchronously off the
// focus handler. A microtask is enough for the response to settle.
await new Promise((r) => setTimeout(r, 0));
// No debounce on focus — the call fires synchronously off the focus
// handler; wait on the rendered option rather than a fixed sleep.
expect(await screen.findByText("alpha")).toBeInTheDocument();
expect(onCompleteArgument).toHaveBeenCalledWith("text", "", {
targetLanguage: "",
});
expect(await screen.findByText("alpha")).toBeInTheDocument();
});
it("calls onCompleteArgument (debounced) and surfaces values when supported", async () => {
const user = userEvent.setup();
const user = userEvent.setup({ delay: null });
const onCompleteArgument = vi
.fn<
(
@ -271,18 +286,19 @@ describe("PromptArgumentsForm", () => {
);
await user.type(screen.getByRole("textbox", { name: /^text/ }), "al");
await new Promise((r) => setTimeout(r, 400));
// The last call carries the typed value and the context for the
// other (still empty) sibling.
// Wait for the debounced response to render, then assert the last call
// carries the typed value and the (still empty) sibling context.
expect(
await screen.findByText("alpha", undefined, SETTLE),
).toBeInTheDocument();
expect(screen.getByText("alphabet")).toBeInTheDocument();
expect(onCompleteArgument).toHaveBeenLastCalledWith("text", "al", {
targetLanguage: "",
});
expect(await screen.findByText("alpha")).toBeInTheDocument();
expect(screen.getByText("alphabet")).toBeInTheDocument();
});
it("sends every sibling argument in context, including the unset ones", async () => {
const user = userEvent.setup();
const user = userEvent.setup({ delay: null });
const onCompleteArgument = vi
.fn<
(
@ -304,16 +320,20 @@ describe("PromptArgumentsForm", () => {
);
await user.type(screen.getByRole("textbox", { name: /^text/ }), "h");
await new Promise((r) => setTimeout(r, 400));
// The completing arg ("text") is excluded from context; every
// other declared argument rides along — even ones still empty.
expect(onCompleteArgument).toHaveBeenLastCalledWith("text", "h", {
targetLanguage: "es",
});
// The completing arg ("text") is excluded from context; every other
// declared argument rides along — even ones still empty. Poll until the
// debounced "text" call lands rather than sleeping past the window.
await waitFor(
() =>
expect(onCompleteArgument).toHaveBeenLastCalledWith("text", "h", {
targetLanguage: "es",
}),
SETTLE,
);
});
it("captures sibling values at fire time, not at schedule time", async () => {
const user = userEvent.setup();
const user = userEvent.setup({ delay: null });
const onCompleteArgument = vi
.fn<
(
@ -335,34 +355,31 @@ describe("PromptArgumentsForm", () => {
// Type into "text" — this schedules a debounced completion call.
await user.type(screen.getByRole("textbox", { name: /^text/ }), "h");
// Before the 300ms debounce fires, update the sibling. The
// text-arg fire that lands at t=300 must see the latest sibling
// value, not the empty one captured at schedule time.
// Before the 300ms debounce fires, update the sibling. Because typing is
// synchronous (delay: null), the sibling holds its value well before the
// debounce window elapses. The text-arg fire must read the sibling at
// *fire* time, not the empty value captured at schedule time.
await user.type(
screen.getByRole("textbox", { name: /targetLanguage/ }),
"es",
);
await new Promise((r) => setTimeout(r, 400));
// The most recent call for "text" carries the up-to-date sibling
// value, even though it was scheduled before the sibling was typed.
// (There's also a focus-fire call when the second input gained focus —
// separate stream, not asserted here.) The exact sibling string depends
// on where the 300ms debounce lands relative to the two real-timer
// keystrokes ("e" then "es"), so assert the load-bearing fact: the fire
// captured a NON-EMPTY sibling, i.e. read at fire time rather than the
// empty value present when the call was scheduled.
const textCalls = onCompleteArgument.mock.calls.filter(
([n]) => n === "text",
);
const lastTextCall = textCalls.at(-1);
expect(lastTextCall?.[0]).toBe("text");
expect(lastTextCall?.[1]).toBe("h");
expect(lastTextCall?.[2].targetLanguage).toMatch(/^es?$/);
// Poll until the debounced "text" call fires, then assert it captured a
// non-empty sibling (read at fire time, not the empty value present when
// the call was scheduled). With delay:null the sibling is fully typed
// ("es") before the debounce fires; the /^es?$/ regex stays tolerant.
await waitFor(() => {
const textCalls = onCompleteArgument.mock.calls.filter(
([n]) => n === "text",
);
const lastTextCall = textCalls.at(-1);
expect(lastTextCall?.[1]).toBe("h");
expect(lastTextCall?.[2].targetLanguage).toMatch(/^es?$/);
}, SETTLE);
});
it("clears stale dropdown options the instant a new keystroke arrives", async () => {
const user = userEvent.setup();
const user = userEvent.setup({ delay: null });
const deferred: Array<{
value: string;
resolve: (values: string[]) => void;
@ -386,22 +403,21 @@ describe("PromptArgumentsForm", () => {
// Focus → first call (value=""). Resolve so the dropdown has
// something to show.
await user.click(screen.getByRole("textbox", { name: /^text/ }));
await new Promise((r) => setTimeout(r, 0));
expect(deferred.length).toBe(1);
await waitFor(() => expect(deferred.length).toBe(1));
deferred[0].resolve(["alpha", "alphabet"]);
expect(await screen.findByText("alpha")).toBeInTheDocument();
// Type a new character — the keystroke handler must drop the
// stale options immediately so the dropdown doesn't show
// "alpha" / "alphabet" while the next request is in flight
// (300ms debounce + network latency).
// Type a new character — the keystroke handler must drop the stale
// options synchronously so the dropdown doesn't show "alpha" /
// "alphabet" while the next request is in flight (300ms debounce +
// network latency).
await user.type(screen.getByRole("textbox", { name: /^text/ }), "z");
expect(screen.queryByText("alpha")).not.toBeInTheDocument();
expect(screen.queryByText("alphabet")).not.toBeInTheDocument();
});
it("aborts an in-flight request when a faster keystroke arrives", async () => {
const user = userEvent.setup();
const user = userEvent.setup({ delay: null });
const calls: Array<{
value: string;
resolve: (values: string[]) => void;
@ -422,22 +438,25 @@ describe("PromptArgumentsForm", () => {
/>,
);
// Focus fires the first call (value=""). Type "h" → second call
// after debounce. Type "i" → third call after debounce.
// Focus fires the first call (value=""). Type "h" → second call after
// the debounce; wait for it to actually be in flight before typing more.
await user.type(screen.getByRole("textbox", { name: /^text/ }), "h");
await new Promise((r) => setTimeout(r, 350));
await waitFor(
() => expect(calls.some((c) => c.value === "h")).toBe(true),
SETTLE,
);
await user.type(screen.getByRole("textbox", { name: /^text/ }), "i");
await new Promise((r) => setTimeout(r, 350));
await waitFor(
() => expect(calls.some((c) => c.value === "hi")).toBe(true),
SETTLE,
);
// Resolve the late "h" response — it should be dropped because
// the form aborted that controller when the "hi" request started.
const hi = calls.find((c) => c.value === "hi");
const h = calls.find((c) => c.value === "h");
expect(hi).toBeDefined();
expect(h).toBeDefined();
h?.resolve(["from-stale-h"]);
hi?.resolve(["from-fresh-hi"]);
await new Promise((r) => setTimeout(r, 0));
// The dropdown shows the fresh response, not the stale one.
expect(await screen.findByText("from-fresh-hi")).toBeInTheDocument();
@ -445,7 +464,7 @@ describe("PromptArgumentsForm", () => {
});
it("surfaces an empty dropdown when the completion request rejects", async () => {
const user = userEvent.setup();
const user = userEvent.setup({ delay: null });
// The first (focus) call resolves with options; the debounced
// keystroke call rejects. The rejection (not aborted) must clear the
// dropdown to [] rather than leave the stale options showing.
@ -471,60 +490,88 @@ describe("PromptArgumentsForm", () => {
const input = screen.getByRole("textbox", { name: /^text/ });
await user.click(input);
await new Promise((r) => setTimeout(r, 0));
expect(await screen.findByText("alpha")).toBeInTheDocument();
// Type a char → debounced call rejects → options reset to [].
// Type a char → the stale options drop synchronously, and the debounced
// call then rejects, leaving the dropdown empty. Wait for the second
// (rejecting) call to fire so the catch → reset-to-[] path runs.
await user.type(input, "z");
await new Promise((r) => setTimeout(r, 400));
expect(screen.queryByText("alpha")).not.toBeInTheDocument();
expect(screen.queryByText("alphabet")).not.toBeInTheDocument();
await waitFor(
() => expect(onCompleteArgument).toHaveBeenCalledTimes(2),
SETTLE,
);
await waitFor(() => {
expect(screen.queryByText("alpha")).not.toBeInTheDocument();
expect(screen.queryByText("alphabet")).not.toBeInTheDocument();
});
});
it("cancels a pending debounce timer when the input is re-focused", async () => {
const user = userEvent.setup();
const onCompleteArgument = vi
.fn<
(
argName: string,
value: string,
context: Record<string, string>,
) => Promise<string[]>
>()
.mockResolvedValue([]);
// This is the one completion test that must let the 300ms debounce
// window fully elapse to be meaningful — asserting at t≈0 (as the
// sibling negative tests do) would pass whether or not handleFocus's
// clearTimeout actually cancels the pending timer. Fake timers make that
// window deterministic: we advance the clock directly. The interaction
// is driven with fireEvent rather than userEvent, because userEvent's
// async internals deadlock under vitest fake timers with this
// Mantine/happy-dom stack. This test is load-bearing: if the
// clearTimeout in handleFocus were removed, the debounce would fire
// during the advance and the final assertion would fail (verified).
vi.useFakeTimers();
try {
const onCompleteArgument = vi
.fn<
(
argName: string,
value: string,
context: Record<string, string>,
) => Promise<string[]>
>()
.mockResolvedValue([]);
renderWithMantine(
<StatefulForm
prompt={promptWithArgs}
onGetPrompt={vi.fn()}
completionsSupported
onCompleteArgument={onCompleteArgument}
/>,
);
renderWithMantine(
<StatefulForm
prompt={promptWithArgs}
onGetPrompt={vi.fn()}
completionsSupported
onCompleteArgument={onCompleteArgument}
/>,
);
const textInput = screen.getByRole("textbox", { name: /^text/ });
const siblingInput = screen.getByRole("textbox", {
name: /targetLanguage/,
});
const textInput = screen.getByRole("textbox", { name: /^text/ });
const siblingInput = screen.getByRole("textbox", {
name: /targetLanguage/,
});
// Type into text → schedules a debounce timer. Immediately move focus
// away and back before the 300ms fires, so handleFocus sees a pending
// timer for "text" and clears it.
await user.type(textInput, "h");
await user.click(siblingInput);
await user.click(textInput);
onCompleteArgument.mockClear();
// The cancelled debounce must not fire a stale keystroke completion.
await new Promise((r) => setTimeout(r, 400));
const textCalls = onCompleteArgument.mock.calls.filter(
([n]) => n === "text",
);
// Only the focus-fire call (value "h"), never the cancelled debounce.
expect(textCalls.every(([, v]) => v === "h")).toBe(true);
// Type into text → schedules the 300ms debounce timer for "text".
fireEvent.focus(textInput);
fireEvent.change(textInput, { target: { value: "h" } });
// Move focus to the sibling and back — the re-focus makes
// handleFocus("text") see the pending debounce timer and clear it.
fireEvent.focus(siblingInput);
fireEvent.focus(textInput);
// Drop the focus-fire calls made above; only a *debounce* fire after
// this point would indicate the cancellation failed.
onCompleteArgument.mockClear();
// Elapse well past the debounce window. The cancelled timer must not
// fire a stale keystroke completion.
await act(async () => {
await vi.advanceTimersByTimeAsync(400);
});
const textCalls = onCompleteArgument.mock.calls.filter(
([n]) => n === "text",
);
expect(textCalls.length).toBe(0);
} finally {
vi.useRealTimers();
}
});
it("does not call onCompleteArgument when completions are unsupported", async () => {
const user = userEvent.setup();
const user = userEvent.setup({ delay: null });
const onCompleteArgument = vi.fn();
renderWithMantine(
<StatefulForm
@ -534,10 +581,10 @@ describe("PromptArgumentsForm", () => {
onCompleteArgument={onCompleteArgument}
/>,
);
// Focus the input first, then type — neither should trigger a call.
// Focus the input first, then type — neither path is wired to
// completions, so no request is ever scheduled or fired.
await user.click(screen.getByPlaceholderText("Enter text..."));
await user.type(screen.getByPlaceholderText("Enter text..."), "ab");
await new Promise((r) => setTimeout(r, 400));
expect(onCompleteArgument).not.toHaveBeenCalled();
});
});

View file

@ -38,7 +38,7 @@ describe("ServerConfigModal", () => {
});
it("rejects an id containing illegal characters", async () => {
const user = userEvent.setup();
const user = userEvent.setup({ delay: null });
renderWithMantine(<ServerConfigModal {...base()} />);
await user.type(screen.getByLabelText(/Server ID/i), "bad id!");
expect(
@ -47,7 +47,7 @@ describe("ServerConfigModal", () => {
});
it("flags duplicate ids against existingIds", async () => {
const user = userEvent.setup();
const user = userEvent.setup({ delay: null });
renderWithMantine(
<ServerConfigModal {...base({ existingIds: ["alpha"] })} />,
);
@ -56,7 +56,7 @@ describe("ServerConfigModal", () => {
});
it("calls onSubmit with a valid stdio config", async () => {
const user = userEvent.setup();
const user = userEvent.setup({ delay: null });
const props = base();
renderWithMantine(<ServerConfigModal {...props} />);
@ -74,7 +74,7 @@ describe("ServerConfigModal", () => {
});
it("requires a command for stdio submission", async () => {
const user = userEvent.setup();
const user = userEvent.setup({ delay: null });
const props = base();
renderWithMantine(<ServerConfigModal {...props} />);
@ -86,7 +86,7 @@ describe("ServerConfigModal", () => {
});
it("rejects malformed env lines", async () => {
const user = userEvent.setup();
const user = userEvent.setup({ delay: null });
const props = base();
renderWithMantine(<ServerConfigModal {...props} />);
@ -118,7 +118,7 @@ describe("ServerConfigModal", () => {
});
it("submits an sse config with just the url (headers move to the settings form)", async () => {
const user = userEvent.setup();
const user = userEvent.setup({ delay: null });
const props = base();
renderWithMantine(
<ServerConfigModal
@ -140,7 +140,7 @@ describe("ServerConfigModal", () => {
});
it("submits a streamable-http config", async () => {
const user = userEvent.setup();
const user = userEvent.setup({ delay: null });
const props = base();
renderWithMantine(
<ServerConfigModal
@ -174,7 +174,7 @@ describe("ServerConfigModal", () => {
});
it("rejects an empty URL on sse submission", async () => {
const user = userEvent.setup();
const user = userEvent.setup({ delay: null });
const props = base();
renderWithMantine(
<ServerConfigModal
@ -234,7 +234,7 @@ describe("ServerConfigModal", () => {
});
it("shows the error message and stays open when onSubmit rejects", async () => {
const user = userEvent.setup();
const user = userEvent.setup({ delay: null });
const props = {
...base(),
onSubmit: vi.fn().mockRejectedValue(new Error("server full")),
@ -252,7 +252,7 @@ describe("ServerConfigModal", () => {
});
it("blocks submit and shows the id error message on bad-id submit click", async () => {
const user = userEvent.setup();
const user = userEvent.setup({ delay: null });
const props = base({ existingIds: ["alpha"] });
renderWithMantine(<ServerConfigModal {...props} />);
@ -265,7 +265,7 @@ describe("ServerConfigModal", () => {
});
it("supports editing the working directory field", async () => {
const user = userEvent.setup();
const user = userEvent.setup({ delay: null });
const props = base();
renderWithMantine(<ServerConfigModal {...props} />);
@ -283,7 +283,7 @@ describe("ServerConfigModal", () => {
});
it("clears the Server ID field via its Clear button", async () => {
const user = userEvent.setup();
const user = userEvent.setup({ delay: null });
renderWithMantine(
<ServerConfigModal
{...base()}
@ -299,7 +299,7 @@ describe("ServerConfigModal", () => {
});
it("clears the Command field via its Clear button", async () => {
const user = userEvent.setup();
const user = userEvent.setup({ delay: null });
renderWithMantine(
<ServerConfigModal
{...base()}
@ -315,7 +315,7 @@ describe("ServerConfigModal", () => {
});
it("clears the URL field via its Clear button", async () => {
const user = userEvent.setup();
const user = userEvent.setup({ delay: null });
renderWithMantine(
<ServerConfigModal
{...base()}
@ -331,7 +331,7 @@ describe("ServerConfigModal", () => {
});
it("clears the Arguments field via its Clear button", async () => {
const user = userEvent.setup();
const user = userEvent.setup({ delay: null });
renderWithMantine(
<ServerConfigModal
{...base()}
@ -351,7 +351,7 @@ describe("ServerConfigModal", () => {
});
it("clears the Environment field via its Clear button", async () => {
const user = userEvent.setup();
const user = userEvent.setup({ delay: null });
renderWithMantine(
<ServerConfigModal
{...base()}
@ -371,7 +371,7 @@ describe("ServerConfigModal", () => {
});
it("clears the Working directory field via its Clear button", async () => {
const user = userEvent.setup();
const user = userEvent.setup({ delay: null });
renderWithMantine(
<ServerConfigModal
{...base()}
@ -391,7 +391,7 @@ describe("ServerConfigModal", () => {
});
it("switches transport via the Transport select, revealing the URL field", async () => {
const user = userEvent.setup();
const user = userEvent.setup({ delay: null });
renderWithMantine(<ServerConfigModal {...base()} />);
// Starts on stdio — Command is shown, URL is not.
@ -411,7 +411,7 @@ describe("ServerConfigModal", () => {
});
it("requires a server id before submitting", async () => {
const user = userEvent.setup();
const user = userEvent.setup({ delay: null });
const props = base();
renderWithMantine(<ServerConfigModal {...props} />);
@ -427,7 +427,7 @@ describe("ServerConfigModal", () => {
});
it("rejects an env line whose '=' is at the start (no key)", async () => {
const user = userEvent.setup();
const user = userEvent.setup({ delay: null });
const props = base();
renderWithMantine(<ServerConfigModal {...props} />);
@ -442,7 +442,7 @@ describe("ServerConfigModal", () => {
});
it("calls onClose when Cancel is clicked", async () => {
const user = userEvent.setup();
const user = userEvent.setup({ delay: null });
const props = base();
renderWithMantine(<ServerConfigModal {...props} />);
await user.click(screen.getByRole("button", { name: /Cancel/ }));

View file

@ -94,14 +94,14 @@ describe("ServerImportConfigModal", () => {
});
it("enables Import once a client is selected", async () => {
const user = userEvent.setup();
const user = userEvent.setup({ delay: null });
setup();
await user.selectOptions(screen.getByLabelText("Client"), "Cursor");
expect(screen.getByRole("button", { name: "Import" })).toBeEnabled();
});
it("fetches a source and shows additions + conflicts in review", async () => {
const user = userEvent.setup();
const user = userEvent.setup({ delay: null });
const h = setup();
await pickClientAndImport(user, "Cursor");
await waitFor(() =>
@ -114,7 +114,7 @@ describe("ServerImportConfigModal", () => {
});
it("imports additions and skips conflicts by default", async () => {
const user = userEvent.setup();
const user = userEvent.setup({ delay: null });
const h = setup();
await pickClientAndImport(user, "Cursor");
await waitFor(() => screen.getByText("New servers (1)"));
@ -136,7 +136,7 @@ describe("ServerImportConfigModal", () => {
});
it("lets the user skip an individual new server", async () => {
const user = userEvent.setup();
const user = userEvent.setup({ delay: null });
setup();
await pickClientAndImport(user, "Cursor");
await waitFor(() => screen.getByText("New servers (1)"));
@ -150,7 +150,7 @@ describe("ServerImportConfigModal", () => {
});
it("skips a deselected new server while still importing others", async () => {
const user = userEvent.setup();
const user = userEvent.setup({ delay: null });
const h = setup();
await pickClientAndImport(user, "Cursor");
await waitFor(() => screen.getByText("New servers (1)"));
@ -171,7 +171,7 @@ describe("ServerImportConfigModal", () => {
});
it("overwrites a conflict when chosen", async () => {
const user = userEvent.setup();
const user = userEvent.setup({ delay: null });
const h = setup();
await pickClientAndImport(user, "Cursor");
await waitFor(() => screen.getByText("Already exists (1)"));
@ -185,7 +185,7 @@ describe("ServerImportConfigModal", () => {
});
it("renames a conflict to the supplied id", async () => {
const user = userEvent.setup();
const user = userEvent.setup({ delay: null });
const h = setup();
await pickClientAndImport(user, "Cursor");
await waitFor(() => screen.getByText("Already exists (1)"));
@ -204,7 +204,7 @@ describe("ServerImportConfigModal", () => {
});
it("flags an invalid/colliding rename target and blocks import", async () => {
const user = userEvent.setup();
const user = userEvent.setup({ delay: null });
setup();
await pickClientAndImport(user, "Cursor");
await waitFor(() => screen.getByText("Already exists (1)"));
@ -240,7 +240,7 @@ describe("ServerImportConfigModal", () => {
});
it("reports a per-server failure in the summary", async () => {
const user = userEvent.setup();
const user = userEvent.setup({ delay: null });
const h = setup();
h.onAddServer.mockRejectedValueOnce(new Error("boom"));
await pickClientAndImport(user, "Cursor");
@ -253,7 +253,7 @@ describe("ServerImportConfigModal", () => {
});
it("shows the searched-paths notice when no config is found", async () => {
const user = userEvent.setup();
const user = userEvent.setup({ delay: null });
setup({
type: "cursor",
found: false,
@ -266,7 +266,7 @@ describe("ServerImportConfigModal", () => {
});
it("shows an error when the source has a parse error", async () => {
const user = userEvent.setup();
const user = userEvent.setup({ delay: null });
setup({
type: "cursor",
found: true,
@ -280,7 +280,7 @@ describe("ServerImportConfigModal", () => {
});
it("shows an error when the fetch rejects", async () => {
const user = userEvent.setup();
const user = userEvent.setup({ delay: null });
setup(new Error("network down"));
await pickClientAndImport(user, "Cursor");
await waitFor(() =>
@ -289,7 +289,7 @@ describe("ServerImportConfigModal", () => {
});
it("parses an uploaded file (mcpServers) into review", async () => {
const user = userEvent.setup();
const user = userEvent.setup({ delay: null });
setup();
const file = new File(
[JSON.stringify({ mcpServers: { fromfile: { command: "x" } } })],
@ -306,7 +306,7 @@ describe("ServerImportConfigModal", () => {
});
it("parses an uploaded VS Code file (servers) into review", async () => {
const user = userEvent.setup();
const user = userEvent.setup({ delay: null });
setup();
const file = new File(
[JSON.stringify({ servers: { vscodesrv: { command: "x" } } })],
@ -323,7 +323,7 @@ describe("ServerImportConfigModal", () => {
});
it("shows an error for an unparseable uploaded file", async () => {
const user = userEvent.setup();
const user = userEvent.setup({ delay: null });
setup();
const file = new File(["{not json"], "mcp.json", {
type: "application/json",
@ -338,7 +338,7 @@ describe("ServerImportConfigModal", () => {
});
it("reports when the selected source has no servers", async () => {
const user = userEvent.setup();
const user = userEvent.setup({ delay: null });
setup({
type: "cursor",
found: true,
@ -352,7 +352,7 @@ describe("ServerImportConfigModal", () => {
});
it("reflects the chosen client as the dropdown value", async () => {
const user = userEvent.setup();
const user = userEvent.setup({ delay: null });
setup();
// Selecting a client makes the dropdown's value non-empty (the truthy
// branch of `vm.selectedType ?? ""`).
@ -361,7 +361,7 @@ describe("ServerImportConfigModal", () => {
});
it("resets the dropdown to no selection when the placeholder is re-chosen", async () => {
const user = userEvent.setup();
const user = userEvent.setup({ delay: null });
setup();
const dropdown = screen.getByLabelText("Client");
await user.selectOptions(dropdown, "Cursor");
@ -374,7 +374,7 @@ describe("ServerImportConfigModal", () => {
});
it("renders a conflicts-only review without a new-servers section", async () => {
const user = userEvent.setup();
const user = userEvent.setup({ delay: null });
// Every incoming server already exists → no additions, only conflicts (the
// false branch of `plan.additions.length > 0`).
setup(
@ -395,7 +395,7 @@ describe("ServerImportConfigModal", () => {
});
it("renders an additions-only review without a conflicts section", async () => {
const user = userEvent.setup();
const user = userEvent.setup({ delay: null });
// No existing ids → every incoming server is a brand-new addition, so the
// "Already exists" conflicts section never renders (the false branch of
// `plan.conflicts.length > 0`).
@ -415,7 +415,7 @@ describe("ServerImportConfigModal", () => {
});
it("goes back from review to the source picker", async () => {
const user = userEvent.setup();
const user = userEvent.setup({ delay: null });
setup();
await pickClientAndImport(user, "Cursor");
await waitFor(() => screen.getByText("New servers (1)"));
@ -426,7 +426,7 @@ describe("ServerImportConfigModal", () => {
});
it("closes from the summary via Done", async () => {
const user = userEvent.setup();
const user = userEvent.setup({ delay: null });
const h = setup({
type: "cursor",
found: true,

View file

@ -114,7 +114,7 @@ describe("ServerImportJsonModal", () => {
});
it("builds the config with env overrides and calls onAddServer", async () => {
const user = userEvent.setup();
const user = userEvent.setup({ delay: null });
const onAddServer = vi.fn().mockResolvedValue(undefined);
const onClose = vi.fn();
renderWithMantine(
@ -142,7 +142,7 @@ describe("ServerImportJsonModal", () => {
});
it("honors a server name override", async () => {
const user = userEvent.setup();
const user = userEvent.setup({ delay: null });
const onAddServer = vi.fn().mockResolvedValue(undefined);
renderWithMantine(
<ServerImportJsonModal
@ -163,7 +163,7 @@ describe("ServerImportJsonModal", () => {
});
it("lets the user pick among multiple packages", async () => {
const user = userEvent.setup();
const user = userEvent.setup({ delay: null });
const onAddServer = vi.fn().mockResolvedValue(undefined);
renderWithMantine(
<ServerImportJsonModal
@ -182,7 +182,7 @@ describe("ServerImportJsonModal", () => {
});
it("surfaces an onAddServer rejection instead of closing", async () => {
const user = userEvent.setup();
const user = userEvent.setup({ delay: null });
const onAddServer = vi.fn().mockRejectedValue(new Error("disk full"));
const onClose = vi.fn();
renderWithMantine(
@ -241,7 +241,7 @@ describe("ServerImportJsonModal", () => {
});
it("loads server.json from a chosen file", async () => {
const user = userEvent.setup();
const user = userEvent.setup({ delay: null });
renderWithMantine(
<ServerImportJsonModal
opened
@ -284,7 +284,7 @@ describe("ServerImportJsonModal", () => {
});
it("re-opens File Contents when the textarea is cleared", async () => {
const user = userEvent.setup();
const user = userEvent.setup({ delay: null });
renderWithMantine(
<ServerImportJsonModal
opened
@ -301,7 +301,7 @@ describe("ServerImportJsonModal", () => {
});
it("rejects an invalid id override and blocks Add Server", async () => {
const user = userEvent.setup();
const user = userEvent.setup({ delay: null });
const onAddServer = vi.fn();
renderWithMantine(
<ServerImportJsonModal
@ -322,7 +322,7 @@ describe("ServerImportJsonModal", () => {
});
it("closes via the Escape key (no Cancel button)", async () => {
const user = userEvent.setup();
const user = userEvent.setup({ delay: null });
const onClose = vi.fn();
renderWithMantine(
<ServerImportJsonModal

View file

@ -77,7 +77,7 @@ describe("ResourcesScreen", () => {
});
it("shows the read error alert when error and a resource is selected", async () => {
const user = userEvent.setup();
const user = userEvent.setup({ delay: null });
renderWithMantine(
<ControlledResourcesScreen
readState={{ status: "error", error: "boom" }}
@ -89,7 +89,7 @@ describe("ResourcesScreen", () => {
});
it("falls back to default error when error message is missing", async () => {
const user = userEvent.setup();
const user = userEvent.setup({ delay: null });
renderWithMantine(
<ControlledResourcesScreen readState={{ status: "error" }} />,
);
@ -98,7 +98,7 @@ describe("ResourcesScreen", () => {
});
it("shows the loading state when reading", async () => {
const user = userEvent.setup();
const user = userEvent.setup({ delay: null });
renderWithMantine(
<ControlledResourcesScreen readState={{ status: "pending" }} />,
);
@ -107,7 +107,7 @@ describe("ResourcesScreen", () => {
});
it("renders the preview panel when readState has a result", async () => {
const user = userEvent.setup();
const user = userEvent.setup({ delay: null });
renderWithMantine(
<ControlledResourcesScreen
readState={{
@ -139,7 +139,7 @@ describe("ResourcesScreen", () => {
});
it("toggles a section's open state through the lifted openSections handler", async () => {
const user = userEvent.setup();
const user = userEvent.setup({ delay: null });
renderWithMantine(<ControlledResourcesScreen />);
const urisHeader = screen.getByRole("button", { name: /URIs \(2\)/ });
// Open by default (compact=false); clicking routes through ResourcesScreen's
@ -150,7 +150,7 @@ describe("ResourcesScreen", () => {
});
it("renders the template panel when a template is selected", async () => {
const user = userEvent.setup();
const user = userEvent.setup({ delay: null });
renderWithMantine(<ControlledResourcesScreen />);
// Templates section is open by default; select the template to open its form.
await user.click(screen.getByText("files"));
@ -160,7 +160,7 @@ describe("ResourcesScreen", () => {
});
it("hides the template panel once the user reads the resource", async () => {
const user = userEvent.setup();
const user = userEvent.setup({ delay: null });
const onReadResource = vi.fn();
renderWithMantine(
<ControlledResourcesScreen onReadResource={onReadResource} />,
@ -176,7 +176,7 @@ describe("ResourcesScreen", () => {
});
it("auto-reads when a resource is clicked in the sidebar", async () => {
const user = userEvent.setup();
const user = userEvent.setup({ delay: null });
const onReadResource = vi.fn();
renderWithMantine(
<ControlledResourcesScreen onReadResource={onReadResource} />,
@ -186,7 +186,7 @@ describe("ResourcesScreen", () => {
});
it("forwards refresh and subscribe events from the preview panel", async () => {
const user = userEvent.setup();
const user = userEvent.setup({ delay: null });
const onReadResource = vi.fn();
const onSubscribeResource = vi.fn();
renderWithMantine(
@ -209,7 +209,7 @@ describe("ResourcesScreen", () => {
});
it("closing the preview returns to the originating template form", async () => {
const user = userEvent.setup();
const user = userEvent.setup({ delay: null });
const onReadResource = vi.fn();
const templates: ResourceTemplate[] = [
{ uriTemplate: "file:///{path}", name: "files" },
@ -251,7 +251,7 @@ describe("ResourcesScreen", () => {
});
it("closing the preview from the error state returns to the template form", async () => {
const user = userEvent.setup();
const user = userEvent.setup({ delay: null });
const templates: ResourceTemplate[] = [
{ uriTemplate: "demo://resource/dynamic/text/{id}", name: "Dynamic" },
];
@ -282,7 +282,7 @@ describe("ResourcesScreen", () => {
});
it("closing the preview for a plain resource returns to the empty state", async () => {
const user = userEvent.setup();
const user = userEvent.setup({ delay: null });
renderWithMantine(
<ControlledResourcesScreen
readState={{
@ -300,7 +300,7 @@ describe("ResourcesScreen", () => {
});
it("invokes onUnsubscribeResource when already subscribed", async () => {
const user = userEvent.setup();
const user = userEvent.setup({ delay: null });
const onUnsubscribeResource = vi.fn();
renderWithMantine(
<ControlledResourcesScreen
@ -319,7 +319,7 @@ describe("ResourcesScreen", () => {
});
it("routes sidebar search text through onUiChange", async () => {
const user = userEvent.setup();
const user = userEvent.setup({ delay: null });
const onUiChange = vi.fn();
renderWithMantine(<ControlledResourcesScreen onUiChange={onUiChange} />);
await user.type(screen.getByPlaceholderText("Search..."), "y.txt");
@ -329,7 +329,7 @@ describe("ResourcesScreen", () => {
});
it("renders nothing in the preview when an ok state carries no result", async () => {
const user = userEvent.setup();
const user = userEvent.setup({ delay: null });
renderWithMantine(
<ControlledResourcesScreen
// readResource is truthy (selected resource exists) but the ok
@ -348,7 +348,7 @@ describe("ResourcesScreen", () => {
});
it("threads onCompleteArgument with a ref/resource envelope from the template form", async () => {
const user = userEvent.setup();
const user = userEvent.setup({ delay: null });
const onCompleteArgument = vi
.fn<
(
@ -379,7 +379,7 @@ describe("ResourcesScreen", () => {
});
it("hides the Subscriptions section and Subscribe button when subscriptionsSupported is false", async () => {
const user = userEvent.setup();
const user = userEvent.setup({ delay: null });
renderWithMantine(
<ControlledResourcesScreen
subscriptionsSupported={false}

View file

@ -243,7 +243,7 @@ describe("InspectorView", () => {
it("dispatches onToggleConnection with the server id when the card toggle is clicked", async () => {
const onToggleConnection = vi.fn();
const user = userEvent.setup();
const user = userEvent.setup({ delay: null });
renderWithMantine(
<StatefulInspectorViewHost
{...makeProps({ servers: [sampleServer], onToggleConnection })}
@ -339,7 +339,7 @@ describe("InspectorView", () => {
})}
/>,
);
const user = userEvent.setup();
const user = userEvent.setup({ delay: null });
const tabSelect = await screen.findByDisplayValue("Servers");
await user.click(tabSelect);
await user.click(await screen.findByText("Tools"));
@ -532,7 +532,7 @@ describe("InspectorView", () => {
});
it("filters tools to apps and auto-launches a no-fields app on the Apps tab", async () => {
const user = userEvent.setup();
const user = userEvent.setup({ delay: null });
// Plain (non-app) tool plus a tool with a malformed UI resource URI
// exercise both branches of the appTools filter: the non-app drop and
// the try/catch around `isAppTool` for malformed metadata.
@ -648,7 +648,7 @@ describe("InspectorView", () => {
});
it("snaps activeTab back to Servers when the Apps tab disappears after a refresh", async () => {
const user = userEvent.setup();
const user = userEvent.setup({ delay: null });
const { rerender } = renderWithMantine(
<StatefulInspectorViewHost
{...makeProps({
@ -836,7 +836,7 @@ describe("InspectorView", () => {
it("dispatches onSetLogLevel through to the Logs screen", async () => {
const onSetLogLevel = vi.fn();
const user = userEvent.setup();
const user = userEvent.setup({ delay: null });
renderWithMantine(
<StatefulInspectorViewHost
{...makeProps({
@ -867,7 +867,7 @@ describe("InspectorView", () => {
});
it("persists Logs sort direction to localStorage and restores it on remount", async () => {
const user = userEvent.setup();
const user = userEvent.setup({ delay: null });
const { unmount } = renderWithMantine(
<StatefulInspectorViewHost
{...makeProps({
@ -918,7 +918,7 @@ describe("InspectorView", () => {
});
it("falls back to newest-first when a corrupted sort value is stored", async () => {
const user = userEvent.setup();
const user = userEvent.setup({ delay: null });
window.localStorage.setItem("inspector.sortDirection.history", "garbage");
renderWithMantine(
<StatefulInspectorViewHost
@ -941,7 +941,7 @@ describe("InspectorView", () => {
});
it("persists History list compact state to localStorage and restores it on remount", async () => {
const user = userEvent.setup();
const user = userEvent.setup({ delay: null });
const historyEntry = {
id: "req-1",
timestamp: new Date("2026-03-17T10:00:00Z"),
@ -999,7 +999,7 @@ describe("InspectorView", () => {
});
it("falls back to collapsed when a corrupted compact value is stored", async () => {
const user = userEvent.setup();
const user = userEvent.setup({ delay: null });
window.localStorage.setItem("inspector.listCompact.history", "garbage");
const historyEntry = {
id: "req-1",
@ -1102,7 +1102,7 @@ describe("InspectorView", () => {
});
it("toggles the Servers list compact state from the list toggle", async () => {
const user = userEvent.setup();
const user = userEvent.setup({ delay: null });
renderWithMantine(
<StatefulInspectorViewHost {...makeProps({ servers: [sampleServer] })} />,
);
@ -1116,7 +1116,7 @@ describe("InspectorView", () => {
});
it("toggles the Network list compact state from the list toggle", async () => {
const user = userEvent.setup();
const user = userEvent.setup({ delay: null });
const httpServer: ServerEntry = {
id: "beta",
name: "Beta",
@ -1185,7 +1185,7 @@ describe("InspectorView", () => {
// The indicator only mounts on the active screen, so each case connects,
// navigates to the target tab, and asserts the "List updated" affordance.
async function gotoTab(tab: string) {
const user = userEvent.setup();
const user = userEvent.setup({ delay: null });
const tabSelect = await screen.findByDisplayValue("Servers");
await user.click(tabSelect);
await user.click(await screen.findByText(tab));

Some files were not shown because too many files have changed in this diff Show more