chore: close quality-gate coverage gaps (#1689) (#1766)
Some checks failed
Run install, format, lint, build, and test on every push / build (push) Has been cancelled
Run install, format, lint, build, and test on every push / publish (push) Has been cancelled
Run install, format, lint, build, and test on every push / publish-github-container-registry (push) Has been cancelled

Closes #1689. Typecheck cli/tui src, add a core/ format+lint gate, and gate core/json + core/client coverage. See PR #1766 for the full review history.
This commit is contained in:
Cliff Hall 2026-07-24 17:20:12 -04:00 • committed by GitHub
commit 373f346e5f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
82 changed files with 2210 additions and 467 deletions

1
.gitignore vendored
View file

@ -20,3 +20,4 @@ test-servers/build
/inspector-network-*.json
/*.server.json
/configs/
pr-screenshots

View file

@ -194,7 +194,7 @@ gh project item-edit --project-id PVT_kwDOCt2Azc4BJVxt --id "$ITEM_ID" --field-i
- Run TUI tests with `npm run test` from `clients/tui/`
- The repo root has no aggregate `test` script — each client self-validates, so run `npm run validate` from the root (all clients, fast) or `cd clients/<name> && npm run validate` (one client). Each client still exposes its own `test` / `test:coverage` for quick iteration.
- **`validate` is fast: it runs `test`, not `test:coverage`.** The coverage gate (slower — adds v8 instrumentation, and for web the integration project) is a **separate** top-level `npm run coverage` (and per-client `coverage:web` / `coverage:cli` / `coverage:tui` / `coverage:launcher`, each delegating to that client's `test:coverage`). Run `npm run coverage` when you want to reproduce the gate locally before pushing. **CI runs `coverage`** on every push (#1550): the per-file ≥90 gate is CI-enforced, so a PR that drops any file below 90 on lines/statements/functions/branches fails the job. CI runs `validate` (fast) for format/lint/build/unit tests, then `coverage` for the instrumented gate. Because web's `test:coverage` already runs the integration project, CI has no separate `test:integration` step — the integration paths are exercised inside the coverage gate.
- Each client's `test:coverage` enforces a **uniform per-file gate of ≥ 90 on all four dimensions** — lines, statements, functions, and branches — across `clients/web`, `clients/cli`, `clients/tui`, and `clients/launcher` (CI enforces this gate). This is the result of a codebase-wide audit: the branch floor was first lifted 50 → 70 for web (#1271), then the whole gate raised to 90 with real tests added for every outlier. Genuinely-unreachable branches are **not** waved through by lowering the gate — they are annotated at the source with a justified `/* v8 ignore … -- <reason> */` comment. Acceptable reasons are happy-dom-inherent paths (Mantine portal mount points, `useMediaQuery` fallbacks, `typeof window` SSR guards), React StrictMode effect-replay blocks, and provably-dead defensive guards (e.g. a `?? fallback` for a value the types guarantee non-null, or a `Select.onChange` receiving a value outside the allowed list). New code must clear 90 on every dimension; reach for a justified `v8 ignore` only when a branch is genuinely impossible to exercise.
- Each client's `test:coverage` enforces a **uniform per-file gate of ≥ 90 on all four dimensions** — lines, statements, functions, and branches — across `clients/web`, `clients/cli`, `clients/tui`, and `clients/launcher` (CI enforces this gate). This is the result of a codebase-wide audit: the branch floor was first lifted 50 → 70 for web (#1271), then the whole gate raised to 90 with real tests added for every outlier. Genuinely-unreachable branches are **not** waved through by lowering the gate — they are annotated at the source with a justified `/* v8 ignore … -- <reason> */` comment. Acceptable reasons are happy-dom-inherent paths (Mantine portal mount points, `useMediaQuery` fallbacks, `typeof window` SSR guards), React StrictMode effect-replay blocks, and provably-dead defensive guards (e.g. a `?? fallback` for a value the types guarantee non-null, or a `Select.onChange` receiving a value outside the allowed list). New code must clear 90 on every dimension; reach for a justified `v8 ignore` only when a branch is genuinely impossible to exercise. The web coverage `include` (in `clients/web/vite.config.ts`) covers the shared `core/` runtime consumed by the browser — `core/mcp`, `core/react`, `core/auth`, `core/storage`, `core/logging`, `core/node`, **`core/json`, and `core/client`** (the last two folded in by #1689). When adding a `core/json/*` or `core/client/*` module, its tests live under `clients/web/src/test/core/…` and are gated the same ≥90 way.
- 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.
@ -209,10 +209,12 @@ gh project item-edit --project-id PVT_kwDOCt2Azc4BJVxt --id "$ITEM_ID" --field-i
- after making the changes, respond to each review comment with what was done (or why it was ignored)
### Mandatory pre-push gate
- ALWAYS do `npm run format` before committing — it auto-fixes any Prettier issues. `validate` runs `format:check` (the non-fixing variant) and will fail in CI on any unformatted file, so always run the auto-fixer first rather than letting `format:check` catch it.
- ALWAYS do `npm run format` before committing — the **root** `format` auto-fixes `core/` (`format:core`) and every client's scope in one shot. `validate` runs `format:check` (the non-fixing variant, including `format:check:core`) and will fail in CI on any unformatted file, so always run the auto-fixer first rather than letting `format:check` catch it.
- **`npm run ci` is the mandatory pre-push command** — it mirrors `.github/workflows/main.yml` (minus `npm install`): `validate` → `coverage` → `smoke` → Storybook play-function tests (installs Playwright chromium if needed). It now runs **`npm run coverage`**, the per-file ≥90 gate (lines/statements/functions/branches) that CI enforces — so `npm run ci` is a true superset of GitHub CI, and passing it locally means CI's gates will pass. Expect several minutes. **`npm run validate`** remains the fast inner-loop check during development (unit tests only — no coverage gate, no smoke, no Storybook), but it is **NOT** an acceptable substitute for `npm run ci` before pushing: `validate` runs `test`, not `test:coverage`, so it does **zero** coverage gating. Skipping the gate is how a push passes every fast local check and still fails CI (this exact gap broke PR #1601 on a function-coverage regression).
- ALWAYS do `npm run format` before committing, then **`npm run ci`** before pushing. From the repo root, `validate` chains the four per-client validations (`validate:web` → `validate:cli` → `validate:tui` → `validate:launcher`); each delegates to that client's own `npm run validate` = `format:check` + `lint` + `build` + `test` in its own folder (no coverage — fast). Every client is self-validating and the top level just chains them, building each client's bundle along the way (no cross-client build dependencies).
- The one CLI nuance: `clients/cli`'s out-of-process `e2e.test.ts` spawns the built binary, so its `test` **builds first** via `pretest` (`test-servers:build && build`). To avoid building it twice, `clients/cli`'s `validate` folds that in — it is `format:check && lint && test` with **no** separate `build` step (the other clients, whose tests don't spawn their bundle, keep an explicit `build`). `validate:web`/`validate:tui`/`validate:launcher` are the uniform `format:check && lint && build && test`.
- ALWAYS do `npm run format` before committing, then **`npm run ci`** before pushing. From the repo root, `validate` runs the **`core/` gate first** (`validate:core`) and then chains the four per-client validations (`validate:web` → `validate:cli` → `validate:tui` → `validate:launcher`); each client delegates to its own `npm run validate` in its own folder (no coverage — fast). Every client is self-validating and the top level just chains them, building each client's bundle along the way (no cross-client build dependencies).
- **`validate:core` is the shared-code format + lint gate (#1689).** Each client's `prettier`/`eslint` is scoped to its own dir, so nothing reached `core/` before — `validate:core` closes that: it runs `format:check:core` (`prettier --check "core/**/*.{ts,tsx}"`) + `lint:core` (`eslint "core/**/*.{ts,tsx}"` via the **root** `eslint.config.js`). Use `npm run format:core` to auto-fix. The root carries prettier/eslint as devDependencies for this; `core/` is isomorphic (browser + Node globals, no JSX today — the `{ts,tsx}` glob future-proofs against a `core/**/*.tsx`). The root `eslint.config.js` honors an `_`-prefix as the intentionally-unused marker (`argsIgnorePattern`/`varsIgnorePattern`/`caughtErrorsIgnorePattern: '^_'`).
- **cli and tui now typecheck their `src` (#1689).** Their `build`/`test` run through esbuild (no type check), so each has a `typecheck` script (`tsc --noEmit -p tsconfig.json`) folded into `validate`. Their `tsconfig.json` matches `clients/web/tsconfig.app.json`'s module/lib *resolution* options — DOM lib, `moduleResolution: bundler`, and **no** `noUncheckedIndexedAccess` (web's app config does not extend `tsconfig.base`, so re-enabling it would surface `core/` issues web never gates) — so the imported `core/` sources are validated the same way web validates them. It does **not** mirror web's extra strictness flags (`noUnusedLocals`, `verbatimModuleSyntax`, ES2023 target, …), so cli/tui's own `src` is checked slightly more loosely than web's. `core/` itself still typechecks through web's `tsc -b`.
- The one CLI nuance: `clients/cli`'s out-of-process `e2e.test.ts` spawns the built binary, so its `test` **builds first** via `pretest` (`test-servers:build && build`). To avoid building it twice, `clients/cli`'s `validate` folds that in — it is `format:check && lint && typecheck && test` with **no** separate `build` step (the other clients, whose tests don't spawn their bundle, keep an explicit `build`). `validate:web`/`validate:tui`/`validate:launcher` are the uniform `format:check && lint && (typecheck &&) build && test`.
- **`npm run coverage`** is the per-file ≥90 gate and is now part of `npm run ci` — never treat it as optional before a push. It supersedes the old standalone `test:integration` step: web's `test:coverage` runs the `unit` **and** `integration` projects under v8 instrumentation, so `coverage` both enforces the ≥90 gate and exercises the same web integration paths CI covers.
- **`smoke` is NOT part of `validate`** — it is included in `npm run ci`. It runs `smoke:launcher` (`--help` dispatch) plus the prod `smoke:cli` / `smoke:tui` / `smoke:web`, and contains **no build commands** — it assumes the cli/tui/launcher bundles already exist (a full `validate` builds them; `smoke:web` builds `clients/web/dist` on demand). CI runs `validate`, then the `coverage` gate (which also covers the web integration project), then `smoke`. Storybook is the only CI step left out (see below).
- `smoke:launcher` (`scripts/smoke-launcher.mjs`) runs the built launcher with `--help`, `--cli --help`, and `--tui --help`, asserting each exits 0 and prints that mode's usage banner (which also proves the launcher resolved and loaded the right client build). It's the cheap dispatch check before the heavier prod smokes below.

View file

@ -138,15 +138,15 @@ Individual clients: `build:web`, `build:cli`, `build:tui`, `build:launcher`. The
Each client self-validates from its own folder; the root scripts chain them. There is **no** aggregate root `test` script — use `validate` (fast) or `coverage` (the gate).
| Script | What it does |
| --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `npm run validate` | `format:check` + `lint` + `build` + fast unit tests, per client. The quick inner-loop check. |
| `npm run coverage` | The **per-file ≥90% gate** (lines/statements/functions/branches) under v8 instrumentation, per client. CI-enforced. For web this also runs the integration project. |
| `npm run smoke` | End-to-end smokes through the built launcher (`--help` dispatch + prod cli/tui/web). |
| `npm run ci` | **Mandatory pre-push command.** `validate` → `coverage` → `smoke` → Storybook. A true superset of GitHub CI. |
| `npm run pack:verify` | Publish smoke — see [Publishing](#publishing). |
| Script | What it does |
| --------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `npm run validate` | Runs `validate:core` (the shared `core/` `format:check` + `lint` gate) first, then per client: `format:check` + `lint` + **`typecheck`** (cli/tui only) + `build` + fast unit tests. The quick inner-loop check. |
| `npm run coverage` | The **per-file ≥90% gate** (lines/statements/functions/branches) under v8 instrumentation, per client. CI-enforced. For web this also runs the integration project and covers the shared `core/` runtime (including `core/json` and `core/client`). |
| `npm run smoke` | End-to-end smokes through the built launcher (`--help` dispatch + prod cli/tui/web). |
| `npm run ci` | **Mandatory pre-push command.** `validate` → `coverage` → `smoke` → Storybook. A true superset of GitHub CI. |
| `npm run pack:verify` | Publish smoke — see [Publishing](#publishing). |
Per-client scripts exist too (`validate:web`, `coverage:cli`, `smoke:tui`, …). Run `npm run format` (per client) before committing — `validate` runs the non-fixing `format:check` and fails CI on any unformatted file.
Per-client scripts exist too (`validate:web`, `coverage:cli`, `smoke:tui`, …), plus root `validate:core` / `format:core` for the shared `core/` package. Run `npm run format` before committing — the root `format` fixes `core/` and every client; `validate` runs the non-fixing `format:check` and fails CI on any unformatted file.
For the full testing rules — the ≥90% per-file gate, where test files live, the unit vs. integration vs. storybook projects, and the `v8 ignore` policy — see [`AGENTS.md`](./AGENTS.md).

View file

@ -16,7 +16,8 @@
],
"scripts": {
"build": "tsup",
"validate": "npm run format:check && npm run lint && npm run test",
"typecheck": "tsc --noEmit -p tsconfig.json",
"validate": "npm run format:check && npm run lint && npm run typecheck && npm run test",
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "npm run test-servers:build && npm run build && vitest run --coverage",

View file

@ -120,6 +120,20 @@ type MethodOutcome =
| { kind: "result"; result: McpResponse; appInfo?: CliAppInfo }
| { kind: "emitted" };
/**
* Tear down a managed list state if it was created for this method call.
*
* The `managed*State` locals below are assigned inside the `runMethod` closure,
* so TypeScript's control-flow analysis keeps them narrowed to `null` at the
* outer `finally` block (a closure "might not have run"). Routing the teardown
* through this helper — whose parameter type is the shared `destroy()` shape —
* both documents that and calls `destroy()` on whatever the closure actually
* assigned at runtime, with no `as` cast.
*/
function destroyManagedState(state: { destroy(): void } | null): void {
state?.destroy();
}
async function callMethod(
serverConfig: MCPServerConfig,
serverSettings: InspectorServerSettings | undefined,
@ -370,10 +384,10 @@ async function callMethod(
await emitResult(outcome.result, outcome.appInfo, args);
}
} finally {
managedToolsState?.destroy();
managedResourcesState?.destroy();
managedResourceTemplatesState?.destroy();
managedPromptsState?.destroy();
destroyManagedState(managedToolsState);
destroyManagedState(managedResourcesState);
destroyManagedState(managedResourceTemplatesState);
destroyManagedState(managedPromptsState);
await inspectorClient.disconnect();
}
}

View file

@ -2,10 +2,24 @@
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"noEmit": true,
// Match clients/web/tsconfig.app.json's module/lib *resolution* options so
// the imported `core/` sources are validated the same way web's own gate
// validates them: DOM lib + bundler resolution are what core/ (browser-side
// auth, DOM types) expects, and `noUncheckedIndexedAccess` is off because
// web's app config does not extend tsconfig.base — re-enabling it here would
// flag core/ issues web never gates. This does NOT mirror web's extra
// strictness flags (noUnusedLocals, verbatimModuleSyntax, ES2023 target,
// …), so cli/tui's own src is checked slightly more loosely than web's src.
"lib": ["ES2023", "DOM", "DOM.Iterable"],
"types": ["node"],
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"module": "ESNext",
"noUncheckedIndexedAccess": false,
"paths": {
"@inspector/core/*": ["../../core/*"]
}
},
"include": ["src/**/*"],
"exclude": ["node_modules", "**/*.test.ts", "build"]
"exclude": ["node_modules", "**/*.test.ts", "**/*.test.tsx", "build"]
}

View file

@ -11,6 +11,7 @@ import type {
MCPServerConfig,
ServerState,
} from "@inspector/core/mcp/index.js";
import { headersToServerSettings } from "@inspector/core/mcp/node/servers.js";
import { InfoTab } from "../src/components/InfoTab.js";
// Ink processes stdin keypresses asynchronously — await this after stdin.write
@ -127,14 +128,11 @@ describe("InfoTab", () => {
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}
serverConfig={config}
serverSettings={headersToServerSettings({ Authorization: "Bearer x" })}
serverState={baseState}
width={80}
height={20}
@ -167,15 +165,15 @@ describe("InfoTab", () => {
});
it("renders a streamable-http config with headers", () => {
const config = {
const config: MCPServerConfig = {
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}
serverSettings={headersToServerSettings({ "X-Key": "abc" })}
serverState={baseState}
width={80}
height={20}

View file

@ -13,6 +13,23 @@ describe("schemaToForm", () => {
expect(form.sections[0]?.fields).toEqual([]);
});
it("treats a non-object property value as an empty schema instead of throwing", () => {
// A malformed server schema whose property value is null/primitive must not
// crash — the field degrades to a plain string input labelled by its key.
const form = schemaToForm(
{ properties: { bad: null, worse: 42 } },
"malformed",
);
const fields = form.sections[0]?.fields ?? [];
expect(fields).toHaveLength(2);
expect(
fields.map((f) => ({ name: f.name, type: f.type, label: f.label })),
).toEqual([
{ name: "bad", type: "string", label: "bad" },
{ name: "worse", type: "string", label: "worse" },
]);
});
it("maps each JSON Schema type to the matching ink-form field type", () => {
const form = schemaToForm(
{

View file

@ -18,7 +18,8 @@
"scripts": {
"dev": "vite-node --config vitest.config.ts dev.ts",
"build": "tsup",
"validate": "npm run format:check && npm run lint && npm run build && npm run test",
"typecheck": "tsc --noEmit -p tsconfig.json",
"validate": "npm run format:check && npm run lint && npm run typecheck && npm run build && npm run test",
"test": "vitest run",
"test:coverage": "vitest run --coverage",
"lint": "eslint .",

View file

@ -1660,6 +1660,7 @@ function App({
<InfoTab
serverName={selectedServer}
serverConfig={selectedServerConfig}
serverSettings={selectedServerEntry?.settings ?? null}
serverState={currentServerState}
width={contentWidth}
height={contentHeight}

View file

@ -5,10 +5,16 @@ import type {
MCPServerConfig,
ServerState,
} from "@inspector/core/mcp/index.js";
import type { InspectorServerSettings } from "@inspector/core/mcp/types.js";
interface InfoTabProps {
serverName: string | null;
serverConfig: MCPServerConfig | null;
// HTTP headers live on the per-server settings (pair-array form), not on the
// transport `serverConfig` — `mcpConfigToServerEntries` lifts the on-disk
// `headers` map into `InspectorServerSettings.headers`. Read them here so the
// SSE / streamable-http header display reflects the real source.
serverSettings?: InspectorServerSettings | null;
serverState: ServerState | null;
width: number;
height: number;
@ -18,11 +24,24 @@ interface InfoTabProps {
export function InfoTab({
serverName,
serverConfig,
serverSettings = null,
serverState,
width,
height,
focused = false,
}: InfoTabProps) {
const headerPairs = serverSettings?.headers ?? [];
// Shared header display for the sse / streamable-http branches (identical for
// both transports — the header source is `serverSettings`, not the transport).
const headersBlock =
headerPairs.length > 0 ? (
<Box marginTop={1}>
<Text dimColor>
Headers:{" "}
{headerPairs.map(({ key, value }) => `${key}=${value}`).join(", ")}
</Text>
</Box>
) : null;
const scrollViewRef = useRef<ScrollViewRef>(null);
// Handle keyboard input for scrolling
@ -111,33 +130,13 @@ export function InfoTab({
<>
<Text dimColor>Type: sse</Text>
<Text dimColor>URL: {serverConfig.url}</Text>
{serverConfig.headers &&
Object.keys(serverConfig.headers).length > 0 && (
<Box marginTop={1}>
<Text dimColor>
Headers:{" "}
{Object.entries(serverConfig.headers)
.map(([k, v]) => `${k}=${v}`)
.join(", ")}
</Text>
</Box>
)}
{headersBlock}
</>
) : serverConfig.type === "streamable-http" ? (
<>
<Text dimColor>Type: streamable-http</Text>
<Text dimColor>URL: {serverConfig.url}</Text>
{serverConfig.headers &&
Object.keys(serverConfig.headers).length > 0 && (
<Box marginTop={1}>
<Text dimColor>
Headers:{" "}
{Object.entries(serverConfig.headers)
.map(([k, v]) => `${k}=${v}`)
.join(", ")}
</Text>
</Box>
)}
{headersBlock}
</>
) : null}
</Box>

View file

@ -34,9 +34,14 @@ function toSelectOptions(
}));
}
/** Minimal JSON Schema object shape (properties + required) */
/**
* Minimal JSON Schema object shape (properties + required). Property values are
* `unknown` so the SDK's broadly-typed `Tool["inputSchema"]` (whose `properties`
* values are the recursive JSON type) is assignable here; each value is narrowed
* to {@link JsonSchemaProperty} at the point of use below.
*/
interface JsonSchemaObject {
properties?: Record<string, JsonSchemaProperty>;
properties?: Record<string, unknown>;
required?: string[];
}
@ -60,7 +65,12 @@ export function schemaToForm(
const required = schema.required || [];
for (const [key, prop] of Object.entries(properties)) {
const property = prop as JsonSchemaProperty;
// `properties` values are `unknown` (the SDK schema admits anything), so
// guard before treating a value as a schema object — a malformed server
// schema with e.g. `properties: { foo: null }` must not throw on `.title`.
const property = (
typeof prop === "object" && prop !== null ? prop : {}
) as JsonSchemaProperty;
const baseField = {
name: key,
label: property.title || key,

View file

@ -1,13 +1,30 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"noEmit": true,
"jsx": "react-jsx",
"outDir": "./build",
"rootDir": ".",
// Match clients/web/tsconfig.app.json's module/lib *resolution* options so
// the imported `core/` sources are validated the same way web's own gate
// validates them: DOM lib + bundler resolution are what core/ (browser-side
// auth, DOM types) expects, `noUncheckedIndexedAccess` is off because web's
// app config does not extend tsconfig.base (re-enabling it would flag core/
// issues web never gates), and the `react` path redirect lets core/ source
// resolve its bare `react` import from this client's node_modules. This does
// NOT mirror web's extra strictness flags (noUnusedLocals, verbatimModule-
// Syntax, ES2023 target, …), so tui's own src is checked slightly more
// loosely than web's src.
"lib": ["ES2023", "DOM", "DOM.Iterable"],
"types": ["node"],
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"module": "ESNext",
"noUncheckedIndexedAccess": false,
"paths": {
"@inspector/core/*": ["../../core/*"]
"@inspector/core/*": ["../../core/*"],
"react": ["./node_modules/@types/react"],
"react/jsx-runtime": ["./node_modules/@types/react/jsx-runtime"]
}
},
"include": ["index.ts", "tui.tsx", "src/**/*"],
"exclude": ["node_modules", "**/*.test.ts", "build"]
"exclude": ["node_modules", "**/*.test.ts", "**/*.test.tsx", "build"]
}

View file

@ -69,7 +69,7 @@
"globals": "^17.4.0",
"happy-dom": "^20.9.0",
"playwright": "^1.58.2",
"prettier": "^3.8.1",
"prettier": "^3.8.4",
"storybook": "^10.2.19",
"tsup": "^8.5.1",
"typescript": "~5.9.3",
@ -8192,9 +8192,9 @@
}
},
"node_modules/prettier": {
"version": "3.8.1",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz",
"integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==",
"version": "3.8.4",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.4.tgz",
"integrity": "sha512-N2MylSdi48+5N/6S5j+maeHbUSIzzZ5uOcX5Hm4QpV8Dkb1HFjfAKTKX6yNPJQD9AhcT3ifHNB66tWTTJDi11Q==",
"dev": true,
"license": "MIT",
"bin": {

View file

@ -10,16 +10,23 @@ import {
import {
CLIENT_KEYCHAIN_ID,
extractSecretsFromClientConfig,
hasClientPlaintextSecret,
mergeSecretsIntoClientConfig,
} from "@inspector/core/client/secrets.js";
import {
getClientConfigFilePath,
loadClientConfig,
parseClientConfig,
saveClientConfig,
} from "@inspector/core/client/config.js";
import {
CIMD_METADATA_URL_HTTPS_ERROR,
CIMD_METADATA_URL_INVALID_ERROR,
CIMD_METADATA_URL_PATH_ERROR,
formatClientConfigLoadError,
getCimdClientMetadataUrlError,
isAbsoluteHttpUrl,
serializeClientConfig,
} from "@inspector/core/client/config-parse.js";
import {
getActiveCimdClientMetadataUrl,
@ -210,6 +217,13 @@ describe("client config", () => {
).toThrow(/path/);
});
it("getClientConfigFilePath honors a custom path and defaults to the storage dir", () => {
expect(getClientConfigFilePath("/custom/client.json")).toBe(
"/custom/client.json",
);
expect(getClientConfigFilePath()).toMatch(/client\.json$/);
});
it("loadClientConfig returns {} when file is absent", async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "client-config-"));
const filePath = path.join(tmpDir, "client.json");
@ -217,6 +231,76 @@ describe("client config", () => {
expect(config).toEqual({});
});
it("saveClientConfig/loadClientConfig round-trip without a secretStore", async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "client-config-"));
const filePath = path.join(tmpDir, "client.json");
const input = {
cimd: {
enabled: true,
clientMetadataUrl: "https://example.com/oauth/client.json",
},
};
await saveClientConfig(input, { filePath });
expect(await loadClientConfig({ filePath })).toEqual(input);
});
it("hasClientPlaintextSecret reflects the presence of an IdP clientSecret", () => {
expect(
hasClientPlaintextSecret({
enterpriseManagedAuth: {
idp: { issuer: "https://idp.example.com", clientId: "c" },
},
}),
).toBe(false);
expect(
hasClientPlaintextSecret({
enterpriseManagedAuth: {
idp: {
issuer: "https://idp.example.com",
clientId: "c",
clientSecret: "s",
},
},
}),
).toBe(true);
});
it("getActiveCimdClientMetadataUrl returns undefined for a whitespace-only URL", () => {
expect(
getActiveCimdClientMetadataUrl({
cimd: { enabled: true, clientMetadataUrl: " " },
}),
).toBeUndefined();
});
it("serializeClientConfig emits pretty-printed JSON", () => {
expect(serializeClientConfig({ cimd: { clientMetadataUrl: "x" } })).toBe(
JSON.stringify({ cimd: { clientMetadataUrl: "x" } }, null, 2),
);
});
describe("getCimdClientMetadataUrlError", () => {
it("returns undefined for an empty value or a valid https URL with a path", () => {
expect(getCimdClientMetadataUrlError("")).toBeUndefined();
expect(getCimdClientMetadataUrlError(" ")).toBeUndefined();
expect(
getCimdClientMetadataUrlError("https://example.com/oauth/client.json"),
).toBeUndefined();
});
it("flags unparseable, non-https, and pathless URLs distinctly", () => {
expect(getCimdClientMetadataUrlError("not-a-url")).toBe(
CIMD_METADATA_URL_INVALID_ERROR,
);
expect(
getCimdClientMetadataUrlError("http://example.com/oauth/client.json"),
).toBe(CIMD_METADATA_URL_HTTPS_ERROR);
expect(getCimdClientMetadataUrlError("https://example.com")).toBe(
CIMD_METADATA_URL_PATH_ERROR,
);
});
});
it("formatClientConfigLoadError summarizes Zod validation failures", () => {
try {
parseClientConfig({

View file

@ -0,0 +1,179 @@
import { describe, it, expect, afterEach } from "vitest";
import * as fs from "node:fs/promises";
import { existsSync, readFileSync } from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import {
InMemorySecretStore,
KeychainUnavailableError,
SECRET_FIELD_IDP_CLIENT_SECRET,
type SecretStore,
} from "@inspector/core/auth/node/secret-store.js";
import { CLIENT_KEYCHAIN_ID } from "@inspector/core/client/secrets.js";
import {
deleteClientConfigStore,
readClientConfigStore,
writeClientConfigStore,
} from "@inspector/core/client/node-persistence.js";
const configWithPlaintextSecret = {
enterpriseManagedAuth: {
idp: {
issuer: "https://idp.example.com",
clientId: "cid",
clientSecret: "plain",
},
},
};
describe("client node-persistence", () => {
let tmpDir: string;
afterEach(async () => {
if (tmpDir) {
await fs.rm(tmpDir, { recursive: true, force: true });
}
});
async function makeTmpFile(contents?: string): Promise<string> {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "client-persist-"));
const filePath = path.join(tmpDir, "client.json");
if (contents !== undefined) {
await fs.writeFile(filePath, contents, "utf-8");
}
return filePath;
}
it("migrates a plaintext secret to an empty keychain and strips it from disk", async () => {
const filePath = await makeTmpFile(
JSON.stringify(configWithPlaintextSecret),
);
const secretStore = new InMemorySecretStore();
const loaded = await readClientConfigStore(filePath, secretStore);
// Rehydrated result still carries the secret (read back from the keychain).
expect(loaded.enterpriseManagedAuth?.idp.clientSecret).toBe("plain");
// On-disk copy is stripped.
expect(readFileSync(filePath, "utf-8")).not.toContain("plain");
// Keychain now holds it.
expect(
await secretStore.get(CLIENT_KEYCHAIN_ID, SECRET_FIELD_IDP_CLIENT_SECRET),
).toBe("plain");
});
it("does not overwrite an existing keychain secret during migration", async () => {
const filePath = await makeTmpFile(
JSON.stringify(configWithPlaintextSecret),
);
const secretStore = new InMemorySecretStore();
await secretStore.set(
CLIENT_KEYCHAIN_ID,
SECRET_FIELD_IDP_CLIENT_SECRET,
"existing",
);
const loaded = await readClientConfigStore(filePath, secretStore);
// The keychain value wins over the disk plaintext.
expect(loaded.enterpriseManagedAuth?.idp.clientSecret).toBe("existing");
expect(
await secretStore.get(CLIENT_KEYCHAIN_ID, SECRET_FIELD_IDP_CLIENT_SECRET),
).toBe("existing");
// Disk is still stripped.
expect(readFileSync(filePath, "utf-8")).not.toContain("plain");
});
it("keeps the plaintext secret on disk when the keychain is unavailable", async () => {
const filePath = await makeTmpFile(
JSON.stringify(configWithPlaintextSecret),
);
// A store whose writes always fail as if libsecret were missing.
const unavailable: SecretStore = {
async get() {
return null;
},
async set() {
throw new KeychainUnavailableError(new Error("no libsecret"));
},
async delete() {},
async deleteAllForServer() {},
};
const loaded = await readClientConfigStore(filePath, unavailable);
// Migration bailed → original config (with the secret) is returned and the
// on-disk copy is left untouched (still contains the plaintext).
expect(loaded.enterpriseManagedAuth?.idp.clientSecret).toBe("plain");
expect(readFileSync(filePath, "utf-8")).toContain("plain");
});
it("rethrows a non-keychain error raised during migration", async () => {
const filePath = await makeTmpFile(
JSON.stringify(configWithPlaintextSecret),
);
const boom: SecretStore = {
async get() {
return null;
},
async set() {
throw new Error("disk on fire");
},
async delete() {},
async deleteAllForServer() {},
};
await expect(readClientConfigStore(filePath, boom)).rejects.toThrow(
/disk on fire/,
);
});
it("returns {} when the client.json file is absent", async () => {
const filePath = await makeTmpFile();
expect(
await readClientConfigStore(filePath, new InMemorySecretStore()),
).toEqual({});
});
it("deletes the keychain secret when writing a config without one", async () => {
const filePath = await makeTmpFile();
const secretStore = new InMemorySecretStore();
await secretStore.set(
CLIENT_KEYCHAIN_ID,
SECRET_FIELD_IDP_CLIENT_SECRET,
"stale",
);
await writeClientConfigStore(
filePath,
{
cimd: { enabled: true, clientMetadataUrl: "https://x.example/c.json" },
},
secretStore,
);
expect(
await secretStore.get(CLIENT_KEYCHAIN_ID, SECRET_FIELD_IDP_CLIENT_SECRET),
).toBeNull();
expect(readFileSync(filePath, "utf-8")).toContain("clientMetadataUrl");
});
it("deleteClientConfigStore removes both the file and the keychain secret", async () => {
const filePath = await makeTmpFile(
JSON.stringify({ cimd: { enabled: false, clientMetadataUrl: "" } }),
);
const secretStore = new InMemorySecretStore();
await secretStore.set(
CLIENT_KEYCHAIN_ID,
SECRET_FIELD_IDP_CLIENT_SECRET,
"gone",
);
await deleteClientConfigStore(filePath, secretStore);
expect(existsSync(filePath)).toBe(false);
expect(
await secretStore.get(CLIENT_KEYCHAIN_ID, SECRET_FIELD_IDP_CLIENT_SECRET),
).toBeNull();
});
});

View file

@ -0,0 +1,176 @@
import { describe, it, expect, vi, afterEach } from "vitest";
import {
loadClientConfigRemote,
saveClientConfigRemote,
} from "@inspector/core/client/remote.js";
import type { ClientConfig } from "@inspector/core/client/types.js";
afterEach(() => {
vi.unstubAllGlobals();
});
const validConfig: ClientConfig = {
enterpriseManagedAuth: {
idp: { issuer: "https://idp.example.com", clientId: "cid" },
},
};
/**
* Build a minimal `Response`-like stub for the mocked fetch. The code under
* test only reads `.ok`, `.status`, and `.json()`, so the stub implements just
* those. The double cast is unavoidable here: `Response` has ~20 required
* members (`headers`, `body`, `arrayBuffer`, `blob`, `clone`, …), so a direct
* `as Response` is a TS2352 error and there is no lighter alternative than
* asserting through `unknown` for a deliberately-partial test double.
*/
function fakeResponse(init: {
ok: boolean;
status: number;
json?: () => Promise<unknown>;
}): Response {
return {
ok: init.ok,
status: init.status,
json: init.json ?? (async () => ({})),
} as unknown as Response;
}
describe("client remote config", () => {
describe("loadClientConfigRemote", () => {
it("parses a populated store and strips the trailing slash from baseUrl", async () => {
const fetchFn = vi.fn(async () =>
fakeResponse({ ok: true, status: 200, json: async () => validConfig }),
);
const config = await loadClientConfigRemote({
baseUrl: "http://localhost:3000/",
fetchFn,
});
expect(config).toEqual(validConfig);
expect(fetchFn).toHaveBeenCalledWith(
"http://localhost:3000/api/storage/client",
expect.objectContaining({ method: "GET" }),
);
});
it("sends the bearer auth header when a token is provided", async () => {
const fetchFn = vi.fn(async () =>
fakeResponse({ ok: true, status: 200, json: async () => ({}) }),
);
await loadClientConfigRemote({
baseUrl: "http://localhost:3000",
authToken: "tok",
fetchFn,
});
expect(fetchFn).toHaveBeenCalledWith(
"http://localhost:3000/api/storage/client",
expect.objectContaining({
headers: { "x-mcp-remote-auth": "Bearer tok" },
}),
);
});
it("returns {} on a 404", async () => {
const fetchFn = vi.fn(async () =>
fakeResponse({ ok: false, status: 404 }),
);
expect(
await loadClientConfigRemote({ baseUrl: "http://x", fetchFn }),
).toEqual({});
});
it("throws on a non-404 error status", async () => {
const fetchFn = vi.fn(async () =>
fakeResponse({ ok: false, status: 500 }),
);
await expect(
loadClientConfigRemote({ baseUrl: "http://x", fetchFn }),
).rejects.toThrow(/Failed to read client config: 500/);
});
it("returns {} when the store is empty or not an object", async () => {
const emptyObj = vi.fn(async () =>
fakeResponse({ ok: true, status: 200, json: async () => ({}) }),
);
expect(
await loadClientConfigRemote({
baseUrl: "http://x",
fetchFn: emptyObj,
}),
).toEqual({});
const nullJson = vi.fn(async () =>
fakeResponse({ ok: true, status: 200, json: async () => null }),
);
expect(
await loadClientConfigRemote({
baseUrl: "http://x",
fetchFn: nullJson,
}),
).toEqual({});
});
});
describe("saveClientConfigRemote", () => {
it("POSTs the validated, serialized config with content-type and auth headers", async () => {
const fetchFn = vi.fn(async () =>
fakeResponse({ ok: true, status: 200 }),
);
await saveClientConfigRemote(validConfig, {
baseUrl: "http://localhost:3000/",
authToken: "tok",
fetchFn,
});
expect(fetchFn).toHaveBeenCalledWith(
"http://localhost:3000/api/storage/client",
expect.objectContaining({
method: "POST",
headers: {
"Content-Type": "application/json",
"x-mcp-remote-auth": "Bearer tok",
},
body: JSON.stringify(validConfig, null, 2),
}),
);
});
it("omits the auth header when no token is given", async () => {
const fetchFn = vi.fn(async () =>
fakeResponse({ ok: true, status: 200 }),
);
await saveClientConfigRemote(validConfig, {
baseUrl: "http://x",
fetchFn,
});
expect(fetchFn).toHaveBeenCalledWith(
"http://x/api/storage/client",
expect.objectContaining({
headers: { "Content-Type": "application/json" },
}),
);
});
it("throws when the write fails", async () => {
const fetchFn = vi.fn(async () =>
fakeResponse({ ok: false, status: 503 }),
);
await expect(
saveClientConfigRemote(validConfig, { baseUrl: "http://x", fetchFn }),
).rejects.toThrow(/Failed to write client config: 503/);
});
});
describe("global fetch fallback", () => {
it("uses globalThis.fetch when no fetchFn is supplied", async () => {
const globalFetch = vi.fn(async () =>
fakeResponse({ ok: true, status: 200, json: async () => validConfig }),
);
vi.stubGlobal("fetch", globalFetch);
const loaded = await loadClientConfigRemote({ baseUrl: "http://x" });
expect(loaded).toEqual(validConfig);
await saveClientConfigRemote(validConfig, { baseUrl: "http://x" });
expect(globalFetch).toHaveBeenCalledTimes(2);
});
});
});

View file

@ -1,7 +1,12 @@
import { describe, it, expect } from "vitest";
import { describe, it, expect, afterEach, vi } from "vitest";
import * as fs from "node:fs/promises";
import * as os from "node:os";
import * as path from "node:path";
import { InMemorySecretStore } from "@inspector/core/auth/node/secret-store.js";
import {
buildRunnerClientAuthOptions,
isOAuthCapableServerConfig,
loadRunnerClientConfig,
} from "@inspector/core/client/runner.js";
import type { ClientConfig } from "@inspector/core/client/types.js";
import type { InspectorServerSettings } from "@inspector/core/mcp/types.js";
@ -80,4 +85,82 @@ describe("runner client auth options", () => {
expect(opts.oauth?.enterpriseManaged).toBe(true);
expect(opts.oauth?.clientId).toBe("resource-client");
});
it("buildRunnerClientAuthOptions returns no oauth when nothing supplies it", () => {
expect(buildRunnerClientAuthOptions({})).toEqual({});
});
it("buildRunnerClientAuthOptions wires CLI client id/secret and marks directAuthRecovery", () => {
const opts = buildRunnerClientAuthOptions({}, undefined, {
clientId: "cli-id",
clientSecret: "cli-secret",
});
expect(opts.oauth?.clientId).toBe("cli-id");
expect(opts.oauth?.clientSecret).toBe("cli-secret");
expect(opts.directAuthRecovery).toBe(true);
});
it("buildRunnerClientAuthOptions carries oauth scopes and client secret from server settings", () => {
const settings: InspectorServerSettings = {
oauthClientSecret: "resource-secret",
oauthScopes: "read write",
requestTimeout: 0,
connectionTimeout: 0,
taskTtl: 60000,
maxFetchRequests: 10,
autoRefreshOnListChanged: false,
metadata: [],
headers: [],
env: [],
roots: [],
};
const opts = buildRunnerClientAuthOptions({}, settings);
expect(opts.oauth?.clientSecret).toBe("resource-secret");
expect(opts.oauth?.scope).toBe("read write");
});
});
describe("loadRunnerClientConfig", () => {
let tmpDir: string;
afterEach(async () => {
if (tmpDir) {
await fs.rm(tmpDir, { recursive: true, force: true });
}
vi.unstubAllEnvs();
});
it("reads client.json from an explicit path (empty when absent)", async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "runner-client-"));
const filePath = path.join(tmpDir, "client.json");
// No injected store here → exercises the default `new KeyringSecretStore()`
// path (its get() tolerates keychain unavailability, returning {}).
expect(
await loadRunnerClientConfig({ clientConfigPath: filePath }),
).toEqual({});
});
it("falls back to MCP_CLIENT_CONFIG_PATH and parses a stored config", async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "runner-client-"));
const filePath = path.join(tmpDir, "client.json");
await fs.writeFile(
filePath,
JSON.stringify({
cimd: {
enabled: true,
clientMetadataUrl: "https://example.com/oauth/client.json",
},
}),
"utf-8",
);
vi.stubEnv("MCP_CLIENT_CONFIG_PATH", filePath);
// Inject an in-memory store so the result never depends on the developer's
// real OS keychain.
const config = await loadRunnerClientConfig({
secretStore: new InMemorySecretStore(),
});
expect(config.cimd?.clientMetadataUrl).toBe(
"https://example.com/oauth/client.json",
);
});
});

View file

@ -3,15 +3,38 @@ import {
convertParameterValue,
convertToolParameters,
convertPromptArguments,
toRecord,
} from "@inspector/core/json/jsonUtils.js";
import type { Tool } from "@modelcontextprotocol/client";
describe("JSON Utils", () => {
describe("toRecord", () => {
it("returns the same object widened to a string-keyed record", () => {
const source = { a: 1, b: "two" };
const widened = toRecord(source);
expect(widened).toBe(source);
expect(widened.a).toBe(1);
expect(Object.keys(widened)).toEqual(["a", "b"]);
});
});
describe("convertParameterValue", () => {
it("should convert string to string", () => {
expect(convertParameterValue("hello", { type: "string" })).toBe("hello");
});
it("returns the raw value unchanged when it is empty", () => {
// Empty string short-circuits before any type coercion.
expect(convertParameterValue("", { type: "number" })).toBe("");
});
it("falls back to the raw string when JSON parsing fails", () => {
expect(convertParameterValue("{not json", { type: "object" })).toBe(
"{not json",
);
expect(convertParameterValue("[oops", { type: "array" })).toBe("[oops");
});
it("should convert string to number", () => {
expect(convertParameterValue("42", { type: "number" })).toBe(42);
expect(convertParameterValue("3.14", { type: "number" })).toBe(3.14);

View file

@ -105,7 +105,8 @@ export default defineConfig(({ command }) => {
'src/lib/**/*.{ts,tsx}',
'clients/web/server/**/*.{ts,tsx}',
path.join(repoRoot, 'core/mcp/**/*.{ts,tsx}'),
path.join(repoRoot, 'core/json/xMcpHeader.ts'),
path.join(repoRoot, 'core/json/**/*.{ts,tsx}'),
path.join(repoRoot, 'core/client/**/*.{ts,tsx}'),
path.join(repoRoot, 'core/react/**/*.{ts,tsx}'),
path.join(repoRoot, 'core/auth/**/*.{ts,tsx}'),
path.join(repoRoot, 'core/storage/**/*.{ts,tsx}'),

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