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
|
/inspector-network-*.json
|
||||||
/*.server.json
|
/*.server.json
|
||||||
/configs/
|
/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/`
|
- 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.
|
- 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.
|
- **`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:
|
- 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.
|
- **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.
|
- **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)
|
- after making the changes, respond to each review comment with what was done (or why it was ignored)
|
||||||
|
|
||||||
### Mandatory pre-push gate
|
### 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).
|
- **`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).
|
- 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).
|
||||||
- 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`.
|
- **`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.
|
- **`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` 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.
|
- `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).
|
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 |
|
| Script | What it does |
|
||||||
| --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| --------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `npm run validate` | `format:check` + `lint` + `build` + fast unit tests, per client. The quick inner-loop check. |
|
| `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. |
|
| `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 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 ci` | **Mandatory pre-push command.** `validate` → `coverage` → `smoke` → Storybook. A true superset of GitHub CI. |
|
||||||
| `npm run pack:verify` | Publish smoke — see [Publishing](#publishing). |
|
| `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).
|
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": {
|
"scripts": {
|
||||||
"build": "tsup",
|
"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": "vitest run",
|
||||||
"test:watch": "vitest",
|
"test:watch": "vitest",
|
||||||
"test:coverage": "npm run test-servers:build && npm run build && vitest run --coverage",
|
"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: "result"; result: McpResponse; appInfo?: CliAppInfo }
|
||||||
| { kind: "emitted" };
|
| { 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(
|
async function callMethod(
|
||||||
serverConfig: MCPServerConfig,
|
serverConfig: MCPServerConfig,
|
||||||
serverSettings: InspectorServerSettings | undefined,
|
serverSettings: InspectorServerSettings | undefined,
|
||||||
|
|
@ -370,10 +384,10 @@ async function callMethod(
|
||||||
await emitResult(outcome.result, outcome.appInfo, args);
|
await emitResult(outcome.result, outcome.appInfo, args);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
managedToolsState?.destroy();
|
destroyManagedState(managedToolsState);
|
||||||
managedResourcesState?.destroy();
|
destroyManagedState(managedResourcesState);
|
||||||
managedResourceTemplatesState?.destroy();
|
destroyManagedState(managedResourceTemplatesState);
|
||||||
managedPromptsState?.destroy();
|
destroyManagedState(managedPromptsState);
|
||||||
await inspectorClient.disconnect();
|
await inspectorClient.disconnect();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,10 +2,24 @@
|
||||||
"extends": "../../tsconfig.base.json",
|
"extends": "../../tsconfig.base.json",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"noEmit": true,
|
"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": {
|
"paths": {
|
||||||
"@inspector/core/*": ["../../core/*"]
|
"@inspector/core/*": ["../../core/*"]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"include": ["src/**/*"],
|
"include": ["src/**/*"],
|
||||||
"exclude": ["node_modules", "**/*.test.ts", "build"]
|
"exclude": ["node_modules", "**/*.test.ts", "**/*.test.tsx", "build"]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import type {
|
||||||
MCPServerConfig,
|
MCPServerConfig,
|
||||||
ServerState,
|
ServerState,
|
||||||
} from "@inspector/core/mcp/index.js";
|
} from "@inspector/core/mcp/index.js";
|
||||||
|
import { headersToServerSettings } from "@inspector/core/mcp/node/servers.js";
|
||||||
import { InfoTab } from "../src/components/InfoTab.js";
|
import { InfoTab } from "../src/components/InfoTab.js";
|
||||||
|
|
||||||
// Ink processes stdin keypresses asynchronously — await this after stdin.write
|
// Ink processes stdin keypresses asynchronously — await this after stdin.write
|
||||||
|
|
@ -127,14 +128,11 @@ describe("InfoTab", () => {
|
||||||
type: "sse",
|
type: "sse",
|
||||||
url: "https://example.com/sse",
|
url: "https://example.com/sse",
|
||||||
};
|
};
|
||||||
const withHeaders = {
|
|
||||||
...config,
|
|
||||||
headers: { Authorization: "Bearer x" },
|
|
||||||
} as unknown as MCPServerConfig;
|
|
||||||
const { lastFrame } = render(
|
const { lastFrame } = render(
|
||||||
<InfoTab
|
<InfoTab
|
||||||
serverName="my-server"
|
serverName="my-server"
|
||||||
serverConfig={withHeaders}
|
serverConfig={config}
|
||||||
|
serverSettings={headersToServerSettings({ Authorization: "Bearer x" })}
|
||||||
serverState={baseState}
|
serverState={baseState}
|
||||||
width={80}
|
width={80}
|
||||||
height={20}
|
height={20}
|
||||||
|
|
@ -167,15 +165,15 @@ describe("InfoTab", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders a streamable-http config with headers", () => {
|
it("renders a streamable-http config with headers", () => {
|
||||||
const config = {
|
const config: MCPServerConfig = {
|
||||||
type: "streamable-http",
|
type: "streamable-http",
|
||||||
url: "https://example.com/mcp",
|
url: "https://example.com/mcp",
|
||||||
headers: { "X-Key": "abc" },
|
};
|
||||||
} as unknown as MCPServerConfig;
|
|
||||||
const { lastFrame } = render(
|
const { lastFrame } = render(
|
||||||
<InfoTab
|
<InfoTab
|
||||||
serverName="my-server"
|
serverName="my-server"
|
||||||
serverConfig={config}
|
serverConfig={config}
|
||||||
|
serverSettings={headersToServerSettings({ "X-Key": "abc" })}
|
||||||
serverState={baseState}
|
serverState={baseState}
|
||||||
width={80}
|
width={80}
|
||||||
height={20}
|
height={20}
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,23 @@ describe("schemaToForm", () => {
|
||||||
expect(form.sections[0]?.fields).toEqual([]);
|
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", () => {
|
it("maps each JSON Schema type to the matching ink-form field type", () => {
|
||||||
const form = schemaToForm(
|
const form = schemaToForm(
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,8 @@
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite-node --config vitest.config.ts dev.ts",
|
"dev": "vite-node --config vitest.config.ts dev.ts",
|
||||||
"build": "tsup",
|
"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": "vitest run",
|
||||||
"test:coverage": "vitest run --coverage",
|
"test:coverage": "vitest run --coverage",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
|
|
|
||||||
|
|
@ -1660,6 +1660,7 @@ function App({
|
||||||
<InfoTab
|
<InfoTab
|
||||||
serverName={selectedServer}
|
serverName={selectedServer}
|
||||||
serverConfig={selectedServerConfig}
|
serverConfig={selectedServerConfig}
|
||||||
|
serverSettings={selectedServerEntry?.settings ?? null}
|
||||||
serverState={currentServerState}
|
serverState={currentServerState}
|
||||||
width={contentWidth}
|
width={contentWidth}
|
||||||
height={contentHeight}
|
height={contentHeight}
|
||||||
|
|
|
||||||
|
|
@ -5,10 +5,16 @@ import type {
|
||||||
MCPServerConfig,
|
MCPServerConfig,
|
||||||
ServerState,
|
ServerState,
|
||||||
} from "@inspector/core/mcp/index.js";
|
} from "@inspector/core/mcp/index.js";
|
||||||
|
import type { InspectorServerSettings } from "@inspector/core/mcp/types.js";
|
||||||
|
|
||||||
interface InfoTabProps {
|
interface InfoTabProps {
|
||||||
serverName: string | null;
|
serverName: string | null;
|
||||||
serverConfig: MCPServerConfig | 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;
|
serverState: ServerState | null;
|
||||||
width: number;
|
width: number;
|
||||||
height: number;
|
height: number;
|
||||||
|
|
@ -18,11 +24,24 @@ interface InfoTabProps {
|
||||||
export function InfoTab({
|
export function InfoTab({
|
||||||
serverName,
|
serverName,
|
||||||
serverConfig,
|
serverConfig,
|
||||||
|
serverSettings = null,
|
||||||
serverState,
|
serverState,
|
||||||
width,
|
width,
|
||||||
height,
|
height,
|
||||||
focused = false,
|
focused = false,
|
||||||
}: InfoTabProps) {
|
}: 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);
|
const scrollViewRef = useRef<ScrollViewRef>(null);
|
||||||
|
|
||||||
// Handle keyboard input for scrolling
|
// Handle keyboard input for scrolling
|
||||||
|
|
@ -111,33 +130,13 @@ export function InfoTab({
|
||||||
<>
|
<>
|
||||||
<Text dimColor>Type: sse</Text>
|
<Text dimColor>Type: sse</Text>
|
||||||
<Text dimColor>URL: {serverConfig.url}</Text>
|
<Text dimColor>URL: {serverConfig.url}</Text>
|
||||||
{serverConfig.headers &&
|
{headersBlock}
|
||||||
Object.keys(serverConfig.headers).length > 0 && (
|
|
||||||
<Box marginTop={1}>
|
|
||||||
<Text dimColor>
|
|
||||||
Headers:{" "}
|
|
||||||
{Object.entries(serverConfig.headers)
|
|
||||||
.map(([k, v]) => `${k}=${v}`)
|
|
||||||
.join(", ")}
|
|
||||||
</Text>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
) : serverConfig.type === "streamable-http" ? (
|
) : serverConfig.type === "streamable-http" ? (
|
||||||
<>
|
<>
|
||||||
<Text dimColor>Type: streamable-http</Text>
|
<Text dimColor>Type: streamable-http</Text>
|
||||||
<Text dimColor>URL: {serverConfig.url}</Text>
|
<Text dimColor>URL: {serverConfig.url}</Text>
|
||||||
{serverConfig.headers &&
|
{headersBlock}
|
||||||
Object.keys(serverConfig.headers).length > 0 && (
|
|
||||||
<Box marginTop={1}>
|
|
||||||
<Text dimColor>
|
|
||||||
Headers:{" "}
|
|
||||||
{Object.entries(serverConfig.headers)
|
|
||||||
.map(([k, v]) => `${k}=${v}`)
|
|
||||||
.join(", ")}
|
|
||||||
</Text>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
</Box>
|
</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 {
|
interface JsonSchemaObject {
|
||||||
properties?: Record<string, JsonSchemaProperty>;
|
properties?: Record<string, unknown>;
|
||||||
required?: string[];
|
required?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -60,7 +65,12 @@ export function schemaToForm(
|
||||||
const required = schema.required || [];
|
const required = schema.required || [];
|
||||||
|
|
||||||
for (const [key, prop] of Object.entries(properties)) {
|
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 = {
|
const baseField = {
|
||||||
name: key,
|
name: key,
|
||||||
label: property.title || key,
|
label: property.title || key,
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,30 @@
|
||||||
{
|
{
|
||||||
"extends": "../../tsconfig.base.json",
|
"extends": "../../tsconfig.base.json",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
|
"noEmit": true,
|
||||||
"jsx": "react-jsx",
|
"jsx": "react-jsx",
|
||||||
"outDir": "./build",
|
// Match clients/web/tsconfig.app.json's module/lib *resolution* options so
|
||||||
"rootDir": ".",
|
// 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": {
|
"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/**/*"],
|
"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",
|
"globals": "^17.4.0",
|
||||||
"happy-dom": "^20.9.0",
|
"happy-dom": "^20.9.0",
|
||||||
"playwright": "^1.58.2",
|
"playwright": "^1.58.2",
|
||||||
"prettier": "^3.8.1",
|
"prettier": "^3.8.4",
|
||||||
"storybook": "^10.2.19",
|
"storybook": "^10.2.19",
|
||||||
"tsup": "^8.5.1",
|
"tsup": "^8.5.1",
|
||||||
"typescript": "~5.9.3",
|
"typescript": "~5.9.3",
|
||||||
|
|
@ -8192,9 +8192,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/prettier": {
|
"node_modules/prettier": {
|
||||||
"version": "3.8.1",
|
"version": "3.8.4",
|
||||||
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz",
|
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.4.tgz",
|
||||||
"integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==",
|
"integrity": "sha512-N2MylSdi48+5N/6S5j+maeHbUSIzzZ5uOcX5Hm4QpV8Dkb1HFjfAKTKX6yNPJQD9AhcT3ifHNB66tWTTJDi11Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"bin": {
|
"bin": {
|
||||||
|
|
|
||||||
|
|
@ -10,16 +10,23 @@ import {
|
||||||
import {
|
import {
|
||||||
CLIENT_KEYCHAIN_ID,
|
CLIENT_KEYCHAIN_ID,
|
||||||
extractSecretsFromClientConfig,
|
extractSecretsFromClientConfig,
|
||||||
|
hasClientPlaintextSecret,
|
||||||
mergeSecretsIntoClientConfig,
|
mergeSecretsIntoClientConfig,
|
||||||
} from "@inspector/core/client/secrets.js";
|
} from "@inspector/core/client/secrets.js";
|
||||||
import {
|
import {
|
||||||
|
getClientConfigFilePath,
|
||||||
loadClientConfig,
|
loadClientConfig,
|
||||||
parseClientConfig,
|
parseClientConfig,
|
||||||
saveClientConfig,
|
saveClientConfig,
|
||||||
} from "@inspector/core/client/config.js";
|
} from "@inspector/core/client/config.js";
|
||||||
import {
|
import {
|
||||||
|
CIMD_METADATA_URL_HTTPS_ERROR,
|
||||||
|
CIMD_METADATA_URL_INVALID_ERROR,
|
||||||
|
CIMD_METADATA_URL_PATH_ERROR,
|
||||||
formatClientConfigLoadError,
|
formatClientConfigLoadError,
|
||||||
|
getCimdClientMetadataUrlError,
|
||||||
isAbsoluteHttpUrl,
|
isAbsoluteHttpUrl,
|
||||||
|
serializeClientConfig,
|
||||||
} from "@inspector/core/client/config-parse.js";
|
} from "@inspector/core/client/config-parse.js";
|
||||||
import {
|
import {
|
||||||
getActiveCimdClientMetadataUrl,
|
getActiveCimdClientMetadataUrl,
|
||||||
|
|
@ -210,6 +217,13 @@ describe("client config", () => {
|
||||||
).toThrow(/path/);
|
).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 () => {
|
it("loadClientConfig returns {} when file is absent", async () => {
|
||||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "client-config-"));
|
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "client-config-"));
|
||||||
const filePath = path.join(tmpDir, "client.json");
|
const filePath = path.join(tmpDir, "client.json");
|
||||||
|
|
@ -217,6 +231,76 @@ describe("client config", () => {
|
||||||
expect(config).toEqual({});
|
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", () => {
|
it("formatClientConfigLoadError summarizes Zod validation failures", () => {
|
||||||
try {
|
try {
|
||||||
parseClientConfig({
|
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 {
|
import {
|
||||||
buildRunnerClientAuthOptions,
|
buildRunnerClientAuthOptions,
|
||||||
isOAuthCapableServerConfig,
|
isOAuthCapableServerConfig,
|
||||||
|
loadRunnerClientConfig,
|
||||||
} from "@inspector/core/client/runner.js";
|
} from "@inspector/core/client/runner.js";
|
||||||
import type { ClientConfig } from "@inspector/core/client/types.js";
|
import type { ClientConfig } from "@inspector/core/client/types.js";
|
||||||
import type { InspectorServerSettings } from "@inspector/core/mcp/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?.enterpriseManaged).toBe(true);
|
||||||
expect(opts.oauth?.clientId).toBe("resource-client");
|
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,
|
convertParameterValue,
|
||||||
convertToolParameters,
|
convertToolParameters,
|
||||||
convertPromptArguments,
|
convertPromptArguments,
|
||||||
|
toRecord,
|
||||||
} from "@inspector/core/json/jsonUtils.js";
|
} from "@inspector/core/json/jsonUtils.js";
|
||||||
import type { Tool } from "@modelcontextprotocol/client";
|
import type { Tool } from "@modelcontextprotocol/client";
|
||||||
|
|
||||||
describe("JSON Utils", () => {
|
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", () => {
|
describe("convertParameterValue", () => {
|
||||||
it("should convert string to string", () => {
|
it("should convert string to string", () => {
|
||||||
expect(convertParameterValue("hello", { type: "string" })).toBe("hello");
|
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", () => {
|
it("should convert string to number", () => {
|
||||||
expect(convertParameterValue("42", { type: "number" })).toBe(42);
|
expect(convertParameterValue("42", { type: "number" })).toBe(42);
|
||||||
expect(convertParameterValue("3.14", { type: "number" })).toBe(3.14);
|
expect(convertParameterValue("3.14", { type: "number" })).toBe(3.14);
|
||||||
|
|
|
||||||
|
|
@ -105,7 +105,8 @@ export default defineConfig(({ command }) => {
|
||||||
'src/lib/**/*.{ts,tsx}',
|
'src/lib/**/*.{ts,tsx}',
|
||||||
'clients/web/server/**/*.{ts,tsx}',
|
'clients/web/server/**/*.{ts,tsx}',
|
||||||
path.join(repoRoot, 'core/mcp/**/*.{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/react/**/*.{ts,tsx}'),
|
||||||
path.join(repoRoot, 'core/auth/**/*.{ts,tsx}'),
|
path.join(repoRoot, 'core/auth/**/*.{ts,tsx}'),
|
||||||
path.join(repoRoot, 'core/storage/**/*.{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