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
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:
parent
d76c4d5379
commit
373f346e5f
82 changed files with 2210 additions and 467 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -20,3 +20,4 @@ test-servers/build
|
|||
/inspector-network-*.json
|
||||
/*.server.json
|
||||
/configs/
|
||||
pr-screenshots
|
||||
|
|
|
|||
10
AGENTS.md
10
AGENTS.md
|
|
@ -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.
|
||||
|
|
|
|||
16
README.md
16
README.md
|
|
@ -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).
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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 .",
|
||||
|
|
|
|||
|
|
@ -1660,6 +1660,7 @@ function App({
|
|||
<InfoTab
|
||||
serverName={selectedServer}
|
||||
serverConfig={selectedServerConfig}
|
||||
serverSettings={selectedServerEntry?.settings ?? null}
|
||||
serverState={currentServerState}
|
||||
width={contentWidth}
|
||||
height={contentHeight}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
}
|
||||
|
|
|
|||
8
clients/web/package-lock.json
generated
8
clients/web/package-lock.json
generated
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
179
clients/web/src/test/core/client/node-persistence.test.ts
Normal file
179
clients/web/src/test/core/client/node-persistence.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
176
clients/web/src/test/core/client/remote.test.ts
Normal file
176
clients/web/src/test/core/client/remote.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Reference in a new issue