Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
09bdac4
test(cli,tui): typecheck the __tests__ dirs (#1791)
cliffhall Jul 27, 2026
373f8e9
test(cli,tui): address review — real spy signatures, config typecheck…
cliffhall Jul 27, 2026
37e174e
test: address round-2 review — correct claims, tighten types, typeche…
cliffhall Jul 27, 2026
f520947
test: address round-3 nits — launcher config typecheck, callTool type…
cliffhall Jul 27, 2026
2d770a5
test: address round-4 — durable typecheck-coverage guard + launcher/d…
cliffhall Jul 27, 2026
718c830
test: address round-5 — wire-check the guard, --listFilesOnly, into v…
cliffhall Jul 27, 2026
464ae40
chore(scripts): address round-6 guard nits — dedupe wiring, robust di…
cliffhall Jul 27, 2026
73bd043
chore(scripts): round-7 — guard .mts/.cts too; drop dead export
cliffhall Jul 27, 2026
1bb5524
test: round-8 — single casts in HistoryTab, uniform wrappers, doc scope
cliffhall Jul 27, 2026
d141cd2
test: round-9 — drop entry()'s harmful cast; guards vouch for each other
cliffhall Jul 27, 2026
25ff5f2
test: round-10 — reject non-checking typecheck passes; pre/post hooks…
cliffhall Jul 27, 2026
d39439c
fix: round-11 — launcher build excludes tests; guard catches all noCh…
cliffhall Jul 27, 2026
0b7c476
fix: round-12 — exclude .mts/.cts tests from build; drop false integr…
cliffhall Jul 27, 2026
b739567
chore(scripts): round-13 — implicit-tsconfig + derived clients; diagn…
cliffhall Jul 27, 2026
9a0edac
chore(scripts): round-14 — enumerate clients from disk (fail-closed)
cliffhall Jul 27, 2026
505cd48
chore(scripts): round-15 — enroll-or-exempt enumeration; normalize `c…
cliffhall Jul 27, 2026
f480d64
chore(scripts): round-16 — anchor wiring match; close enumeration axes
cliffhall Jul 27, 2026
ad66aef
chore(scripts): round-17 — share quote-aware tokenizer between guards
cliffhall Jul 27, 2026
68d7d6b
chore(scripts): round-18 — match tsc by basename; strip quotes on bot…
cliffhall Jul 27, 2026
a393bb8
test: round-19 — typecheck test-servers' unimported bin; --prefix wir…
cliffhall Jul 27, 2026
893a55b
chore(scripts): round-20 — anchor validate tail; guard shared-source …
cliffhall Jul 27, 2026
8087349
chore(scripts): round-21 — deny-by-default non-client coverage
cliffhall Jul 27, 2026
d700c08
chore(scripts): round-22 — narrow core/ exemption to what web's tsc -…
cliffhall Jul 27, 2026
9004f8d
chore(scripts): round-23 — core exemption mirrors both web projects
cliffhall Jul 27, 2026
04f87f6
chore(scripts): round-24 — enroll clients/web via tsconfig references
cliffhall Jul 27, 2026
a6a82ba
chore(scripts): round-25 — drop redundant core exemption; guard tsc -…
cliffhall Jul 27, 2026
be8ca3b
chore(scripts): round-26 — expand solution configs wherever harvested
cliffhall Jul 27, 2026
82db89e
chore(scripts): round-27 — non-inertness follows references on both p…
cliffhall Jul 27, 2026
be22b28
chore(scripts): round-28 — dedup shared leaf in checkingLeaves
cliffhall Jul 27, 2026
0a415d1
test(scripts): unit-test the typecheck-coverage guard's parsers
cliffhall Jul 27, 2026
1bfa9b9
test(scripts): make test:scripts self-guarding; fill parser test gaps
cliffhall Jul 27, 2026
901e4f8
chore(scripts): round-31 — glob harvest follows delegation; brace/? g…
cliffhall Jul 27, 2026
b05c56d
chore(scripts): round-32 — measure globs with node's own matcher
cliffhall Jul 27, 2026
d6964dc
chore(scripts): round-33 — scope the glob harvest to `node --test` se…
cliffhall Jul 27, 2026
41175ad
chore(scripts): round-34 — harvest only positional args; normalize `./`
cliffhall Jul 27, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,10 @@ jobs:
# clients/launcher, so this single step sets up every client.
run: npm install

- name: Validate (format, lint, build, fast tests)
# Each client self-validates: format:check + lint + build + test (no
- name: Validate (coverage guards, format, lint, typecheck, build, fast tests)
# Runs the two coverage guards first (verify:format-coverage +
# verify:typecheck-coverage), then validate:core, then each client's
# self-validation: format:check + lint + typecheck + build + test (no
# coverage instrumentation — fast). This also builds every client bundle
# (web dist, cli/tui/launcher) that the smokes below need. The heavier
# per-file coverage gate runs in its own step below. Unit tests run in
Expand Down
6 changes: 4 additions & 2 deletions AGENTS.md

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,8 @@ Each client self-validates from its own folder; the root scripts chain them. The
| `npm run smoke` | End-to-end smokes through the built launcher (`--help` dispatch + prod cli/tui/web), plus a headless-Chromium boot smoke that runs the prod web bundle and asserts a clean first render (no uncaught error — sync exception or unhandled rejection, how a Node built-in reaching the browser bundle manifests). |
| `npm run verify:build-gate` | Runs a real `vite build` with a Node built-in forced into the browser graph and asserts the build **fails** via the #1769 gate (which turns Vite's browser-externalization warning into a hard error). Guards against the warning phrasing drifting in a Vite bump and silently disabling the gate. Part of `npm run ci`. |
| `npm run verify:format-coverage` | Parses the `format:check` globs out of every `package.json` (only those reachable from `validate`), enumerates all tracked source files, and **fails** listing any not covered by a glob — the durable guard for the "every first-party source file is format-gated" invariant (#1792). Runs first in `validate`. |
| `npm run test:scripts` | Table-driven unit tests (`node --test`) for the guard's own pure parsers (`scripts/lib/npm-scripts.mjs` + the exported helpers of `verify-typecheck-coverage.mjs`), one case per rule they encode. Runs in `validate` — and `verify:typecheck-coverage` guards *this* gate in turn (reachable from `validate`, non-empty test set, every test file matched by the `test:scripts` glob), since `node --test` silently skips a file its glob misses and still exits 0. |
| `npm run verify:typecheck-coverage` | The typecheck-coverage analog of the above (#1791): for each Node client (auto-discovered from disk — enrolled via its `typecheck` script's projects, or for a `tsc -b` client like `clients/web` via its `tsconfig.json` `references`) it runs those projects with `tsc --listFilesOnly`, unions them, and **fails** listing any tracked `.ts`/`.tsx`/`.mts`/`.cts` under the client that lands in no project (so a new top-level config/helper can't silently go untypechecked). It also requires, deny-by-default, the first-party TS no client owns (`test-servers/src`, the root `vitest.shared.mts`, all of `core/`, and any new top-level location) to land in some client project's tsc pass — so a `core` `*.tsx` web's projects don't reach is caught too. Also asserts the gate is wired (each client's typecheck pass — its `typecheck` script, or web's `tsc -b` — is reachable from its `validate`, and the root chain runs each client's `validate`). Runs in `validate`. |
| `npm run ci` | **Mandatory pre-push command.** `validate` → `coverage` → `verify:build-gate` → `smoke` → Storybook. A true superset of GitHub CI. |
| `npm run pack:verify` | Publish smoke — see [Publishing](#publishing). |

Expand Down
6 changes: 3 additions & 3 deletions clients/cli/__tests__/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ import {
createEchoTool,
createTestServerInfo,
} from "@modelcontextprotocol/inspector-test-server";
import type { MCPServerConfig } from "@modelcontextprotocol/inspector-core/mcp/index.js";
import type { MCPServerConfig } from "@inspector/core/mcp/index.js";

describe("CLI Tests", () => {
describe("Basic CLI Mode", () => {
Expand Down Expand Up @@ -368,7 +368,7 @@ describe("CLI Tests", () => {
// the config file into the connection's settings.
const sawHeader = server
.getRecordedRequests()
.some((r) => r.headers["x-custom-token"] === "secret-123");
.some((r) => r.headers?.["x-custom-token"] === "secret-123");
expect(sawHeader).toBe(true);
} finally {
await server.stop();
Expand Down Expand Up @@ -409,7 +409,7 @@ describe("CLI Tests", () => {
expectCliSuccess(result);
const tokens = server
.getRecordedRequests()
.map((r) => r.headers["x-custom-token"]);
.map((r) => r.headers?.["x-custom-token"]);
expect(tokens).toContain("from-cli");
expect(tokens).not.toContain("from-disk");
} finally {
Expand Down
76 changes: 36 additions & 40 deletions clients/cli/__tests__/cliOAuth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@ import {
} from "../src/cliOAuth.js";
import type { MCPServerConfig } from "@inspector/core/mcp/types.js";
import { createInterface } from "node:readline/promises";
import {
makeFakeCliOAuthClient,
makeFakeServerSettings,
} from "./helpers/oauth-test-fakes.js";

// `confirmStepUpFromStdin` (the default step-up confirmer) reads a line from
// stdin via node:readline/promises. Mock the module so the default path can be
Expand Down Expand Up @@ -103,7 +107,7 @@ describe("cliOAuth", () => {
expect(
isStandardOAuthStepUp(
{ reason: "insufficient_scope", requiredScopes: ["weather:read"] },
{},
makeFakeServerSettings(),
),
).toBe(true);
});
Expand All @@ -112,7 +116,7 @@ describe("cliOAuth", () => {
expect(
isStandardOAuthStepUp(
{ reason: "insufficient_scope", requiredScopes: ["weather:read"] },
{ enterpriseManaged: true },
makeFakeServerSettings({ enterpriseManaged: true }),
),
).toBe(false);
});
Expand Down Expand Up @@ -220,7 +224,7 @@ describe("cliOAuth", () => {
error,
new MutableRedirectUrlProvider(),
{ hostname: "127.0.0.1", port: 6276, pathname: "/oauth/callback" },
{},
makeFakeServerSettings(),
{ confirmStepUp: async () => false, isTTY: true },
),
).rejects.toThrow("Step-up authorization declined.");
Expand Down Expand Up @@ -250,7 +254,7 @@ describe("cliOAuth", () => {
error,
new MutableRedirectUrlProvider(),
{ hostname: "127.0.0.1", port: 6276, pathname: "/oauth/callback" },
{},
makeFakeServerSettings(),
{ confirmStepUp: async () => true, isTTY: true },
);

Expand Down Expand Up @@ -338,7 +342,7 @@ describe("cliOAuth", () => {
OAUTH_HTTP_CONFIG,
new MutableRedirectUrlProvider(),
{ hostname: "127.0.0.1", port: 6276, pathname: "/oauth/callback" },
{ enterpriseManaged: true },
makeFakeServerSettings({ enterpriseManaged: true }),
fn,
{ confirmStepUp: async () => true, isTTY: true },
);
Expand Down Expand Up @@ -375,7 +379,7 @@ describe("cliOAuth", () => {
standardStepUpError(),
new MutableRedirectUrlProvider(),
CALLBACK_URL_CONFIG,
{},
makeFakeServerSettings(),
INTERACTIVE,
);

Expand All @@ -395,7 +399,7 @@ describe("cliOAuth", () => {
standardStepUpError(),
new MutableRedirectUrlProvider(),
CALLBACK_URL_CONFIG,
{},
makeFakeServerSettings(),
INTERACTIVE,
);

Expand All @@ -412,7 +416,7 @@ describe("cliOAuth", () => {
standardStepUpError(),
new MutableRedirectUrlProvider(),
CALLBACK_URL_CONFIG,
{},
makeFakeServerSettings(),
INTERACTIVE,
),
).rejects.toThrow("Step-up authorization declined.");
Expand All @@ -431,7 +435,7 @@ describe("cliOAuth", () => {
standardStepUpError(),
new MutableRedirectUrlProvider(),
CALLBACK_URL_CONFIG,
{},
makeFakeServerSettings(),
INTERACTIVE,
),
).rejects.toThrow("Step-up authorization declined.");
Expand All @@ -455,7 +459,7 @@ describe("cliOAuth", () => {
standardStepUpError(),
new MutableRedirectUrlProvider(),
CALLBACK_URL_CONFIG,
{},
makeFakeServerSettings(),
INTERACTIVE,
),
).rejects.toThrow("Step-up authorization declined.");
Expand All @@ -482,7 +486,7 @@ describe("cliOAuth", () => {
standardStepUpError(),
new MutableRedirectUrlProvider(),
CALLBACK_URL_CONFIG,
{},
makeFakeServerSettings(),
INTERACTIVE,
);

Expand All @@ -503,7 +507,7 @@ describe("cliOAuth", () => {
standardStepUpError(),
new MutableRedirectUrlProvider(),
CALLBACK_URL_CONFIG,
{},
makeFakeServerSettings(),
{ isTTY: false },
);
expect(runSpy).toHaveBeenCalled();
Expand All @@ -522,7 +526,7 @@ describe("cliOAuth", () => {
standardStepUpError(),
new MutableRedirectUrlProvider(),
CALLBACK_URL_CONFIG,
{},
makeFakeServerSettings(),
{ isTTY: true, stepUpPromptTimeoutMs: 20 },
),
).rejects.toMatchObject({
Expand Down Expand Up @@ -556,7 +560,7 @@ describe("cliOAuth", () => {
standardStepUpError(),
new MutableRedirectUrlProvider(),
CALLBACK_URL_CONFIG,
{},
makeFakeServerSettings(),
INTERACTIVE,
);
let settled = false;
Expand Down Expand Up @@ -596,11 +600,10 @@ describe("cliOAuth", () => {
),
)
.mockResolvedValueOnce(undefined);
const client = {
const client = makeFakeCliOAuthClient({
connect,
disconnect: vi.fn(),
checkAuthChallengeSatisfied: vi.fn().mockResolvedValue(true),
};
});

await connectInspectorWithOAuth(
client,
Expand All @@ -626,11 +629,10 @@ describe("cliOAuth", () => {
),
)
.mockResolvedValueOnce(undefined);
const client = {
const client = makeFakeCliOAuthClient({
connect,
disconnect: vi.fn(),
checkAuthChallengeSatisfied: vi.fn().mockResolvedValue(false),
};
});

await connectInspectorWithOAuth(
client,
Expand All @@ -654,11 +656,11 @@ describe("cliOAuth", () => {
.mockRejectedValueOnce(new Error("Connection failed for server (401)"))
.mockResolvedValueOnce(undefined);
// A rejecting disconnect exercises the `.catch(() => {})` guard.
const client = {
const client = makeFakeCliOAuthClient({
connect,
disconnect: vi.fn().mockRejectedValue(new Error("disconnect failed")),
checkAuthChallengeSatisfied: vi.fn(),
};
});

await connectInspectorWithOAuth(
client,
Expand All @@ -679,11 +681,10 @@ describe("cliOAuth", () => {
const connect = vi
.fn()
.mockRejectedValue(new Error("some unrelated failure"));
const client = {
const client = makeFakeCliOAuthClient({
connect,
disconnect: vi.fn(),
checkAuthChallengeSatisfied: vi.fn(),
};
});

await expect(
connectInspectorWithOAuth(
Expand All @@ -698,11 +699,10 @@ describe("cliOAuth", () => {

it("rethrows when the server config is not OAuth-capable", async () => {
const connect = vi.fn().mockRejectedValue(new Error("nope (401)"));
const client = {
const client = makeFakeCliOAuthClient({
connect,
disconnect: vi.fn(),
checkAuthChallengeSatisfied: vi.fn(),
};
});

await expect(
connectInspectorWithOAuth(
Expand All @@ -721,11 +721,10 @@ describe("cliOAuth", () => {
reason: "token_expired",
}),
);
const client = {
const client = makeFakeCliOAuthClient({
connect,
disconnect: vi.fn(),
checkAuthChallengeSatisfied: vi.fn().mockResolvedValue(false),
};
});

await expect(
connectInspectorWithOAuth(
Expand All @@ -751,11 +750,10 @@ describe("cliOAuth", () => {
),
)
.mockResolvedValueOnce(undefined);
const client = {
const client = makeFakeCliOAuthClient({
connect,
disconnect: vi.fn(),
checkAuthChallengeSatisfied: vi.fn().mockResolvedValue(true),
};
});

await connectInspectorWithOAuth(
client,
Expand All @@ -775,11 +773,10 @@ describe("cliOAuth", () => {
const connect = vi
.fn()
.mockRejectedValue(new Error("Connection failed for server (401)"));
const client = {
const client = makeFakeCliOAuthClient({
connect,
disconnect: vi.fn(),
checkAuthChallengeSatisfied: vi.fn(),
};
});

await expect(
connectInspectorWithOAuth(
Expand Down Expand Up @@ -1101,11 +1098,10 @@ describe("cliOAuth", () => {
const connect = vi.fn().mockRejectedValue(err);
await expect(
connectInspectorWithOAuth(
{
makeFakeCliOAuthClient({
connect,
disconnect: vi.fn(),
checkAuthChallengeSatisfied: vi.fn().mockResolvedValue(false),
},
}),
OAUTH_HTTP_CONFIG,
new MutableRedirectUrlProvider(),
CALLBACK_URL_CONFIG,
Expand Down
2 changes: 1 addition & 1 deletion clients/cli/__tests__/helpers/fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import * as path from "path";
import * as os from "os";
import * as crypto from "crypto";
import { getTestMcpServerCommand } from "@modelcontextprotocol/inspector-test-server";
import type { MCPServerConfig } from "@modelcontextprotocol/inspector-core/mcp/index.js";
import type { MCPServerConfig } from "@inspector/core/mcp/index.js";

/**
* Sentinel value for tests that don't need a real server
Expand Down
75 changes: 75 additions & 0 deletions clients/cli/__tests__/helpers/oauth-test-fakes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { vi } from "vitest";
import {
DEFAULT_MAX_FETCH_REQUESTS,
DEFAULT_TASK_TTL_MS,
} from "@inspector/core/mcp/types.js";
import type { InspectorServerSettings } from "@inspector/core/mcp/types.js";
import type { CliOAuthClient } from "../../src/cliOAuth.js";

/**
* Typed mock factories for the CLI OAuth tests. They exist so a test can supply
* only the surface a given code path exercises while `tsc` still sees a
* complete `CliOAuthClient` / `InspectorServerSettings` — avoiding a spray of
* `as unknown as` casts (see #1791 and the AGENTS.md `as`-cast policy).
*/

/**
* A full {@link CliOAuthClient} whose every method is a `vi.fn()` resolving to a
* benign value. Pass `overrides` to swap in the method(s) a test drives (e.g. a
* `connect` that rejects with `AuthRecoveryRequiredError`).
*/
export function makeFakeCliOAuthClient(
overrides: Partial<CliOAuthClient> = {},
): CliOAuthClient {
// The defaults are typed against the real CliOAuthClient signatures (not bare
// vi.fn()s) so a default with a wrong implementation/return would be rejected,
// the same way the tui App spies are typed. Note this does NOT extend to
// `overrides`: a caller passing a bare `vi.fn()` gets `Mock<(...args) => any>`,
// assignable to any member, so an override's implementation is unchecked — the
// factory guarantees the object's shape, not each override's signature. (And
// since `exactOptionalPropertyTypes` is off, `Partial<…>` even admits an
// explicit `undefined` for a required member.)
return {
connect: vi.fn<CliOAuthClient["connect"]>().mockResolvedValue(undefined),
disconnect: vi
.fn<CliOAuthClient["disconnect"]>()
.mockResolvedValue(undefined),
authenticate: vi
.fn<CliOAuthClient["authenticate"]>()
.mockResolvedValue(undefined),
beginInteractiveAuthorization: vi
.fn<CliOAuthClient["beginInteractiveAuthorization"]>()
.mockResolvedValue(undefined),
completeOAuthFlow: vi
.fn<CliOAuthClient["completeOAuthFlow"]>()
.mockResolvedValue(undefined),
checkAuthChallengeSatisfied: vi
.fn<CliOAuthClient["checkAuthChallengeSatisfied"]>()
.mockResolvedValue(false),
...overrides,
};
}

/**
* A full {@link InspectorServerSettings} with representative defaults for its
* required fields (the timeouts at 0 = "SDK default", `taskTtl` /
* `maxFetchRequests` at their product defaults, empty lists elsewhere). Pass
* `overrides` for the field(s) under test (e.g. `{ enterpriseManaged: true }`);
* as a `Partial<…>` with `exactOptionalPropertyTypes` off it also admits an
* explicit `undefined` for a required field, so don't rely on it to reject one.
*/
export function makeFakeServerSettings(
overrides: Partial<InspectorServerSettings> = {},
): InspectorServerSettings {
return {
headers: [],
metadata: [],
env: [],
connectionTimeout: 0,
requestTimeout: 0,
taskTtl: DEFAULT_TASK_TTL_MS,
maxFetchRequests: DEFAULT_MAX_FETCH_REQUESTS,
roots: [],
...overrides,
};
}
Loading
Loading