Merge remote-tracking branch 'origin/v2/main' into merge-1537

This commit is contained in:
cliffhall 2026-07-01 20:57:34 -04:00
commit 3df4f0e7e2
154 changed files with 13345 additions and 3915 deletions

1
.gitignore vendored
View file

@ -18,3 +18,4 @@ test-servers/build
/*.png
/inspector-history-*.json
/*.server.json
/configs/

View file

@ -67,11 +67,10 @@ After installing, `npm run build` builds all clients. The launcher scripts (`npm
## Repository & Project Board
- **Repo**: https://github.com/modelcontextprotocol/inspector.git
- **Base Branches**: v2/main, v1.5/main, main
- **Base Branches**: v2/main (active), main (v1). v1.5/main is merged into v2/main and no longer takes new work.
- **Project Boards**:
- v2 - https://github.com/orgs/modelcontextprotocol/projects/28
- v1.5 - https://github.com/orgs/modelcontextprotocol/projects/39
- v1 - https://github.com/orgs/modelcontextprotocol/projects/11
- v2 - https://github.com/orgs/modelcontextprotocol/projects/28 (active board — all current work goes here)
- v1 - https://github.com/orgs/modelcontextprotocol/projects/11 (existing inspector version, no new activity except security and bug fixes)
## Contributing
@ -83,11 +82,11 @@ diff. See [`CONTRIBUTORS.md`](./CONTRIBUTORS.md) for the full policy.
## Project Status and Direction
* The main branch currently contains the legacy version of the Inspector, which we are accepting bug fixes and minor improvement PRs for.
* The v1.5/main branch contains an intermediate version of the Inspector, where the shared logic between the three incarnations of the Inspector are extracted into a core subsystem with InspectorClient class as the common entry point. It also includes the TUI, a refactored CLI, and streamlined launcher.
* The v1.5/main branch was the intermediate version of the Inspector, where the shared logic between the three incarnations of the Inspector was extracted into a core subsystem with InspectorClient class as the common entry point. It also included the TUI, a refactored CLI, and streamlined launcher. The branch still exists but is **frozen** — it takes no new work. It is kept as a reference point (e.g. for tracking down a regression introduced by the merge into v2/main), so do not delete it.
* The v2/main branch currently contains the new version of the web Inspector, composed of "dumb" components which accept data and callbacks as props and contain only display logic.
* The v2/main branch currently contains the new version of the web Inspector, composed of "dumb" components which accept data and callbacks as props and contain only display logic.
* The InspectorClient from v1.5/main will be merged into v2/main, and wired up to the new web Inspector. The TUI and CLI will follow. Eventually when everything works on v2/main we will replace main with v2/main, eliminating the legacy implementations.
The Launcher, TUI, CLI, and InspectorClient from v1.5/main have been merged into v2/main. InspectorClient is wired up to the new web Inspector. Eventually, we will replace main with v2/main, eliminating the legacy implementations.
## Web backend auth token
@ -111,20 +110,67 @@ Injection is a no-op when auth is disabled (`DANGEROUSLY_OMIT_AUTH`), and the gl
All work should be driven by items on the project board.
> **A v2 issue is not "created" until it is BOTH labeled `v2` AND on board #28 with a Status set.** Labeling alone is not enough — a label is a repo tag; the board is a separate org project. Applying `--label v2` does **not** add the item to the board, and adding it to the board does **not** set a Status. All three are distinct steps; do all three (see the recipes below). **Only issues go on the board — never PRs.** A PR still gets the `v2` label, but it is tracked through its linked issue's card (via `Closes #N`), not its own board item.
- Before starting work, check the board for the relevant item.
- **Draft items vs. issues**: Board items may be draft items (no issue number) or full GitHub issues. Before creating a new issue, always check if a matching draft item already exists on the board. If it does, convert it to an issue using `gh project item-edit` or create the issue and link it — **never create a duplicate**.
- **Every board item is a real GitHub issue.** Do not create draft items (board cards with no issue number). If you find work that needs tracking, create an actual issue and add that to the board. Before creating a new issue, check the board for a matching item to avoid duplicates — **never create a duplicate**.
- **Assign the issue to its creator.** When you create an issue, assign it to the user it is created on behalf of (`gh issue create --assignee @me ...`, or `--assignee <login>`). Board items should never be unassigned.
- **Label by version.** New issues and PRs must carry the label matching the target board / branch:
- `main` → `v1`
- `v1.5/main` → `v1.5`
- `v2/main` → `v2`
Set the label at create time (`gh issue create --label v2 ...`, `gh pr create --label v2 ...`) — don't rely on backfilling later, since unlabeled PRs are easy to miss when filtering by version.
- When work begins, create a feature branch and move the item to "In Progress".
- **Add the issue to the board and set Status.** After creating an issue, add it to board #28 and set its Status. (PRs are never added to the board — they're tracked through their linked issue's card.) This is the step most easily forgotten because it needs several IDs — copy the recipes below verbatim.
- When work begins, create a feature branch and set the item's Status to **In progress** (or one of the building statuses below).
- When work is complete:
- Run format, lint, typecheck, build, and test — ensure all checks pass
- Open a PR against the matching base branch (`main` for v1, `v1.5/main` for v1.5, `v2/main` for v2) and move the item to "In Review"
- Open a PR against the matching base branch (`main` for v1, `v2/main` for v2) and set the item's Status to **In review**
- **Link the PR to its issue.** The PR body's **first line must be `Closes #<ISSUE_NUMBER>`**. ⚠️ Note: closing keywords only auto-link/auto-close for PRs targeting the repo's **default branch** (`main`). Because v2 PRs target `v2/main` (a non-default branch), `Closes #N` there is only a cross-reference — it will **not** create a hard link or close the issue on merge. (There is no `gh` flag for manual linking — `gh pr edit` has no `--add-issue`; closing keywords are the only mechanism GitHub exposes, and they're gated to the default branch.)
- **On merge of a v2 PR, manually close its issue and move the board item to Done** (option id `1bbc5632`), since auto-close won't fire on `v2/main`. Keep the `Closes #N` line anyway so the issues close automatically if/when `v2/main` is eventually merged to `main`.
- If new tasks are discovered or requested during development, create issues and add them to the board.
#### V2 board (#28) `gh` recipes
The board is an **org project**, so all commands use `--owner modelcontextprotocol` and the numeric project `28`. The IDs below are stable; if a command rejects one, re-fetch with `gh project field-list 28 --owner modelcontextprotocol --format json`.
| Thing | ID |
| --- | --- |
| Project node ID | `PVT_kwDOCt2Azc4BJVxt` |
| Status field ID | `PVTSSF_lADOCt2Azc4BJVxtzg5iI8c` |
Status option IDs (`--single-select-option-id`):
| Status | Option ID |
| --- | --- |
| Backlog | `6080ca99` |
| Building CLI / TUI / CORE | `fe170c62` |
| Building Web | `4faeae7a` |
| MCP Apps Extension | `588c6a63` |
| In progress | `d43284fe` |
| In review | `fb2103f2` |
| Done | `1bbc5632` |
Use **In progress** for general work, one of the **Building** statuses (or **MCP Apps Extension**) while actively coding that surface, **In review** once a PR is open, and **Done** on merge.
```sh
# 1. Add an issue to the board — prints the item id (PVTI_…); capture it.
gh project item-add 28 --owner modelcontextprotocol --url <issue-url> --format json
# 2. Set its Status (here: In progress). Use the option id from the table above.
gh project item-edit \
--project-id PVT_kwDOCt2Azc4BJVxt \
--id <item-id-from-step-1> \
--field-id PVTSSF_lADOCt2Azc4BJVxtzg5iI8c \
--single-select-option-id d43284fe
```
The one-liner that does both, capturing the item id (use the option id for the status you want):
```sh
ITEM_ID=$(gh project item-add 28 --owner modelcontextprotocol --url <issue-url> --format json --jq '.id')
gh project item-edit --project-id PVT_kwDOCt2Azc4BJVxt --id "$ITEM_ID" --field-id PVTSSF_lADOCt2Azc4BJVxtzg5iI8c --single-select-option-id d43284fe
```
### Always test new or modified code
- Ensure all code has corresponding tests
- Ensure test coverage for each file is at least 90%
@ -138,8 +184,8 @@ All work should be driven by items on the project board.
- 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 v1.5-ported 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.
- Test files live alongside the source as `<Name>.test.tsx` (or `.test.ts` for non-React modules). v1.5-ported 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`.
- 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.
- 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
### Responding to Code Reviews

View file

@ -100,6 +100,49 @@ Options that specify the MCP server (catalog/config file, ad-hoc command/URL, en
| `--metadata <key=value>` | General metadata (key=value); applied to all methods. |
| `--tool-metadata <key=value>` | Tool-specific metadata for `tools/call`. |
### CLI-specific (OAuth for HTTP servers)
The CLI **reuses** OAuth tokens from `~/.mcp-inspector/storage/oauth.json` (same file as the TUI). Complete first-time authorization in the **web** or **TUI** client, then run one-shot CLI commands against HTTP/SSE servers without signing in again.
The CLI does **not** start a local callback server or retry connect on 401. If tokens are missing or expired, connect fails; `ConsoleNavigation` may print an authorize URL to stdout, but the CLI cannot finish the redirect flow. Use the TUI for interactive runner OAuth until Phase 4 adds a CLI callback server.
**Shared with TUI** (config only, not interactive login):
- Per-server OAuth fields from `mcp.json` (static client, EMA resource credentials, scopes)
- Install-level settings from **`~/.mcp-inspector/storage/client.json`** (or `--client-config` / `MCP_CLIENT_CONFIG_PATH`) — EMA IdP, CIMD
- CLI flags `--client-id`, `--client-secret`, `--client-metadata-url` override `client.json` when set
- Keychain-backed secrets in `mcp.json` are rehydrated on catalog load (same as TUI)
#### OAuth callback URL
| Surface | Default callback |
| ------- | ---------------- |
| **Web** | `http://localhost:6274/oauth/callback` |
| **TUI** | `http://127.0.0.1:6276/oauth/callback` (interactive — callback server) |
| **CLI** | `http://127.0.0.1:6276/oauth/callback` (redirect URI in OAuth metadata only; no listener) |
Register `http://127.0.0.1:6276/oauth/callback` on static or enterprise IdPs that require pre-registered redirect URIs before using the **TUI** (or when your OAuth app expects that URI). Override with `--callback-url` or `MCP_OAUTH_CALLBACK_URL`. The CLI passes this value as `redirect_uri` when an OAuth flow runs, but does not listen on the port.
#### 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`). |
**Example** — list tools on an OAuth-protected server using stored tokens and CIMD from the command line:
```bash
npx @modelcontextprotocol/inspector --cli --catalog mcp.json --server my-http-server \
--client-metadata-url https://example.com/.well-known/oauth/client-metadata.json \
--method tools/list
```
See [EMA / enterprise-managed auth](../../specification/v2_auth_ema.md) and [OAuth smoke testing](../../specification/v2_auth_smoke_testing.md) for configuration details and staging servers.
## Why use the CLI?
While the Web Client provides a rich visual interface, the CLI is designed for:

View file

@ -88,3 +88,22 @@ export function createInvalidConfig(): string {
export function deleteConfigFile(configPath: string): void {
cleanupTempDir(path.dirname(configPath));
}
/**
* Create a temporary install-level client.json for --client-config tests.
*/
export function createClientConfigFile(
config: Record<string, unknown>,
): string {
const tempDir = createTempDir("mcp-inspector-client-config-");
const configPath = path.join(tempDir, "client.json");
fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
return configPath;
}
/**
* Delete a client config file and its containing directory
*/
export function deleteClientConfigFile(configPath: string): void {
cleanupTempDir(path.dirname(configPath));
}

View file

@ -0,0 +1,188 @@
import { describe, it, expect, vi, afterEach } from "vitest";
import * as runner from "@inspector/core/client/runner.js";
import {
createTestServerHttp,
createEchoTool,
createTestServerInfo,
} from "@modelcontextprotocol/inspector-test-server";
import { runCli } from "./helpers/cli-runner.js";
import {
expectCliFailure,
expectCliSuccess,
expectOutputContains,
} from "./helpers/assertions.js";
import {
createClientConfigFile,
deleteClientConfigFile,
} from "./helpers/fixtures.js";
/**
* Covers OAuth runner flag wiring in `src/cli.ts` (#1514): --client-config,
* --client-id/--client-secret/--client-metadata-url, and --callback-url /
* MCP_OAUTH_CALLBACK_URL. Shared auth logic is unit-tested in
* clients/web/src/test/core/client/runner.test.ts; these assert the CLI
* parses flags and passes them through on HTTP (OAuth-capable) connects.
*/
describe("CLI OAuth runner flags", () => {
afterEach(() => {
vi.restoreAllMocks();
});
it("connects over HTTP with OAuth CLI overrides and custom callback URL", async () => {
const server = createTestServerHttp({
serverInfo: createTestServerInfo(),
tools: [createEchoTool()],
});
const buildSpy = vi.spyOn(runner, "buildRunnerClientAuthOptions");
try {
await server.start();
const result = await runCli([
server.url,
"--cli",
"--method",
"tools/list",
"--transport",
"http",
"--client-id",
"test-client-id",
"--client-secret",
"test-client-secret",
"--client-metadata-url",
"https://example.com/oauth/client-metadata.json",
"--callback-url",
"http://127.0.0.1:9999/oauth/callback",
]);
expectCliSuccess(result);
expect(buildSpy).toHaveBeenCalled();
expect(buildSpy.mock.calls[0]?.[2]).toEqual({
clientId: "test-client-id",
clientSecret: "test-client-secret",
clientMetadataUrl: "https://example.com/oauth/client-metadata.json",
});
} finally {
await server.stop();
}
});
it("loads install client.json from --client-config and prefers CLI CIMD override", async () => {
const server = createTestServerHttp({
serverInfo: createTestServerInfo(),
tools: [createEchoTool()],
});
const buildSpy = vi.spyOn(runner, "buildRunnerClientAuthOptions");
const clientConfigPath = createClientConfigFile({
cimd: {
enabled: true,
clientMetadataUrl: "https://example.com/from-client-json.json",
},
});
try {
await server.start();
const result = await runCli([
server.url,
"--cli",
"--method",
"tools/list",
"--transport",
"http",
"--client-config",
clientConfigPath,
"--client-metadata-url",
"https://example.com/from-cli-flag.json",
]);
expectCliSuccess(result);
expect(buildSpy).toHaveBeenCalled();
expect(buildSpy.mock.calls[0]?.[0]).toMatchObject({
cimd: {
enabled: true,
clientMetadataUrl: "https://example.com/from-client-json.json",
},
});
expect(buildSpy.mock.calls[0]?.[2]).toEqual({
clientMetadataUrl: "https://example.com/from-cli-flag.json",
});
} finally {
await server.stop();
deleteClientConfigFile(clientConfigPath);
}
});
it("accepts port 0 callback URL for ephemeral listener binding", async () => {
const server = createTestServerHttp({
serverInfo: createTestServerInfo(),
tools: [createEchoTool()],
});
try {
await server.start();
const result = await runCli([
server.url,
"--cli",
"--method",
"tools/list",
"--transport",
"http",
"--callback-url",
"http://127.0.0.1:0/oauth/callback",
]);
expectCliSuccess(result);
} finally {
await server.stop();
}
});
it("uses MCP_OAUTH_CALLBACK_URL when --callback-url is absent", async () => {
const server = createTestServerHttp({
serverInfo: createTestServerInfo(),
tools: [createEchoTool()],
});
try {
await server.start();
const result = await runCli(
[server.url, "--cli", "--method", "tools/list", "--transport", "http"],
{
env: {
MCP_OAUTH_CALLBACK_URL:
"http://127.0.0.1:8888/custom/oauth/callback",
},
},
);
expectCliSuccess(result);
} finally {
await server.stop();
}
});
it("rejects an invalid OAuth callback URL before connecting", async () => {
const server = createTestServerHttp({
serverInfo: createTestServerInfo(),
tools: [createEchoTool()],
});
try {
await server.start();
const result = await runCli([
server.url,
"--cli",
"--method",
"tools/list",
"--transport",
"http",
"--callback-url",
"not-a-valid-url",
]);
expectCliFailure(result);
expectOutputContains(result, "Invalid OAuth callback URL");
} finally {
await server.stop();
}
});
});

View file

@ -10,6 +10,7 @@
"license": "MIT",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.29.0",
"@napi-rs/keyring": "^1.3.0",
"ajv": "^8.17.1",
"atomically": "^2.1.1",
"commander": "^13.1.0",
@ -907,6 +908,225 @@
}
}
},
"node_modules/@napi-rs/keyring": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/@napi-rs/keyring/-/keyring-1.3.0.tgz",
"integrity": "sha512-WrOw/bcXm0f9qHkumlT1QlArXSTWqaY9sunsDpOk+yCCorCKMxvWT/a3xko4EYHVdeZoh00yI2TydXn6eyICDA==",
"license": "MIT",
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
},
"optionalDependencies": {
"@napi-rs/keyring-darwin-arm64": "1.3.0",
"@napi-rs/keyring-darwin-x64": "1.3.0",
"@napi-rs/keyring-freebsd-x64": "1.3.0",
"@napi-rs/keyring-linux-arm-gnueabihf": "1.3.0",
"@napi-rs/keyring-linux-arm64-gnu": "1.3.0",
"@napi-rs/keyring-linux-arm64-musl": "1.3.0",
"@napi-rs/keyring-linux-riscv64-gnu": "1.3.0",
"@napi-rs/keyring-linux-x64-gnu": "1.3.0",
"@napi-rs/keyring-linux-x64-musl": "1.3.0",
"@napi-rs/keyring-win32-arm64-msvc": "1.3.0",
"@napi-rs/keyring-win32-ia32-msvc": "1.3.0",
"@napi-rs/keyring-win32-x64-msvc": "1.3.0"
}
},
"node_modules/@napi-rs/keyring-darwin-arm64": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/@napi-rs/keyring-darwin-arm64/-/keyring-darwin-arm64-1.3.0.tgz",
"integrity": "sha512-pl76hJvdYUBn6I24bXiOBMA9nbDapo3I5B+f3OorjDU4dUMSypXeKbOVehJe8fhgTiH24flMyTS3aAIy43xegQ==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/keyring-darwin-x64": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/@napi-rs/keyring-darwin-x64/-/keyring-darwin-x64-1.3.0.tgz",
"integrity": "sha512-YcJtEV5LA3cvA4z3BurgxH5IhTsW1JfIvcAAcqcecwk06Si9F9NqkxbZVIfDwQ8oRHgaBmT3zZJnLAotCrVahw==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/keyring-freebsd-x64": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/@napi-rs/keyring-freebsd-x64/-/keyring-freebsd-x64-1.3.0.tgz",
"integrity": "sha512-vlLf31TGhfRAaxLDBhg8b89ss0HHD/lyNmL5F3UjSaz5CUXElsJmKYq9fqA/B+cZKUEUcLHHGhF0I/CqcFdaVw==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/keyring-linux-arm-gnueabihf": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-arm-gnueabihf/-/keyring-linux-arm-gnueabihf-1.3.0.tgz",
"integrity": "sha512-KiWdMMu/Inz/bHHIAGrnF7r54FZDYXuHO6UFF/rhIrshUsxbMG1Rl9lEymNtqqsVo927G0VYcb02FzWQ3iBQRQ==",
"cpu": [
"arm"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/keyring-linux-arm64-gnu": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-arm64-gnu/-/keyring-linux-arm64-gnu-1.3.0.tgz",
"integrity": "sha512-eyKGpY40lm9Jvs1aD294XRH4y7+TlJM0YVAryZeXA6TX0mb4gMkxVXwSQv7MCwgah7raeUd0dKUb4BPAYIgcMg==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/keyring-linux-arm64-musl": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-arm64-musl/-/keyring-linux-arm64-musl-1.3.0.tgz",
"integrity": "sha512-iIK6JWHXAJqDrEyLY3TmswwloVyt2vj+04TZnew+uSJ9gnDO8EwRbp3/iw3LpWaXiDO7VomGO6y8I0Id8uBZSw==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/keyring-linux-riscv64-gnu": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-riscv64-gnu/-/keyring-linux-riscv64-gnu-1.3.0.tgz",
"integrity": "sha512-/PGqrwn6EwgtK6vccASSXJRfOSP4vN1F4ASsIQ+7MdrK6hNvAJ1FZPrIuD5gGGdxezo3F++To2Wq7DbuGIeuNQ==",
"cpu": [
"riscv64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/keyring-linux-x64-gnu": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-x64-gnu/-/keyring-linux-x64-gnu-1.3.0.tgz",
"integrity": "sha512-2PDK1WKWTu9lBGq9VvNEkSlQD3O7YwVpmnyN2M3cy4v7NJ/8gDMd9GXv3G+FVXN13uhp4gnnPBS+ScefmEeD2A==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/keyring-linux-x64-musl": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-x64-musl/-/keyring-linux-x64-musl-1.3.0.tgz",
"integrity": "sha512-oJ2HkX8YUo46QBkn0pG+HuIKQNqr523q6vBobCn+P95s4C4K6/kLBqHY/1bg5J4ap31DzsznhnFKcfBNBsjCnw==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/keyring-win32-arm64-msvc": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/@napi-rs/keyring-win32-arm64-msvc/-/keyring-win32-arm64-msvc-1.3.0.tgz",
"integrity": "sha512-tOd3c/uAaeoE4ycVlmAdSvygz0Zt3zdca6Y7gokBeIbaRDWpjDIUOpU3MvML59XAaqyuKGsVVu0F/DZb1lHPmw==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/keyring-win32-ia32-msvc": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/@napi-rs/keyring-win32-ia32-msvc/-/keyring-win32-ia32-msvc-1.3.0.tgz",
"integrity": "sha512-sPSqeAFZMGqP1R++M2JTza7GQJJ/TpCo6JU6Vcd4jnebvOaEDs9b7eipakU1PJdSvhpC2yXMCNRk9gXfrhuwHQ==",
"cpu": [
"ia32"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/keyring-win32-x64-msvc": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/@napi-rs/keyring-win32-x64-msvc/-/keyring-win32-x64-msvc-1.3.0.tgz",
"integrity": "sha512-4DnCWXwDc0HRKwyRlG5y0VhKZW2tNRQfKKfyj6IX/KWfDNyq9hn4n+GL1auyDcOO/v8PwnhmYo2+rOOqCkvvOg==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/wasm-runtime": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.5.tgz",

View file

@ -33,6 +33,7 @@
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.29.0",
"@napi-rs/keyring": "^1.3.0",
"ajv": "^8.17.1",
"atomically": "^2.1.1",
"commander": "^13.1.0",

View file

@ -6,6 +6,7 @@ import { awaitableLog } from "./utils/awaitable-log.js";
import type {
InspectorServerSettings,
MCPServerConfig,
InspectorClientEnvironment,
} from "@inspector/core/mcp/types.js";
import { InspectorClient } from "@inspector/core/mcp/index.js";
import {
@ -22,6 +23,24 @@ import {
parseHeaderPair,
} from "@inspector/core/mcp/node/index.js";
import type { JsonValue } from "@inspector/core/mcp/index.js";
import {
ConsoleNavigation,
MutableRedirectUrlProvider,
} from "@inspector/core/auth/index.js";
import { NodeOAuthStorage } from "@inspector/core/auth/node/index.js";
import {
DEFAULT_RUNNER_OAUTH_CALLBACK_URL,
formatRunnerOAuthRedirectUrl,
parseRunnerOAuthCallbackUrl,
type RunnerOAuthCallbackConfig,
} from "@inspector/core/auth/node/runner-oauth-callback.js";
import type { ClientConfig } from "@inspector/core/client/types.js";
import {
buildRunnerClientAuthOptions,
isOAuthCapableServerConfig,
loadRunnerClientConfig,
type RunnerClientConfigOverrides,
} from "@inspector/core/client/runner.js";
import {
LoggingLevelSchema,
type LoggingLevel,
@ -47,6 +66,9 @@ async function callMethod(
serverConfig: MCPServerConfig,
serverSettings: InspectorServerSettings | undefined,
args: MethodArgs & { method: string },
clientConfig: ClientConfig,
cliAuthOverrides?: RunnerClientConfigOverrides,
callbackUrlConfig?: RunnerOAuthCallbackConfig,
): Promise<void> {
const __dirname = dirname(fileURLToPath(import.meta.url));
const packageJsonPath = join(__dirname, "../package.json");
@ -62,16 +84,36 @@ async function callMethod(
const version = packageJson.version;
const clientIdentity = { name, version };
const environment: InspectorClientEnvironment = {
transport: createTransportNode,
};
const redirectUrlProvider = new MutableRedirectUrlProvider();
if (isOAuthCapableServerConfig(serverConfig)) {
redirectUrlProvider.redirectUrl = formatRunnerOAuthRedirectUrl(
callbackUrlConfig!,
);
environment.oauth = {
storage: new NodeOAuthStorage(),
navigation: new ConsoleNavigation(),
redirectUrlProvider,
};
}
const clientAuthOptions = buildRunnerClientAuthOptions(
clientConfig,
serverSettings,
cliAuthOverrides,
);
const inspectorClient = new InspectorClient(serverConfig, {
environment: {
transport: createTransportNode,
},
environment,
clientIdentity,
initialLoggingLevel: "debug",
progress: false,
sample: false,
elicit: false,
serverSettings,
...clientAuthOptions,
});
let managedToolsState: ManagedToolsState | null = null;
@ -240,11 +282,16 @@ function parseKeyValuePair(
return { ...previous, [key as string]: parsedValue };
}
function parseArgs(argv?: string[]): {
async function parseArgs(argv?: string[]): Promise<{
serverConfig: MCPServerConfig;
serverSettings: InspectorServerSettings | undefined;
methodArgs: MethodArgs & { method: string };
} {
clientConfigPath?: string;
clientId?: string;
clientSecret?: string;
clientMetadataUrl?: string;
callbackUrl?: string;
}> {
const program = new Command();
// On a parse/usage ERROR (exitCode !== 0), throw the CommanderError instead
// of letting commander call process.exit(). The binary entry (index.ts) still
@ -368,6 +415,26 @@ function parseArgs(argv?: string[]): {
"Tool-specific metadata as key=value pairs (for tools/call method only)",
parseKeyValuePair,
{},
)
.option(
"--client-config <path>",
"Install-level client config (default: ~/.mcp-inspector/storage/client.json, or MCP_CLIENT_CONFIG_PATH)",
)
.option(
"--client-id <id>",
"OAuth client ID (static client) for HTTP servers",
)
.option(
"--client-secret <secret>",
"OAuth client secret (for confidential clients)",
)
.option(
"--client-metadata-url <url>",
"OAuth Client ID Metadata Document URL (CIMD) for HTTP servers",
)
.option(
"--callback-url <url>",
`OAuth redirect/callback listener URL (default: ${DEFAULT_RUNNER_OAUTH_CALLBACK_URL}, or MCP_OAUTH_CALLBACK_URL)`,
);
program.parse(preArgs);
@ -390,6 +457,11 @@ function parseArgs(argv?: string[]): {
transport?: "sse" | "http" | "stdio";
serverUrl?: string;
header?: Record<string, string>;
clientConfig?: string;
clientId?: string;
clientSecret?: string;
clientMetadataUrl?: string;
callbackUrl?: string;
};
const serverOptions = {
@ -410,7 +482,7 @@ function parseArgs(argv?: string[]): {
// Shared with the TUI: resolves the catalog/config source (or ad-hoc target),
// enforces the conflict matrix, and lifts disk headers/timeouts/OAuth into
// per-server settings. `--server` selects one when the file has several.
const entries = loadServerEntries(serverOptions);
const entries = await loadServerEntries(serverOptions);
const { config: serverConfig, settings: serverSettings } = selectServerEntry(
entries,
options.server,
@ -452,12 +524,37 @@ function parseArgs(argv?: string[]): {
serverConfig,
serverSettings,
methodArgs,
clientConfigPath: options.clientConfig,
clientId: options.clientId,
clientSecret: options.clientSecret,
clientMetadataUrl: options.clientMetadataUrl,
callbackUrl: options.callbackUrl,
};
}
export async function runCli(argv?: string[]): Promise<void> {
const { serverConfig, serverSettings, methodArgs } = parseArgs(
argv ?? process.argv,
const {
serverConfig,
serverSettings,
methodArgs,
clientConfigPath,
clientId,
clientSecret,
clientMetadataUrl,
callbackUrl,
} = await parseArgs(argv ?? process.argv);
const clientConfig = await loadRunnerClientConfig({ clientConfigPath });
const callbackUrlConfig = parseRunnerOAuthCallbackUrl(callbackUrl);
await callMethod(
serverConfig,
serverSettings,
methodArgs,
clientConfig,
{
clientId,
clientSecret,
clientMetadataUrl,
},
callbackUrlConfig,
);
await callMethod(serverConfig, serverSettings, methodArgs);
}

View file

@ -15,6 +15,7 @@ export default defineConfig({
platform: 'node',
// Bundle core source; leave npm deps external.
noExternal: [/^@inspector\/core/],
external: ['@napi-rs/keyring', '@modelcontextprotocol/sdk', 'commander', 'pino', 'zustand'],
esbuildOptions(options) {
options.alias = {
'@inspector/core': path.join(repoRoot, 'core'),

View file

@ -33,14 +33,41 @@ Options that specify the MCP server(s) (catalog/config file, ad-hoc command/URL,
### TUI-specific (OAuth for HTTP servers)
When connecting to SSE or Streamable HTTP servers that use OAuth, you can pass:
The TUI supports OAuth for **SSE** and **Streamable HTTP** servers. Per-server OAuth fields in `mcp.json` (static client id/secret, scopes, enterprise-managed flag) are applied automatically when loaded from `--catalog` or `--config`. Install-wide settings (CIMD, enterprise IdP) come from **`~/.mcp-inspector/storage/client.json`** — the same file the web **Client Settings** dialog writes. You can point at a different file with `--client-config` or `MCP_CLIENT_CONFIG_PATH`.
| Option | Description |
| ----------------------------- | ------------------------------------------------------------------------------------ |
| `--client-id <id>` | OAuth client ID (static client). |
| `--client-secret <secret>` | OAuth client secret (confidential clients). |
| `--client-metadata-url <url>` | OAuth Client ID Metadata Document URL (CIMD). |
| `--callback-url <url>` | OAuth redirect/callback listener URL (default: `http://127.0.0.1:0/oauth/callback`). |
#### OAuth callback URL
The TUI starts a small loopback HTTP server to receive the authorization redirect after you sign in in the browser. Defaults:
| Surface | Default callback |
| ------- | ---------------- |
| **Web** | `http://localhost:6274/oauth/callback` (main app server) |
| **TUI** | `http://127.0.0.1:6276/oauth/callback` (dedicated runner port; avoids colliding with web on 6274) |
**Why a fixed default port?** Enterprise-managed auth (EMA), CIMD, and many static OAuth apps require **pre-registered redirect URIs**. A predictable default (`http://127.0.0.1:6276/oauth/callback`) lets you register once on the IdP and reuse it across TUI sessions. Dynamic registration (DCR) can use ephemeral ports instead — see below.
**Trade-off:** only **one TUI OAuth flow at a time** can listen on the default port. A second instance starting OAuth while another is in progress may fail with `EADDRINUSE`. Override the listener with `--callback-url` or `MCP_OAUTH_CALLBACK_URL` (e.g. a different fixed port per instance, or `http://127.0.0.1:0/oauth/callback` when the authorization server accepts dynamically registered redirect URIs).
OAuth redirect URIs must match **exactly** what you register on the authorization server — `localhost` and `127.0.0.1` are different URIs. Register the TUI default on your OAuth app / IdP when using pre-registered (static), CIMD, or enterprise-managed clients. Override the listener with `--callback-url` or `MCP_OAUTH_CALLBACK_URL`; use `http://127.0.0.1:0/oauth/callback` for an OS-assigned ephemeral port when the authorization server registers redirect URIs dynamically (DCR).
#### Flags
| Option | Env | Description |
| ------ | --- | ----------- |
| `--client-config <path>` | `MCP_CLIENT_CONFIG_PATH` | Install-level client config (default: `~/.mcp-inspector/storage/client.json`). |
| `--client-id <id>` | — | OAuth client ID (static client); overrides `client.json`. |
| `--client-secret <secret>` | — | OAuth client secret (confidential clients); overrides `client.json`. |
| `--client-metadata-url <url>` | — | Client ID Metadata Document URL (CIMD); overrides `client.json`. |
| `--callback-url <url>` | `MCP_OAUTH_CALLBACK_URL` | OAuth redirect/callback listener (default: `http://127.0.0.1:6276/oauth/callback`). |
#### Authenticating in the TUI
1. Select an HTTP/SSE server and press **C** to connect.
2. If authorization is required, the TUI starts OAuth automatically (browser opens for sign-in).
3. After the callback completes, connect finishes without a second **C**.
4. Use the **Auth** tab to inspect OAuth state (same fields as web Connection Info) or **Clear OAuth state** (disconnects when connected).
See also [EMA / enterprise-managed auth](../../specification/v2_auth_ema.md) and [OAuth smoke testing](../../specification/v2_auth_smoke_testing.md) for staging servers and verification steps.
## Features

View file

@ -0,0 +1,939 @@
import React from "react";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render } from "ink-testing-library";
type RenderResult = ReturnType<typeof render>;
vi.mock("ink-scroll-view", () => import("./helpers/inkScrollViewMock.js"));
vi.mock("ink-form", () => import("./helpers/inkFormMock.js"));
// ---------------------------------------------------------------------------
// Controllable mock of the entire @inspector/core surface App.tsx depends on.
// `ctrl` is mutated by individual tests (reset in beforeEach) to drive what the
// hooks return and what the InspectorClient methods do.
// ---------------------------------------------------------------------------
const h = vi.hoisted(() => {
interface Ctrl {
status: string;
capabilities: Record<string, unknown> | null;
serverInfo: { name?: string; version?: string } | null;
instructions: string | null;
serverType: "stdio" | "sse" | "streamable-http";
oauthFlowState: unknown;
tools: unknown[];
resources: unknown[];
resourceTemplates: unknown[];
prompts: unknown[];
messages: unknown[];
fetchRequests: unknown[];
stderrLogs: unknown[];
}
const ctrl: Ctrl = {
status: "disconnected",
capabilities: null,
serverInfo: null,
instructions: null,
serverType: "stdio",
oauthFlowState: null,
tools: [],
resources: [],
resourceTemplates: [],
prompts: [],
messages: [],
fetchRequests: [],
stderrLogs: [],
};
const connect = vi.fn().mockResolvedValue(undefined);
const disconnect = vi.fn().mockResolvedValue(undefined);
const openUrl = vi.fn().mockResolvedValue(undefined);
// Shared OAuth-related spies so a test can configure resolve/reject and
// assert calls regardless of which per-server FakeClient instance App built.
const clientSpies = {
authenticate: vi.fn(
async (): Promise<string | undefined> => "https://auth.example/start",
),
clearOAuthTokens: vi.fn(),
completeOAuthFlow: vi.fn(async (): Promise<void> => {}),
getOAuthState: vi.fn(async () => undefined),
};
// Captured options from the most recent callbackServer.start(), so a test can
// drive the onCallback / onError handlers the OAuth flows register.
interface CallbackOpts {
onCallback: (p: { code: string }) => Promise<void> | void;
onError: (p: { error?: string; error_description?: string }) => void;
}
const cb: { opts: CallbackOpts | null } = { opts: null };
const callbackStart = vi.fn(async (opts: CallbackOpts) => {
cb.opts = opts;
return { redirectUrl: "http://localhost/cb" };
});
const callbackStop = vi.fn().mockResolvedValue(undefined);
const createOAuthCallbackServer = vi.fn(() => ({
start: callbackStart,
stop: callbackStop,
}));
class FakeManager {
destroy = vi.fn();
}
class FakeClient {
cfg: { type?: string } | undefined;
constructor(config?: { type?: string }) {
this.cfg = config;
}
// Derive the transport type from the server config the client was built
// with (config.type aligns with the serverType union) so per-server gating
// (logging/requests tabs) works in mixed catalogs; fall back to ctrl.
getServerType = vi.fn(
() =>
(this.cfg?.type ?? ctrl.serverType) as
| "stdio"
| "sse"
| "streamable-http",
);
authenticate = (...a: unknown[]) => clientSpies.authenticate(...a);
clearOAuthTokens = (...a: unknown[]) => clientSpies.clearOAuthTokens(...a);
completeOAuthFlow = (...a: unknown[]) =>
clientSpies.completeOAuthFlow(...a);
getOAuthState = (...a: unknown[]) => clientSpies.getOAuthState(...a);
readResource = vi.fn(async () => ({
result: { contents: [{ uri: "file://x", text: "hello" }] },
}));
addEventListener = vi.fn();
removeEventListener = vi.fn();
// Reject so the unmount cleanup's `.catch(() => {})` arrow is exercised.
disconnect = vi.fn().mockRejectedValue(new Error("cleanup disconnect"));
}
return {
ctrl,
connect,
disconnect,
openUrl,
clientSpies,
cb,
createOAuthCallbackServer,
callbackStart,
callbackStop,
FakeManager,
FakeClient,
useInspectorClient: vi.fn(() => ({
status: ctrl.status,
capabilities: ctrl.capabilities,
serverInfo: ctrl.serverInfo,
instructions: ctrl.instructions,
connect,
disconnect,
})),
useManagedTools: vi.fn(() => ({ tools: ctrl.tools })),
useManagedResources: vi.fn(() => ({ resources: ctrl.resources })),
useManagedResourceTemplates: vi.fn(() => ({
resourceTemplates: ctrl.resourceTemplates,
})),
useManagedPrompts: vi.fn(() => ({ prompts: ctrl.prompts })),
useMessageLog: vi.fn(() => ({ messages: ctrl.messages })),
useFetchRequestLog: vi.fn(() => ({ fetchRequests: ctrl.fetchRequests })),
useStderrLog: vi.fn(() => ({ stderrLogs: ctrl.stderrLogs })),
};
});
vi.mock("@inspector/core/mcp/index.js", () => ({
InspectorClient: h.FakeClient,
}));
vi.mock("@inspector/core/mcp/state/index.js", () => ({
ManagedToolsState: h.FakeManager,
ManagedResourcesState: h.FakeManager,
ManagedResourceTemplatesState: h.FakeManager,
ManagedPromptsState: h.FakeManager,
MessageLogState: h.FakeManager,
FetchRequestLogState: h.FakeManager,
StderrLogState: h.FakeManager,
}));
vi.mock("@inspector/core/mcp/node/index.js", () => ({
createTransportNode: vi.fn(),
}));
vi.mock("@inspector/core/react/useInspectorClient.js", () => ({
useInspectorClient: h.useInspectorClient,
}));
vi.mock("@inspector/core/react/useManagedTools.js", () => ({
useManagedTools: h.useManagedTools,
}));
vi.mock("@inspector/core/react/useManagedResources.js", () => ({
useManagedResources: h.useManagedResources,
}));
vi.mock("@inspector/core/react/useManagedResourceTemplates.js", () => ({
useManagedResourceTemplates: h.useManagedResourceTemplates,
}));
vi.mock("@inspector/core/react/useManagedPrompts.js", () => ({
useManagedPrompts: h.useManagedPrompts,
}));
vi.mock("@inspector/core/react/useMessageLog.js", () => ({
useMessageLog: h.useMessageLog,
}));
vi.mock("@inspector/core/react/useFetchRequestLog.js", () => ({
useFetchRequestLog: h.useFetchRequestLog,
}));
vi.mock("@inspector/core/react/useStderrLog.js", () => ({
useStderrLog: h.useStderrLog,
}));
vi.mock("@inspector/core/auth/index.js", async (importOriginal) => {
const actual =
await importOriginal<typeof import("@inspector/core/auth/index.js")>();
return {
...actual,
CallbackNavigation: class {},
MutableRedirectUrlProvider: class {
redirectUrl = "";
},
};
});
vi.mock("@inspector/core/auth/node/index.js", () => ({
createOAuthCallbackServer: h.createOAuthCallbackServer,
NodeOAuthStorage: class {},
}));
vi.mock("../src/utils/openUrl.js", () => ({
openUrl: h.openUrl,
}));
import App from "../src/App.js";
import type { TuiServer } from "../src/tui-servers.js";
const tick = () => new Promise((r) => setTimeout(r, 25));
const callbackUrlConfig = { hostname: "127.0.0.1", port: 0, pathname: "/cb" };
const emptyClientConfig = {};
function stdioServer(): Record<string, TuiServer> {
return {
alpha: {
config: { type: "stdio", command: "node", args: ["s.js"] },
} as never,
beta: {
config: { type: "stdio", command: "node", args: ["b.js"] },
} as never,
};
}
function httpServer(): Record<string, TuiServer> {
return {
web: { config: { type: "streamable-http", url: "http://x" } } as never,
};
}
// Single-server catalogs auto-select their only server on mount, so action
// tests can drive accelerators without first navigating the server list.
function oneStdio(): Record<string, TuiServer> {
return {
alpha: {
config: { type: "stdio", command: "node", args: ["s.js"] },
} as never,
};
}
// Single streamable-http server catalog (auto-selected on mount).
function oneHttp(): Record<string, TuiServer> {
return {
web: { config: { type: "streamable-http", url: "http://x" } } as never,
};
}
// Mixed catalog: an OAuth-capable http server first (auto-selected) followed by
// a stdio server <20> drives per-server tab gating + the tab-switch-away effects.
function httpThenStdio(): Record<string, TuiServer> {
return {
web: { config: { type: "streamable-http", url: "http://x" } } as never,
cli: {
config: { type: "stdio", command: "node", args: ["s.js"] },
} as never,
};
}
function stdioThenHttp(): Record<string, TuiServer> {
return {
cli: {
config: { type: "stdio", command: "node", args: ["s.js"] },
} as never,
web: { config: { type: "streamable-http", url: "http://x" } } as never,
};
}
// An http server carrying saved settings (metadata, oauth creds, timeout) to
// exercise the per-server option-building branches in the mount effect.
function httpWithSettings(): Record<string, TuiServer> {
return {
web: {
config: { type: "streamable-http", url: "http://x" },
settings: {
requestTimeout: 5000,
metadata: [
{ key: "team", value: "alpha" },
{ key: " ", value: "ignored" },
],
oauthClientId: "cid",
oauthClientSecret: "secret",
oauthScopes: "read write",
},
} as never,
};
}
// Realistic minimal fixtures for tab content / details modals.
const sampleTool = {
name: "alpha",
description: "Tool desc line1\nline2",
inputSchema: { type: "object", properties: { x: { type: "string" } } },
};
const sampleResource = {
name: "res1",
uri: "file://x",
description: "rdesc",
mimeType: "text/plain",
};
const sampleTemplate = {
name: "tmpl1",
uriTemplate: "file://{id}",
description: "tdesc",
};
const promptWithArgs = {
name: "p1",
description: "pdesc",
arguments: [{ name: "arg1", description: "a1" }],
};
const promptNoName = { name: "", description: "no name prompt" };
const reqMessage = {
id: "m1",
direction: "request",
message: { jsonrpc: "2.0", id: 1, method: "tools/list" },
response: { jsonrpc: "2.0", id: 1, result: {} },
timestamp: new Date(0),
duration: 5,
};
const notifMessage = {
id: "m2",
direction: "notification",
message: { jsonrpc: "2.0", method: "notifications/message" },
timestamp: new Date(0),
};
const fullRequest = {
id: "r1",
method: "POST",
url: "http://x/mcp",
category: "transport",
responseStatus: 200,
responseStatusText: "OK",
duration: 12,
timestamp: new Date(0),
requestHeaders: { "content-type": "application/json" },
requestBody: JSON.stringify({ a: 1 }),
responseHeaders: { "x-h": "v" },
responseBody: JSON.stringify({ ok: true }),
};
const errorRequest = {
id: "r2",
method: "GET",
url: "http://x/auth",
category: "auth",
error: "boom",
timestamp: new Date(0),
requestHeaders: { accept: "*/*" },
requestBody: "not json{",
responseBody: "also not json{",
};
const respMessage = {
id: "m3",
direction: "response",
message: { jsonrpc: "2.0", id: 2, result: { ok: true } },
timestamp: new Date(0),
};
const bareRequest = {
id: "r3",
method: "GET",
url: "http://x/idle",
category: "transport",
timestamp: new Date(0),
requestHeaders: {},
};
const stderrLog = { timestamp: new Date(0), message: "log line" };
// Track rendered instances so each is unmounted after its test <20> concurrent
// mounted ink apps share raw-mode stdin handling and interfere with useInput.
const mounted: RenderResult[] = [];
function renderApp(servers: Record<string, TuiServer>) {
const r = render(
<App
mcpServers={servers}
clientConfig={emptyClientConfig}
callbackUrlConfig={callbackUrlConfig}
/>,
);
mounted.push(r);
return r;
}
/**
* Render and absorb ink-testing-library's intermittently-dropped first
* keypress with a benign no-op key ("x" is not bound while the server list is
* focused), so subsequent navigation keys register deterministically.
*/
async function mount(servers: Record<string, TuiServer>) {
const r = renderApp(servers);
await tick();
r.stdin.write("x");
await tick();
return r;
}
// Arrow / shift-tab only parse as those keys when ESC-prefixed (a bare "[B" is
// read as the literal characters). Tab and Enter are real control characters.
const ESC = String.fromCharCode(27);
const DOWN = `${ESC}[B`;
const UP = `${ESC}[A`;
const RIGHT = `${ESC}[C`;
const LEFT = `${ESC}[D`;
const STAB = `${ESC}[Z`;
const TAB = "\t";
const ENTER = "\r";
/**
* Write each key in order. ink re-subscribes the active component's useInput on
* re-render, so a key that changes focus/tab must let that re-render flush
* before the next key <EFBFBD> otherwise the next key is routed to the old handler.
* Two ticks per key keeps multi-step navigation deterministic under the heavier
* v8 coverage instrumentation (where a single tick can race the render).
*/
async function press(r: RenderResult, keys: string[]) {
for (const k of keys) {
r.stdin.write(k);
await tick();
await tick();
}
}
/**
* Poll until `predicate` is true (or the tries run out). React + ink schedule
* renders across several macrotasks, so async state set by a flow can take more
* than one fixed tick to land under coverage instrumentation.
*/
async function waitUntil(predicate: () => boolean, tries = 25) {
for (let i = 0; i < tries; i++) {
if (predicate()) return;
await tick();
}
}
/**
* Poll the frame until it contains `substr` (or the tries run out). Render
* settling races a single fixed tick under v8 coverage instrumentation, so
* frame assertions that follow a mount/keypress use this instead of one tick.
*/
async function waitForFrame(r: RenderResult, substr: string, tries = 25) {
await waitUntil(() => (r.lastFrame() ?? "").includes(substr), tries);
}
/** Poll until the frame contains `substr`, then assert it <20> stable under load. */
async function expectFrame(r: RenderResult, substr: string) {
await waitForFrame(r, substr);
expect(r.lastFrame() ?? "").toContain(substr);
}
beforeEach(() => {
Object.assign(h.ctrl, {
status: "disconnected",
capabilities: null,
serverInfo: null,
instructions: null,
serverType: "stdio",
oauthFlowState: null,
tools: [],
resources: [],
resourceTemplates: [],
prompts: [],
messages: [],
fetchRequests: [],
stderrLogs: [],
});
h.connect.mockClear();
h.connect.mockResolvedValue(undefined);
h.disconnect.mockClear();
h.disconnect.mockResolvedValue(undefined);
h.openUrl.mockClear();
h.openUrl.mockResolvedValue(undefined);
h.cb.opts = null;
h.callbackStart.mockClear();
h.callbackStop.mockClear();
h.clientSpies.authenticate.mockReset();
h.clientSpies.authenticate.mockResolvedValue("https://auth.example/start");
h.clientSpies.clearOAuthTokens.mockReset();
h.clientSpies.completeOAuthFlow.mockReset();
h.clientSpies.completeOAuthFlow.mockResolvedValue(undefined);
h.clientSpies.getOAuthState.mockReset();
h.clientSpies.getOAuthState.mockResolvedValue(undefined);
});
afterEach(() => {
while (mounted.length) mounted.pop()?.unmount();
});
describe("App (foundation)", () => {
it("renders the server list with the MCP Servers header", async () => {
const r = renderApp(stdioServer());
await expectFrame(r, "MCP Servers");
const frame = r.lastFrame() ?? "";
expect(frame).toContain("alpha");
expect(frame).toContain("beta");
});
it("auto-selects the first server and shows its config", async () => {
const r = renderApp(stdioServer());
await expectFrame(r, "Server Configuration");
});
it("moves selection down to the next server with the down arrow", async () => {
const r = await mount(stdioServer());
await press(r, [DOWN]); // alpha -> beta
await expectFrame(r, "b.js");
});
it("connects with 'c' when disconnected", async () => {
const { stdin } = await mount(oneStdio());
stdin.write("c");
await tick();
expect(h.connect).toHaveBeenCalled();
});
it("disconnects with 'd' when connected", async () => {
h.ctrl.status = "connected";
const { stdin } = await mount(oneStdio());
stdin.write("d");
await tick();
expect(h.disconnect).toHaveBeenCalled();
});
it("switches tabs via accelerator keys", async () => {
const r = await mount(stdioServer());
await press(r, ["t"]); // tools tab (server is auto-selected)
await expectFrame(r, "Tools");
});
it("cycles focus with tab and shift+tab", async () => {
const { stdin } = renderApp(stdioServer());
await tick();
stdin.write("[B");
await tick();
stdin.write("\t"); // forward
await tick();
// shift+tab is delivered as ESC [ Z
stdin.write("");
await tick();
// no assertion on hidden focus state <20> exercising the branches
});
it("shows the Auth tab via the accelerator for an OAuth-capable server", async () => {
h.ctrl.serverType = "streamable-http";
const r = await mount(httpServer());
await press(r, ["a"]);
await expectFrame(r, "OAuth");
});
it("renders connected status with capabilities", async () => {
h.ctrl.status = "connected";
h.ctrl.capabilities = { tools: {}, resources: {}, prompts: {} };
h.ctrl.serverInfo = { name: "srv", version: "1.0.0" };
const r = await mount(oneStdio());
await expectFrame(r, "connected");
});
});
describe("App (status, layout, modals)", () => {
it("renders the connecting status symbol/color", async () => {
h.ctrl.status = "connecting";
const r = await mount(oneStdio());
await expectFrame(r, "connecting");
});
it("renders the error status symbol/color", async () => {
h.ctrl.status = "error";
const r = await mount(oneStdio());
await expectFrame(r, "error");
});
it("shows error status when an http server has a 401 response logged", async () => {
h.ctrl.status = "error";
h.ctrl.fetchRequests = [{ ...errorRequest, responseStatus: 401 }];
const r = await mount(oneHttp());
await expectFrame(r, "error");
await expectFrame(r, "HTTP Requests (1)");
});
it("updates dimensions when the terminal resizes", async () => {
const r = await mount(oneStdio());
process.stdout.emit("resize");
await tick();
await expectFrame(r, "MCP Servers");
});
it("renders Tools tab content when connected", async () => {
h.ctrl.status = "connected";
h.ctrl.tools = [sampleTool];
const r = await mount(oneStdio());
await press(r, ["t"]);
const f = r.lastFrame() ?? "";
expect(f).toContain("Tools (1)");
expect(f).toContain("alpha");
});
it("opens the tool test modal with Enter from the list pane", async () => {
h.ctrl.status = "connected";
h.ctrl.tools = [sampleTool];
const r = await mount(oneStdio());
await press(r, ["t", TAB, ENTER]);
await expectFrame(r, "MOCK_FORM");
await press(r, [ESC]); // ESC closes the modal
expect(r.lastFrame() ?? "").not.toContain("MOCK_FORM");
});
it("opens the tool details modal with '+' and closes it on ESC", async () => {
h.ctrl.status = "connected";
h.ctrl.tools = [sampleTool];
const r = await mount(oneStdio());
await press(r, ["t", TAB, TAB, "+"]);
await expectFrame(r, "Input Schema:");
expect(r.lastFrame() ?? "").toContain("Full JSON:");
await press(r, [ESC]);
expect(r.lastFrame() ?? "").not.toContain("Full JSON:");
});
it("fetches a resource and opens its details modal", async () => {
h.ctrl.status = "connected";
h.ctrl.resources = [sampleResource];
const r = await mount(oneStdio());
await press(r, ["r", TAB, ENTER]);
await tick();
await press(r, [TAB, "+"]);
await expectFrame(r, "Full JSON:");
});
it("opens the resource template test modal via Enter on a template", async () => {
h.ctrl.status = "connected";
h.ctrl.resources = [sampleResource];
h.ctrl.resourceTemplates = [sampleTemplate];
const r = await mount(oneStdio());
await press(r, ["r", TAB, DOWN, ENTER]);
await expectFrame(r, "MOCK_FORM");
await press(r, [ESC]);
expect(r.lastFrame() ?? "").not.toContain("MOCK_FORM");
});
it("opens the prompt test modal via Enter on a prompt with arguments", async () => {
h.ctrl.status = "connected";
h.ctrl.prompts = [promptWithArgs];
const r = await mount(oneStdio());
await press(r, ["p", TAB, ENTER]);
await expectFrame(r, "MOCK_FORM");
await press(r, [ESC]);
expect(r.lastFrame() ?? "").not.toContain("MOCK_FORM");
});
it("opens the prompt details modal with '+'", async () => {
h.ctrl.status = "connected";
h.ctrl.prompts = [promptWithArgs];
const r = await mount(oneStdio());
await press(r, ["p", TAB, TAB, "+"]);
const f = r.lastFrame() ?? "";
expect(f).toContain("Arguments:");
expect(f).toContain("Full JSON:");
});
it("opens details for a nameless prompt with no arguments", async () => {
h.ctrl.status = "connected";
h.ctrl.prompts = [promptNoName];
const r = await mount(oneStdio());
await press(r, ["p", TAB, TAB, "+"]);
const f = r.lastFrame() ?? "";
expect(f).toContain("Prompt: Unknown");
expect(f).not.toContain("Arguments:");
});
it("opens message details for a request message (with response)", async () => {
h.ctrl.messages = [reqMessage];
const r = await mount(oneStdio());
await press(r, ["m", TAB, TAB, "+"]);
const f = r.lastFrame() ?? "";
expect(f).toContain("Direction: request");
expect(f).toContain("Response:");
});
it("opens message details for a notification message", async () => {
h.ctrl.messages = [notifMessage];
const r = await mount(oneStdio());
await press(r, ["m", TAB, TAB, "+"]);
await expectFrame(r, "Notification:");
});
it("opens message details for a response message", async () => {
h.ctrl.messages = [respMessage];
const r = await mount(oneStdio());
await press(r, ["m", TAB, TAB, "+"]);
await expectFrame(r, "Response:");
});
it("opens in-progress request details (no status, error, or bodies)", async () => {
h.ctrl.status = "connected";
h.ctrl.fetchRequests = [bareRequest];
const r = await mount(oneHttp());
await press(r, ["h", TAB, TAB, "+"]);
await expectFrame(r, "Request Headers:");
});
it("connects with 'c' from the error state", async () => {
h.ctrl.status = "error";
const r = await mount(oneStdio());
await press(r, ["c"]);
await tick();
expect(h.connect).toHaveBeenCalled();
});
it("disconnects with 'd' from the connecting state", async () => {
h.ctrl.status = "connecting";
const r = await mount(oneStdio());
await press(r, ["d"]);
await tick();
expect(h.disconnect).toHaveBeenCalled();
});
it("renders the HTTP requests tab and opens full request details", async () => {
h.ctrl.status = "connected";
h.ctrl.fetchRequests = [fullRequest];
const r = await mount(oneHttp());
await press(r, ["h", TAB, TAB, "+"]);
const f = r.lastFrame() ?? "";
expect(f).toContain("Request Headers:");
expect(f).toContain("Status: 200");
});
it("opens error-request details (error branch + unparseable bodies)", async () => {
h.ctrl.status = "connected";
h.ctrl.fetchRequests = [errorRequest];
const r = await mount(oneHttp());
await press(r, ["h", TAB, TAB, "+"]);
await expectFrame(r, "Error: boom");
});
it("renders the Logging tab for a stdio server", async () => {
h.ctrl.status = "connected";
h.ctrl.stderrLogs = [stderrLog];
const r = await mount(oneStdio());
await press(r, ["l"]);
const f = r.lastFrame() ?? "";
expect(f).toContain("Logging (1)");
expect(f).toContain("log line");
});
});
describe("App (input handling, focus, effects)", () => {
it("switches tabs with left/right arrows when the tabs row is focused", async () => {
h.ctrl.status = "connected";
const r = await mount(oneHttp());
await press(r, [TAB]); // serverList -> tabs
await press(r, [LEFT, RIGHT, RIGHT, LEFT]); // wrap + cycle both directions
await expectFrame(r, "MCP Servers");
});
it("switches tabs with arrows on a stdio server (logging tab, no requests)", async () => {
h.ctrl.status = "connected";
const r = await mount(oneStdio());
await press(r, [TAB]); // serverList -> tabs
await press(r, [RIGHT, RIGHT, LEFT]);
await expectFrame(r, "MCP Servers");
});
it("updates the resources tab count when the resource list changes", async () => {
h.ctrl.status = "connected";
h.ctrl.resources = [];
const r = await mount(oneStdio());
await press(r, ["r"]);
h.ctrl.resources = [sampleResource];
await press(r, [TAB]); // a focus change forces a re-render with new resources
await tick();
await expectFrame(r, "Resources (1)");
});
it("exits on Ctrl+C", async () => {
const r = await mount(oneStdio());
r.stdin.write("\x03"); // ETX -> ctrl+c
await tick();
expect(r.lastFrame() ?? "").toBeDefined();
});
it("exits on Escape", async () => {
const r = await mount(oneStdio());
await press(r, [ESC]);
expect(r.lastFrame() ?? "").toBeDefined();
});
it("moves and wraps server selection with up and down arrows", async () => {
const r = await mount(stdioServer()); // alpha(0), beta(1); alpha selected
await press(r, [DOWN]); // alpha -> beta (down, index+1)
await press(r, [UP]); // beta -> alpha (up, index-1)
await press(r, [UP]); // alpha -> beta (up wrap to last)
await press(r, [DOWN]); // beta -> alpha (down wrap to first)
await expectFrame(r, "Server Configuration");
});
it("handles arrow keys with an empty server catalog", async () => {
const r = await mount({});
await press(r, [UP, DOWN]);
await expectFrame(r, "MCP Servers");
});
it("cycles focus order through the messages tab panes", async () => {
h.ctrl.messages = [reqMessage];
const r = await mount(oneStdio());
await press(r, ["m"]);
await press(r, [TAB, TAB, TAB, TAB]); // forward through messages focusOrder
await press(r, [STAB, STAB]); // reverse
await expectFrame(r, "Messages");
});
it("cycles focus order through the requests tab panes", async () => {
h.ctrl.status = "connected";
h.ctrl.fetchRequests = [fullRequest];
const r = await mount(oneHttp());
await press(r, ["h"]);
await press(r, [TAB, TAB, TAB, TAB]);
await press(r, [STAB, STAB]);
await expectFrame(r, "Requests");
});
it("switches away from the Auth tab when selecting a non-OAuth server", async () => {
const r = await mount(httpThenStdio());
await press(r, ["a", STAB, STAB, DOWN]);
await waitUntil(() => (r.lastFrame() ?? "").includes("Type: stdio"));
await expectFrame(r, "Server Configuration");
expect(r.lastFrame() ?? "").not.toContain("No OAuth information yet");
});
it("switches away from the Logging tab when selecting a non-stdio server", async () => {
const r = await mount(stdioThenHttp());
await press(r, ["l"]); // Logging tab (stdio)
await press(r, [STAB]); // tabs -> serverList
await press(r, [DOWN]); // select the http server -> effect leaves Logging
await expectFrame(r, "Server Configuration");
});
it("swallows connect errors", async () => {
h.connect.mockRejectedValue(new Error("connfail"));
const r = await mount(oneStdio());
await press(r, ["c"]);
await tick();
expect(h.connect).toHaveBeenCalled();
});
it("builds a client with saved settings (metadata, oauth, timeout)", async () => {
const r = await mount(httpWithSettings());
await expectFrame(r, "MCP Servers");
});
it("passes top-level oauth client credentials into an http client", async () => {
const r = render(
<App
mcpServers={oneHttp()}
clientConfig={emptyClientConfig}
callbackUrlConfig={callbackUrlConfig}
clientId="cid"
clientSecret="sec"
clientMetadataUrl="http://meta"
/>,
);
mounted.push(r);
await tick();
await expectFrame(r, "MCP Servers");
});
});
describe("App (OAuth flows)", () => {
const unauthorized = Object.assign(new Error("request failed (401)"), {
status: 401,
});
it("runs OAuth on connect when the server returns 401", async () => {
h.connect.mockRejectedValueOnce(unauthorized).mockResolvedValue(undefined);
h.clientSpies.authenticate.mockResolvedValue(undefined);
const r = await mount(oneHttp());
await press(r, ["c"]);
await waitUntil(() => h.callbackStart.mock.calls.length > 0);
expect(h.disconnect).toHaveBeenCalled();
expect(h.clientSpies.authenticate).toHaveBeenCalled();
expect(h.connect).toHaveBeenCalledTimes(2);
});
it("completes OAuth when the callback fires during a 401 connect", async () => {
h.connect.mockRejectedValueOnce(unauthorized).mockResolvedValue(undefined);
const r = await mount(oneHttp());
await press(r, ["c"]);
await waitUntil(() => h.cb.opts !== null);
await h.cb.opts!.onCallback({ code: "abc" });
await tick();
expect(h.clientSpies.completeOAuthFlow).toHaveBeenCalledWith("abc");
});
it("reports an OAuth callback error during a 401 connect", async () => {
h.connect.mockRejectedValueOnce(unauthorized);
const r = await mount(oneHttp());
await press(r, ["c"]);
await waitUntil(() => h.cb.opts !== null);
h.cb.opts!.onError({ error_description: "denied" });
await expectFrame(r, "denied");
});
it("clears OAuth state from the Auth tab", async () => {
const r = await mount(oneHttp());
await press(r, ["a", "s"]);
await tick();
expect(h.clientSpies.clearOAuthTokens).toHaveBeenCalled();
});
it("reports callback completion failure during a 401 connect", async () => {
h.connect.mockRejectedValueOnce(unauthorized);
h.clientSpies.completeOAuthFlow.mockRejectedValue(new Error("qcfail"));
const r = await mount(oneHttp());
await press(r, ["c"]);
await waitUntil(() => h.cb.opts !== null);
await h.cb.opts!.onCallback({ code: "x" });
await expectFrame(r, "qcfail");
});
it("stringifies a non-Error authenticate rejection on 401 connect", async () => {
h.connect.mockRejectedValueOnce(unauthorized);
h.clientSpies.authenticate.mockRejectedValue("plainstring");
const r = await mount(oneHttp());
await press(r, ["c"]);
await expectFrame(r, "plainstring");
});
it("uses the default OAuth error label when the callback error is empty", async () => {
h.connect.mockRejectedValueOnce(unauthorized);
const r = await mount(oneHttp());
await press(r, ["c"]);
await waitUntil(() => h.cb.opts !== null);
h.cb.opts!.onError({});
await expectFrame(r, "OAuth error");
});
it("falls back to params.error when the callback has no description", async () => {
h.connect.mockRejectedValueOnce(unauthorized);
const r = await mount(oneHttp());
await press(r, ["c"]);
await waitUntil(() => h.cb.opts !== null);
h.cb.opts!.onError({ error: "oauth-error-code" });
await expectFrame(r, "oauth-error-code");
});
it("wraps a non-Error callback completion failure into an Error", async () => {
h.connect.mockRejectedValueOnce(unauthorized);
h.clientSpies.completeOAuthFlow.mockRejectedValue("cb-string");
const r = await mount(oneHttp());
await press(r, ["c"]);
await waitUntil(() => h.cb.opts !== null);
await h.cb.opts!.onCallback({ code: "x" });
await expectFrame(r, "cb-string");
});
});

View file

@ -0,0 +1,244 @@
import React from "react";
import { describe, it, expect, vi } from "vitest";
import { render } from "ink-testing-library";
import type { OAuthConnectionState } from "@inspector/core/auth/types.js";
import type { InspectorClient } from "@inspector/core/mcp/index.js";
vi.mock("ink-scroll-view", () => import("./helpers/inkScrollViewMock.js"));
import { AuthTab } from "../src/components/AuthTab.js";
const tick = async () => {
for (let i = 0; i < 8; i++)
await new Promise((resolve) => setTimeout(resolve, 4));
};
const ESC = String.fromCharCode(27);
const UP = `${ESC}[A`;
const DOWN = `${ESC}[B`;
const PAGE_UP = `${ESC}[5~`;
const PAGE_DOWN = `${ESC}[6~`;
const sampleOAuthState: OAuthConnectionState = {
authorized: true,
protocol: "standard",
serverUrl: "http://x/mcp",
client: {
clientId: "abc123",
registrationKind: "dcr",
},
tokens: {
access_token: "tok-abcdefghijklmnopqrstuvwxyz",
token_type: "Bearer",
},
authorizationServerMetadata: {
authorization_endpoint: "https://auth.example.com/authorize",
},
configuredScope: "read write",
};
function makeClient(oauthState?: OAuthConnectionState) {
const listeners = new Map<string, Set<() => void>>();
const getOAuthState = vi.fn(async () => oauthState);
const client = {
getOAuthState,
addEventListener: (event: string, fn: () => void) => {
if (!listeners.has(event)) listeners.set(event, new Set());
listeners.get(event)!.add(fn);
},
removeEventListener: (event: string, fn: () => void) => {
listeners.get(event)?.delete(fn);
},
};
const fire = (event: string) => {
listeners.get(event)?.forEach((fn) => fn());
};
return {
client: client as unknown as InspectorClient,
getOAuthState,
fire,
listeners,
};
}
const baseProps = {
serverName: "srv" as string | null,
serverConfig: null,
width: 120,
height: 30,
oauthRevision: 0,
onClearOAuth: vi.fn(),
connectionStatus: "disconnected" as const,
};
describe("AuthTab", () => {
it("renders the placeholder when there is no server", () => {
const { lastFrame } = render(
<AuthTab
{...baseProps}
serverName={null}
inspectorClient={null}
oauthStatus="idle"
oauthMessage={null}
/>,
);
expect(lastFrame() ?? "").toContain(
"Select a server to view authentication",
);
});
it("renders OAuth details from getOAuthState", async () => {
const { client } = makeClient(sampleOAuthState);
const { lastFrame } = render(
<AuthTab
{...baseProps}
inspectorClient={client}
oauthStatus="idle"
oauthMessage={null}
/>,
);
await tick();
const frame = lastFrame() ?? "";
expect(frame).toContain("OAuth Details");
expect(frame).toContain("Authorized");
expect(frame).toContain("abc123");
expect(frame).toContain("Dynamic (DCR)");
expect(frame).toContain("read, write");
expect(frame).toContain("tok-abcdefghijklmnopqrst");
});
it("shows the not-yet-authorized hint when getOAuthState is empty", async () => {
const { client } = makeClient(undefined);
const { lastFrame } = render(
<AuthTab
{...baseProps}
inspectorClient={client}
oauthStatus="idle"
oauthMessage={null}
/>,
);
await tick();
const frame = lastFrame() ?? "";
expect(frame).toContain("No OAuth information yet");
expect(frame).toContain("Connect (C) to authorize");
});
it("renders authenticating and error status messages", async () => {
const { client } = makeClient(undefined);
const { lastFrame, rerender } = render(
<AuthTab
{...baseProps}
inspectorClient={client}
oauthStatus="authenticating"
oauthMessage={null}
/>,
);
expect(lastFrame() ?? "").toContain("Authenticating");
rerender(
<AuthTab
{...baseProps}
inspectorClient={client}
oauthStatus="error"
oauthMessage="Something went wrong"
/>,
);
expect(lastFrame() ?? "").toContain("Something went wrong");
});
it("clears OAuth state on S and shows confirmation", async () => {
const onClearOAuth = vi.fn();
const { client } = makeClient(sampleOAuthState);
const { lastFrame, stdin } = render(
<AuthTab
{...baseProps}
focused
inspectorClient={client}
oauthStatus="idle"
oauthMessage={null}
onClearOAuth={onClearOAuth}
/>,
);
stdin.write("s");
await tick();
expect(onClearOAuth).toHaveBeenCalled();
expect(lastFrame() ?? "").toContain("OAuth state cleared");
});
it("shows clear+disconnect label when connected", async () => {
const { client } = makeClient(sampleOAuthState);
const { lastFrame } = render(
<AuthTab
{...baseProps}
focused
connectionStatus="connected"
inspectorClient={client}
oauthStatus="idle"
oauthMessage={null}
/>,
);
await tick();
expect(lastFrame() ?? "").toContain("clear+disconnect");
});
it("shows the focused footer when focused", async () => {
const { client } = makeClient(sampleOAuthState);
const { lastFrame } = render(
<AuthTab
{...baseProps}
focused
inspectorClient={client}
oauthStatus="idle"
oauthMessage={null}
/>,
);
await tick();
expect(lastFrame() ?? "").toContain("S clear");
});
it("scrolls with arrow and page keys when focused", async () => {
const { client } = makeClient(sampleOAuthState);
const { lastFrame, stdin } = render(
<AuthTab
{...baseProps}
height={80}
focused
inspectorClient={client}
oauthStatus="idle"
oauthMessage={null}
/>,
);
await tick();
stdin.write(UP);
await tick();
stdin.write(DOWN);
await tick();
stdin.write(PAGE_UP);
await tick();
stdin.write(PAGE_DOWN);
await tick();
expect(lastFrame() ?? "").toContain("OAuth Details");
});
it("refreshes OAuth state when oauthComplete fires", async () => {
const { client, getOAuthState, fire, listeners } =
makeClient(sampleOAuthState);
const { unmount } = render(
<AuthTab
{...baseProps}
inspectorClient={client}
oauthStatus="idle"
oauthMessage={null}
/>,
);
await tick();
expect(getOAuthState).toHaveBeenCalled();
expect(listeners.get("oauthComplete")?.size).toBe(1);
getOAuthState.mockClear();
fire("oauthComplete");
await tick();
expect(getOAuthState).toHaveBeenCalled();
unmount();
expect(listeners.get("oauthComplete")?.size).toBe(0);
});
});

View file

@ -0,0 +1,102 @@
import React from "react";
import { describe, it, expect, vi } from "vitest";
import { render } from "ink-testing-library";
import { Text } from "ink";
// ScrollView: passthrough so `content` mounts and the imperative ref API
// (scrollBy / getViewportHeight) exists for the scroll-key handlers.
vi.mock("ink-scroll-view", () => import("./helpers/inkScrollViewMock.js"));
import { DetailsModal } from "../src/components/DetailsModal.js";
// Ink processes stdin keypresses asynchronously — await this after stdin.write.
const tick = async () => {
// Flush several macrotask cycles so an effect -> setState -> re-render chain
// settles before assertions, even on slow/loaded CI (a single tick can race).
for (let i = 0; i < 8; i++)
await new Promise((resolve) => setTimeout(resolve, 4));
};
const ESC = String.fromCharCode(27);
const UP = `${ESC}[A`;
const DOWN = `${ESC}[B`;
const PAGE_UP = `${ESC}[5~`;
const PAGE_DOWN = `${ESC}[6~`;
describe("DetailsModal", () => {
it("renders without crashing with content", () => {
const { unmount } = render(
<DetailsModal
title="Details"
content={<Text>some content</Text>}
width={120}
height={30}
onClose={() => {}}
/>,
);
// Modal is position="absolute" so lastFrame is empty; just confirm it
// mounted and unmounts cleanly (running the resize cleanup effect).
unmount();
});
it("handles all scroll keys via the ScrollView ref", async () => {
const { stdin } = render(
<DetailsModal
title="Details"
content={<Text>scrollable</Text>}
width={120}
height={30}
onClose={() => {}}
/>,
);
await tick();
stdin.write(DOWN);
await tick();
stdin.write(UP);
await tick();
stdin.write(PAGE_DOWN);
await tick();
stdin.write(PAGE_UP);
await tick();
});
it("ignores keys it does not handle", async () => {
const onClose = vi.fn();
const { stdin } = render(
<DetailsModal
title="Details"
content={<Text>x</Text>}
width={120}
height={30}
onClose={onClose}
/>,
);
await tick();
// A plain character key matches none of the branches.
stdin.write("a");
await tick();
expect(onClose).not.toHaveBeenCalled();
});
it("calls onClose on ESC", async () => {
const onClose = vi.fn();
const { stdin } = render(
<DetailsModal
title="Details"
content={<Text>x</Text>}
width={120}
height={30}
onClose={onClose}
/>,
);
await tick();
stdin.write(ESC);
await tick();
expect(onClose).toHaveBeenCalledTimes(1);
});
});

View file

@ -0,0 +1,275 @@
import React from "react";
import { describe, it, expect, vi } from "vitest";
import { render } from "ink-testing-library";
import type { MessageEntry } from "@inspector/core/mcp/index.js";
// MUST mock ink-scroll-view: the real ScrollView renders a placeholder minimap
// in the non-TTY test env and never mounts its children. This passthrough
// renders children directly and stubs scrollBy/scrollTo/getViewportHeight.
vi.mock("ink-scroll-view", () => import("./helpers/inkScrollViewMock.js"));
import { HistoryTab } from "../src/components/HistoryTab.js";
// Ink processes stdin keypresses asynchronously — await this after stdin.write
// and after rerender() before asserting.
const tick = async () => {
// Flush several macrotask cycles so an effect -> setState -> re-render chain
// settles before assertions, even on slow/loaded CI (a single tick can race).
for (let i = 0; i < 8; i++)
await new Promise((resolve) => setTimeout(resolve, 4));
};
// Real terminal escape sequences so ink parses them as arrow / page keys.
const ESC = String.fromCharCode(27);
const UP = `${ESC}[A`;
const DOWN = `${ESC}[B`;
const PAGE_UP = `${ESC}[5~`;
const PAGE_DOWN = `${ESC}[6~`;
const ts = new Date("2024-01-01T12:34:56Z");
const entry = (over: Partial<MessageEntry>): MessageEntry =>
({
id: "id",
timestamp: ts,
direction: "request",
message: { jsonrpc: "2.0", id: 1, method: "ping" },
...over,
}) as unknown as MessageEntry;
// One entry exercising each label / direction / detail branch.
const reqWithResponse = entry({
id: "m0",
direction: "request",
message: { jsonrpc: "2.0", id: 1, method: "tools/list", params: {} },
response: { jsonrpc: "2.0", id: 1, result: { tools: [] } },
duration: 5,
});
const reqPending = entry({
id: "m1",
direction: "request",
message: { jsonrpc: "2.0", id: 2, method: "tools/call" },
});
const respResult = entry({
id: "m2",
direction: "response",
message: { jsonrpc: "2.0", id: 3, result: { ok: true } },
});
const respError = entry({
id: "m3",
direction: "response",
message: { jsonrpc: "2.0", id: 4, error: { code: -32601, message: "no" } },
});
const respPlain = entry({
id: "m4",
direction: "response",
message: { jsonrpc: "2.0", id: 5 },
});
const notification = entry({
id: "m5",
direction: "notification",
message: { jsonrpc: "2.0", method: "notifications/message" },
});
const unknownEntry = entry({
id: "m6",
direction: "notification",
message: { jsonrpc: "2.0" },
});
const allMessages: MessageEntry[] = [
reqWithResponse,
reqPending,
respResult,
respError,
respPlain,
notification,
unknownEntry,
];
describe("HistoryTab", () => {
it("renders the empty state when there are no messages", () => {
const onCountChange = vi.fn();
const { lastFrame } = render(
<HistoryTab
serverName="srv"
messages={[]}
width={120}
height={30}
onCountChange={onCountChange}
/>,
);
const frame = lastFrame() ?? "";
expect(frame).toContain("Messages (0)");
expect(frame).toContain("No messages");
expect(frame).toContain("Select a message to view details");
expect(onCountChange).toHaveBeenCalledWith(0);
});
it("renders every list-label and direction-symbol variant", () => {
const { lastFrame } = render(
<HistoryTab
serverName="srv"
messages={allMessages}
width={120}
height={40}
/>,
);
const frame = lastFrame() ?? "";
expect(frame).toContain("Messages (7)");
// request with response → "✓"; pending request → "..."
expect(frame).toContain("→ tools/list ✓");
expect(frame).toContain("→ tools/call ...");
// response labels
expect(frame).toContain("← Response (result)");
expect(frame).toContain("← Response (error: -32601)");
expect(frame).toContain("← Response");
// notification + unknown
expect(frame).toContain("• notifications/message");
expect(frame).toContain("• Unknown");
expect(frame).toContain("▶ ");
});
it("renders request details with a response section and duration", () => {
const { lastFrame } = render(
<HistoryTab
serverName="srv"
messages={[reqWithResponse]}
width={120}
height={40}
/>,
);
const frame = lastFrame() ?? "";
expect(frame).toContain("Direction: request");
expect(frame).toContain("(5ms)");
expect(frame).toContain("Request:");
expect(frame).toContain("Response:");
});
it("renders the waiting-for-response placeholder for a pending request", () => {
const { lastFrame } = render(
<HistoryTab
serverName="srv"
messages={[reqPending]}
width={120}
height={40}
/>,
);
const frame = lastFrame() ?? "";
expect(frame).toContain("Request:");
expect(frame).toContain("Waiting for response...");
});
it("renders response details with a Response label and Response header", () => {
const { lastFrame } = render(
<HistoryTab
serverName="srv"
messages={[respResult]}
width={120}
height={40}
/>,
);
const frame = lastFrame() ?? "";
expect(frame).toContain("Direction: response");
expect(frame).toContain("Response:");
});
it("renders notification details with a Notification label", () => {
const { lastFrame } = render(
<HistoryTab
serverName="srv"
messages={[notification]}
width={120}
height={40}
/>,
);
const frame = lastFrame() ?? "";
expect(frame).toContain("Direction: notification");
expect(frame).toContain("Notification:");
// header uses the notification method
expect(frame).toContain("notifications/message");
});
it("falls back to the Message header for a methodless notification", () => {
const { lastFrame } = render(
<HistoryTab
serverName="srv"
messages={[unknownEntry]}
width={120}
height={40}
/>,
);
expect(lastFrame() ?? "").toContain("Message");
});
it("moves selection with arrows and page keys when the list is focused", async () => {
const { lastFrame, stdin } = render(
<HistoryTab
serverName="srv"
messages={allMessages}
width={120}
height={12}
focusedPane="messages"
/>,
);
// up at top boundary: no movement
stdin.write(UP);
await tick();
// down to the next message
stdin.write(DOWN);
await tick();
expect(lastFrame() ?? "").toContain("Direction: request");
// pageDown jumps toward the end, pageUp back toward the start
stdin.write(PAGE_DOWN);
await tick();
stdin.write(PAGE_UP);
await tick();
// up to move back toward the top
stdin.write(UP);
await tick();
expect(lastFrame() ?? "").toContain("Messages (7)");
});
it("handles details-pane scrolling, footer, and zoom shortcut", async () => {
const onViewDetails = vi.fn();
const { lastFrame, stdin } = render(
<HistoryTab
serverName="srv"
messages={allMessages}
width={120}
height={40}
focusedPane="details"
onViewDetails={onViewDetails}
/>,
);
expect(lastFrame() ?? "").toContain("↑/↓ to scroll, + to zoom");
stdin.write(UP);
await tick();
stdin.write(DOWN);
await tick();
stdin.write(PAGE_UP);
await tick();
stdin.write(PAGE_DOWN);
await tick();
stdin.write("+");
await tick();
expect(onViewDetails).toHaveBeenCalledWith(allMessages[0]);
});
it("does not fire input handlers when a modal is open", async () => {
const onViewDetails = vi.fn();
const { stdin } = render(
<HistoryTab
serverName="srv"
messages={allMessages}
width={120}
height={40}
focusedPane="details"
onViewDetails={onViewDetails}
modalOpen={true}
/>,
);
stdin.write("+");
await tick();
expect(onViewDetails).not.toHaveBeenCalled();
});
});

View file

@ -0,0 +1,401 @@
import React from "react";
import { describe, it, expect, vi } from "vitest";
import { render } from "ink-testing-library";
// MUST mock ink-scroll-view: the real ScrollView renders a placeholder minimap
// in the non-TTY test env and never mounts its children. This passthrough
// renders children directly and stubs scrollBy/scrollTo/getViewportHeight.
vi.mock("ink-scroll-view", () => import("./helpers/inkScrollViewMock.js"));
import type {
MCPServerConfig,
ServerState,
} from "@inspector/core/mcp/index.js";
import { InfoTab } from "../src/components/InfoTab.js";
// Ink processes stdin keypresses asynchronously — await this after stdin.write
// and after rerender() before asserting.
const tick = async () => {
// Flush several macrotask cycles so an effect -> setState -> re-render chain
// settles before assertions, even on slow/loaded CI (a single tick can race).
for (let i = 0; i < 8; i++)
await new Promise((resolve) => setTimeout(resolve, 4));
};
// Real terminal escape sequences (with the leading ESC) so ink reliably parses
// them as arrow / page keys.
const ESC = String.fromCharCode(27);
const UP = `${ESC}[A`;
const DOWN = `${ESC}[B`;
const PAGE_UP = `${ESC}[5~`;
const PAGE_DOWN = `${ESC}[6~`;
const stdioConfig: MCPServerConfig = {
type: "stdio",
command: "node",
args: ["server.js", "--flag"],
env: { FOO: "bar", BAZ: "qux" },
cwd: "/tmp/work",
};
const baseState: ServerState = {
status: "disconnected",
error: null,
resources: [],
prompts: [],
tools: [],
stderrLogs: [],
};
describe("InfoTab", () => {
it("renders nothing beyond the header when serverName is null", () => {
const { lastFrame } = render(
<InfoTab
serverName={null}
serverConfig={null}
serverState={null}
width={80}
height={20}
/>,
);
const frame = lastFrame() ?? "";
expect(frame).toContain("Info");
expect(frame).not.toContain("Server Configuration");
});
it("renders 'No configuration available' when config is null", () => {
const { lastFrame } = render(
<InfoTab
serverName="my-server"
serverConfig={null}
serverState={baseState}
width={80}
height={20}
/>,
);
const frame = lastFrame() ?? "";
expect(frame).toContain("Server Configuration");
expect(frame).toContain("No configuration available");
expect(frame).toContain("Server not connected");
});
it("renders a full stdio config (command, args, env, cwd)", () => {
const { lastFrame } = render(
<InfoTab
serverName="my-server"
serverConfig={stdioConfig}
serverState={baseState}
width={80}
height={20}
/>,
);
const frame = lastFrame() ?? "";
expect(frame).toContain("Type: stdio");
expect(frame).toContain("Command: node");
expect(frame).toContain("Args:");
expect(frame).toContain("server.js");
expect(frame).toContain("--flag");
expect(frame).toContain("Env:");
expect(frame).toContain("FOO=bar");
expect(frame).toContain("CWD: /tmp/work");
});
it("renders a stdio config with type omitted (undefined defaults to stdio)", () => {
const config: MCPServerConfig = {
command: "python",
};
const { lastFrame } = render(
<InfoTab
serverName="my-server"
serverConfig={config}
serverState={baseState}
width={80}
height={20}
/>,
);
const frame = lastFrame() ?? "";
expect(frame).toContain("Type: stdio");
expect(frame).toContain("Command: python");
// No args/env/cwd → those optional blocks are skipped
expect(frame).not.toContain("Args:");
expect(frame).not.toContain("Env:");
expect(frame).not.toContain("CWD:");
});
it("renders an sse config with headers", () => {
const config: MCPServerConfig = {
type: "sse",
url: "https://example.com/sse",
};
const withHeaders = {
...config,
headers: { Authorization: "Bearer x" },
} as unknown as MCPServerConfig;
const { lastFrame } = render(
<InfoTab
serverName="my-server"
serverConfig={withHeaders}
serverState={baseState}
width={80}
height={20}
/>,
);
const frame = lastFrame() ?? "";
expect(frame).toContain("Type: sse");
expect(frame).toContain("URL: https://example.com/sse");
expect(frame).toContain("Headers:");
expect(frame).toContain("Authorization=Bearer x");
});
it("renders an sse config without headers", () => {
const config: MCPServerConfig = {
type: "sse",
url: "https://example.com/sse",
};
const { lastFrame } = render(
<InfoTab
serverName="my-server"
serverConfig={config}
serverState={baseState}
width={80}
height={20}
/>,
);
const frame = lastFrame() ?? "";
expect(frame).toContain("Type: sse");
expect(frame).not.toContain("Headers:");
});
it("renders a streamable-http config with headers", () => {
const config = {
type: "streamable-http",
url: "https://example.com/mcp",
headers: { "X-Key": "abc" },
} as unknown as MCPServerConfig;
const { lastFrame } = render(
<InfoTab
serverName="my-server"
serverConfig={config}
serverState={baseState}
width={80}
height={20}
/>,
);
const frame = lastFrame() ?? "";
expect(frame).toContain("Type: streamable-http");
expect(frame).toContain("URL: https://example.com/mcp");
expect(frame).toContain("Headers:");
expect(frame).toContain("X-Key=abc");
});
it("renders a streamable-http config without headers", () => {
const config: MCPServerConfig = {
type: "streamable-http",
url: "https://example.com/mcp",
};
const { lastFrame } = render(
<InfoTab
serverName="my-server"
serverConfig={config}
serverState={baseState}
width={80}
height={20}
/>,
);
const frame = lastFrame() ?? "";
expect(frame).toContain("Type: streamable-http");
expect(frame).not.toContain("Headers:");
});
it("renders connected server information (name, version, instructions)", () => {
const state: ServerState = {
...baseState,
status: "connected",
serverInfo: { name: "Test Server", version: "1.2.3" },
instructions: "Use me wisely",
};
const { lastFrame } = render(
<InfoTab
serverName="my-server"
serverConfig={stdioConfig}
serverState={state}
width={80}
height={30}
/>,
);
const frame = lastFrame() ?? "";
expect(frame).toContain("Server Information");
expect(frame).toContain("Name: Test Server");
expect(frame).toContain("Version: 1.2.3");
expect(frame).toContain("Instructions:");
expect(frame).toContain("Use me wisely");
});
it("renders connected server info without optional fields", () => {
const state: ServerState = {
...baseState,
status: "connected",
serverInfo: { name: "", version: "" },
};
const { lastFrame } = render(
<InfoTab
serverName="my-server"
serverConfig={stdioConfig}
serverState={state}
width={80}
height={30}
/>,
);
const frame = lastFrame() ?? "";
expect(frame).toContain("Server Information");
expect(frame).not.toContain("Name:");
expect(frame).not.toContain("Version:");
expect(frame).not.toContain("Instructions:");
});
it("does not render Server Information when connected but no serverInfo", () => {
const state: ServerState = {
...baseState,
status: "connected",
};
const { lastFrame } = render(
<InfoTab
serverName="my-server"
serverConfig={stdioConfig}
serverState={state}
width={80}
height={30}
/>,
);
expect(lastFrame() ?? "").not.toContain("Server Information");
});
it("renders an error status with error message", () => {
const state: ServerState = {
...baseState,
status: "error",
error: "boom failed to connect",
};
const { lastFrame } = render(
<InfoTab
serverName="my-server"
serverConfig={null}
serverState={state}
width={80}
height={40}
/>,
);
const frame = lastFrame() ?? "";
expect(frame).toContain("Error");
expect(frame).toContain("boom failed to connect");
});
it("renders an error status without an error message", () => {
const state: ServerState = {
...baseState,
status: "error",
error: null,
};
const { lastFrame } = render(
<InfoTab
serverName="my-server"
serverConfig={null}
serverState={state}
width={80}
height={40}
/>,
);
expect(lastFrame() ?? "").toContain("Error");
});
it("shows the footer and handles scroll keys when focused", async () => {
const { lastFrame, stdin } = render(
<InfoTab
serverName="my-server"
serverConfig={stdioConfig}
serverState={baseState}
width={80}
height={20}
focused
/>,
);
const frame = lastFrame() ?? "";
expect(frame).toContain("to scroll");
// Drive every useInput branch
stdin.write(""); // up arrow
await tick();
stdin.write(""); // down arrow
await tick();
stdin.write("[5~"); // pageUp
await tick();
stdin.write("[6~"); // pageDown
await tick();
// A non-handled key (no branch) to exercise the else fall-through
stdin.write("x");
await tick();
expect(lastFrame() ?? "").toContain("Info");
});
it("renders no config details for an unrecognized server type", () => {
// type is none of stdio / sse / streamable-http → the final `: null`
// ternary branch in the config block.
const config = {
type: "websocket",
url: "ws://example.com",
} as unknown as MCPServerConfig;
const { lastFrame } = render(
<InfoTab
serverName="my-server"
serverConfig={config}
serverState={baseState}
width={80}
height={20}
/>,
);
const frame = lastFrame() ?? "";
expect(frame).toContain("Server Configuration");
expect(frame).not.toContain("Type: stdio");
expect(frame).not.toContain("Type: sse");
expect(frame).not.toContain("Type: streamable-http");
});
it("handles scroll keys when focused but the scroll ref is null", async () => {
// serverName is null → no ScrollView mounts, so scrollViewRef.current is
// null. The handler is still active (isActive: focused), so the keys
// exercise the optional-chaining + `getViewportHeight() || 1` fallbacks.
const { stdin } = render(
<InfoTab
serverName={null}
serverConfig={null}
serverState={null}
width={80}
height={20}
focused
/>,
);
stdin.write(UP);
await tick();
stdin.write(DOWN);
await tick();
stdin.write(PAGE_UP); // null ref → `|| 1`
await tick();
stdin.write(PAGE_DOWN); // null ref → `|| 1`
await tick();
expect(true).toBe(true);
});
it("does not show the footer when not focused", () => {
const { lastFrame } = render(
<InfoTab
serverName="my-server"
serverConfig={stdioConfig}
serverState={baseState}
width={80}
height={20}
/>,
);
expect(lastFrame() ?? "").not.toContain("to scroll");
});
});

View file

@ -0,0 +1,184 @@
import React from "react";
import { describe, it, expect, vi } from "vitest";
import { render } from "ink-testing-library";
// MUST mock ink-scroll-view: the real ScrollView renders a placeholder minimap
// in the non-TTY test env and never mounts its children. This passthrough
// renders children directly and stubs scrollBy/scrollTo/getViewportHeight.
vi.mock("ink-scroll-view", () => import("./helpers/inkScrollViewMock.js"));
import type { StderrLogEntry } from "@inspector/core/mcp/index.js";
import { NotificationsTab } from "../src/components/NotificationsTab.js";
// Ink processes stdin keypresses asynchronously — await this after stdin.write
// and after rerender() before asserting.
const tick = async () => {
// Flush several macrotask cycles so an effect -> setState -> re-render chain
// settles before assertions, even on slow/loaded CI (a single tick can race).
for (let i = 0; i < 8; i++)
await new Promise((resolve) => setTimeout(resolve, 4));
};
// Real terminal escape sequences (with the leading ESC) so ink reliably parses
// them as arrow / page keys for this component's useInput handler.
const ESC = String.fromCharCode(27);
const UP = `${ESC}[A`;
const DOWN = `${ESC}[B`;
const PAGE_UP = `${ESC}[5~`;
const PAGE_DOWN = `${ESC}[6~`;
const makeLog = (message: string): StderrLogEntry => ({
timestamp: new Date("2026-06-27T12:34:56Z"),
message,
});
describe("NotificationsTab", () => {
it("renders the empty state when there are no logs", () => {
const { lastFrame } = render(
<NotificationsTab stderrLogs={[]} width={80} height={20} />,
);
const frame = lastFrame() ?? "";
expect(frame).toContain("Logging (0)");
expect(frame).toContain("No stderr output yet");
});
it("renders log entries with timestamps and messages", () => {
const logs = [makeLog("first error"), makeLog("second error")];
const { lastFrame } = render(
<NotificationsTab stderrLogs={logs} width={80} height={20} />,
);
const frame = lastFrame() ?? "";
expect(frame).toContain("Logging (2)");
expect(frame).toContain("first error");
expect(frame).toContain("second error");
expect(frame).not.toContain("No stderr output yet");
});
it("invokes onCountChange with the initial log count", () => {
const onCountChange = vi.fn();
render(
<NotificationsTab
stderrLogs={[makeLog("a"), makeLog("b")]}
width={80}
height={20}
onCountChange={onCountChange}
/>,
);
expect(onCountChange).toHaveBeenCalledWith(2);
});
it("re-invokes onCountChange when the log count changes", async () => {
const onCountChange = vi.fn();
const { rerender } = render(
<NotificationsTab
stderrLogs={[makeLog("a")]}
width={80}
height={20}
onCountChange={onCountChange}
/>,
);
expect(onCountChange).toHaveBeenLastCalledWith(1);
rerender(
<NotificationsTab
stderrLogs={[makeLog("a"), makeLog("b"), makeLog("c")]}
width={80}
height={20}
onCountChange={onCountChange}
/>,
);
await tick();
expect(onCountChange).toHaveBeenLastCalledWith(3);
});
it("picks up a changed onCountChange callback via the ref effect", async () => {
const first = vi.fn();
const second = vi.fn();
const { rerender } = render(
<NotificationsTab
stderrLogs={[makeLog("a")]}
width={80}
height={20}
onCountChange={first}
/>,
);
expect(first).toHaveBeenLastCalledWith(1);
// New callback identity + new count → the ref is updated, then the
// count-change effect fires the latest callback.
rerender(
<NotificationsTab
stderrLogs={[makeLog("a"), makeLog("b")]}
width={80}
height={20}
onCountChange={second}
/>,
);
await tick();
expect(second).toHaveBeenLastCalledWith(2);
});
it("renders without an onCountChange prop", () => {
const { lastFrame } = render(
<NotificationsTab
stderrLogs={[makeLog("solo")]}
width={80}
height={20}
/>,
);
expect(lastFrame() ?? "").toContain("solo");
});
it("highlights the header and handles scroll keys when focused", async () => {
const logs = [makeLog("line one"), makeLog("line two")];
const { lastFrame, stdin } = render(
<NotificationsTab stderrLogs={logs} width={80} height={20} focused />,
);
expect(lastFrame() ?? "").toContain("Logging (2)");
// Drive every useInput branch
stdin.write(UP);
await tick();
stdin.write(DOWN);
await tick();
stdin.write(PAGE_UP);
await tick();
stdin.write(PAGE_DOWN);
await tick();
stdin.write("x"); // non-handled key → else fall-through
await tick();
expect(lastFrame() ?? "").toContain("line one");
});
it("handles scroll keys with no logs (ScrollView absent → null scroll ref)", async () => {
// With an empty log list the ScrollView (and its ref) is never mounted, so
// scrollViewRef.current is null. Driving the scroll keys exercises the
// optional-chaining + `getViewportHeight() || 1` fallback paths.
const { lastFrame, stdin } = render(
<NotificationsTab stderrLogs={[]} width={80} height={20} focused />,
);
expect(lastFrame() ?? "").toContain("No stderr output yet");
stdin.write(UP);
await tick();
stdin.write(DOWN);
await tick();
stdin.write(PAGE_UP);
await tick();
stdin.write(PAGE_DOWN);
await tick();
expect(lastFrame() ?? "").toContain("No stderr output yet");
});
it("does not react to keys when not focused", async () => {
const logs = [makeLog("line one")];
const { lastFrame, stdin } = render(
<NotificationsTab stderrLogs={logs} width={80} height={20} />,
);
stdin.write(DOWN);
await tick();
expect(lastFrame() ?? "").toContain("line one");
});
});

View file

@ -0,0 +1,344 @@
import React from "react";
import { describe, it, expect, vi, afterEach } from "vitest";
import { render } from "ink-testing-library";
import type { InspectorClient } from "@inspector/core/mcp/index.js";
import type { Prompt } from "@modelcontextprotocol/sdk/types.js";
// ScrollView: passthrough so the results JSX actually mounts (and is counted
// for coverage) and the imperative ref API exists for the scroll handlers.
vi.mock("ink-scroll-view", () => import("./helpers/inkScrollViewMock.js"));
// Form: a double that fires onSubmit when the user presses Enter ("\r").
vi.mock("ink-form", () => import("./helpers/inkFormMock.js"));
import { PromptTestModal } from "../src/components/PromptTestModal.js";
// Ink processes stdin keypresses asynchronously — await this after stdin.write
// and let the async getPrompt promise + setState settle.
const tick = async () => {
// Flush several macrotask cycles so an effect -> setState -> re-render chain
// settles before assertions, even on slow/loaded CI (a single tick can race).
for (let i = 0; i < 8; i++)
await new Promise((resolve) => setTimeout(resolve, 4));
};
const ESC = String.fromCharCode(27);
const UP = `${ESC}[A`;
const DOWN = `${ESC}[B`;
const PAGE_UP = `${ESC}[5~`;
const PAGE_DOWN = `${ESC}[6~`;
const ENTER = "\r";
const makePrompt = (over: Partial<Prompt> = {}): Prompt =>
({
name: "alpha",
description: "First prompt",
arguments: [{ name: "topic", description: "a topic", required: true }],
...over,
}) as unknown as Prompt;
// Set the value the mock Form submits on Enter; reset afterward so cases
// don't leak into one another.
function setFormSubmitValue(value: Record<string, string> | undefined) {
if (value === undefined) {
delete (globalThis as Record<string, unknown>).__INK_FORM_SUBMIT_VALUE__;
} else {
(globalThis as Record<string, unknown>).__INK_FORM_SUBMIT_VALUE__ = value;
}
}
afterEach(() => {
setFormSubmitValue(undefined);
vi.restoreAllMocks();
});
describe("PromptTestModal", () => {
it("submits the form, calls getPrompt, and shows results (success path)", async () => {
const getPrompt = vi.fn().mockResolvedValue({
result: {
description: "ok",
messages: [{ role: "user", content: { type: "text", text: "hello" } }],
},
});
const inspectorClient = { getPrompt } as unknown as InspectorClient;
const prompt = makePrompt();
// Submit a non-empty value so the "Arguments:" block (input length > 0)
// is rendered in the results view.
setFormSubmitValue({ topic: "weather" });
const { stdin } = render(
<PromptTestModal
prompt={prompt}
inspectorClient={inspectorClient}
width={120}
height={30}
onClose={() => {}}
/>,
);
await tick();
stdin.write(ENTER);
await tick();
await tick();
expect(getPrompt).toHaveBeenCalledWith("alpha", { topic: "weather" });
// Now in results mode — drive every scroll key branch.
stdin.write(DOWN);
await tick();
stdin.write(UP);
await tick();
stdin.write(PAGE_DOWN);
await tick();
stdin.write(PAGE_UP);
await tick();
});
it("renders the loading state while getPrompt is pending", async () => {
let resolveGetPrompt!: (value: { result: { messages: unknown[] } }) => void;
const getPrompt = vi.fn().mockReturnValue(
new Promise<{ result: { messages: unknown[] } }>((resolve) => {
resolveGetPrompt = resolve;
}),
);
const inspectorClient = { getPrompt } as unknown as InspectorClient;
const { stdin } = render(
<PromptTestModal
prompt={makePrompt()}
inspectorClient={inspectorClient}
width={120}
height={30}
onClose={() => {}}
/>,
);
await tick();
stdin.write(ENTER);
// One tick: state has transitioned to "loading" but the promise is still
// pending, so the loading JSX is mounted.
await tick();
expect(getPrompt).toHaveBeenCalledTimes(1);
// Now let it resolve and settle into results.
resolveGetPrompt({ result: { messages: [] } });
await tick();
await tick();
});
it("renders results with no arguments block when submitted value is empty", async () => {
const getPrompt = vi.fn().mockResolvedValue({
result: { messages: [] },
});
const inspectorClient = { getPrompt } as unknown as InspectorClient;
// Default submit value is {} → Object.keys(input).length === 0 branch.
const { stdin } = render(
<PromptTestModal
prompt={makePrompt({ description: undefined })}
inspectorClient={inspectorClient}
width={120}
height={30}
onClose={() => {}}
/>,
);
await tick();
stdin.write(ENTER);
await tick();
await tick();
expect(getPrompt).toHaveBeenCalledTimes(1);
});
it("shows error results when getPrompt rejects with an Error", async () => {
const getPrompt = vi.fn().mockRejectedValue(new Error("boom"));
const inspectorClient = { getPrompt } as unknown as InspectorClient;
setFormSubmitValue({ topic: "x" });
const { stdin } = render(
<PromptTestModal
prompt={makePrompt()}
inspectorClient={inspectorClient}
width={120}
height={30}
onClose={() => {}}
/>,
);
await tick();
stdin.write(ENTER);
await tick();
await tick();
expect(getPrompt).toHaveBeenCalledTimes(1);
// Scroll through the error results to exercise the results scroll branches.
stdin.write(DOWN);
await tick();
});
it("handles a rejected getPrompt that throws a string", async () => {
const getPrompt = vi.fn().mockRejectedValue("string failure");
const inspectorClient = { getPrompt } as unknown as InspectorClient;
const { stdin } = render(
<PromptTestModal
prompt={makePrompt()}
inspectorClient={inspectorClient}
width={120}
height={30}
onClose={() => {}}
/>,
);
await tick();
stdin.write(ENTER);
await tick();
await tick();
expect(getPrompt).toHaveBeenCalledTimes(1);
});
it("handles a rejected getPrompt that throws an object with a message", async () => {
const getPrompt = vi
.fn()
.mockRejectedValue({ message: "obj msg", code: 7 });
const inspectorClient = { getPrompt } as unknown as InspectorClient;
const { stdin } = render(
<PromptTestModal
prompt={makePrompt()}
inspectorClient={inspectorClient}
width={120}
height={30}
onClose={() => {}}
/>,
);
await tick();
stdin.write(ENTER);
await tick();
await tick();
expect(getPrompt).toHaveBeenCalledTimes(1);
});
it("handles a rejected getPrompt that throws an object without a message", async () => {
const getPrompt = vi.fn().mockRejectedValue({ code: 99 });
const inspectorClient = { getPrompt } as unknown as InspectorClient;
const { stdin } = render(
<PromptTestModal
prompt={makePrompt()}
inspectorClient={inspectorClient}
width={120}
height={30}
onClose={() => {}}
/>,
);
await tick();
stdin.write(ENTER);
await tick();
await tick();
expect(getPrompt).toHaveBeenCalledTimes(1);
});
it("handles a rejected getPrompt that throws a non-object, non-string value", async () => {
const getPrompt = vi.fn().mockRejectedValue(42);
const inspectorClient = { getPrompt } as unknown as InspectorClient;
const { stdin } = render(
<PromptTestModal
prompt={makePrompt()}
inspectorClient={inspectorClient}
width={120}
height={30}
onClose={() => {}}
/>,
);
await tick();
stdin.write(ENTER);
await tick();
await tick();
expect(getPrompt).toHaveBeenCalledTimes(1);
});
it("does nothing when inspectorClient is null (early return)", async () => {
const onClose = vi.fn();
const { stdin } = render(
<PromptTestModal
prompt={makePrompt()}
inspectorClient={null}
width={120}
height={30}
onClose={onClose}
/>,
);
await tick();
// Submitting the form hits handleFormSubmit which returns early because
// inspectorClient is null — state stays "form", no crash.
stdin.write(ENTER);
await tick();
await tick();
// Still in form mode: a non-escape key is ignored by the form-state branch.
stdin.write("a");
await tick();
expect(onClose).not.toHaveBeenCalled();
});
it("closes on ESC and resets state", async () => {
const onClose = vi.fn();
const getPrompt = vi.fn().mockResolvedValue({ result: { messages: [] } });
const inspectorClient = { getPrompt } as unknown as InspectorClient;
const { stdin } = render(
<PromptTestModal
prompt={makePrompt()}
inspectorClient={inspectorClient}
width={120}
height={30}
onClose={onClose}
/>,
);
await tick();
// Get into results mode first so the escape-from-results path runs.
stdin.write(ENTER);
await tick();
await tick();
stdin.write(ESC);
await tick();
expect(onClose).toHaveBeenCalledTimes(1);
});
it("unmounts cleanly, running cleanup effects", async () => {
const getPrompt = vi.fn().mockResolvedValue({ result: { messages: [] } });
const inspectorClient = { getPrompt } as unknown as InspectorClient;
const { unmount } = render(
<PromptTestModal
prompt={makePrompt({ arguments: undefined, description: undefined })}
inspectorClient={inspectorClient}
width={120}
height={30}
onClose={() => {}}
/>,
);
await tick();
unmount();
await tick();
expect(true).toBe(true);
});
});

View file

@ -0,0 +1,291 @@
import React from "react";
import { describe, it, expect, vi } from "vitest";
import { render } from "ink-testing-library";
import type { InspectorClient } from "@inspector/core/mcp/index.js";
import type { Prompt } from "@modelcontextprotocol/sdk/types.js";
// MUST mock ink-scroll-view: the real ScrollView renders a placeholder minimap
// in the non-TTY test env and never mounts its children. This passthrough
// renders children directly and stubs scrollBy/scrollTo/getViewportHeight.
vi.mock("ink-scroll-view", () => import("./helpers/inkScrollViewMock.js"));
import { PromptsTab } from "../src/components/PromptsTab.js";
// Ink processes stdin keypresses asynchronously — await this after stdin.write
// and after rerender() before asserting. The longer delay also lets the async
// getPrompt IIFE + setState settle.
const tick = async () => {
// Flush several macrotask cycles so an effect -> setState -> re-render chain
// settles before assertions, even on slow/loaded CI (a single tick can race).
for (let i = 0; i < 8; i++)
await new Promise((resolve) => setTimeout(resolve, 4));
};
const ESC = String.fromCharCode(27);
const UP = `${ESC}[A`;
const DOWN = `${ESC}[B`;
const PAGE_UP = `${ESC}[5~`;
const PAGE_DOWN = `${ESC}[6~`;
const makePrompt = (over: Partial<Prompt> = {}): Prompt =>
({
name: "alpha",
description: "First prompt",
...over,
}) as unknown as Prompt;
// p0: full prompt with multi-line description + three argument variants
// (description present / `type` fallback / "string" fallback)
// p1: prompt with no description and no arguments (absent branches)
// p2: empty name → "Prompt N" fallback label + index key
// p3: trailing prompt for scroll-window coverage
const prompts: Prompt[] = [
makePrompt({
name: "alpha",
description: "Line one\nLine two",
arguments: [
{ name: "withDesc", description: "the description" },
{ name: "withType", type: "number" } as never,
{ name: "bare" },
],
}),
makePrompt({ name: "beta", description: undefined, arguments: undefined }),
makePrompt({ name: "", description: "gamma desc" }),
makePrompt({ name: "delta", description: "Delta desc" }),
];
describe("PromptsTab", () => {
it("renders empty state when there are no prompts", () => {
const { lastFrame } = render(
<PromptsTab
prompts={[]}
inspectorClient={null}
width={120}
height={30}
/>,
);
const frame = lastFrame() ?? "";
expect(frame).toContain("Prompts (0)");
expect(frame).toContain("No prompts available");
expect(frame).toContain("Select a prompt to view details");
});
it("renders a populated list with the first prompt selected (unfocused)", () => {
const { lastFrame } = render(
<PromptsTab
prompts={prompts}
inspectorClient={null}
width={120}
height={30}
/>,
);
const frame = lastFrame() ?? "";
expect(frame).toContain("Prompts (4)");
expect(frame).toContain("alpha");
expect(frame).toContain("beta");
// empty-name prompt falls back to "Prompt N"
expect(frame).toContain("Prompt 3");
expect(frame).toContain("▶ ");
// selected prompt details (cyan branch since details not focused)
expect(frame).toContain("Line one");
expect(frame).toContain("Line two");
// arguments + their three description fallbacks
expect(frame).toContain("Arguments:");
expect(frame).toContain("the description");
expect(frame).toContain("number");
expect(frame).toContain("bare: string");
expect(frame).toContain("[Enter to Get Prompt]");
});
it("moves selection down/up with arrow keys when the list is focused", async () => {
const { lastFrame, stdin } = render(
<PromptsTab
prompts={prompts}
inspectorClient={null}
width={120}
height={30}
focusedPane="list"
/>,
);
// up at the top boundary: no movement
stdin.write(UP);
await tick();
// down moves selection to "beta" (no description, no arguments)
stdin.write(DOWN);
await tick();
let frame = lastFrame() ?? "";
expect(frame).toContain("beta");
expect(frame).not.toContain("Arguments:");
// back up to alpha
stdin.write(UP);
await tick();
frame = lastFrame() ?? "";
expect(frame).toContain("Line one");
});
it("scrolls the visible window when navigating past the viewport", async () => {
// height 9 → visibleCount = 2; 4 prompts force firstVisible to advance
const { lastFrame, stdin } = render(
<PromptsTab
prompts={prompts}
inspectorClient={null}
width={120}
height={9}
focusedPane="list"
/>,
);
// Overshoot: the downArrow guard clamps at the last index, so extra
// presses are harmless and absorb any dropped first keypress.
stdin.write(DOWN);
await tick();
stdin.write(DOWN);
await tick();
stdin.write(DOWN);
await tick();
stdin.write(DOWN);
await tick();
const frame = lastFrame() ?? "";
expect(frame).toContain("delta");
// down boundary: pressing down again does nothing
stdin.write(DOWN);
await tick();
expect(lastFrame() ?? "").toContain("delta");
});
it("calls onFetchPrompt when Enter is pressed on a prompt with arguments", async () => {
const onFetchPrompt = vi.fn();
const inspectorClient = {
getPrompt: vi.fn(),
} as unknown as InspectorClient;
const { stdin } = render(
<PromptsTab
prompts={prompts}
inspectorClient={inspectorClient}
width={120}
height={30}
focusedPane="list"
onFetchPrompt={onFetchPrompt}
/>,
);
stdin.write("\r");
await tick();
expect(onFetchPrompt).toHaveBeenCalledWith(prompts[0]);
});
it("fetches directly and calls onViewDetails when Enter is pressed on an argument-less prompt", async () => {
const onFetchPrompt = vi.fn();
const onViewDetails = vi.fn();
const result = { messages: [] };
const getPrompt = vi.fn().mockResolvedValue({ result });
const inspectorClient = { getPrompt } as unknown as InspectorClient;
const noArgPrompt = makePrompt({ name: "solo", arguments: undefined });
const { stdin } = render(
<PromptsTab
prompts={[noArgPrompt]}
inspectorClient={inspectorClient}
width={120}
height={30}
focusedPane="list"
onFetchPrompt={onFetchPrompt}
onViewDetails={onViewDetails}
/>,
);
stdin.write("\r");
await tick();
expect(getPrompt).toHaveBeenCalledWith("solo");
expect(onFetchPrompt).not.toHaveBeenCalled();
expect(onViewDetails).toHaveBeenCalledWith(
expect.objectContaining({ name: "solo", result }),
);
});
it("renders the Error message when getPrompt rejects with an Error", async () => {
const getPrompt = vi.fn().mockRejectedValue(new Error("boom failure"));
const inspectorClient = { getPrompt } as unknown as InspectorClient;
const noArgPrompt = makePrompt({ name: "solo", arguments: undefined });
const { lastFrame, stdin } = render(
<PromptsTab
prompts={[noArgPrompt]}
inspectorClient={inspectorClient}
width={120}
height={30}
focusedPane="list"
onFetchPrompt={vi.fn()}
onViewDetails={vi.fn()}
/>,
);
stdin.write("\r");
await tick();
expect(lastFrame() ?? "").toContain("boom failure");
});
it("falls back to a generic Error message when getPrompt rejects with a non-Error", async () => {
const getPrompt = vi.fn().mockRejectedValue("oops");
const inspectorClient = { getPrompt } as unknown as InspectorClient;
const noArgPrompt = makePrompt({ name: "solo", arguments: undefined });
const { lastFrame, stdin } = render(
<PromptsTab
prompts={[noArgPrompt]}
inspectorClient={inspectorClient}
width={120}
height={30}
focusedPane="list"
onFetchPrompt={vi.fn()}
onViewDetails={vi.fn()}
/>,
);
stdin.write("\r");
await tick();
expect(lastFrame() ?? "").toContain("Failed to get prompt");
});
it("handles details-pane scrolling, footer, and zoom shortcut", async () => {
const onViewDetails = vi.fn();
const { lastFrame, stdin } = render(
<PromptsTab
prompts={prompts}
inspectorClient={null}
width={120}
height={30}
focusedPane="details"
onViewDetails={onViewDetails}
/>,
);
// footer only shows while the details pane is focused
expect(lastFrame() ?? "").toContain("↑/↓ to scroll, + to zoom");
// scroll keys (exercise scrollBy / pageUp / pageDown branches)
stdin.write(UP);
await tick();
stdin.write(DOWN);
await tick();
stdin.write(PAGE_UP);
await tick();
stdin.write(PAGE_DOWN);
await tick();
// "+" opens the full-screen modal
stdin.write("+");
await tick();
expect(onViewDetails).toHaveBeenCalledWith(prompts[0]);
});
it("does not fire input handlers when a modal is open", async () => {
const onFetchPrompt = vi.fn();
const inspectorClient = {
getPrompt: vi.fn(),
} as unknown as InspectorClient;
const { stdin } = render(
<PromptsTab
prompts={prompts}
inspectorClient={inspectorClient}
width={120}
height={30}
focusedPane="list"
onFetchPrompt={onFetchPrompt}
modalOpen={true}
/>,
);
stdin.write("\r");
await tick();
expect(onFetchPrompt).not.toHaveBeenCalled();
});
});

View file

@ -0,0 +1,364 @@
import React from "react";
import { describe, it, expect, vi } from "vitest";
import { render } from "ink-testing-library";
import type { FetchRequestEntry } from "@inspector/core/mcp/index.js";
// MUST mock ink-scroll-view: the real ScrollView renders a placeholder minimap
// in the non-TTY test env and never mounts its children. This passthrough
// renders children directly and stubs scrollBy/scrollTo/getViewportHeight.
vi.mock("ink-scroll-view", () => import("./helpers/inkScrollViewMock.js"));
import { RequestsTab } from "../src/components/RequestsTab.js";
// Ink processes stdin keypresses asynchronously — await this after stdin.write.
const tick = async () => {
// Flush several macrotask cycles so an effect -> setState -> re-render chain
// settles before assertions, even on slow/loaded CI (a single tick can race).
for (let i = 0; i < 8; i++)
await new Promise((resolve) => setTimeout(resolve, 4));
};
const ESC = String.fromCharCode(27);
const UP = `${ESC}[A`;
const DOWN = `${ESC}[B`;
const PAGE_UP = `${ESC}[5~`;
const PAGE_DOWN = `${ESC}[6~`;
const TS = new Date("2024-01-01T12:34:56Z");
const req = (over: Partial<FetchRequestEntry>): FetchRequestEntry => ({
id: "id",
timestamp: TS,
method: "POST",
url: "https://example.com/mcp",
requestHeaders: { "content-type": "application/json" },
category: "transport",
...over,
});
// A fully-populated, successful transport request (2xx, JSON bodies).
const fullRequest = req({
id: "r0",
category: "auth",
method: "GET",
url: "https://example.com/oauth",
responseStatus: 200,
responseStatusText: "OK",
duration: 12,
requestHeaders: { authorization: "Bearer x" },
requestBody: JSON.stringify({ grant_type: "code" }),
responseHeaders: { "content-type": "application/json" },
responseBody: JSON.stringify({ access_token: "tok" }),
});
const errorRequest = req({
id: "r1",
method: "POST",
error: "connection refused",
});
const pendingRequest = req({
id: "r2",
method: "DELETE",
});
const nonJsonRequest = req({
id: "r3",
method: "PUT",
responseStatus: 404,
responseStatusText: "Not Found",
requestBody: "this is not json",
responseHeaders: {},
responseBody: "neither is this",
});
const redirectRequest = req({
id: "r4",
method: "GET",
responseStatus: 301,
});
const informationalRequest = req({
id: "r5",
method: "GET",
responseStatus: 100,
});
describe("RequestsTab", () => {
it("renders the empty state and reports a count of 0", () => {
const onCountChange = vi.fn();
const { lastFrame } = render(
<RequestsTab
serverName="srv"
requests={[]}
width={120}
height={30}
onCountChange={onCountChange}
/>,
);
const frame = lastFrame() ?? "";
expect(frame).toContain("Requests (0)");
expect(frame).toContain("No requests");
expect(frame).toContain("Select a request to view details");
expect(onCountChange).toHaveBeenCalledWith(0);
});
it("works without an onCountChange callback", () => {
const { lastFrame } = render(
<RequestsTab serverName="srv" requests={[]} width={120} height={30} />,
);
expect(lastFrame() ?? "").toContain("Requests (0)");
});
it("renders the list with status colors, labels and durations", () => {
const { lastFrame } = render(
<RequestsTab
serverName="srv"
requests={[
fullRequest,
errorRequest,
pendingRequest,
informationalRequest,
]}
width={120}
height={30}
/>,
);
const frame = lastFrame() ?? "";
expect(frame).toContain("Requests (4)");
// category labels
expect(frame).toContain("AUTH");
expect(frame).toContain("MCP");
// GET is padded; non-GET methods shown as-is
expect(frame).toContain("GET");
expect(frame).toContain("POST");
// status text variants: numeric / ERROR / "..."
expect(frame).toContain("200");
expect(frame).toContain("ERROR");
expect(frame).toContain("...");
// duration suffix
expect(frame).toContain("12ms");
// selection marker
expect(frame).toContain("▶ ");
});
it("renders full details for a successful request with JSON bodies", () => {
const { lastFrame } = render(
<RequestsTab
serverName="srv"
requests={[fullRequest]}
width={120}
height={40}
/>,
);
const frame = lastFrame() ?? "";
expect(frame).toContain("GET https://example.com/oauth");
expect(frame).toContain("Category:");
expect(frame).toContain("auth");
expect(frame).toContain("Status:");
expect(frame).toContain("200 OK");
expect(frame).toContain("(12ms)");
expect(frame).toContain("Request Headers:");
expect(frame).toContain("authorization: Bearer x");
expect(frame).toContain("Request Body:");
expect(frame).toContain("grant_type");
expect(frame).toContain("Response Headers:");
expect(frame).toContain("Response Body:");
expect(frame).toContain("access_token");
});
it("renders an error request detail", () => {
const { lastFrame } = render(
<RequestsTab
serverName="srv"
requests={[errorRequest]}
width={120}
height={40}
/>,
);
const frame = lastFrame() ?? "";
expect(frame).toContain("transport");
expect(frame).toContain("Error: connection refused");
});
it("renders the in-progress placeholder when there is no status or error", () => {
const { lastFrame } = render(
<RequestsTab
serverName="srv"
requests={[pendingRequest]}
width={120}
height={40}
/>,
);
expect(lastFrame() ?? "").toContain("Request in progress...");
});
it("renders raw (non-JSON) request and response bodies", () => {
const { lastFrame } = render(
<RequestsTab
serverName="srv"
requests={[nonJsonRequest]}
width={120}
height={40}
/>,
);
const frame = lastFrame() ?? "";
expect(frame).toContain("404 Not Found");
expect(frame).toContain("this is not json");
expect(frame).toContain("neither is this");
// empty responseHeaders object → section omitted
expect(frame).not.toContain("Response Headers:");
});
it("renders a redirect (3xx) status detail", () => {
const { lastFrame } = render(
<RequestsTab
serverName="srv"
requests={[redirectRequest]}
width={120}
height={40}
/>,
);
expect(lastFrame() ?? "").toContain("301");
});
it("highlights the details header when the details pane is focused", () => {
const { lastFrame } = render(
<RequestsTab
serverName="srv"
requests={[fullRequest]}
width={120}
height={40}
focusedPane="details"
/>,
);
const frame = lastFrame() ?? "";
expect(frame).toContain("↑/↓ to scroll, + to zoom");
expect(frame).toContain("GET https://example.com/oauth");
});
it("moves selection with arrows and page keys when the list is focused", async () => {
const many: FetchRequestEntry[] = Array.from({ length: 8 }, (_, i) =>
req({
id: `m${i}`,
method: i % 2 === 0 ? "GET" : "POST",
url: `https://example.com/r${i}`,
responseStatus: 200,
}),
);
const { lastFrame, stdin } = render(
<RequestsTab
serverName="srv"
requests={many}
width={120}
height={12}
focusedPane="requests"
/>,
);
// up at top boundary: no movement
stdin.write(UP);
await tick();
// down moves selection to the next request
stdin.write(DOWN);
await tick();
expect(lastFrame() ?? "").toContain("https://example.com/r1");
// pageDown jumps toward the end
stdin.write(PAGE_DOWN);
await tick();
expect(lastFrame() ?? "").toContain("https://example.com/r");
// pageUp back toward the start
stdin.write(PAGE_UP);
await tick();
// up moves back toward the top
stdin.write(UP);
await tick();
expect(lastFrame() ?? "").toContain("Requests (8)");
});
it("clamps at the bottom when paging past the end", async () => {
const many: FetchRequestEntry[] = Array.from({ length: 4 }, (_, i) =>
req({ id: `c${i}`, url: `https://example.com/c${i}` }),
);
const { lastFrame, stdin } = render(
<RequestsTab
serverName="srv"
requests={many}
width={120}
height={10}
focusedPane="requests"
/>,
);
// overshoot down to the last index, then page down past the end
stdin.write(DOWN);
await tick();
stdin.write(DOWN);
await tick();
stdin.write(DOWN);
await tick();
stdin.write(PAGE_DOWN);
await tick();
stdin.write(DOWN);
await tick();
expect(lastFrame() ?? "").toContain("https://example.com/c3");
});
it("handles details-pane scrolling and the zoom shortcut", async () => {
const onViewDetails = vi.fn();
const { lastFrame, stdin } = render(
<RequestsTab
serverName="srv"
requests={[fullRequest]}
width={120}
height={40}
focusedPane="details"
onViewDetails={onViewDetails}
/>,
);
expect(lastFrame() ?? "").toContain("↑/↓ to scroll, + to zoom");
stdin.write(UP);
await tick();
stdin.write(DOWN);
await tick();
stdin.write(PAGE_UP);
await tick();
stdin.write(PAGE_DOWN);
await tick();
stdin.write("+");
await tick();
expect(onViewDetails).toHaveBeenCalledWith(fullRequest);
});
it("does not fire input handlers when a modal is open", async () => {
const onViewDetails = vi.fn();
const { stdin } = render(
<RequestsTab
serverName="srv"
requests={[fullRequest]}
width={120}
height={40}
focusedPane="details"
onViewDetails={onViewDetails}
modalOpen={true}
/>,
);
stdin.write("+");
await tick();
expect(onViewDetails).not.toHaveBeenCalled();
});
it("ignores '+' when no onViewDetails handler is provided", async () => {
const { lastFrame, stdin } = render(
<RequestsTab
serverName="srv"
requests={[fullRequest]}
width={120}
height={40}
focusedPane="details"
/>,
);
stdin.write("+");
await tick();
// still rendered, no crash
expect(lastFrame() ?? "").toContain("GET https://example.com/oauth");
});
});

View file

@ -0,0 +1,267 @@
import React from "react";
import { describe, it, expect, vi, afterEach } from "vitest";
import { render } from "ink-testing-library";
import type { InspectorClient } from "@inspector/core/mcp/index.js";
// ScrollView passthrough so the results JSX actually mounts (and is covered).
vi.mock("ink-scroll-view", () => import("./helpers/inkScrollViewMock.js"));
// Form double that fires onSubmit when the user presses Enter ("\r").
vi.mock("ink-form", () => import("./helpers/inkFormMock.js"));
import { ResourceTestModal } from "../src/components/ResourceTestModal.js";
// These modals render position="absolute", which produces an EMPTY frame under
// ink-testing-library. So we assert on BEHAVIOR — the injected client fake's
// readResourceFromTemplate, onClose, and the state transitions they drive —
// rather than on lastFrame(). React still EXECUTES the inner results/error JSX,
// so its coverage is collected even though it isn't visible.
const tick = async () => {
// Flush several macrotask cycles so an effect -> setState -> re-render chain
// settles before assertions, even on slow/loaded CI (a single tick can race).
for (let i = 0; i < 8; i++)
await new Promise((resolve) => setTimeout(resolve, 4));
};
const ESC = String.fromCharCode(27);
const UP = `${ESC}[A`;
const DOWN = `${ESC}[B`;
const PAGE_UP = `${ESC}[5~`;
const PAGE_DOWN = `${ESC}[6~`;
type Template = {
name: string;
uriTemplate: string;
description?: string;
};
const makeTemplate = (over: Partial<Template> = {}): Template => ({
name: "Greeting",
uriTemplate: "greeting://{name}",
...over,
});
const fakeClient = (readResourceFromTemplate: unknown): InspectorClient =>
({ readResourceFromTemplate }) as unknown as InspectorClient;
const setSubmitValue = (value: Record<string, unknown>) => {
(globalThis as Record<string, unknown>).__INK_FORM_SUBMIT_VALUE__ = value;
};
afterEach(() => {
delete (globalThis as Record<string, unknown>).__INK_FORM_SUBMIT_VALUE__;
vi.restoreAllMocks();
});
const renderAndSubmit = async (
client: InspectorClient | null,
template: Template = makeTemplate(),
submitValue: Record<string, unknown> = { name: "world" },
) => {
const onClose = vi.fn();
const api = render(
<ResourceTestModal
template={template}
inspectorClient={client}
width={80}
height={24}
onClose={onClose}
/>,
);
await tick();
setSubmitValue(submitValue);
api.stdin.write("\r");
await tick();
await tick();
return { ...api, onClose };
};
describe("ResourceTestModal", () => {
it("renders the form initially without invoking the client", async () => {
const read = vi.fn();
const api = render(
<ResourceTestModal
template={makeTemplate()}
inspectorClient={fakeClient(read)}
width={80}
height={24}
onClose={vi.fn()}
/>,
);
await tick();
expect(read).not.toHaveBeenCalled();
api.unmount();
});
it("renders the template description when present", async () => {
const read = vi.fn();
const api = render(
<ResourceTestModal
template={makeTemplate({ description: "A friendly greeting" })}
inspectorClient={fakeClient(read)}
width={80}
height={24}
onClose={vi.fn()}
/>,
);
await tick();
api.unmount();
});
it("uses the 'Unknown Template' label when the template has an empty name", async () => {
const read = vi.fn();
const api = render(
<ResourceTestModal
template={makeTemplate({ name: "" })}
inspectorClient={fakeClient(read)}
width={80}
height={24}
onClose={vi.fn()}
/>,
);
await tick();
api.unmount();
});
it("renders the loading state while the read is in flight", async () => {
let resolveRead: (v: unknown) => void = () => {};
const pending = new Promise((resolve) => {
resolveRead = resolve;
});
const read = vi.fn().mockReturnValue(pending);
const api = render(
<ResourceTestModal
template={makeTemplate()}
inspectorClient={fakeClient(read)}
width={80}
height={24}
onClose={vi.fn()}
/>,
);
await tick();
setSubmitValue({ name: "world" });
api.stdin.write("\r");
await tick();
// The component is now committed in the "loading" state (read not resolved).
expect(read).toHaveBeenCalled();
resolveRead({
result: { contents: [{ uri: "greeting://world", text: "hi" }] },
expandedUri: "greeting://world",
});
await tick();
await tick();
api.unmount();
});
it("reads the resource and shows successful content", async () => {
const read = vi.fn().mockResolvedValue({
result: { contents: [{ uri: "greeting://world", text: "hi" }] },
expandedUri: "greeting://world",
});
const { onClose, stdin, unmount } = await renderAndSubmit(
fakeClient(read),
makeTemplate(),
{ name: "world" },
);
expect(read).toHaveBeenCalledWith("greeting://{name}", { name: "world" });
// Drive scroll keys in results state for scrollBy / page coverage.
stdin.write(DOWN);
await tick();
stdin.write(UP);
await tick();
stdin.write(PAGE_DOWN);
await tick();
stdin.write(PAGE_UP);
await tick();
expect(onClose).not.toHaveBeenCalled();
unmount();
});
it("catches an Error thrown by readResourceFromTemplate", async () => {
const read = vi.fn().mockRejectedValue(new Error("not found"));
const { unmount } = await renderAndSubmit(fakeClient(read));
expect(read).toHaveBeenCalled();
unmount();
});
it("catches a string thrown by readResourceFromTemplate", async () => {
const read = vi.fn().mockRejectedValue("boom");
const { unmount } = await renderAndSubmit(fakeClient(read));
expect(read).toHaveBeenCalled();
unmount();
});
it("catches an object error carrying uri and message (uri + Object.assign branches)", async () => {
const read = vi
.fn()
.mockRejectedValue({ message: "bad uri", uri: "greeting://expanded" });
const { unmount } = await renderAndSubmit(fakeClient(read));
expect(read).toHaveBeenCalled();
unmount();
});
it("catches an object error without message or uri (Object.assign + Unknown error)", async () => {
const read = vi.fn().mockRejectedValue({ code: 42 });
const { unmount } = await renderAndSubmit(fakeClient(read));
expect(read).toHaveBeenCalled();
unmount();
});
it("catches a non-object, non-string throw (else branch)", async () => {
const read = vi.fn().mockRejectedValue(12345);
const { unmount } = await renderAndSubmit(fakeClient(read));
expect(read).toHaveBeenCalled();
unmount();
});
it("does nothing on submit when inspectorClient is null (early-return guard)", async () => {
const { onClose, unmount } = await renderAndSubmit(null);
expect(onClose).not.toHaveBeenCalled();
unmount();
});
it("closes on ESC while in form state", async () => {
const onClose = vi.fn();
const api = render(
<ResourceTestModal
template={makeTemplate()}
inspectorClient={fakeClient(vi.fn())}
width={80}
height={24}
onClose={onClose}
/>,
);
await tick();
api.stdin.write(ESC);
await tick();
expect(onClose).toHaveBeenCalledTimes(1);
api.unmount();
});
it("closes on ESC while in results state", async () => {
const read = vi.fn().mockResolvedValue({
result: { contents: [{ uri: "greeting://world", text: "hi" }] },
expandedUri: "greeting://world",
});
const { onClose, stdin, unmount } = await renderAndSubmit(fakeClient(read));
stdin.write(ESC);
await tick();
expect(onClose).toHaveBeenCalledTimes(1);
unmount();
});
it("responds to a stdout resize event", async () => {
const api = render(
<ResourceTestModal
template={makeTemplate()}
inspectorClient={fakeClient(vi.fn())}
width={80}
height={24}
onClose={vi.fn()}
/>,
);
await tick();
process.stdout.emit("resize");
await tick();
api.unmount();
});
});

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