diff --git a/AGENTS.md b/AGENTS.md index ed03359f1..d4927fade 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,6 @@ # Inspector V2 -This is an application for inspecting MCP servers. Has three incarnations, Web, TUI, and CLI. +This is an application for inspecting MCP servers. Has four client packages: Web, TUI, one-shot CLI, and session CLI (`mcpi`). ## Project Structure @@ -18,7 +18,12 @@ inspector/ │ │ │ # inject-auth-token.ts (embeds the API token into served index.html), │ │ │ # vite-base-config.ts (shared optimizeDeps exclusions) │ │ └── static/ # sandbox_proxy.html (served by sandbox-controller for MCP Apps tab) -│ ├── cli/ # CLI client (tsup bundle, @inspector/core alias) +│ ├── cli/ # One-shot CLI (tsup bundle, @inspector/core alias) +│ │ ├── src/cli.ts # One-shot entry (`mcp-inspector --cli`) +│ │ └── src/handlers/ # Shared runMethod + method types (one-shot + mcpi daemon) +│ ├── mcpi/ # Session CLI (`mcpi` bin — connect once, many commands) +│ │ ├── src/session/ # Front-end (`mcp.ts` → mcp-bin.ts) +│ │ └── src/daemon/ # Implicit Unix-socket session daemon │ ├── tui/ # TUI client (Ink + React, tsup bundle) │ ├── launcher/ # Shared launcher (relative imports into sibling build/ outputs) ├── core/ # Shared core code (no package.json — consumed via the `@inspector/core` vite alias) @@ -68,7 +73,7 @@ inspector/ ## Development setup -v2 is **not** an npm workspace — each client under `clients/*` keeps its own `package.json` and `node_modules` (see the rationale in [specification/v2_cli_tui_launcher.md](specification/v2_cli_tui_launcher.md)). A single `npm install` at the repo root is still all you need: the root `postinstall` (`scripts/install-clients.mjs`) cascades `npm install` into `clients/web`, `clients/cli`, `clients/tui`, and `clients/launcher`. +v2 is **not** an npm workspace — each client under `clients/*` keeps its own `package.json` and `node_modules` (see the rationale in [specification/v2_cli_tui_launcher.md](specification/v2_cli_tui_launcher.md)). A single `npm install` at the repo root is still all you need: the root `postinstall` (`scripts/install-clients.mjs`) cascades `npm install` into `clients/web`, `clients/cli`, `clients/mcpi`, `clients/tui`, and `clients/launcher`. - **Fresh clone / first-time setup:** run `npm install` at the repo root. - **After a pull that changes a client's dependencies:** re-run `npm install` at the root to re-sync every client (the `postinstall` cascade handles it). @@ -191,12 +196,14 @@ gh project item-edit --project-id PVT_kwDOCt2Azc4BJVxt --id "$ITEM_ID" --field-i - In unit tests that expect error output, suppress it from the console - Run unit tests with `npm run test` (or `npm run test:watch` during development) from `clients/web/` - Run CLI tests with `npm run test` from `clients/cli/` (builds test-servers + CLI bin first via `pretest`) +- Run session CLI (`mcpi`) tests with `npm run test` from `clients/mcpi/` (builds test-servers + mcpi bins first via `pretest`) - 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/ && 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 … -- */` 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. +- **`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:mcpi` / `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/mcpi`, `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 … -- */` 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. + - **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. Coverage exclusion: `src/index.ts` — see `clients/cli/vitest.config.ts`. `commander` uses `.exitOverride()` so a parse error throws instead of tearing down the test worker. + - **mcpi** (`clients/mcpi`): session front-end + daemon tests run **in-process** via `__tests__/helpers/mcp-runner.ts` (`runMcp()`). Coverage exclusions (bootstraps + hard-to-stabilize accept/stream races): `src/mcp-bin.ts`, `src/daemon/run.ts`, `src/daemon/ipc-glue.ts`, `src/daemon/stream-client.ts` — see `clients/mcpi/vitest.config.ts` and [specification/v2_cli_v2.md](specification/v2_cli_v2.md) §8.2 for follow-ups. Build-time `@inspector/cli` alias reaches into `clients/cli/src` for shared handlers / error-handler / OAuth helpers (temporary; not a published API). - **TUI** (`clients/tui`): the gate covers the **non-React logic** only — `logger.ts`, `components/tabsConfig.ts`, and `utils/*` (server resolution lives in `core/` and is measured by the web suite). The Ink components, `App.tsx`, and `hooks/` are an **interim exclusion** in `clients/tui/vitest.config.ts` pending the renderer-based follow-up (#1501). When adding new **non-React** logic under `clients/tui/src`, it falls under the gate automatically — add tests for it. - Run `npm run test:integration` (also from `clients/web/`) for the InspectorClient + transport + auth integration suite. It runs under a separate `integration` vitest project in node env (no happy-dom) with 30s timeouts. The script builds `test-servers/` first via `tsc -p ../../test-servers --noCheck` so the stdio MCP test server can be spawned as a real subprocess. CI does not run `test:integration` as its own step — the integration project is covered by the CI `coverage` gate, whose web `test:coverage` runs `--project=unit --project=integration --coverage`. - Test files live alongside the source as `.test.tsx` (or `.test.ts` for non-React modules). Integration tests live under `clients/web/src/test/integration/`, mirroring the `core/` source layout (`mcp/`, `mcp/node/`, `mcp/remote/`, `auth/`, `auth/node/`, `storage/`). Any test file under that folder is automatically picked up by the `integration` vitest project (node env, 30s timeouts) via the folder glob in `vite.config.ts` — placement is the manifest, there is no enumeration to keep in sync. Tests outside the folder run in the `unit` project (happy-dom). When adding a new test for, e.g., `core/mcp/remote/foo.ts`, put it at `src/test/integration/mcp/remote/foo.test.ts`. @@ -211,10 +218,10 @@ gh project item-edit --project-id PVT_kwDOCt2Azc4BJVxt --id "$ITEM_ID" --field-i ### Mandatory pre-push gate - 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` 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). +- 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 per-client validations (`validate:web` → `validate:cli` → `validate:mcpi` → `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`. + - The CLI / mcpi nuance: both clients' tests need their built bundles (`e2e` / daemon spawn), so `test` **builds first** via `pretest` (`test-servers:build && build`). To avoid building twice, `clients/cli` and `clients/mcpi` `validate` fold that in — CLI is `format:check && lint && typecheck && test`, mcpi 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 && (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` / `smoke:web:browser`, 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` (with Playwright chromium installed just before it, since `smoke:web:browser` needs it). 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. diff --git a/README.md b/README.md index 709637e5f..7ef5d0ca6 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ All three run through one global `mcp-inspector` binary: ```bash npx @modelcontextprotocol/inspector # web UI (default) -npx @modelcontextprotocol/inspector --cli # CLI +npx @modelcontextprotocol/inspector --cli # one-shot CLI npx @modelcontextprotocol/inspector --tui # TUI ``` @@ -24,7 +24,7 @@ v2 is **not** an npm workspace. Each client under `clients/*` keeps its own `pac inspector/ ├── clients/ │ ├── web/ # Web client (Vite + React + Mantine). src/ = browser app; server/ = Node dev/prod backend -│ ├── cli/ # CLI client (tsup bundle, @inspector/core alias) +│ ├── cli/ # CLI client (tsup bundle) — one-shot `mcp-inspector --cli` │ ├── tui/ # TUI client (Ink + React, tsup bundle) │ └── launcher/ # Shared launcher — provides the `mcp-inspector` bin, dispatches to web/cli/tui ├── core/ # Shared code consumed via the `@inspector/core` alias (no package.json) diff --git a/clients/cli/README.md b/clients/cli/README.md index 7c49d3a35..c1199b11d 100644 --- a/clients/cli/README.md +++ b/clients/cli/README.md @@ -1,16 +1,16 @@ # MCP Inspector CLI Client -The CLI mode enables programmatic interaction with MCP servers from the command line. It is ideal for scripting, automation, continuous integration, and establishing an efficient feedback loop with AI coding assistants. +One-shot CLI: connect → one `--method` → disconnect. Invoked as `mcp-inspector --cli`. -## Running the CLI +## One-shot CLI (`mcp-inspector --cli`) -You can run the CLI client directly via `npx`: +You can run the one-shot CLI via `npx`: ```bash npx @modelcontextprotocol/inspector --cli node build/index.js ``` -The CLI mode supports operations across tools, resources, and prompts, returning structured JSON output. +Supports tools, resources, and prompts (plus `--method servers/list` / `servers/show` for catalog entries without connecting). ### Examples @@ -98,7 +98,7 @@ Options that specify the MCP server (catalog/config file, ad-hoc command/URL, en | Option | Description | | ----------------------------- | ----------------------------------------------------------------------------------------- | -| `--method ` | MCP method to invoke. Supports `initialize` (connect-only probe → `{serverInfo, protocolVersion, capabilities, instructions}`), `tools/list`, `tools/call`, `resources/list`, `resources/read`, `resources/templates/list`, `prompts/list`, `prompts/get`, `logging/setLevel`. | +| `--method ` | MCP method to invoke. Supports `initialize` (connect-only probe → `{serverInfo, protocolVersion, capabilities, instructions}`), `tools/list`, `tools/call`, `resources/list`, `resources/read`, `resources/templates/list`, `prompts/list`, `prompts/get`, `logging/setLevel`, plus catalog-only `servers/list` / `servers/show` (no MCP connect). Stream / session-only methods (e.g. `logging/tail`) are rejected. | | `--tool-name ` | Tool name (for `tools/call`). | | `--tool-arg ` | Tool argument; repeat for multiple. Use `key='{"json":true}'` for JSON. Values are coerced (JSON-parsed, so `count=1` becomes a number). | | `--tool-args-json ` | Tool arguments as a single JSON object (e.g. `'{"zip":"10001"}'`). Passed verbatim — no `key=value` coercion, so `"012"` stays a string. Mutually exclusive with `--tool-arg`. | @@ -111,6 +111,8 @@ Options that specify the MCP server (catalog/config file, ad-hoc command/URL, en | `--connect-timeout ` | Connection timeout in ms. Defaults to `15000` for ad-hoc `--server-url`/target runs (so a black-holed host fails fast) and to the file-level timeout for `--catalog`/`--config` runs. `0` disables the timeout. | | `--app-info` | Probe a tool's MCP App UI metadata without invoking it. With `--method tools/call --tool-name `: prints one JSON line (`hasApp`, `resourceUri`, `csp`, `permissions`, `domain`, …) and exits `0` if the tool has an app or `2` (`no_app`) if not. With `--method tools/list`: emits NDJSON — one app-info line per tool over a single connection. | | `--format ` | Output format. `text` (default) pretty-prints the result. `json` emits a single JSON object on stdout (`{ "result": … }`, plus `{ "appInfo": … }` as a sibling key for App tools) with no banners, so the whole output pipes cleanly into `jq`. | +| `--relogin` | Ignore any stored OAuth for this run’s server URL before connect; interactive login still only runs if the server requires auth. No-op for stdio (no URL-keyed store entry). Conflicts with `--stored-auth-only` / `--use-stored-auth` / `--wait-for-auth`. | +| `--stored-auth-only` | Never start interactive OAuth / step-up; use the shared store if present, otherwise fail with `auth_required`. | #### App probing (`--app-info`) and machine-readable output (`--format json`) @@ -140,13 +142,15 @@ A `tools/call` that returns `isError:true` still prints its payload but exits `5 ### CLI-specific (OAuth for HTTP servers) -The CLI runs the same loopback callback server as the TUI (`http://127.0.0.1:6276/oauth/callback` by default). On connect **401** or mid-session interactive auth (re-login / step-up), it: +The CLI runs the same loopback callback server as the TUI (`http://127.0.0.1:6276/oauth/callback` by default). + +**One-shot (`mcp-inspector --cli`):** on connect **401** or mid-session interactive auth (re-login / step-up), it: 1. Starts the callback listener on `--callback-url` (or `MCP_OAUTH_CALLBACK_URL`) -2. Prints the authorization URL to the console (`ConsoleNavigation`) +2. Prints the authorization URL to stderr (OSC 8 hyperlink when stderr is a TTY) and **opens the default browser** on a TTY; non-TTY / CI prints a plain URL only and never launches a browser 3. Waits for the browser redirect, exchanges the code, and retries connect or the failed RPC -**Step-up (standard OAuth):** when an RPC needs extra scopes, the CLI prompts on stderr: `Proceed with step-up authorization? [y/N]`. **y** continues; **N** exits with an error. EMA step-up re-mints silently (no prompt). +**Step-up (standard OAuth, one-shot only):** when an RPC needs extra scopes, the CLI prompts on stderr: `Proceed with step-up authorization? [y/N]`. **y** continues; **N** exits with an error. EMA step-up re-mints silently (no prompt). **Shared OAuth storage:** the CLI **reuses** tokens from `~/.mcp-inspector/storage/oauth.json` when they already exist (same file as other Inspector clients). That is passive file sharing, not launching another app. @@ -256,17 +260,16 @@ While the Web Client provides a rich visual interface, the CLI is designed for: Like the other clients, the CLI self-validates from its own folder: ```bash -npm run validate # format:check && lint && test:coverage +npm run validate # format:check && lint && test (fast; no coverage gate) npm test # build test-servers + binary, then run all tests -npm run test:coverage # build + tests under the per-file coverage gate +npm run test:coverage # build + tests under the per-file ≥90 coverage gate ``` -The CLI's `test:coverage` **builds the binary first** (its out-of-process -`e2e.test.ts` spawns it, so it must run against a fresh build). `validate` -therefore folds the build into `test:coverage` rather than repeating it — it is -`format:check && lint && test:coverage`, with no separate `build` step (the -other clients, whose tests don't spawn their bundle, keep an explicit `build`). -The repo-root `validate:cli` just delegates here. +The CLI's `test` / `test:coverage` **build the binary first** (out-of-process +`e2e.test.ts` spawns it). `validate` is `format:check && lint && test` with no +separate `build` step (`pretest` builds). Repo-root `validate:cli` delegates +here; the coverage gate is `npm run coverage` / `coverage:cli` (also in +`npm run ci`), matching AGENTS.md. Tests run the CLI **in-process** (importing `runCli()`) so `src/` is measured under coverage, with a thin out-of-process spawn layer for the real binary. See diff --git a/clients/cli/__tests__/README.md b/clients/cli/__tests__/README.md index 09aec386f..a51413da3 100644 --- a/clients/cli/__tests__/README.md +++ b/clients/cli/__tests__/README.md @@ -1,4 +1,4 @@ -# CLI Tests +# CLI Tests (one-shot) ## Running Tests @@ -47,6 +47,8 @@ root provides a further end-to-end check of the binary.) - `cliOAuth.test.ts` - Unit tests for `cliOAuth.ts` (step-up confirm, helper wiring, retry) - `oauth-interactive.test.ts` - **Integration** smoke parity for CLI interactive OAuth: connect-time callback server + step-up **y/N** against composable `TestServerHttp` (auto-completes authorize URL programmatically; not a subprocess binary e2e) - `e2e.test.ts` - Out-of-process spawn of the built binary (exit codes + boot; no OAuth) +- `style.test.ts` - ANSI / OSC 8 helpers used by OAuth navigation prompts +- `stored-auth.test.ts` / `programmatic-ergonomics.test.ts` - Stored-auth / handoff / flag conflicts ## Helpers @@ -67,7 +69,6 @@ and the bundled stdio server is launched via `getTestMcpServerCommand()`. a file run sequentially - Config files use `crypto.randomUUID()` for uniqueness - HTTP/SSE servers use dynamic port allocation to avoid conflicts -- Coverage is enforced per-file (lines ≥ 90, statements ≥ 85, functions ≥ 80, - branches ≥ 50). `src/index.ts` (the binary bootstrap) is excluded because it - only runs in the spawned binary, which coverage can't instrument +- Coverage is enforced per-file at ≥90 on lines, statements, functions, and + branches. Exclusion: `src/index.ts` (see `vitest.config.ts`) - All tests use the built-in MCP test servers — no external/registry dependencies diff --git a/clients/cli/__tests__/clear-stored-auth-for-relogin.test.ts b/clients/cli/__tests__/clear-stored-auth-for-relogin.test.ts new file mode 100644 index 000000000..0e4ecee93 --- /dev/null +++ b/clients/cli/__tests__/clear-stored-auth-for-relogin.test.ts @@ -0,0 +1,68 @@ +import { describe, it, expect, afterEach } from "vitest"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { + getStateFilePath, + resetNodeOAuthStorageCache, +} from "@inspector/core/auth/node/storage-node.js"; +import { clearStoredAuthForRelogin } from "../src/clear-stored-auth-for-relogin.js"; + +describe("clearStoredAuthForRelogin", () => { + let dir: string | undefined; + let prevPath: string | undefined; + + afterEach(() => { + if (prevPath === undefined) + delete process.env.MCP_INSPECTOR_OAUTH_STATE_PATH; + else process.env.MCP_INSPECTOR_OAUTH_STATE_PATH = prevPath; + resetNodeOAuthStorageCache(); + if (dir) { + fs.rmSync(dir, { recursive: true, force: true }); + dir = undefined; + } + }); + + it("no-ops when serverUrl is missing or blank", async () => { + await clearStoredAuthForRelogin(undefined); + await clearStoredAuthForRelogin(" "); + }); + + it("clears a URL-keyed entry and tolerates non-URL keys", async () => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), "cli-relogin-")); + const file = path.join(dir, "oauth.json"); + fs.writeFileSync( + file, + JSON.stringify({ + servers: { + "https://example.com/mcp": { + tokens: { access_token: "a", token_type: "Bearer" }, + }, + "not a url": { + tokens: { access_token: "b", token_type: "Bearer" }, + }, + }, + idpSessions: {}, + }), + "utf8", + ); + prevPath = process.env.MCP_INSPECTOR_OAUTH_STATE_PATH; + process.env.MCP_INSPECTOR_OAUTH_STATE_PATH = file; + resetNodeOAuthStorageCache(); + expect(getStateFilePath()).toBe(file); + + await clearStoredAuthForRelogin("https://example.com/mcp"); + let blob = JSON.parse(fs.readFileSync(file, "utf8")) as { + servers: Record; + }; + expect(blob.servers["https://example.com/mcp"]).toBeUndefined(); + expect(blob.servers["not a url"]).toBeDefined(); + + // normalizeServerUrl catch path — clears under the raw key + await clearStoredAuthForRelogin("not a url"); + blob = JSON.parse(fs.readFileSync(file, "utf8")) as { + servers: Record; + }; + expect(blob.servers["not a url"]).toBeUndefined(); + }); +}); diff --git a/clients/cli/__tests__/cli-oauth-navigation.test.ts b/clients/cli/__tests__/cli-oauth-navigation.test.ts new file mode 100644 index 000000000..4cc9a2dbf --- /dev/null +++ b/clients/cli/__tests__/cli-oauth-navigation.test.ts @@ -0,0 +1,114 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { createCliOAuthNavigation } from "../src/cli-oauth-navigation.js"; +import { openUrl } from "../src/open-url.js"; + +vi.mock("../src/open-url.js", () => ({ + openUrl: vi.fn().mockResolvedValue(undefined), +})); + +const openUrlMock = vi.mocked(openUrl); + +describe("createCliOAuthNavigation", () => { + afterEach(() => { + openUrlMock.mockClear(); + openUrlMock.mockResolvedValue(undefined); + }); + + it("prints OSC 8 link and opens browser on a TTY", async () => { + const lines: string[] = []; + const openBrowser = vi.fn().mockResolvedValue(undefined); + const nav = createCliOAuthNavigation({ + isTTY: true, + // Force ANSI on even when the test runner exports NO_COLOR. + noColorEnv: "", + write: (line) => lines.push(line), + openBrowser, + }); + const url = new URL("https://as.example/authorize?x=1"); + nav.navigateToAuthorization(url); + await vi.waitFor(() => expect(openBrowser).toHaveBeenCalledOnce()); + expect(lines.join("")).toContain("Please navigate to:"); + expect(lines.join("")).toContain( + "\u001b]8;;https://as.example/authorize?x=1\u0007", + ); + expect(openBrowser).toHaveBeenCalledWith( + "https://as.example/authorize?x=1", + ); + expect(openUrlMock).not.toHaveBeenCalled(); + }); + + it("prints a plain URL and does not open a browser when not a TTY", async () => { + const lines: string[] = []; + const openBrowser = vi.fn(); + const nav = createCliOAuthNavigation({ + isTTY: false, + write: (line) => lines.push(line), + openBrowser, + }); + nav.navigateToAuthorization(new URL("https://as.example/authorize")); + await vi.waitFor(() => expect(lines.length).toBe(1)); + expect(lines.join("")).toBe( + "Please navigate to: https://as.example/authorize\n", + ); + expect(lines.join("")).not.toContain("\u001b]8;;"); + expect(openBrowser).not.toHaveBeenCalled(); + expect(openUrlMock).not.toHaveBeenCalled(); + }); + + it("skips OSC 8 when NO_COLOR is set but still opens on a TTY", async () => { + const lines: string[] = []; + const openBrowser = vi.fn().mockResolvedValue(undefined); + const nav = createCliOAuthNavigation({ + isTTY: true, + noColorEnv: "1", + write: (line) => lines.push(line), + openBrowser, + }); + nav.navigateToAuthorization(new URL("https://as.example/a")); + await vi.waitFor(() => expect(openBrowser).toHaveBeenCalledOnce()); + expect(lines.join("")).toBe("Please navigate to: https://as.example/a\n"); + expect(lines.join("")).not.toContain("\u001b]8;;"); + }); + + it("swallows browser-open failures after printing the URL", async () => { + const lines: string[] = []; + const openBrowser = vi.fn().mockRejectedValue(new Error("no browser")); + const nav = createCliOAuthNavigation({ + isTTY: true, + noColorEnv: "1", + write: (line) => lines.push(line), + openBrowser, + }); + nav.navigateToAuthorization(new URL("https://as.example/a")); + await vi.waitFor(() => expect(openBrowser).toHaveBeenCalledOnce()); + expect(lines.join("")).toContain("Please navigate to:"); + }); + + it("writes to stderr and uses openUrl by default on a TTY", async () => { + const writeSpy = vi + .spyOn(process.stderr, "write") + .mockImplementation(() => true); + const ttyDesc = Object.getOwnPropertyDescriptor(process.stderr, "isTTY"); + Object.defineProperty(process.stderr, "isTTY", { + configurable: true, + get: () => true, + }); + try { + const nav = createCliOAuthNavigation({ + noColorEnv: "1", + }); + nav.navigateToAuthorization(new URL("https://as.example/default")); + await vi.waitFor(() => expect(openUrlMock).toHaveBeenCalledOnce()); + expect(openUrlMock).toHaveBeenCalledWith("https://as.example/default"); + const written = writeSpy.mock.calls.map((c) => String(c[0])).join(""); + expect(written).toContain( + "Please navigate to: https://as.example/default", + ); + } finally { + writeSpy.mockRestore(); + if (ttyDesc) { + Object.defineProperty(process.stderr, "isTTY", ttyDesc); + } + } + }); +}); diff --git a/clients/cli/__tests__/cliOAuth.test.ts b/clients/cli/__tests__/cliOAuth.test.ts index f9d191539..f075f13ca 100644 --- a/clients/cli/__tests__/cliOAuth.test.ts +++ b/clients/cli/__tests__/cliOAuth.test.ts @@ -453,5 +453,76 @@ describe("cliOAuth", () => { ), ).rejects.toThrow("nope (401)"); }); + + it("fails with AUTH_REQUIRED under --stored-auth-only without interactive OAuth", async () => { + const runSpy = vi.spyOn(runnerInteractive, "runRunnerInteractiveOAuth"); + const connect = vi.fn().mockRejectedValue( + new AuthRecoveryRequiredError(new URL("https://as.example/authorize"), { + reason: "token_expired", + }), + ); + const client = { + connect, + disconnect: vi.fn(), + checkAuthChallengeSatisfied: vi.fn().mockResolvedValue(false), + }; + + await expect( + connectInspectorWithOAuth( + client, + oauthServerConfig, + new MutableRedirectUrlProvider(), + CALLBACK_URL_CONFIG, + undefined, + { storedAuthOnly: true }, + ), + ).rejects.toMatchObject({ exitCode: 3 }); + expect(runSpy).not.toHaveBeenCalled(); + }); + + it("fails stored-auth-only on plain unauthorized errors", async () => { + const runSpy = vi.spyOn(runnerInteractive, "runRunnerInteractiveOAuth"); + const connect = vi + .fn() + .mockRejectedValue(new Error("Connection failed for server (401)")); + const client = { + connect, + disconnect: vi.fn(), + checkAuthChallengeSatisfied: vi.fn(), + }; + + await expect( + connectInspectorWithOAuth( + client, + oauthServerConfig, + new MutableRedirectUrlProvider(), + CALLBACK_URL_CONFIG, + undefined, + { storedAuthOnly: true }, + ), + ).rejects.toMatchObject({ exitCode: 3 }); + expect(runSpy).not.toHaveBeenCalled(); + }); + }); + + it("withCliAuthRecoveryRetry respects storedAuthOnly", async () => { + const fn = vi.fn().mockRejectedValue( + new AuthRecoveryRequiredError(new URL("https://as.example/authorize"), { + reason: "token_expired", + }), + ); + await expect( + withCliAuthRecoveryRetry( + { + checkAuthChallengeSatisfied: vi.fn(), + } as never, + new MutableRedirectUrlProvider(), + CALLBACK_URL_CONFIG, + undefined, + fn, + undefined, + { storedAuthOnly: true }, + ), + ).rejects.toMatchObject({ exitCode: 3 }); }); }); diff --git a/clients/cli/__tests__/emit-result.test.ts b/clients/cli/__tests__/emit-result.test.ts index df4e8fc6f..dfa16b379 100644 --- a/clients/cli/__tests__/emit-result.test.ts +++ b/clients/cli/__tests__/emit-result.test.ts @@ -31,6 +31,22 @@ describe("emitResult", () => { process.stdout.write = originalWrite; }); + it("uses a default appInfo shell when --app-info has no collected info", async () => { + const promise = emitResult(undefined as never, undefined, { + appInfo: true, + toolName: "missing-app", + format: "text", + }); + await expect(promise).rejects.toBeInstanceOf(CliExitCodeError); + await promise.catch((e: CliExitCodeError) => { + expect(e.exitCode).toBe(2); + }); + expect(JSON.parse(stdout.trim())).toMatchObject({ + hasApp: false, + toolName: "missing-app", + }); + }); + it("throws TOOL_ERROR (code:tool_is_error) when the result has isError:true", async () => { const promise = emitResult({ isError: true }, undefined, { toolName: "boom", @@ -108,6 +124,25 @@ describe("collectAppInfo", () => { expect(info.resourceError).toBe("string failure"); }); + it("stringifies a non-Error throw from extractAppInfo", async () => { + const extract = await import("@inspector/core/mcp/apps.js"); + const spy = vi + .spyOn(extract, "extractAppInfo") + .mockImplementationOnce(() => { + throw "boom-string"; + }); + try { + const info = await collectAppInfo( + { readResource: vi.fn() } as never, + { name: "t", inputSchema: { type: "object" as const } }, + undefined, + ); + expect(info.resourceError).toBe("boom-string"); + } finally { + spy.mockRestore(); + } + }); + it("folds a malformed _meta.ui.resourceUri into {hasApp:false, resourceError} instead of throwing", async () => { // extractAppInfo throws when the advertised UI URI is not a `ui://` string; // collectAppInfo must tolerate that so the tools/list --app-info NDJSON loop diff --git a/clients/cli/__tests__/format-output.test.ts b/clients/cli/__tests__/format-output.test.ts new file mode 100644 index 000000000..14d633207 --- /dev/null +++ b/clients/cli/__tests__/format-output.test.ts @@ -0,0 +1,50 @@ +import { describe, it, expect, afterEach } from "vitest"; +import { writeFormattedResult } from "../src/handlers/format-output.js"; + +describe("writeFormattedResult", () => { + let originalWrite: typeof process.stdout.write; + + afterEach(() => { + if (originalWrite) process.stdout.write = originalWrite; + }); + + function captureStdout(): { get: () => string; restore: () => void } { + let out = ""; + originalWrite = process.stdout.write; + process.stdout.write = ((chunk: unknown, ...rest: unknown[]) => { + out += String(chunk); + const cb = rest.find((x) => typeof x === "function") as + | (() => void) + | undefined; + cb?.(); + return true; + }) as typeof process.stdout.write; + return { + get: () => out, + restore: () => { + process.stdout.write = originalWrite; + }, + }; + } + + it("defaults to pretty text and supports the json envelope", async () => { + const cap = captureStdout(); + try { + await writeFormattedResult({ ok: 1 }); + expect(cap.get()).toContain('"ok": 1'); + expect(cap.get()).not.toContain('"result"'); + + // reset buffer by re-capturing + cap.restore(); + const cap2 = captureStdout(); + try { + await writeFormattedResult({ ok: 2 }, "json"); + expect(JSON.parse(cap2.get())).toEqual({ result: { ok: 2 } }); + } finally { + cap2.restore(); + } + } finally { + cap.restore(); + } + }); +}); diff --git a/clients/cli/__tests__/metadata.test.ts b/clients/cli/__tests__/metadata.test.ts index ac2876f6c..b5867d50e 100644 --- a/clients/cli/__tests__/metadata.test.ts +++ b/clients/cli/__tests__/metadata.test.ts @@ -428,6 +428,43 @@ describe("Metadata Tests", () => { } }); + it("JSON.stringifies object/array metadata (not String → [object Object])", async () => { + const server = createTestServerHttp({ + serverInfo: createTestServerInfo(), + tools: [createEchoTool()], + }); + + try { + await server.start(); + const result = await runCli([ + server.url, + "--cli", + "--method", + "tools/list", + "--metadata", + 'nested={"key":"value"}', + "list=[1,2,3]", + "flag=true", + "--transport", + "http", + ]); + + expectCliSuccess(result); + + const recordedRequests = server.getRecordedRequests(); + const toolsListRequest = recordedRequests.find( + (r) => r.method === "tools/list", + ); + expect(toolsListRequest?.metadata).toEqual({ + nested: '{"key":"value"}', + list: "[1,2,3]", + flag: "true", + }); + } finally { + await server.stop(); + } + }); + it("should handle special characters", async () => { const server = createTestServerHttp({ serverInfo: createTestServerInfo(), diff --git a/clients/cli/__tests__/method-types.test.ts b/clients/cli/__tests__/method-types.test.ts new file mode 100644 index 000000000..927e4c717 --- /dev/null +++ b/clients/cli/__tests__/method-types.test.ts @@ -0,0 +1,36 @@ +import { describe, it, expect } from "vitest"; +import { + isOneShotMethod, + metaValueToString, + ONE_SHOT_METHODS, + SESSION_RPC_METHODS, +} from "../src/handlers/method-types.js"; + +describe("SESSION_RPC_METHODS", () => { + it("lists the RPC methods the daemon / session CLI share", () => { + expect(SESSION_RPC_METHODS).toContain("tools/list"); + expect(SESSION_RPC_METHODS).toContain("tools/call"); + expect(SESSION_RPC_METHODS).toContain("logging/tail"); + expect(SESSION_RPC_METHODS).toContain("roots/set"); + expect(new Set(SESSION_RPC_METHODS).size).toBe(SESSION_RPC_METHODS.length); + }); +}); + +describe("ONE_SHOT_METHODS", () => { + it("excludes stream and session-only methods", () => { + expect(isOneShotMethod("tools/list")).toBe(true); + expect(isOneShotMethod("logging/setLevel")).toBe(true); + expect(isOneShotMethod("logging/tail")).toBe(false); + expect(isOneShotMethod("resources/subscribe")).toBe(false); + expect(isOneShotMethod("tasks/list")).toBe(false); + expect(ONE_SHOT_METHODS).not.toContain("logging/tail"); + }); +}); + +describe("metaValueToString", () => { + it("passes strings through and JSON-encodes structured values", () => { + expect(metaValueToString("plain")).toBe("plain"); + expect(metaValueToString({ a: 1 })).toBe('{"a":1}'); + expect(metaValueToString([1, 2])).toBe("[1,2]"); + }); +}); diff --git a/clients/cli/__tests__/methods.test.ts b/clients/cli/__tests__/methods.test.ts index 0fb720b36..a41459586 100644 --- a/clients/cli/__tests__/methods.test.ts +++ b/clients/cli/__tests__/methods.test.ts @@ -59,20 +59,43 @@ describe("CLI method coverage", () => { expect(Array.isArray(json.tools)).toBe(true); }); - it("throws for an unsupported method after connecting", async () => { - // Reaches the final `else` in callMethod: the connection succeeds, none of - // the known method branches match, so it throws "Unsupported method". - const result = await runCli([ + it("rejects unsupported and stream methods before connecting", async () => { + const unknown = await runCli([ command, ...args, "--cli", "--method", "definitely/not-a-real-method", ]); + expectCliFailure(unknown); + expectOutputContains(unknown, "Unsupported method"); + expectOutputContains(unknown, "definitely/not-a-real-method"); + // Stream methods used to hang via consumeMethodOutcome; now rejected. + const tail = await runCli([ + command, + ...args, + "--cli", + "--method", + "logging/tail", + ]); + expectCliFailure(tail); + expectOutputContains(tail, "Unsupported method"); + expectOutputContains(tail, "logging/tail"); + }); + + it("rejects bad methods before --use-stored-auth network work", async () => { + // No oauth.json / refresh — must fail on allowlist, not no_stored_token. + const result = await runCli([ + "--method", + "logging/tail", + "--use-stored-auth", + "--server-url", + "https://example.com/mcp", + ]); expectCliFailure(result); expectOutputContains(result, "Unsupported method"); - expectOutputContains(result, "definitely/not-a-real-method"); + expect(result.stderr).not.toMatch(/No stored OAuth token/); }); it("rejects an invalid --transport value before connecting", async () => { diff --git a/clients/cli/__tests__/open-url.test.ts b/clients/cli/__tests__/open-url.test.ts new file mode 100644 index 000000000..304dd2570 --- /dev/null +++ b/clients/cli/__tests__/open-url.test.ts @@ -0,0 +1,28 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const openMock = vi.fn().mockResolvedValue(undefined); + +vi.mock("open", () => ({ + default: (...args: unknown[]) => openMock(...args), +})); + +describe("openUrl", () => { + beforeEach(() => { + openMock.mockClear(); + openMock.mockResolvedValue(undefined); + }); + + it("forwards a string URL to open", async () => { + const { openUrl } = await import("../src/open-url.js"); + await openUrl("https://example.com/auth"); + expect(openMock).toHaveBeenCalledWith("https://example.com/auth"); + }); + + it("forwards URL.href for URL instances", async () => { + const { openUrl } = await import("../src/open-url.js"); + await openUrl(new URL("https://example.com/callback?code=1")); + expect(openMock).toHaveBeenCalledWith( + "https://example.com/callback?code=1", + ); + }); +}); diff --git a/clients/cli/__tests__/programmatic-ergonomics.test.ts b/clients/cli/__tests__/programmatic-ergonomics.test.ts index 63ea39cbd..44f1a4aff 100644 --- a/clients/cli/__tests__/programmatic-ergonomics.test.ts +++ b/clients/cli/__tests__/programmatic-ergonomics.test.ts @@ -135,6 +135,52 @@ describe("--tool-args-json", () => { }); }); +describe("one-shot --relogin flag conflicts", () => { + it("rejects --relogin with --stored-auth-only", async () => { + const result = await runCli([ + "--relogin", + "--stored-auth-only", + "--server-url", + "https://example.com/mcp", + "--method", + "tools/list", + ]); + expectCliFailure(result); + expect(result.stderr).toMatch( + /--relogin cannot be combined with --stored-auth-only/, + ); + }); + + it("rejects --relogin with --use-stored-auth", async () => { + const result = await runCli([ + "--relogin", + "--use-stored-auth", + "--server-url", + "https://example.com/mcp", + "--method", + "tools/list", + ]); + expectCliFailure(result); + expect(result.stderr).toMatch(/--use-stored-auth/); + }); + + it("accepts --relogin and clears stored auth before connect", async () => { + // Unreachable port: proves --relogin runs (clears store) then fails connect. + const result = await runCli([ + "--relogin", + "--server-url", + "http://127.0.0.1:1/mcp", + "--transport", + "http", + "--connect-timeout", + "200", + "--method", + "initialize", + ]); + expectCliFailure(result); + }); +}); + describe("MCP_CATALOG_PATH with an ad-hoc target", () => { it("does not conflict with an ad-hoc target (env catalog is ignored)", async () => { const { command, args } = getTestMcpServerCommand(); diff --git a/clients/cli/__tests__/run-method-mocks.test.ts b/clients/cli/__tests__/run-method-mocks.test.ts new file mode 100644 index 000000000..4fda0c519 --- /dev/null +++ b/clients/cli/__tests__/run-method-mocks.test.ts @@ -0,0 +1,232 @@ +import { describe, it, expect, vi } from "vitest"; +import { runMethod } from "../src/handlers/run-method.js"; +import type { InspectorClient } from "@inspector/core/mcp/index.js"; + +function mockClient(overrides: Partial = {}): InspectorClient { + const base = { + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + subscribeToResource: vi.fn().mockResolvedValue(undefined), + unsubscribeFromResource: vi.fn().mockResolvedValue(undefined), + getCompletions: vi.fn().mockResolvedValue({ values: ["a"] }), + getRequestorTask: vi.fn().mockResolvedValue({ taskId: "t1" }), + cancelRequestorTask: vi.fn().mockResolvedValue(undefined), + getRequestorTaskResult: vi.fn().mockResolvedValue({ content: [] }), + getRoots: vi.fn().mockReturnValue([]), + setRoots: vi.fn().mockResolvedValue(undefined), + setLoggingLevel: vi.fn().mockResolvedValue(undefined), + getServerInfo: vi.fn().mockReturnValue({ name: "t" }), + getProtocolVersion: vi.fn().mockReturnValue("1"), + getCapabilities: vi.fn().mockReturnValue({}), + getInstructions: vi.fn().mockReturnValue(undefined), + callTool: vi.fn(), + callToolStream: vi.fn(), + readResource: vi.fn(), + getPrompt: vi.fn(), + }; + return { ...base, ...overrides } as unknown as InspectorClient; +} + +vi.mock("@inspector/core/mcp/state/index.js", async (importOriginal) => { + const actual = + await importOriginal(); + class FakeManaged { + setMetadata = vi.fn(); + refresh = vi.fn().mockResolvedValue(undefined); + destroy = vi.fn(); + getTools = vi + .fn() + .mockReturnValue([ + { name: "echo", description: "e", inputSchema: { type: "object" } }, + ]); + getResources = vi.fn().mockReturnValue([]); + getResourceTemplates = vi.fn().mockReturnValue([]); + getPrompts = vi.fn().mockReturnValue([{ name: "p" }]); + getTasks = vi.fn().mockReturnValue([{ taskId: "t1" }]); + } + class FakeLog { + static last: FakeLog | undefined; + addEventListener = vi.fn(); + removeEventListener = vi.fn(); + destroy = vi.fn(); + constructor() { + FakeLog.last = this; + } + } + return { + ...actual, + ManagedToolsState: FakeManaged, + ManagedResourcesState: FakeManaged, + ManagedResourceTemplatesState: FakeManaged, + ManagedPromptsState: FakeManaged, + ManagedRequestorTasksState: FakeManaged, + MessageLogState: FakeLog, + }; +}); + +describe("runMethod (mocked client)", () => { + it("covers subscribe stream, tasks, complete, and app-info call", async () => { + const client = mockClient({ + callTool: vi.fn().mockResolvedValue({ + result: { content: [{ type: "text", text: "ok" }] }, + toolName: "echo", + params: {}, + timestamp: new Date(), + success: true, + }), + callToolStream: vi.fn().mockResolvedValue({ + result: { content: [] }, + toolName: "echo", + params: {}, + timestamp: new Date(), + success: true, + }), + readResource: vi.fn().mockResolvedValue({ + result: { contents: [] }, + }), + }); + + const sub = await runMethod(client, { + method: "resources/subscribe", + uri: "test://x", + }); + expect(sub.kind).toBe("stream"); + if (sub.kind === "stream") { + const lines: unknown[] = []; + const stop = sub.start((o) => lines.push(o)); + expect(lines[0]).toMatchObject({ type: "subscribed" }); + // Simulate update event + const listener = ( + client.addEventListener as ReturnType + ).mock.calls.find((c) => c[0] === "resourceUpdated")?.[1] as ( + ev: Event, + ) => void; + listener?.( + new CustomEvent("resourceUpdated", { detail: { uri: "test://x" } }), + ); + expect( + lines.some( + (l) => (l as { type?: string }).type === "resources/updated", + ), + ).toBe(true); + stop(); + } + + const tasks = await runMethod(client, { method: "tasks/list" }); + expect(tasks.kind).toBe("result"); + + const got = await runMethod(client, { + method: "tasks/get", + taskId: "t1", + }); + expect(got.kind).toBe("result"); + + const cancelled = await runMethod(client, { + method: "tasks/cancel", + taskId: "t1", + }); + expect(cancelled.kind).toBe("result"); + + const result = await runMethod(client, { + method: "tasks/result", + taskId: "t1", + }); + expect(result.kind).toBe("result"); + + const complete = await runMethod(client, { + method: "prompts/complete", + completeRefType: "ref/prompt", + completeRef: "p", + completeArgName: "a", + completeArgValue: "x", + }); + expect(complete.kind).toBe("result"); + + const completeRes = await runMethod(client, { + method: "prompts/complete", + completeRefType: "ref/resource", + completeRef: "test://x", + completeArgName: "a", + }); + expect(completeRes.kind).toBe("result"); + + const appOnly = await runMethod(client, { + method: "tools/call", + toolName: "echo", + appInfo: true, + }); + expect(appOnly.kind).toBe("result"); + + const tasked = await runMethod(client, { + method: "tools/call", + toolName: "echo", + task: true, + toolArg: { message: "x" }, + }); + expect(tasked.kind).toBe("result"); + expect(client.callToolStream).toHaveBeenCalled(); + + vi.mocked(client.callTool).mockResolvedValueOnce({ + result: null, + error: "Tool call failed hard", + toolName: "echo", + params: {}, + timestamp: new Date(), + success: false, + } as never); + const nullResult = await runMethod(client, { + method: "tools/call", + toolName: "echo", + toolArg: { message: "x" }, + }); + expect(nullResult.kind).toBe("result"); + if (nullResult.kind === "result") { + expect(nullResult.result).toMatchObject({ isError: true }); + } + + await expect(runMethod(client, { method: "tasks/cancel" })).rejects.toThrow( + /tasks\/cancel/, + ); + await expect(runMethod(client, { method: "tasks/result" })).rejects.toThrow( + /tasks\/result/, + ); + + await expect( + runMethod(client, { + method: "roots/set", + rootsJson: "not-json", + }), + ).rejects.toThrow(/roots-json is invalid/); + + const tail = await runMethod(client, { method: "logging/tail" }); + expect(tail.kind).toBe("stream"); + if (tail.kind === "stream") { + const lines: unknown[] = []; + const stop = tail.start((o) => lines.push(o)); + const logMod = await import("@inspector/core/mcp/state/index.js"); + const FakeLog = logMod.MessageLogState as unknown as { + last?: { + addEventListener: ReturnType; + }; + }; + const listener = FakeLog.last?.addEventListener.mock.calls[0]?.[1] as + | ((ev: Event) => void) + | undefined; + listener?.( + new CustomEvent("message", { + detail: { + direction: "notification", + message: { method: "notifications/message" }, + }, + }), + ); + listener?.( + new CustomEvent("message", { + detail: { direction: "request", message: { method: "tools/list" } }, + }), + ); + expect(lines).toHaveLength(1); + stop(); + } + }); +}); diff --git a/clients/cli/__tests__/run-method.test.ts b/clients/cli/__tests__/run-method.test.ts new file mode 100644 index 000000000..242e0cd03 --- /dev/null +++ b/clients/cli/__tests__/run-method.test.ts @@ -0,0 +1,249 @@ +import { describe, it, expect, afterEach } from "vitest"; +import { getTestMcpServerCommand } from "@modelcontextprotocol/inspector-test-server"; +import { InspectorClient } from "@inspector/core/mcp/index.js"; +import { createTransportNode } from "@inspector/core/mcp/node/index.js"; +import { runMethod } from "../src/handlers/run-method.js"; +import { consumeMethodOutcome } from "../src/handlers/consume-outcome.js"; + +describe("runMethod", () => { + let client: InspectorClient | undefined; + + afterEach(async () => { + if (client) { + await client.disconnect().catch(() => {}); + client = undefined; + } + }); + + async function connectStdio(): Promise { + const { command, args } = getTestMcpServerCommand(); + client = new InspectorClient( + { type: "stdio", command, args }, + { + environment: { transport: createTransportNode }, + clientIdentity: { name: "test", version: "0.0.0" }, + progress: false, + sample: false, + elicit: false, + }, + ); + await client.connect(); + return client; + } + + it("lists tools and reads initialize fields", async () => { + const c = await connectStdio(); + const tools = await runMethod(c, { method: "tools/list" }); + expect(tools.kind).toBe("result"); + if (tools.kind === "result") { + expect( + (tools.result.tools as unknown[] | undefined)?.length, + ).toBeGreaterThan(0); + } + + const init = await runMethod(c, { method: "initialize" }); + expect(init.kind).toBe("result"); + }); + + it("returns ndjson lines for tools/list --app-info", async () => { + const c = await connectStdio(); + const outcome = await runMethod(c, { + method: "tools/list", + appInfo: true, + }); + expect(outcome.kind).toBe("ndjson"); + if (outcome.kind === "ndjson") { + expect(outcome.lines.length).toBeGreaterThan(0); + } + }); + + it("lists resources and prompts", async () => { + const c = await connectStdio(); + const resources = await runMethod(c, { method: "resources/list" }); + expect(resources.kind).toBe("result"); + const prompts = await runMethod(c, { method: "prompts/list" }); + expect(prompts.kind).toBe("result"); + const templates = await runMethod(c, { + method: "resources/templates/list", + }); + expect(templates.kind).toBe("result"); + }); + + it("sets logging level", async () => { + const c = await connectStdio(); + const outcome = await runMethod(c, { + method: "logging/setLevel", + logLevel: "info", + }); + expect(outcome.kind).toBe("result"); + }); + + it("roots/list and roots/set round-trip", async () => { + const c = await connectStdio(); + const listed = await runMethod(c, { method: "roots/list" }); + expect(listed.kind).toBe("result"); + const set = await runMethod(c, { + method: "roots/set", + rootsJson: JSON.stringify([{ uri: "file:///tmp", name: "tmp" }]), + }); + expect(set.kind).toBe("result"); + if (set.kind === "result") { + expect(set.result.roots).toEqual([{ uri: "file:///tmp", name: "tmp" }]); + } + }); + + it("consumeMethodOutcome writes result json", async () => { + let stdout = ""; + const original = process.stdout.write; + process.stdout.write = ((chunk: unknown, ...rest: unknown[]) => { + stdout += typeof chunk === "string" ? chunk : String(chunk); + const cb = rest.find((r) => typeof r === "function") as + | (() => void) + | undefined; + cb?.(); + return true; + }) as typeof process.stdout.write; + try { + await consumeMethodOutcome( + { kind: "result", result: { ok: true } }, + { format: "json" }, + ); + expect(JSON.parse(stdout.trim())).toEqual({ result: { ok: true } }); + } finally { + process.stdout.write = original; + } + }); + + it("rejects unknown methods", async () => { + const c = await connectStdio(); + await expect(runMethod(c, { method: "nope/method" })).rejects.toThrow( + /Unsupported method/, + ); + }); + + it("calls echo tool and rejects missing tool name", async () => { + const c = await connectStdio(); + await expect(runMethod(c, { method: "tools/call" })).rejects.toThrow( + /Tool name is required/, + ); + + const missing = runMethod(c, { + method: "tools/call", + toolName: "does-not-exist", + }); + await expect(missing).rejects.toMatchObject({ exitCode: 5 }); + + const called = await runMethod(c, { + method: "tools/call", + toolName: "echo", + toolArg: { message: "hi" }, + format: "json", + }); + expect(called.kind).toBe("result"); + }); + + it("reads a resource and gets a prompt", async () => { + const c = await connectStdio(); + const read = await runMethod(c, { + method: "resources/read", + uri: "test://env", + }); + expect(read.kind).toBe("result"); + + await expect(runMethod(c, { method: "resources/read" })).rejects.toThrow( + /URI is required/, + ); + + const prompts = await runMethod(c, { method: "prompts/list" }); + expect(prompts.kind).toBe("result"); + if (prompts.kind === "result") { + const list = prompts.result.prompts as { name: string }[]; + if (list.length > 0) { + const got = await runMethod(c, { + method: "prompts/get", + promptName: list[0]!.name, + }); + expect(got.kind).toBe("result"); + } + } + }); + + it("returns a stream starter for logging/tail", async () => { + const c = await connectStdio(); + const outcome = await runMethod(c, { method: "logging/tail" }); + expect(outcome.kind).toBe("stream"); + if (outcome.kind === "stream") { + const stop = outcome.start(() => {}); + stop(); + } + }); + + it("requires uri for subscribe/unsubscribe", async () => { + const c = await connectStdio(); + await expect( + runMethod(c, { method: "resources/subscribe" }), + ).rejects.toThrow(/URI is required/); + await expect( + runMethod(c, { method: "resources/unsubscribe" }), + ).rejects.toThrow(/URI is required/); + }); + + it("rejects bad roots/set JSON and missing logging level", async () => { + const c = await connectStdio(); + await expect(runMethod(c, { method: "logging/setLevel" })).rejects.toThrow( + /Log level is required/, + ); + await expect( + runMethod(c, { method: "roots/set", rootsJson: "{}" }), + ).rejects.toThrow(/must be a JSON array/); + await expect(runMethod(c, { method: "roots/set" })).rejects.toThrow( + /roots\/set requires/, + ); + await expect(runMethod(c, { method: "tasks/get" })).rejects.toThrow( + /Task id is required/, + ); + await expect(runMethod(c, { method: "prompts/get" })).rejects.toThrow( + /Prompt name is required/, + ); + await expect(runMethod(c, { method: "prompts/complete" })).rejects.toThrow( + /prompts\/complete requires/, + ); + }); + + it("consumeMethodOutcome writes ndjson and stream until SIGINT", async () => { + let stdout = ""; + const original = process.stdout.write; + process.stdout.write = ((chunk: unknown, ...rest: unknown[]) => { + stdout += typeof chunk === "string" ? chunk : String(chunk); + const cb = rest.find((r) => typeof r === "function") as + | (() => void) + | undefined; + cb?.(); + return true; + }) as typeof process.stdout.write; + try { + await consumeMethodOutcome( + { kind: "ndjson", lines: [{ a: 1 }, { b: 2 }] }, + {}, + ); + expect(stdout.trim().split("\n")).toHaveLength(2); + + const streamPromise = consumeMethodOutcome( + { + kind: "stream", + label: "t", + start: (write) => { + write({ hi: true }); + queueMicrotask(() => process.emit("SIGINT")); + return () => {}; + }, + }, + {}, + ); + await streamPromise; + expect(stdout).toContain('"hi":true'); + } finally { + process.stdout.write = original; + } + }); +}); diff --git a/clients/cli/__tests__/servers-list.test.ts b/clients/cli/__tests__/servers-list.test.ts new file mode 100644 index 000000000..703bd97ba --- /dev/null +++ b/clients/cli/__tests__/servers-list.test.ts @@ -0,0 +1,286 @@ +import { describe, it, expect, afterEach } from "vitest"; +import { runCli } from "./helpers/cli-runner.js"; +import { + createSampleTestConfig, + deleteConfigFile, +} from "./helpers/fixtures.js"; +import { expectCliSuccess } from "./helpers/assertions.js"; +import { + annotateServerEntriesWithSessions, + listServerEntries, + sanitizeServerConfig, + sanitizeServerSettings, + showServerEntry, + summarizeServerConfig, +} from "../src/handlers/servers-list.js"; +import type { + InspectorServerSettings, + MCPServerConfig, +} from "@inspector/core/mcp/types.js"; +import { expectCliFailure } from "./helpers/assertions.js"; + +describe("summarizeServerConfig", () => { + it("summarises stdio, sse, and streamable-http configs", () => { + expect( + summarizeServerConfig({ + type: "stdio", + command: "node", + args: ["server.js"], + }), + ).toEqual({ type: "stdio", detail: "node server.js" }); + expect( + summarizeServerConfig({ + type: "stdio", + command: "node", + }), + ).toEqual({ type: "stdio", detail: "node" }); + expect( + summarizeServerConfig({ + type: "sse", + url: "http://localhost/sse", + }), + ).toEqual({ type: "sse", detail: "http://localhost/sse" }); + expect( + summarizeServerConfig({ + type: "streamable-http", + url: "http://localhost/mcp", + } as MCPServerConfig), + ).toEqual({ type: "streamable-http", detail: "http://localhost/mcp" }); + expect( + summarizeServerConfig({ + type: "streamable-http", + } as MCPServerConfig), + ).toEqual({ type: "streamable-http", detail: "" }); + }); +}); + +describe("annotateServerEntriesWithSessions", () => { + const entries = [ + { name: "a", type: "stdio", detail: "node a" }, + { name: "b", type: "stdio", detail: "node b" }, + ]; + + it("returns entries unchanged when there are no sessions", () => { + expect(annotateServerEntriesWithSessions(entries, [])).toBe(entries); + }); + + it("marks matching entry names and MRU", () => { + expect( + annotateServerEntriesWithSessions(entries, [ + { name: "b", isMru: true }, + { name: "other" }, + ]), + ).toEqual([ + { name: "a", type: "stdio", detail: "node a" }, + { name: "b", type: "stdio", detail: "node b", session: "b", isMru: true }, + ]); + }); + + it("omits isMru when the session is not MRU", () => { + expect( + annotateServerEntriesWithSessions(entries, [{ name: "a", isMru: false }]), + ).toEqual([ + { name: "a", type: "stdio", detail: "node a", session: "a" }, + { name: "b", type: "stdio", detail: "node b" }, + ]); + }); +}); + +describe("listServerEntries / --method servers/list", () => { + let configPath: string | undefined; + + afterEach(() => { + if (configPath) { + deleteConfigFile(configPath); + configPath = undefined; + } + }); + + it("lists catalog entries without connecting", async () => { + configPath = createSampleTestConfig(); + const entries = await listServerEntries({ configPath }); + expect(entries.map((e) => e.name).sort()).toEqual([ + "test-http", + "test-stdio", + ]); + expect(entries.find((e) => e.name === "test-stdio")?.type).toBe("stdio"); + }); + + it("works via one-shot --method servers/list", async () => { + configPath = createSampleTestConfig(); + const result = await runCli([ + "--config", + configPath, + "--method", + "servers/list", + "--format", + "json", + ]); + expectCliSuccess(result); + const body = JSON.parse(result.stdout) as { + result: { servers: { name: string }[] }; + }; + expect(body.result.servers.map((s) => s.name).sort()).toEqual([ + "test-http", + "test-stdio", + ]); + }); +}); + +describe("showServerEntry / servers/show", () => { + let configPath: string | undefined; + + afterEach(() => { + if (configPath) { + deleteConfigFile(configPath); + configPath = undefined; + } + }); + + it("redacts env values, init headers, and sensitive settings fields", () => { + expect( + sanitizeServerConfig({ + type: "stdio", + command: "node", + env: { SECRET: "value", HELLO: "world" }, + }), + ).toEqual({ + type: "stdio", + command: "node", + env: { SECRET: "[redacted]", HELLO: "[redacted]" }, + }); + + expect( + sanitizeServerConfig({ + type: "streamable-http", + url: "https://example.com/mcp", + requestInit: { + headers: { + Authorization: "Bearer secret", + "X-Custom": "ok", + }, + }, + eventSourceInit: { + headers: { + "X-Api-Key": "k", + Accept: "text/event-stream", + }, + }, + } as MCPServerConfig), + ).toMatchObject({ + requestInit: { + headers: { + Authorization: "[redacted]", + "X-Custom": "ok", + }, + }, + eventSourceInit: { + headers: { + "X-Api-Key": "[redacted]", + Accept: "text/event-stream", + }, + }, + }); + + expect( + sanitizeServerConfig({ + type: "streamable-http", + url: "https://example.com/mcp", + requestInit: { + headers: [ + ["Authorization", "Bearer secret"], + ["X-Custom", "ok"], + ], + }, + } as MCPServerConfig), + ).toMatchObject({ + requestInit: { + headers: [ + ["Authorization", "[redacted]"], + ["X-Custom", "ok"], + ], + }, + }); + + const settings: InspectorServerSettings = { + headers: [ + { key: "Authorization", value: "Bearer x" }, + { key: "X-Custom", value: "ok" }, + ], + metadata: [], + env: [ + { key: "TOKEN", value: "secret" }, + { key: "", value: "still-secret" }, + ], + connectionTimeout: 0, + requestTimeout: 0, + taskTtl: 60_000, + maxFetchRequests: 100, + roots: [], + oauthClientSecret: "shh", + oauthClientId: "cid", + }; + const sanitized = sanitizeServerSettings(settings); + expect(sanitized.oauthClientSecret).toBe("[redacted]"); + expect(sanitized.oauthClientId).toBe("cid"); + expect(sanitized.headers).toEqual([ + { key: "Authorization", value: "[redacted]" }, + { key: "X-Custom", value: "ok" }, + ]); + expect(sanitized.env).toEqual([ + { key: "TOKEN", value: "[redacted]" }, + { key: "", value: "[redacted]" }, + ]); + }); + + it("shows one resolved entry without connecting", async () => { + configPath = createSampleTestConfig(); + const entry = await showServerEntry("test-stdio", { configPath }); + expect(entry.name).toBe("test-stdio"); + expect(entry.type).toBe("stdio"); + expect(entry.config).toMatchObject({ + type: "stdio", + command: expect.any(String), + }); + expect(entry.config.env).toEqual({ HELLO: "[redacted]" }); + }); + + it("works via one-shot --method servers/show --server", async () => { + configPath = createSampleTestConfig(); + const result = await runCli([ + "--config", + configPath, + "--method", + "servers/show", + "--server", + "test-http", + "--format", + "json", + ]); + expectCliSuccess(result); + const body = JSON.parse(result.stdout) as { + result: { name: string; type: string }; + }; + expect(body.result.name).toBe("test-http"); + expect(body.result.type).toBe("streamable-http"); + }); + + it("rejects one-shot servers/show without --server", async () => { + configPath = createSampleTestConfig(); + const result = await runCli([ + "--config", + configPath, + "--method", + "servers/show", + ]); + expectCliFailure(result); + expect(result.stderr).toMatch(/--server/); + }); + + it("rejects unknown server names", async () => { + configPath = createSampleTestConfig(); + await expect(showServerEntry("nope", { configPath })).rejects.toThrow( + /not found/, + ); + }); +}); diff --git a/clients/cli/__tests__/style.test.ts b/clients/cli/__tests__/style.test.ts new file mode 100644 index 000000000..f9b8cfeff --- /dev/null +++ b/clients/cli/__tests__/style.test.ts @@ -0,0 +1,40 @@ +import { describe, it, expect } from "vitest"; +import { + createStyle, + resolveAnsiEnabled, + styleFromOpts, + PLAIN, +} from "../src/style.js"; + +describe("resolveAnsiEnabled", () => { + it("is off for --plain, json, NO_COLOR, and non-TTY", () => { + expect(resolveAnsiEnabled({ plain: true, isTTY: true })).toBe(false); + expect(resolveAnsiEnabled({ format: "json", isTTY: true })).toBe(false); + expect(resolveAnsiEnabled({ isTTY: true, noColorEnv: "1" })).toBe(false); + expect(resolveAnsiEnabled({ isTTY: false, noColorEnv: "" })).toBe(false); + }); + + it("is on for TTY text without plain/NO_COLOR", () => { + expect( + resolveAnsiEnabled({ isTTY: true, noColorEnv: "", format: "text" }), + ).toBe(true); + expect(styleFromOpts({ isTTY: true, noColorEnv: "" }).ansi).toBe(true); + }); +}); + +describe("createStyle", () => { + it("PLAIN is identity; ansi wraps SGR and OSC 8", () => { + expect(PLAIN.bold("x")).toBe("x"); + expect(PLAIN.link("https://example.com")).toBe("https://example.com"); + + const s = createStyle(true); + expect(s.bold("x")).toContain("\u001b[1m"); + expect(s.dim("x")).toContain("\u001b[2m"); + expect(s.red("x")).toContain("\u001b[31m"); + expect(s.link("https://example.com", "ex")).toContain( + "\u001b]8;;https://example.com", + ); + expect(s.link("https://example.com", "ex")).toContain("ex"); + expect(s.link("", "ex")).toBe("ex"); + }); +}); diff --git a/clients/cli/package-lock.json b/clients/cli/package-lock.json index 2760136d3..36d3aad62 100644 --- a/clients/cli/package-lock.json +++ b/clients/cli/package-lock.json @@ -16,6 +16,7 @@ "ajv": "^8.17.1", "atomically": "^2.1.1", "commander": "^13.1.0", + "open": "^10.2.0", "pino": "^9.14.0", "undici": "^8.5.0", "zod": "^4.3.6" @@ -2449,6 +2450,21 @@ "concat-map": "0.0.1" } }, + "node_modules/bundle-name": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz", + "integrity": "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==", + "license": "MIT", + "dependencies": { + "run-applescript": "^7.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/bundle-require": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/bundle-require/-/bundle-require-5.1.0.tgz", @@ -2726,6 +2742,46 @@ "dev": true, "license": "MIT" }, + "node_modules/default-browser": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.5.0.tgz", + "integrity": "sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw==", + "license": "MIT", + "dependencies": { + "bundle-name": "^4.1.0", + "default-browser-id": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/default-browser-id": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/default-browser-id/-/default-browser-id-5.0.1.tgz", + "integrity": "sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/define-lazy-prop": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz", + "integrity": "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -3583,6 +3639,21 @@ "node": ">= 0.10" } }, + "node_modules/is-docker": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", + "integrity": "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==", + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -3606,6 +3677,24 @@ "node": ">=0.10.0" } }, + "node_modules/is-inside-container": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz", + "integrity": "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==", + "license": "MIT", + "dependencies": { + "is-docker": "^3.0.0" + }, + "bin": { + "is-inside-container": "cli.js" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-promise": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", @@ -3613,6 +3702,21 @@ "license": "MIT", "peer": true }, + "node_modules/is-wsl": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.1.tgz", + "integrity": "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==", + "license": "MIT", + "dependencies": { + "is-inside-container": "^1.0.0" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -4311,6 +4415,24 @@ "wrappy": "1" } }, + "node_modules/open": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/open/-/open-10.2.0.tgz", + "integrity": "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA==", + "license": "MIT", + "dependencies": { + "default-browser": "^5.2.1", + "define-lazy-prop": "^3.0.0", + "is-inside-container": "^1.0.0", + "wsl-utils": "^0.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -4832,6 +4954,18 @@ "node": ">= 18" } }, + "node_modules/run-applescript": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.1.0.tgz", + "integrity": "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/safe-stable-stringify": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", @@ -5693,6 +5827,21 @@ "license": "ISC", "peer": true }, + "node_modules/wsl-utils": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-0.1.0.tgz", + "integrity": "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==", + "license": "MIT", + "dependencies": { + "is-wsl": "^3.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/clients/cli/package.json b/clients/cli/package.json index 9ef0e9bd8..2f277877c 100644 --- a/clients/cli/package.json +++ b/clients/cli/package.json @@ -40,6 +40,7 @@ "ajv": "^8.17.1", "atomically": "^2.1.1", "commander": "^13.1.0", + "open": "^10.2.0", "pino": "^9.14.0", "undici": "^8.5.0", "zod": "^4.3.6" diff --git a/clients/cli/src/clear-stored-auth-for-relogin.ts b/clients/cli/src/clear-stored-auth-for-relogin.ts new file mode 100644 index 000000000..c177579bf --- /dev/null +++ b/clients/cli/src/clear-stored-auth-for-relogin.ts @@ -0,0 +1,30 @@ +import { + NodeOAuthStorage, + resetNodeOAuthStorageCache, +} from "@inspector/core/auth/node/storage-node.js"; + +/** Same canonicalisation as one-shot `normalizeServerUrl` (avoid cycles). */ +function normalizeServerUrl(serverUrl: string): string { + try { + return new URL(serverUrl).href; + } catch { + return serverUrl; + } +} + +/** + * Drop stored OAuth state for an HTTP(S) server URL so the next connect cannot + * silently reuse tokens (`--relogin`). No-op when `serverUrl` is missing. + * + * Kept in the one-shot CLI package so `--relogin` does not depend on the mcpi + * client (which owns the fuller `auth/list` / `auth/clear` surface). + */ +export async function clearStoredAuthForRelogin( + serverUrl: string | undefined, +): Promise { + if (!serverUrl?.trim()) return; + const url = normalizeServerUrl(serverUrl.trim()); + const storage = new NodeOAuthStorage(); + await storage.clear(url); + resetNodeOAuthStorageCache(); +} diff --git a/clients/cli/src/cli-oauth-navigation.ts b/clients/cli/src/cli-oauth-navigation.ts new file mode 100644 index 000000000..03352a697 --- /dev/null +++ b/clients/cli/src/cli-oauth-navigation.ts @@ -0,0 +1,46 @@ +import { CallbackNavigation } from "@inspector/core/auth/index.js"; +import { openUrl } from "./open-url.js"; +import { createStyle, resolveAnsiEnabled } from "./style.js"; + +export type CliOAuthNavigationOptions = { + /** Override TTY detection (tests). Defaults to stderr.isTTY. */ + isTTY?: boolean; + /** Override NO_COLOR (tests). */ + noColorEnv?: string | undefined; + /** Write the prompt line (defaults to stderr). */ + write?: (line: string) => void; + /** Open the browser (defaults to {@link openUrl}). */ + openBrowser?: (url: string) => Promise; +}; + +/** + * CLI OAuth navigation: print the authorization URL (OSC 8 when TTY allows + * ANSI) and auto-open the default browser on a TTY. Non-TTY / CI: plain URL + * only — never launches a browser. + */ +export function createCliOAuthNavigation( + options: CliOAuthNavigationOptions = {}, +): CallbackNavigation { + return new CallbackNavigation(async (url) => { + const href = url.href; + const tty = + options.isTTY !== undefined + ? options.isTTY + : process.stderr.isTTY === true; + const style = createStyle( + resolveAnsiEnabled({ + isTTY: tty, + noColorEnv: options.noColorEnv, + }), + ); + const write = + options.write ?? ((line: string) => process.stderr.write(line)); + write(`Please navigate to: ${style.link(href)}\n`); + if (!tty) return; + try { + await (options.openBrowser ?? openUrl)(href); + } catch { + // URL already printed; browser open is best-effort. + } + }); +} diff --git a/clients/cli/src/cli.ts b/clients/cli/src/cli.ts index f63815bca..1d476feb8 100644 --- a/clients/cli/src/cli.ts +++ b/clients/cli/src/cli.ts @@ -6,18 +6,15 @@ import type { MCPServerConfig, InspectorClientEnvironment, } from "@inspector/core/mcp/types.js"; +import { eraToVersionNegotiation } from "@inspector/core/mcp/types.js"; import { - DEFAULT_MAX_FETCH_REQUESTS, - DEFAULT_TASK_TTL_MS, - eraToVersionNegotiation, -} from "@inspector/core/mcp/types.js"; + DEFAULT_CONNECT_TIMEOUT_MS, + withConnectTimeout, +} from "./handlers/connect-timeout.js"; +import { listServerEntries, showServerEntry } from "./handlers/servers-list.js"; +import { writeFormattedResult } from "./handlers/format-output.js"; +import { clearStoredAuthForRelogin } from "./clear-stored-auth-for-relogin.js"; import { InspectorClient } from "@inspector/core/mcp/index.js"; -import { - ManagedToolsState, - ManagedResourcesState, - ManagedResourceTemplatesState, - ManagedPromptsState, -} from "@inspector/core/mcp/state/index.js"; import { createTransportNode, loadServerEntries, @@ -26,9 +23,18 @@ import { parseHeaderPair, } from "@inspector/core/mcp/node/index.js"; import type { JsonValue } from "@inspector/core/mcp/index.js"; -import { extractAppInfo } from "@inspector/core/mcp/apps.js"; -import type { AppInfo } from "@inspector/core/mcp/apps.js"; import { getStateFilePath } from "@inspector/core/auth/node/storage-node.js"; +import { consumeMethodOutcome } from "./handlers/consume-outcome.js"; +import { runMethod } from "./handlers/run-method.js"; +import { + isOneShotMethod, + metaValueToString, + ONE_SHOT_METHODS, + type MethodArgs, +} from "./handlers/method-types.js"; +export type { CliAppInfo } from "./handlers/method-types.js"; +export { emitResult } from "./handlers/emit-result.js"; +export { collectAppInfo } from "./handlers/collect-app-info.js"; import { parseOAuthPersistBlob, serializeOAuthPersistBlob, @@ -46,11 +52,9 @@ import type { OAuthTokens, } from "@modelcontextprotocol/client"; import { CliExitCodeError, EXIT_CODES } from "./error-handler.js"; -import { - ConsoleNavigation, - MutableRedirectUrlProvider, -} from "@inspector/core/auth/index.js"; +import { MutableRedirectUrlProvider } from "@inspector/core/auth/index.js"; import { NodeOAuthStorage } from "@inspector/core/auth/node/index.js"; +import { createCliOAuthNavigation } from "./cli-oauth-navigation.js"; import { connectInspectorWithOAuth, withCliAuthRecoveryRetry, @@ -79,61 +83,10 @@ export const validLogLevels: LoggingLevel[] = Object.values( /** Client identity name the CLI reports to servers. */ const CLI_CLIENT_NAME = "inspector-cli"; -/** - * Default connect timeout (ms) for ad-hoc server invocations. Without this an - * unreachable server (e.g. a partner edge that drops the SYN) hangs the CLI - * indefinitely; the value is generous enough for cold-start OAuth discovery - * round-trips while still failing fast on a black-holed host. - */ -export const DEFAULT_CONNECT_TIMEOUT_MS = 15000; +export { DEFAULT_CONNECT_TIMEOUT_MS, withConnectTimeout }; type OutputFormat = "text" | "json"; -type MethodArgs = { - method?: string; - promptName?: string; - promptArgs?: Record; - uri?: string; - logLevel?: LoggingLevel; - toolName?: string; - toolArg?: Record; - toolMeta?: Record; - metadata?: Record; - appInfo?: boolean; - format?: OutputFormat; -}; - -/** - * {@link AppInfo} plus a CLI-only `resourceError` so a `resources/read` failure - * during the probe is reported instead of being silently swallowed (which would - * make "no CSP declared" indistinguishable from "resource unreadable"). - */ -export type CliAppInfo = AppInfo & { resourceError?: string }; - -/** - * Discriminated outcome from {@link callMethod}'s per-method runner. Most - * methods return a `result` (with optional collected `appInfo`) for - * {@link emitResult} to format; the `tools/list --app-info` NDJSON path writes - * its lines itself and reports `emitted` so the caller skips a second write. - */ -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, @@ -141,6 +94,7 @@ async function callMethod( clientConfig: ClientConfig, cliAuthOverrides: RunnerClientConfigOverrides, callbackUrlConfig: RunnerOAuthCallbackConfig, + storedAuthOnly: boolean, ): Promise { // Version comes from the single source of truth — the root package.json — // via the shared core reader, not the CLI's own manifest. @@ -158,7 +112,7 @@ async function callMethod( formatRunnerOAuthRedirectUrl(callbackUrlConfig); environment.oauth = { storage: new NodeOAuthStorage(), - navigation: new ConsoleNavigation(), + navigation: createCliOAuthNavigation(), redirectUrlProvider, }; } @@ -185,182 +139,6 @@ async function callMethod( ...clientAuthOptions, }); - let managedToolsState: ManagedToolsState | null = null; - let managedResourcesState: ManagedResourcesState | null = null; - let managedResourceTemplatesState: ManagedResourceTemplatesState | null = - null; - let managedPromptsState: ManagedPromptsState | null = null; - - const runMethod = async (): Promise => { - let result: McpResponse; - let appInfo: CliAppInfo | undefined; - - if (args.method === "tools/list" || args.method === "tools/call") { - managedToolsState = new ManagedToolsState(inspectorClient); - managedToolsState.setMetadata(args.metadata); - await managedToolsState.refresh(); - } - - if (args.method === "resources/list") { - managedResourcesState = new ManagedResourcesState(inspectorClient); - managedResourcesState.setMetadata(args.metadata); - await managedResourcesState.refresh(); - } else if (args.method === "resources/templates/list") { - managedResourceTemplatesState = new ManagedResourceTemplatesState( - inspectorClient, - ); - managedResourceTemplatesState.setMetadata(args.metadata); - await managedResourceTemplatesState.refresh(); - } else if (args.method === "prompts/list") { - managedPromptsState = new ManagedPromptsState(inspectorClient); - managedPromptsState.setMetadata(args.metadata); - await managedPromptsState.refresh(); - } - - if (args.method === "tools/list") { - const tools = managedToolsState!.getTools(); - if (args.appInfo) { - // NDJSON: one app-info line per tool, all on a single connection. A - // caller that wants only the App tools can `| jq -c 'select(.hasApp)'`. - // collectAppInfo never throws — a tool with a malformed `_meta.ui` - // surfaces as `{hasApp:false, resourceError}` — so one bad tool can't - // abort the whole listing. Emitted verbatim as NDJSON regardless of - // --format (the list-probe shape is fixed; --format json only reshapes - // the single-result paths). - for (const tool of tools) { - const info = await collectAppInfo( - inspectorClient, - tool, - args.metadata, - ); - await awaitableLog(JSON.stringify(info) + "\n"); - } - return { kind: "emitted" }; - } - result = { tools }; - } else if (args.method === "tools/call") { - if (!args.toolName) { - throw new Error( - "Tool name is required for tools/call method. Use --tool-name to specify the tool name.", - ); - } - - const tool = managedToolsState! - .getTools() - .find((t) => t.name === args.toolName); - if (!tool) { - // Distinct from `isError:true` and (for --app-info) from "tool has no - // app": the named tool does not exist on the server. Exit TOOL_ERROR - // with `code: "tool_not_found"` so a caller can tell a typo/rename - // apart from a real tool failure or a no-app probe result. - throw new CliExitCodeError( - EXIT_CODES.TOOL_ERROR, - `Tool '${args.toolName}' not found on server.`, - { code: "tool_not_found" }, - ); - } - - // Only collect app-info when the caller asked for it (`--app-info` or - // `--format json`); a plain text-mode `tools/call` shouldn't fail just - // because the tool's `_meta.ui.resourceUri` is malformed or its resource - // is unreadable. - if (args.appInfo || args.format === "json") { - appInfo = await collectAppInfo(inspectorClient, tool, args.metadata); - } - if (args.appInfo) { - // --app-info: probe-only — emit the app metadata and skip the tool - // call entirely. The no-app exit code is handled in emitResult. - result = { ...appInfo }; - } else { - const invocation = await inspectorClient.callTool( - tool, - args.toolArg || {}, - args.metadata, - args.toolMeta, - ); - if (invocation.result !== null) { - result = invocation.result; - } else { - /* v8 ignore next 9 -- unreachable: InspectorClient.callTool either - throws on a tool-execution/transport error (caught by the outer - try) or returns an invocation whose `result` is guaranteed - non-null (attemptToolCall throws if finalResult is falsy). This - else is defensive only. */ - result = { - content: [ - { - type: "text" as const, - text: invocation.error || "Tool call failed", - }, - ], - isError: true, - }; - } - } - } else if (args.method === "resources/list") { - result = { - resources: managedResourcesState!.getResources(), - }; - } else if (args.method === "resources/read") { - if (!args.uri) { - throw new Error( - "URI is required for resources/read method. Use --uri to specify the resource URI.", - ); - } - - const invocation = await inspectorClient.readResource( - args.uri, - args.metadata, - ); - result = invocation.result; - } else if (args.method === "resources/templates/list") { - result = { - resourceTemplates: - managedResourceTemplatesState!.getResourceTemplates(), - }; - } else if (args.method === "prompts/list") { - result = { prompts: managedPromptsState!.getPrompts() }; - } else if (args.method === "prompts/get") { - if (!args.promptName) { - throw new Error( - "Prompt name is required for prompts/get method. Use --prompt-name to specify the prompt name.", - ); - } - - const invocation = await inspectorClient.getPrompt( - args.promptName, - args.promptArgs || {}, - args.metadata, - ); - result = invocation.result; - } else if (args.method === "initialize") { - // Connect-only probe: emit the cached InitializeResult fields so a - // caller can read serverInfo / protocolVersion / capabilities / - // instructions without picking a list method. - result = { - serverInfo: inspectorClient.getServerInfo(), - protocolVersion: inspectorClient.getProtocolVersion(), - capabilities: inspectorClient.getCapabilities(), - instructions: inspectorClient.getInstructions(), - }; - } else if (args.method === "logging/setLevel") { - if (!args.logLevel) { - throw new Error( - "Log level is required for logging/setLevel method. Use --log-level to specify the log level.", - ); - } - - await inspectorClient.setLoggingLevel(args.logLevel); - result = {}; - } else { - throw new Error( - `Unsupported method: ${args.method}. Supported methods include: initialize, tools/list, tools/call, resources/list, resources/read, resources/templates/list, prompts/list, prompts/get, logging/setLevel`, - ); - } - - return { kind: "result", result, appInfo }; - }; - try { await connectInspectorWithOAuth( inspectorClient, @@ -368,6 +146,7 @@ async function callMethod( redirectUrlProvider, callbackUrlConfig, serverSettings, + { storedAuthOnly }, ); const outcome = await withCliAuthRecoveryRetry( @@ -375,117 +154,17 @@ async function callMethod( redirectUrlProvider, callbackUrlConfig, serverSettings, - runMethod, + () => runMethod(inspectorClient, args), + undefined, + { storedAuthOnly }, ); - // The NDJSON `tools/list --app-info` path already wrote its lines; every - // other method hands its result to emitResult for format/exit handling. - if (outcome.kind === "result") { - await emitResult(outcome.result, outcome.appInfo, args); - } + await consumeMethodOutcome(outcome, args); } finally { - destroyManagedState(managedToolsState); - destroyManagedState(managedResourcesState); - destroyManagedState(managedResourceTemplatesState); - destroyManagedState(managedPromptsState); await inspectorClient.disconnect(); } } -/** - * Write the method result (and any app-info) to stdout, honouring `--format` - * and `--app-info`, then map `isError`/no-app outcomes onto the exit-code map. - * Extracted from `callMethod` so the format/exit handling is in one place. - */ -export async function emitResult( - result: McpResponse, - appInfo: CliAppInfo | undefined, - args: MethodArgs, -): Promise { - const json = args.format === "json"; - - if (args.appInfo) { - const info: CliAppInfo = appInfo ?? { - hasApp: false, - toolName: args.toolName ?? "", - }; - // Single-line JSON either way; --format json wraps it under an `appInfo` - // key so the envelope shape is uniform with the non-probe path. - await awaitableLog(JSON.stringify(json ? { appInfo: info } : info) + "\n"); - if (!info.hasApp) { - throw new CliExitCodeError( - EXIT_CODES.NO_APP, - `Tool '${args.toolName}' has no MCP App UI resource (_meta.ui.resourceUri).`, - ); - } - return; - } - - if (json) { - // One JSON object on stdout — `result` plus, when present, `appInfo` as a - // sibling key. No `--- MCP App Info ---` banner, so `| jq` works for App - // tools as well as plain ones. - const envelope: Record = { result }; - if (appInfo?.hasApp) envelope.appInfo = appInfo; - await awaitableLog(JSON.stringify(envelope) + "\n"); - } else { - // Text mode emits the result only; app-info is not collected on this path - // (use `--format json` or `--app-info` to get it). - await awaitableLog(JSON.stringify(result, null, 2) + "\n"); - } - - // A tool that returned `isError:true` (or whose call failed) is still printed - // above so the caller sees the payload, but the process exits TOOL_ERROR so - // `&&` chains don't proceed on a failed call. - if ((result as { isError?: unknown }).isError === true) { - throw new CliExitCodeError( - EXIT_CODES.TOOL_ERROR, - `Tool '${args.toolName}' returned isError:true.`, - { code: "tool_is_error" }, - ); - } -} - -/** - * Build the CLI's app-info for a tool: extract the tool-side `_meta.ui` and, - * when the tool advertises a UI resource, follow it with a `resources/read` so - * the resource-side csp/permissions/domain are included. - * - * Never throws — the two failure modes both fold into a `{hasApp:false, - * resourceError}` result rather than propagating: - * - a malformed `_meta.ui.resourceUri` (extractAppInfo throws), so the - * `tools/list --app-info` NDJSON loop stays per-tool tolerant (one bad tool - * can't abort the whole listing); - * - a `resources/read` failure, since "tool says it has an app but the - * resource is unreadable" is itself a useful probe result. - */ -export async function collectAppInfo( - client: Pick, - tool: Parameters[0], - metadata: Record | undefined, -): Promise { - let base: AppInfo; - try { - base = extractAppInfo(tool); - } catch (e) { - return { - hasApp: false, - toolName: tool.name, - resourceError: e instanceof Error ? e.message : String(e), - }; - } - if (!base.hasApp || base.resourceUri === undefined) return base; - try { - const read = await client.readResource(base.resourceUri, metadata); - return extractAppInfo(tool, read.result); - } catch (e) { - return { - ...base, - resourceError: e instanceof Error ? e.message : String(e), - }; - } -} - /** * Canonicalise a server URL the same way the web inspector does before storing * OAuth state (`new URL().href` lowercases the host, normalises the scheme, and @@ -754,32 +433,6 @@ function buildHandoff( }; } -/** - * Apply a connection timeout to a resolved server's settings, building a - * minimal {@link InspectorServerSettings} when none came from the file. Ad-hoc - * invocations get {@link DEFAULT_CONNECT_TIMEOUT_MS} so a black-holed host - * fails fast; catalog/config invocations keep their file-level timeout unless - * `--connect-timeout` is passed explicitly. - */ -export function withConnectTimeout( - settings: InspectorServerSettings | undefined, - connectionTimeout: number | undefined, -): InspectorServerSettings | undefined { - if (connectionTimeout === undefined) return settings; - if (settings) return { ...settings, connectionTimeout }; - return { - headers: [], - metadata: [], - env: [], - connectionTimeout, - requestTimeout: 0, - taskTtl: DEFAULT_TASK_TTL_MS, - maxFetchRequests: DEFAULT_MAX_FETCH_REQUESTS, - autoRefreshOnListChanged: false, - roots: [], - }; -} - function parseKeyValuePair( value: string, previous: Record = {}, @@ -815,6 +468,7 @@ type ParseResult = clientSecret?: string; clientMetadataUrl?: string; callbackUrl?: string; + storedAuthOnly?: boolean; } // Short-circuit modes (`--list-stored-auth`, `--print-handoff`) do their own // output and need no server connection; runCli returns immediately. @@ -998,6 +652,14 @@ async function parseArgs(argv?: string[]): Promise { "--use-stored-auth", "Read the OAuth access token for --server-url from the OAuth state file (written by the web inspector) and inject it as Authorization: Bearer.", ) + .option( + "--stored-auth-only", + "Never start interactive OAuth; use the shared store if present, otherwise fail with auth_required. No-op when the server does not require auth.", + ) + .option( + "--relogin", + "Ignore stored OAuth for this run (HTTP/SSE URL keys only); interactive login runs only if the server requires auth. No-op for stdio / servers with no stored entry", + ) .option( "--wait-for-auth ", "Poll the OAuth state file until a token for --server-url appears (or the timeout elapses), then proceed as if --use-stored-auth were set. Use after handing off to a human to complete OAuth in a browser.", @@ -1050,11 +712,24 @@ async function parseArgs(argv?: string[]): Promise { clientMetadataUrl?: string; callbackUrl?: string; useStoredAuth?: boolean; + storedAuthOnly?: boolean; + relogin?: boolean; waitForAuth?: number; listStoredAuth?: boolean; printHandoff?: boolean; }; + if (options.relogin) { + if (options.storedAuthOnly) { + throw new Error("--relogin cannot be combined with --stored-auth-only"); + } + if (options.useStoredAuth || options.waitForAuth !== undefined) { + throw new Error( + "--relogin cannot be combined with --use-stored-auth or --wait-for-auth", + ); + } + } + // State-path precedence (getStateFilePath): MCP_INSPECTOR_OAUTH_STATE_PATH → // /oauth.json → ~/.mcp-inspector/storage/oauth.json — the // same file the web backend writes, so tokens are shared across surfaces. @@ -1083,6 +758,21 @@ async function parseArgs(argv?: string[]): Promise { return { shortCircuit: true }; } + // Validate --method before stored-auth network work (refresh / wait) so a + // typo or stream method fails locally without burning a token round-trip. + if (!options.method) { + throw new Error( + "Method is required. Use --method to specify the method to invoke.", + ); + } + const isCatalogMethod = + options.method === "servers/list" || options.method === "servers/show"; + if (!isCatalogMethod && !isOneShotMethod(options.method)) { + throw new Error( + `Unsupported method: ${options.method}. One-shot --cli supports: ${ONE_SHOT_METHODS.join(", ")}, servers/list, servers/show.`, + ); + } + // Honour MCP_CATALOG_PATH only when no ad-hoc target is given. Applying it // unconditionally meant a homespace that exports the env var could never run // `--server-url …` (serverSourceConflict rejects catalog + ad-hoc). @@ -1172,6 +862,29 @@ async function parseArgs(argv?: string[]): Promise { }; } + // Catalog list / show — no MCP connection. + if (options.method === "servers/list") { + const servers = await listServerEntries(serverOptions); + await writeFormattedResult( + { servers }, + options.format === "json" ? "json" : "text", + ); + return { shortCircuit: true }; + } + if (options.method === "servers/show") { + if (!options.server?.trim()) { + throw new Error( + "servers/show requires --server to select a catalog entry.", + ); + } + const server = await showServerEntry(options.server, serverOptions); + await writeFormattedResult( + server, + options.format === "json" ? "json" : "text", + ); + return { shortCircuit: true }; + } + // Shared with the TUI: resolves the catalog/config source (or ad-hoc target), // enforces the conflict matrix, and lifts disk headers/timeouts/OAuth into // per-server settings. `--server` selects one when the file has several. @@ -1186,10 +899,8 @@ async function parseArgs(argv?: string[]): Promise { options.connectTimeout ?? (adHoc ? DEFAULT_CONNECT_TIMEOUT_MS : undefined), ); - if (!options.method) { - throw new Error( - "Method is required. Use --method to specify the method to invoke.", - ); + if (options.relogin && "url" in serverConfig && serverConfig.url) { + await clearStoredAuthForRelogin(serverConfig.url); } if ( @@ -1241,7 +952,7 @@ async function parseArgs(argv?: string[]): Promise { ? Object.fromEntries( Object.entries(options.metadata).map(([key, value]) => [ key, - String(value), + metaValueToString(value), ]), ) : undefined, @@ -1249,7 +960,7 @@ async function parseArgs(argv?: string[]): Promise { ? Object.fromEntries( Object.entries(options.toolMetadata).map(([key, value]) => [ key, - String(value), + metaValueToString(value), ]), ) : undefined, @@ -1266,6 +977,7 @@ async function parseArgs(argv?: string[]): Promise { clientSecret: options.clientSecret, clientMetadataUrl: options.clientMetadataUrl, callbackUrl: options.callbackUrl, + storedAuthOnly: options.storedAuthOnly === true, }; } @@ -1282,6 +994,7 @@ export async function runCli(argv?: string[]): Promise { clientSecret, clientMetadataUrl, callbackUrl, + storedAuthOnly, } = parsed; const clientConfig = await loadRunnerClientConfig({ clientConfigPath }); const callbackUrlConfig = parseRunnerOAuthCallbackUrl(callbackUrl); @@ -1296,5 +1009,6 @@ export async function runCli(argv?: string[]): Promise { clientMetadataUrl, }, callbackUrlConfig, + storedAuthOnly === true, ); } diff --git a/clients/cli/src/cliOAuth.ts b/clients/cli/src/cliOAuth.ts index 24aa60582..a15bc0c4f 100644 --- a/clients/cli/src/cliOAuth.ts +++ b/clients/cli/src/cliOAuth.ts @@ -17,6 +17,21 @@ import type { InspectorServerSettings } from "@inspector/core/mcp/types.js"; import { isOAuthCapableServerConfig } from "@inspector/core/client/runner.js"; import type { MCPServerConfig } from "@inspector/core/mcp/types.js"; import { createInterface } from "node:readline/promises"; +import { CliExitCodeError, EXIT_CODES } from "./error-handler.js"; + +export type CliOAuthConnectOptions = { + /** + * When true, never open interactive OAuth / step-up prompts. Use the shared + * store if it can satisfy the challenge; otherwise fail with AUTH_REQUIRED. + */ + storedAuthOnly?: boolean; +}; + +function storedAuthOnlyFailure(message: string): never { + throw new CliExitCodeError(EXIT_CODES.AUTH_REQUIRED, message, { + code: "auth_required", + }); +} /** Standard-OAuth step-up (not EMA silent re-mint). */ export function isStandardOAuthStepUp( @@ -111,6 +126,7 @@ export async function connectInspectorWithOAuth( redirectUrlProvider: MutableRedirectUrlProvider, callbackUrlConfig: RunnerOAuthCallbackConfig, serverSettings?: InspectorServerSettings, + options?: CliOAuthConnectOptions, ): Promise { try { await inspectorClient.connect(); @@ -126,6 +142,12 @@ export async function connectInspectorWithOAuth( await inspectorClient.connect(); return; } + if (options?.storedAuthOnly) { + storedAuthOnlyFailure( + err.message || + "Authentication required and --stored-auth-only is set; refusing interactive OAuth.", + ); + } await handleCliAuthRecoveryRequired( inspectorClient, err, @@ -138,6 +160,13 @@ export async function connectInspectorWithOAuth( } if (isUnauthorizedError(err)) { + if (options?.storedAuthOnly) { + storedAuthOnlyFailure( + err instanceof Error + ? err.message + : "Authentication required and --stored-auth-only is set; refusing interactive OAuth.", + ); + } await inspectorClient.disconnect().catch(() => {}); await runCliInteractiveOAuth( inspectorClient, @@ -163,6 +192,7 @@ export async function withCliAuthRecoveryRetry( serverSettings: InspectorServerSettings | undefined, fn: () => Promise, confirmStepUp: () => Promise = confirmStepUpFromStdin, + options?: CliOAuthConnectOptions, ): Promise { try { return await fn(); @@ -170,6 +200,12 @@ export async function withCliAuthRecoveryRetry( if (!(err instanceof AuthRecoveryRequiredError)) { throw err; } + if (options?.storedAuthOnly) { + storedAuthOnlyFailure( + err.message || + "Authentication required and --stored-auth-only is set; refusing interactive OAuth.", + ); + } await handleCliAuthRecoveryRequired( inspectorClient, err, diff --git a/clients/cli/src/handlers/collect-app-info.ts b/clients/cli/src/handlers/collect-app-info.ts new file mode 100644 index 000000000..aa2df1fc1 --- /dev/null +++ b/clients/cli/src/handlers/collect-app-info.ts @@ -0,0 +1,35 @@ +import { InspectorClient } from "@inspector/core/mcp/index.js"; +import { extractAppInfo } from "@inspector/core/mcp/apps.js"; +import type { AppInfo } from "@inspector/core/mcp/apps.js"; +import type { CliAppInfo } from "./method-types.js"; + +/** + * Build the CLI's app-info for a tool. Never throws — failures fold into + * `{hasApp:false, resourceError}` so list probes stay per-tool tolerant. + */ +export async function collectAppInfo( + client: Pick, + tool: Parameters[0], + metadata: Record | undefined, +): Promise { + let base: AppInfo; + try { + base = extractAppInfo(tool); + } catch (e) { + return { + hasApp: false, + toolName: tool.name, + resourceError: e instanceof Error ? e.message : String(e), + }; + } + if (!base.hasApp || base.resourceUri === undefined) return base; + try { + const read = await client.readResource(base.resourceUri, metadata); + return extractAppInfo(tool, read.result); + } catch (e) { + return { + ...base, + resourceError: e instanceof Error ? e.message : String(e), + }; + } +} diff --git a/clients/cli/src/handlers/connect-timeout.ts b/clients/cli/src/handlers/connect-timeout.ts new file mode 100644 index 000000000..7d5a7b2cc --- /dev/null +++ b/clients/cli/src/handlers/connect-timeout.ts @@ -0,0 +1,35 @@ +import { + DEFAULT_MAX_FETCH_REQUESTS, + DEFAULT_TASK_TTL_MS, + type InspectorServerSettings, +} from "@inspector/core/mcp/types.js"; + +/** + * Default connect timeout (ms) for ad-hoc server invocations. Without this an + * unreachable server hangs the CLI indefinitely. + */ +export const DEFAULT_CONNECT_TIMEOUT_MS = 15000; + +/** + * Apply a connection timeout to a resolved server's settings, building a + * minimal {@link InspectorServerSettings} when none came from the file. + */ +export function withConnectTimeout( + settings: InspectorServerSettings | undefined, + connectionTimeout: number | undefined, +): InspectorServerSettings | undefined { + if (connectionTimeout === undefined) return settings; + if (settings) return { ...settings, connectionTimeout }; + return { + headers: [], + metadata: [], + env: [], + connectionTimeout, + requestTimeout: 0, + taskTtl: DEFAULT_TASK_TTL_MS, + maxFetchRequests: DEFAULT_MAX_FETCH_REQUESTS, + autoRefreshOnListChanged: false, + paginatedLists: false, + roots: [], + }; +} diff --git a/clients/cli/src/handlers/consume-outcome.ts b/clients/cli/src/handlers/consume-outcome.ts new file mode 100644 index 000000000..89b4ac7a8 --- /dev/null +++ b/clients/cli/src/handlers/consume-outcome.ts @@ -0,0 +1,37 @@ +import { awaitableLog } from "../utils/awaitable-log.js"; +import { emitResult } from "./emit-result.js"; +import type { MethodArgs, MethodOutcome } from "./method-types.js"; + +/** + * Write a {@link MethodOutcome} to stdout (result / NDJSON / long-lived stream). + * Stream methods stay attached until SIGINT/SIGTERM. + */ +export async function consumeMethodOutcome( + outcome: MethodOutcome, + args: MethodArgs, +): Promise { + if (outcome.kind === "result") { + await emitResult(outcome.result, outcome.appInfo, args); + return; + } + if (outcome.kind === "ndjson") { + for (const line of outcome.lines) { + await awaitableLog(JSON.stringify(line) + "\n"); + } + return; + } + + await new Promise((resolve) => { + const stop = outcome.start((obj) => { + void awaitableLog(JSON.stringify(obj) + "\n"); + }); + const onSignal = () => { + stop(); + process.off("SIGINT", onSignal); + process.off("SIGTERM", onSignal); + resolve(); + }; + process.on("SIGINT", onSignal); + process.on("SIGTERM", onSignal); + }); +} diff --git a/clients/cli/src/handlers/emit-result.ts b/clients/cli/src/handlers/emit-result.ts new file mode 100644 index 000000000..b4d370deb --- /dev/null +++ b/clients/cli/src/handlers/emit-result.ts @@ -0,0 +1,46 @@ +import { awaitableLog } from "../utils/awaitable-log.js"; +import { CliExitCodeError, EXIT_CODES } from "../error-handler.js"; +import type { CliAppInfo, McpResponse, MethodArgs } from "./method-types.js"; + +/** + * Write the method result (and any app-info) to stdout, honouring `--format` + * and `--app-info`, then map `isError`/no-app outcomes onto the exit-code map. + */ +export async function emitResult( + result: McpResponse, + appInfo: CliAppInfo | undefined, + args: MethodArgs, +): Promise { + const json = args.format === "json"; + + if (args.appInfo) { + const info: CliAppInfo = appInfo ?? { + hasApp: false, + toolName: args.toolName ?? "", + }; + await awaitableLog(JSON.stringify(json ? { appInfo: info } : info) + "\n"); + if (!info.hasApp) { + throw new CliExitCodeError( + EXIT_CODES.NO_APP, + `Tool '${args.toolName}' has no MCP App UI resource (_meta.ui.resourceUri).`, + ); + } + return; + } + + if (json) { + const envelope: Record = { result }; + if (appInfo?.hasApp) envelope.appInfo = appInfo; + await awaitableLog(JSON.stringify(envelope) + "\n"); + } else { + await awaitableLog(JSON.stringify(result, null, 2) + "\n"); + } + + if ((result as { isError?: unknown }).isError === true) { + throw new CliExitCodeError( + EXIT_CODES.TOOL_ERROR, + `Tool '${args.toolName}' returned isError:true.`, + { code: "tool_is_error" }, + ); + } +} diff --git a/clients/cli/src/handlers/format-output.ts b/clients/cli/src/handlers/format-output.ts new file mode 100644 index 000000000..1bba0acd4 --- /dev/null +++ b/clients/cli/src/handlers/format-output.ts @@ -0,0 +1,18 @@ +import { awaitableLog } from "../utils/awaitable-log.js"; + +export type OutputFormat = "text" | "json"; + +/** + * Write a command result to stdout. Text = pretty-printed JSON; json = single + * `{ "result": … }` envelope (same family as one-shot `--format json`). + */ +export async function writeFormattedResult( + result: unknown, + format: OutputFormat = "text", +): Promise { + if (format === "json") { + await awaitableLog(JSON.stringify({ result }) + "\n"); + return; + } + await awaitableLog(JSON.stringify(result, null, 2) + "\n"); +} diff --git a/clients/cli/src/handlers/method-types.ts b/clients/cli/src/handlers/method-types.ts new file mode 100644 index 000000000..1af2a536a --- /dev/null +++ b/clients/cli/src/handlers/method-types.ts @@ -0,0 +1,116 @@ +import type { JsonValue } from "@inspector/core/mcp/index.js"; +import type { AppInfo } from "@inspector/core/mcp/apps.js"; +import type { LoggingLevel } from "@modelcontextprotocol/client"; +import type { OutputFormat } from "./format-output.js"; + +export type { OutputFormat }; + +/** + * {@link AppInfo} plus a CLI-only `resourceError` so a `resources/read` failure + * during the probe is reported instead of being silently swallowed. + */ +export type CliAppInfo = AppInfo & { resourceError?: string }; + +/** Arguments for a single MCP method invocation (one-shot or session RPC). */ +export type MethodArgs = { + method?: string; + promptName?: string; + promptArgs?: Record; + uri?: string; + logLevel?: LoggingLevel; + toolName?: string; + toolArg?: Record; + toolMeta?: Record; + metadata?: Record; + appInfo?: boolean; + format?: OutputFormat; + /** Task id for tasks/get, tasks/cancel, tasks/result. */ + taskId?: string; + /** When true, tools/call uses callToolStream (task-augmented). */ + task?: boolean; + /** roots/set payload (JSON array of {uri, name?}). */ + rootsJson?: string; + /** prompts/complete: argument name / value / ref. */ + completeRefType?: "ref/prompt" | "ref/resource"; + completeRef?: string; + completeArgName?: string; + completeArgValue?: string; +}; + +export type McpResponse = Record; + +/** + * Discriminated outcome from {@link runMethod}. Most methods return a `result` + * for formatting; `tools/list --app-info` returns `ndjson` lines for the + * caller to write. Stream methods return `stream` for a long-lived consumer. + */ +export type MethodOutcome = + | { kind: "result"; result: McpResponse; appInfo?: CliAppInfo } + /** One JSON object per line (e.g. tools/list --app-info). Caller writes stdout. */ + | { kind: "ndjson"; lines: unknown[] } + | { + kind: "stream"; + /** Human label for errors. */ + label: string; + /** Subscribe and push NDJSON object lines; return an unsubscribe. */ + start: (writeLine: (obj: unknown) => void) => () => void; + }; + +/** Methods supported by {@link runMethod}. */ +export const SESSION_RPC_METHODS = [ + "initialize", + "tools/list", + "tools/call", + "resources/list", + "resources/read", + "resources/templates/list", + "resources/subscribe", + "resources/unsubscribe", + "prompts/list", + "prompts/get", + "prompts/complete", + "logging/setLevel", + "logging/tail", + "tasks/list", + "tasks/get", + "tasks/cancel", + "tasks/result", + "roots/list", + "roots/set", +] as const; + +export type SessionRpcMethod = (typeof SESSION_RPC_METHODS)[number]; + +/** + * Methods accepted by one-shot `mcp-inspector --cli` (plus catalog-only + * `servers/list` / `servers/show`, handled before connect). Stream methods and + * session-only RPCs (`logging/tail`, `tasks/*`, …) are rejected so one-shot + * never hangs waiting for SIGINT. + */ +export const ONE_SHOT_METHODS = [ + "initialize", + "tools/list", + "tools/call", + "resources/list", + "resources/read", + "resources/templates/list", + "prompts/list", + "prompts/get", + "logging/setLevel", +] as const; + +export type OneShotMethod = (typeof ONE_SHOT_METHODS)[number]; + +export function isOneShotMethod(method: string): method is OneShotMethod { + return (ONE_SHOT_METHODS as readonly string[]).includes(method); +} + +/** + * Encode a metadata / tool-metadata value for MethodArgs (string map). + * Strings pass through; objects/arrays use JSON.stringify so structure is kept + * (avoids String(obj) → "[object Object]"). + */ +export function metaValueToString(value: JsonValue): string { + if (typeof value === "string") return value; + return JSON.stringify(value); +} diff --git a/clients/cli/src/handlers/run-method.ts b/clients/cli/src/handlers/run-method.ts new file mode 100644 index 000000000..fddd146e5 --- /dev/null +++ b/clients/cli/src/handlers/run-method.ts @@ -0,0 +1,310 @@ +import type { InspectorClient } from "@inspector/core/mcp/index.js"; +import type { Root } from "@modelcontextprotocol/client"; +import { + ManagedToolsState, + ManagedResourcesState, + ManagedResourceTemplatesState, + ManagedPromptsState, + ManagedRequestorTasksState, + MessageLogState, +} from "@inspector/core/mcp/state/index.js"; +import { CliExitCodeError, EXIT_CODES } from "../error-handler.js"; +import { collectAppInfo } from "./collect-app-info.js"; +import type { + CliAppInfo, + McpResponse, + MethodArgs, + MethodOutcome, +} from "./method-types.js"; + +/** + * Run one MCP method against a connected {@link InspectorClient}. + * Shared by one-shot CLI and the session daemon `rpc` path. + */ +export async function runMethod( + inspectorClient: InspectorClient, + args: MethodArgs & { method: string }, +): Promise { + let managedToolsState: ManagedToolsState | null = null; + let managedResourcesState: ManagedResourcesState | null = null; + let managedResourceTemplatesState: ManagedResourceTemplatesState | null = + null; + let managedPromptsState: ManagedPromptsState | null = null; + let managedTasksState: ManagedRequestorTasksState | null = null; + + try { + let result: McpResponse; + let appInfo: CliAppInfo | undefined; + + if (args.method === "tools/list" || args.method === "tools/call") { + managedToolsState = new ManagedToolsState(inspectorClient); + managedToolsState.setMetadata(args.metadata); + await managedToolsState.refresh(); + } + + if (args.method === "resources/list") { + managedResourcesState = new ManagedResourcesState(inspectorClient); + managedResourcesState.setMetadata(args.metadata); + await managedResourcesState.refresh(); + } else if (args.method === "resources/templates/list") { + managedResourceTemplatesState = new ManagedResourceTemplatesState( + inspectorClient, + ); + managedResourceTemplatesState.setMetadata(args.metadata); + await managedResourceTemplatesState.refresh(); + } else if (args.method === "prompts/list") { + managedPromptsState = new ManagedPromptsState(inspectorClient); + managedPromptsState.setMetadata(args.metadata); + await managedPromptsState.refresh(); + } else if (args.method === "tasks/list") { + managedTasksState = new ManagedRequestorTasksState(inspectorClient); + await managedTasksState.refresh(); + } + + if (args.method === "tools/list") { + const tools = managedToolsState!.getTools(); + if (args.appInfo) { + const lines: unknown[] = []; + for (const tool of tools) { + lines.push( + await collectAppInfo(inspectorClient, tool, args.metadata), + ); + } + return { kind: "ndjson", lines }; + } + result = { tools }; + } else if (args.method === "tools/call") { + if (!args.toolName) { + throw new Error( + "Tool name is required for tools/call method. Use --tool-name to specify the tool name.", + ); + } + + const tool = managedToolsState! + .getTools() + .find((t) => t.name === args.toolName); + if (!tool) { + throw new CliExitCodeError( + EXIT_CODES.TOOL_ERROR, + `Tool '${args.toolName}' not found on server.`, + { code: "tool_not_found" }, + ); + } + + if (args.appInfo || args.format === "json") { + appInfo = await collectAppInfo(inspectorClient, tool, args.metadata); + } + if (args.appInfo) { + result = { ...appInfo }; + } else { + const invocation = args.task + ? await inspectorClient.callToolStream( + tool, + args.toolArg || {}, + args.metadata, + args.toolMeta, + ) + : await inspectorClient.callTool( + tool, + args.toolArg || {}, + args.metadata, + args.toolMeta, + ); + if (invocation.result !== null) { + result = invocation.result; + } else { + result = { + content: [ + { + type: "text" as const, + text: invocation.error || "Tool call failed", + }, + ], + isError: true, + }; + } + } + } else if (args.method === "resources/list") { + result = { resources: managedResourcesState!.getResources() }; + } else if (args.method === "resources/read") { + if (!args.uri) { + throw new Error( + "URI is required for resources/read method. Use --uri to specify the resource URI.", + ); + } + const invocation = await inspectorClient.readResource( + args.uri, + args.metadata, + ); + result = invocation.result; + } else if (args.method === "resources/templates/list") { + result = { + resourceTemplates: + managedResourceTemplatesState!.getResourceTemplates(), + }; + } else if (args.method === "resources/subscribe") { + if (!args.uri) { + throw new Error( + "URI is required for resources/subscribe. Use --uri to specify the resource URI.", + ); + } + await inspectorClient.subscribeToResource(args.uri); + return { + kind: "stream", + label: "resources/subscribe", + start: (writeLine) => { + writeLine({ type: "subscribed", uri: args.uri }); + const onUpdate = (ev: Event) => { + const detail = (ev as CustomEvent<{ uri: string }>).detail; + writeLine({ + type: "resources/updated", + uri: detail?.uri ?? args.uri, + }); + }; + inspectorClient.addEventListener("resourceUpdated", onUpdate); + return () => { + inspectorClient.removeEventListener("resourceUpdated", onUpdate); + void inspectorClient.unsubscribeFromResource(args.uri!); + }; + }, + }; + } else if (args.method === "resources/unsubscribe") { + if (!args.uri) { + throw new Error( + "URI is required for resources/unsubscribe. Use --uri to specify the resource URI.", + ); + } + await inspectorClient.unsubscribeFromResource(args.uri); + result = { unsubscribed: true, uri: args.uri }; + } else if (args.method === "prompts/list") { + result = { prompts: managedPromptsState!.getPrompts() }; + } else if (args.method === "prompts/get") { + if (!args.promptName) { + throw new Error( + "Prompt name is required for prompts/get method. Use --prompt-name to specify the prompt name.", + ); + } + const invocation = await inspectorClient.getPrompt( + args.promptName, + args.promptArgs || {}, + args.metadata, + ); + result = invocation.result; + } else if (args.method === "prompts/complete") { + if (!args.completeRefType || !args.completeRef || !args.completeArgName) { + throw new Error( + "prompts/complete requires --complete-ref-type, --complete-ref, and --complete-arg-name.", + ); + } + const ref = + args.completeRefType === "ref/prompt" + ? ({ type: "ref/prompt", name: args.completeRef } as const) + : ({ type: "ref/resource", uri: args.completeRef } as const); + result = await inspectorClient.getCompletions( + ref, + args.completeArgName, + args.completeArgValue ?? "", + undefined, + args.metadata, + ); + } else if (args.method === "initialize") { + result = { + serverInfo: inspectorClient.getServerInfo(), + protocolVersion: inspectorClient.getProtocolVersion(), + capabilities: inspectorClient.getCapabilities(), + instructions: inspectorClient.getInstructions(), + }; + } else if (args.method === "logging/setLevel") { + if (!args.logLevel) { + throw new Error( + "Log level is required for logging/setLevel method. Use --log-level to specify the log level.", + ); + } + await inspectorClient.setLoggingLevel(args.logLevel); + result = {}; + } else if (args.method === "logging/tail") { + return { + kind: "stream", + label: "logging/tail", + start: (writeLine) => { + const log = new MessageLogState(inspectorClient); + const onMessage = (ev: Event) => { + const entry = (ev as CustomEvent).detail as { + direction?: string; + message?: { method?: string }; + }; + // Follow server logging notifications only (not full RPC history). + if ( + entry.direction === "notification" && + entry.message?.method === "notifications/message" + ) { + writeLine(entry); + } + }; + log.addEventListener("message", onMessage); + return () => { + log.removeEventListener("message", onMessage); + log.destroy(); + }; + }, + }; + } else if (args.method === "tasks/list") { + result = { tasks: managedTasksState!.getTasks() }; + } else if (args.method === "tasks/get") { + if (!args.taskId) { + throw new Error("Task id is required for tasks/get. Use --task-id."); + } + result = { + task: await inspectorClient.getRequestorTask(args.taskId), + }; + } else if (args.method === "tasks/cancel") { + if (!args.taskId) { + throw new Error("Task id is required for tasks/cancel. Use --task-id."); + } + await inspectorClient.cancelRequestorTask(args.taskId); + result = { cancelled: true, taskId: args.taskId }; + } else if (args.method === "tasks/result") { + if (!args.taskId) { + throw new Error("Task id is required for tasks/result. Use --task-id."); + } + // SDK return type is a protocol result object; we only need a JSON bag. + result = (await inspectorClient.getRequestorTaskResult( + args.taskId, + )) as McpResponse; + } else if (args.method === "roots/list") { + result = { roots: inspectorClient.getRoots() }; + } else if (args.method === "roots/set") { + if (!args.rootsJson) { + throw new Error( + "roots/set requires --roots-json ''.", + ); + } + let roots: Root[]; + try { + const parsed: unknown = JSON.parse(args.rootsJson); + if (!Array.isArray(parsed)) { + throw new Error("must be a JSON array"); + } + roots = parsed as Root[]; + } catch (e) { + throw new Error( + `--roots-json is invalid: ${e instanceof Error ? e.message : String(e)}`, + ); + } + await inspectorClient.setRoots(roots); + result = { roots: inspectorClient.getRoots() }; + } else { + throw new Error( + `Unsupported method: ${args.method}. Supported methods include: initialize, tools/*, resources/*, prompts/*, logging/*, tasks/*, roots/*.`, + ); + } + + return { kind: "result", result, appInfo }; + } finally { + managedToolsState?.destroy(); + managedResourcesState?.destroy(); + managedResourceTemplatesState?.destroy(); + managedPromptsState?.destroy(); + managedTasksState?.destroy(); + } +} diff --git a/clients/cli/src/handlers/servers-list.ts b/clients/cli/src/handlers/servers-list.ts new file mode 100644 index 000000000..e7bcc5691 --- /dev/null +++ b/clients/cli/src/handlers/servers-list.ts @@ -0,0 +1,213 @@ +import type { + InspectorServerSettings, + MCPServerConfig, +} from "@inspector/core/mcp/types.js"; +import { + loadServerEntries, + selectServerEntry, + type ServerLoadOptions, +} from "@inspector/core/mcp/node/index.js"; + +/** One catalog/config entry as returned by `servers/list`. */ +export type ServerListEntry = { + name: string; + type: string; + /** Command line, URL, or other short identity for display. */ + detail: string; + /** + * Live session name when a daemon session’s name matches this entry + * (session CLI only; omitted on one-shot). + */ + session?: string; + /** True when that session is the daemon MRU. */ + isMru?: boolean; +}; + +/** Minimal session shape needed to annotate catalog entries. */ +export type SessionListRef = { + name: string; + isMru?: boolean; +}; + +/** + * Mark catalog entries that have a live session with the same name + * (`mcpi connect ` default). Does not mutate `entries`. + */ +export function annotateServerEntriesWithSessions( + entries: ServerListEntry[], + sessions: SessionListRef[], +): ServerListEntry[] { + if (sessions.length === 0) return entries; + const byName = new Map(sessions.map((s) => [s.name, s] as const)); + return entries.map((entry) => { + const session = byName.get(entry.name); + if (!session) return entry; + return { + ...entry, + session: session.name, + ...(session.isMru === true ? { isMru: true } : {}), + }; + }); +} + +/** Detail view for `servers/show` (secrets redacted). */ +export type ServerShowEntry = { + name: string; + type: string; + detail: string; + config: Record; + settings?: Record; +}; + +const REDACTED = "[redacted]"; + +/** + * Summarise an {@link MCPServerConfig} for catalog listing (no connection). + */ +export function summarizeServerConfig(config: MCPServerConfig): { + type: string; + detail: string; +} { + // Narrow on the URL-bearing transports first — stdio's `type` is optional, so + // an else-after-`=== "stdio"` check would still leave `StdioServerConfig` in + // the residual union (see `StdioServerConfig` in core/mcp/types.ts). + if (config.type === "sse" || config.type === "streamable-http") { + return { type: config.type, detail: config.url ?? "" }; + } + const args = config.args?.length ? ` ${config.args.join(" ")}` : ""; + return { type: "stdio", detail: `${config.command}${args}` }; +} + +/** + * Load catalog/config entries and return a sorted name + summary list. + * Shared by one-shot `--method servers/list` and `mcpi servers/list`. + */ +export async function listServerEntries( + serverOptions: ServerLoadOptions = {}, +): Promise { + const entries = await loadServerEntries(serverOptions); + return Object.entries(entries) + .map(([name, resolved]) => { + const { type, detail } = summarizeServerConfig(resolved.config); + return { name, type, detail }; + }) + .sort((a, b) => a.name.localeCompare(b.name)); +} + +/** + * Resolve one catalog/config entry for `servers/show` (no MCP connection). + * Secret-bearing fields (env values, OAuth client secret, sensitive headers) + * are replaced with {@link REDACTED}. + */ +export async function showServerEntry( + serverName: string, + serverOptions: ServerLoadOptions = {}, +): Promise { + const name = serverName.trim(); + if (!name) { + throw new Error("servers/show requires a server name."); + } + const entries = await loadServerEntries(serverOptions); + const selected = selectServerEntry(entries, name); + const { type, detail } = summarizeServerConfig(selected.config); + const result: ServerShowEntry = { + name, + type, + detail, + config: sanitizeServerConfig(selected.config), + }; + if (selected.settings) { + result.settings = sanitizeServerSettings(selected.settings); + } + return result; +} + +/** Visible for tests. */ +export function sanitizeServerConfig( + config: MCPServerConfig, +): Record { + const out: Record = { ...config }; + if ("env" in config && config.env) { + out.env = redactStringRecord(config.env); + } + if ("requestInit" in config && isPlainObject(config.requestInit)) { + out.requestInit = sanitizeInitRecord(config.requestInit); + } + if ("eventSourceInit" in config && isPlainObject(config.eventSourceInit)) { + out.eventSourceInit = sanitizeInitRecord(config.eventSourceInit); + } + return out; +} + +/** Visible for tests. */ +export function sanitizeServerSettings( + settings: InspectorServerSettings, +): Record { + const out: Record = { + ...settings, + headers: settings.headers.map((h) => ({ + key: h.key, + value: isSensitiveHeader(h.key) ? REDACTED : h.value, + })), + env: settings.env.map((e) => ({ + key: e.key, + value: REDACTED, + })), + }; + if (settings.oauthClientSecret !== undefined) { + out.oauthClientSecret = REDACTED; + } + return out; +} + +function redactStringRecord( + record: Record, +): Record { + return Object.fromEntries(Object.keys(record).map((key) => [key, REDACTED])); +} + +function isPlainObject(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +/** Redact sensitive header values inside requestInit / eventSourceInit. */ +function sanitizeInitRecord( + init: Record, +): Record { + const out: Record = { ...init }; + if (isPlainObject(init.headers)) { + out.headers = Object.fromEntries( + Object.entries(init.headers).map(([key, value]) => [ + key, + isSensitiveHeader(key) ? REDACTED : value, + ]), + ); + } else if (Array.isArray(init.headers)) { + // HeadersInit pair form: [["Authorization", "Bearer …"], …] + out.headers = init.headers.map((entry) => { + if ( + Array.isArray(entry) && + entry.length >= 2 && + typeof entry[0] === "string" + ) { + const key = entry[0]; + const value = entry[1]; + return [key, isSensitiveHeader(key) ? REDACTED : value]; + } + return entry; + }); + } + return out; +} + +function isSensitiveHeader(key: string): boolean { + const k = key.toLowerCase(); + return ( + k === "authorization" || + k.includes("secret") || + k.includes("token") || + k.includes("password") || + k.includes("api-key") || + k.includes("apikey") + ); +} diff --git a/clients/cli/src/open-url.ts b/clients/cli/src/open-url.ts new file mode 100644 index 000000000..f5b42a8ac --- /dev/null +++ b/clients/cli/src/open-url.ts @@ -0,0 +1,6 @@ +import open from "open"; + +/** Open a URL in the user's default browser (best-effort). */ +export async function openUrl(url: string | URL): Promise { + await open(typeof url === "string" ? url : url.href); +} diff --git a/clients/cli/src/style.ts b/clients/cli/src/style.ts new file mode 100644 index 000000000..315edb381 --- /dev/null +++ b/clients/cli/src/style.ts @@ -0,0 +1,76 @@ +import type { OutputFormat } from "./handlers/format-output.js"; + +export type Style = { + /** Whether ANSI styling is enabled. */ + readonly ansi: boolean; + bold: (s: string) => string; + dim: (s: string) => string; + red: (s: string) => string; + yellow: (s: string) => string; + green: (s: string) => string; + cyan: (s: string) => string; + /** OSC 8 hyperlink when ansi; otherwise plain label (defaulting to uri). */ + link: (uri: string, label?: string) => string; +}; + +const identity = (s: string): string => s; + +function sgr(enabled: boolean, open: number, close: number) { + if (!enabled) return identity; + return (s: string): string => `\u001b[${open}m${s}\u001b[${close}m`; +} + +/** + * Build a style helper. When `ansi` is false, all methods are identity + * (and `link` returns the label only — no OSC 8). + */ +export function createStyle(ansi: boolean): Style { + return { + ansi, + bold: sgr(ansi, 1, 22), + dim: sgr(ansi, 2, 22), + red: sgr(ansi, 31, 39), + yellow: sgr(ansi, 33, 39), + green: sgr(ansi, 32, 39), + cyan: sgr(ansi, 36, 39), + link: (uri: string, label?: string): string => { + const text = label ?? uri; + if (!ansi || !uri) return text; + // OSC 8 hyperlink: ESC ] 8 ; ; URI BEL text ESC ] 8 ; ; BEL + return `\u001b]8;;${uri}\u0007${text}\u001b]8;;\u0007`; + }, + }; +} + +/** Plain (no ANSI) style — default for tests and `--plain`. */ +export const PLAIN: Style = createStyle(false); + +export type ResolveAnsiOptions = { + /** `--plain` — force no ANSI. */ + plain?: boolean; + /** `--format`; json never uses ANSI for the payload path. */ + format?: OutputFormat; + /** Override TTY detection (tests). */ + isTTY?: boolean; + /** Override `NO_COLOR` (tests). */ + noColorEnv?: string | undefined; +}; + +/** + * Decide whether session human output should use ANSI. + * Off when: `--plain`, `NO_COLOR` set, `--format json`, or stdout is not a TTY. + */ +export function resolveAnsiEnabled(opts: ResolveAnsiOptions = {}): boolean { + if (opts.plain) return false; + if (opts.format === "json") return false; + const noColor = + opts.noColorEnv !== undefined ? opts.noColorEnv : process.env.NO_COLOR; + if (noColor !== undefined && noColor !== "") return false; + const tty = + opts.isTTY !== undefined ? opts.isTTY : process.stdout.isTTY === true; + return tty; +} + +export function styleFromOpts(opts: ResolveAnsiOptions = {}): Style { + return createStyle(resolveAnsiEnabled(opts)); +} diff --git a/clients/cli/tsup.config.ts b/clients/cli/tsup.config.ts index d65d37ec3..5a58c1ab8 100644 --- a/clients/cli/tsup.config.ts +++ b/clients/cli/tsup.config.ts @@ -6,7 +6,9 @@ const dirname = path.dirname(fileURLToPath(import.meta.url)); const repoRoot = path.resolve(dirname, '../..'); export default defineConfig({ - entry: ['src/index.ts'], + entry: { + index: 'src/index.ts', + }, format: ['esm'], outDir: 'build', clean: true, diff --git a/clients/mcpi/README.md b/clients/mcpi/README.md new file mode 100644 index 000000000..13724be94 --- /dev/null +++ b/clients/mcpi/README.md @@ -0,0 +1,78 @@ +# MCP Inspector session CLI (`mcpi`) + +**Experimental** separate client — not part of the published `@modelcontextprotocol/inspector` package. Connect once, then run many MCP commands against a named session via an implicit local daemon (ssh-agent style). + +> **Layout note:** Source lives in `clients/mcpi/`. At build time it bundles some modules from `clients/cli/src` (`handlers/`, `error-handler`, OAuth helpers) via the `@inspector/cli` alias. That reach-in is intentional and temporary — not a published library API — until a cleaner shared package exists. + +## Install / run (from this repo) + +Build, then put `mcpi` on your PATH with `npm link` (points at this package’s `build/mcp-bin.js`): + +```bash +# from the repo root — install deps once if needed +npm install + +cd clients/mcpi +npm run build +npm link + +mcpi --help +``` + +Rebuild after pulling source changes (`npm run build` in `clients/mcpi`). You usually do **not** need to re-link unless the package `bin` entry changes. + +Without linking, run the built file directly: + +```bash +node clients/mcpi/build/mcp-bin.js --help +``` + +Remove the link when you’re done: + +```bash +npm unlink -g @modelcontextprotocol/mcpi +``` + +## Usage + +```bash +mcpi servers/list --config path/to/mcp.json +mcpi servers/show test-stdio --config path/to/mcp.json +mcpi connect test-stdio --config path/to/mcp.json +mcpi connect my-http --config path/to/mcp.json --relogin # ignore stored OAuth; login only if auth required +mcpi auth/list +mcpi auth/clear https://example.com/mcp +mcpi auth/clear --all --yes +mcpi tools/list +mcpi tools/call echo message:=hi +mcpi tools/call echo '{"message":"hi"}' +mcpi @test-stdio resources/list +mcpi logging/tail # long-lived; Ctrl-C to stop +mcpi sessions/list +mcpi disconnect --session test-stdio +mcpi daemon status +mcpi daemon stop + +# Optional: private daemon for this shell only +eval "$(mcpi private)" +mcpi connect test-stdio --config path/to/mcp.json +mcpi tools/list +``` + +**Globals (before subcommand):** `--format text|json`, `--plain`, `--session `, `--catalog` / `--config`, `--stored-auth-only`. + +**Output:** `--format text` (default) is human-readable (TTY ANSI unless `--plain` / `NO_COLOR`). `--format json` is pretty-printed payload with **no** `{ result }` envelope. + +**Auth:** shared `oauth.json` with other Inspector clients. Connect-time OAuth only on this CLI; mid-session step-up remains on one-shot `mcp-inspector --cli`. `--relogin` clears any URL-keyed store entry before connect (no-op for stdio). + +See [`specification/v2_cli_v2.md`](../../specification/v2_cli_v2.md) for the as-built design and to-do list. + +## Relation to one-shot CLI + +| | One-shot | Session (`mcpi`) | +| --- | --- | --- | +| Entrypoint | `mcp-inspector --cli` | `mcpi` | +| Package (dev) | `clients/cli` | `clients/mcpi` | +| Lifecycle | Connect → one `--method` → disconnect | Connect once → many subcommands | + +One-shot docs: [`clients/cli/README.md`](../cli/README.md). diff --git a/clients/mcpi/__tests__/authorize.test.ts b/clients/mcpi/__tests__/authorize.test.ts new file mode 100644 index 000000000..a96f5b3a0 --- /dev/null +++ b/clients/mcpi/__tests__/authorize.test.ts @@ -0,0 +1,66 @@ +import { describe, it, expect, vi, afterEach } from "vitest"; +import type { MCPServerConfig } from "@inspector/core/mcp/types.js"; + +const connectSpy = vi.fn(); +const disconnectSpy = vi.fn().mockResolvedValue(undefined); + +vi.mock("@inspector/cli/cliOAuth.js", () => ({ + connectInspectorWithOAuth: (...args: unknown[]) => connectSpy(...args), +})); + +vi.mock("@inspector/core/mcp/index.js", () => ({ + InspectorClient: class { + connect = vi.fn(); + disconnect = disconnectSpy; + }, +})); + +vi.mock("@inspector/core/client/runner.js", async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + loadRunnerClientConfig: vi.fn().mockResolvedValue({}), + buildRunnerClientAuthOptions: vi.fn().mockReturnValue({}), + }; +}); + +describe("authorizeInFrontend", () => { + afterEach(() => { + connectSpy.mockReset(); + disconnectSpy.mockClear(); + }); + + it("no-ops for non-OAuth-capable (stdio) configs", async () => { + const { authorizeInFrontend } = await import("../src/session/authorize.js"); + await authorizeInFrontend( + { type: "stdio", command: "x" } as MCPServerConfig, + undefined, + ); + expect(connectSpy).not.toHaveBeenCalled(); + }); + + it("runs connectInspectorWithOAuth for HTTP configs", async () => { + connectSpy.mockResolvedValue(undefined); + const { authorizeInFrontend } = await import("../src/session/authorize.js"); + await authorizeInFrontend( + { type: "streamable-http", url: "https://example.com/mcp" }, + { protocolEra: "2025-11-25" } as never, + { storedAuthOnly: true }, + ); + expect(connectSpy).toHaveBeenCalled(); + expect(disconnectSpy).toHaveBeenCalled(); + }); + + it("swallows disconnect failures in finally", async () => { + connectSpy.mockResolvedValue(undefined); + disconnectSpy.mockRejectedValueOnce(new Error("bye")); + const { authorizeInFrontend } = await import("../src/session/authorize.js"); + await expect( + authorizeInFrontend( + { type: "streamable-http", url: "https://example.com/mcp" }, + undefined, + ), + ).resolves.toBeUndefined(); + }); +}); diff --git a/clients/mcpi/__tests__/daemon-coverage.test.ts b/clients/mcpi/__tests__/daemon-coverage.test.ts new file mode 100644 index 000000000..41d5c82a4 --- /dev/null +++ b/clients/mcpi/__tests__/daemon-coverage.test.ts @@ -0,0 +1,791 @@ +import { describe, it, expect, afterEach, vi } from "vitest"; +import * as fs from "node:fs"; +import * as net from "node:net"; +import * as os from "node:os"; +import * as path from "node:path"; +import { getTestMcpServerCommand } from "@modelcontextprotocol/inspector-test-server"; +import { DaemonServer } from "../src/daemon/server.js"; +import { callDaemon } from "../src/daemon/client.js"; +import { ensureDaemon, resolveDaemonScriptPath } from "../src/daemon/ensure.js"; +import { SessionRegistry } from "../src/daemon/sessions.js"; +import { CliExitCodeError } from "@inspector/cli/error-handler.js"; +import { runMcp } from "./helpers/mcp-runner.js"; +import { + createSampleTestConfig, + deleteConfigFile, +} from "../../cli/__tests__/helpers/fixtures.js"; +import { + expectCliSuccess, + expectCliFailure, +} from "../../cli/__tests__/helpers/assertions.js"; + +describe("daemon coverage", () => { + let server: DaemonServer | undefined; + let dir: string | undefined; + let configPath: string | undefined; + + afterEach(async () => { + if (server) { + await server.stop("stop"); + server = undefined; + } + if (dir) { + fs.rmSync(dir, { recursive: true, force: true }); + dir = undefined; + } + if (configPath) { + deleteConfigFile(configPath); + configPath = undefined; + } + }); + + function freshDir(): string { + dir = fs.mkdtempSync(path.join(os.tmpdir(), "mcp-cov-")); + return dir; + } + + it("handle() covers invalid connect / sessions/use / unknown op", async () => { + server = new DaemonServer({ dir: freshDir(), idleMs: 0 }); + const badConnect = await server.handle({ + id: "1", + op: "connect", + params: { name: "" } as never, + }); + expect(badConnect.ok).toBe(false); + if (!badConnect.ok) expect(badConnect.error.code).toBe("invalid_params"); + + const badUse = await server.handle({ + id: "2", + op: "sessions/use", + params: {}, + }); + expect(badUse.ok).toBe(false); + + const unknown = await server.handle({ + id: "3", + op: "nope" as never, + }); + expect(unknown.ok).toBe(false); + if (!unknown.ok) expect(unknown.error.code).toBe("unknown_op"); + + // CliExitCodeError without an envelope → default code "cli_error". + const bare = new CliExitCodeError(1, "bare"); + vi.spyOn(server.registry, "list").mockImplementationOnce(() => { + throw bare; + }); + const listed = await server.handle({ id: "4", op: "sessions/list" }); + expect(listed.ok).toBe(false); + if (!listed.ok) expect(listed.error.code).toBe("cli_error"); + + vi.spyOn(server.registry, "list").mockImplementationOnce(() => { + throw new Error("boom"); + }); + const boom = await server.handle({ id: "5", op: "sessions/list" }); + expect(boom.ok).toBe(false); + // Non-CliExitCodeError failures go through classifyError (code "error"). + if (!boom.ok) expect(boom.error.code).toBe("error"); + + vi.spyOn(server.registry, "list").mockImplementationOnce(() => { + throw "string-throw"; + }); + const strErr = await server.handle({ id: "6", op: "sessions/list" }); + expect(strErr.ok).toBe(false); + + const disc = await server.handle({ + id: "7", + op: "disconnect", + params: undefined, + }); + expect(disc.ok).toBe(false); + + // Defaults constructor + stop without onShutdown + re-entrant stop. + const plain = new DaemonServer({ dir: freshDir(), idleMs: 0 }); + await plain.start(); + await plain.stop("stop"); + await plain.stop("stop"); + + // Constructor default dir/idle/onShutdown branches (isolated storage dir). + const prev = process.env.MCP_INSPECTOR_DAEMON_DIR; + process.env.MCP_INSPECTOR_DAEMON_DIR = freshDir(); + try { + const defs = new DaemonServer(); + expect(defs.socketPath).toContain("daemon.sock"); + } finally { + if (prev === undefined) delete process.env.MCP_INSPECTOR_DAEMON_DIR; + else process.env.MCP_INSPECTOR_DAEMON_DIR = prev; + } + }); + + it("rejects a second listen when a live daemon owns the socket", async () => { + const d = freshDir(); + server = new DaemonServer({ dir: d, idleMs: 0 }); + await server.start(); + const other = new DaemonServer({ dir: d, idleMs: 0 }); + await expect(other.start()).rejects.toThrow(/already running/); + }); + + it("removes a stale socket before binding", async () => { + const d = freshDir(); + const sock = path.join(d, "daemon.sock"); + fs.writeFileSync(sock, ""); + server = new DaemonServer({ dir: d, idleMs: 0 }); + await server.start(); + expect(fs.existsSync(sock)).toBe(true); + }); + + it("daemon/stop responds then shuts down", async () => { + const d = freshDir(); + server = new DaemonServer({ dir: d, idleMs: 0 }); + await server.start(); + const result = await callDaemon<{ stopping: boolean }>( + "daemon/stop", + {}, + { socketPath: server.socketPath }, + ); + expect(result.stopping).toBe(true); + // Allow async stop to finish. + await new Promise((r) => setTimeout(r, 100)); + server = undefined; + }); + + it("accepts malformed NDJSON lines without crashing", async () => { + const d = freshDir(); + server = new DaemonServer({ dir: d, idleMs: 0 }); + await server.start(); + await new Promise((resolve, reject) => { + const socket = net.createConnection(server!.socketPath); + let data = ""; + socket.on("data", (chunk) => { + data += String(chunk); + if (data.includes("invalid_request")) { + socket.on("error", () => {}); + socket.end(); + resolve(); + } + }); + socket.on("error", reject); + socket.write("not-json\n"); + }); + }); + + it("callDaemon maps error responses and unreachable sockets", async () => { + await expect( + callDaemon( + "ping", + {}, + { socketPath: path.join(freshDir(), "missing.sock") }, + ), + ).rejects.toThrow(CliExitCodeError); + + const d = freshDir(); + server = new DaemonServer({ dir: d, idleMs: 0 }); + await server.start(); + await expect( + callDaemon("sessions/use", {}, { socketPath: server.socketPath }), + ).rejects.toThrow(/requires a session name/); + }); + + it("callDaemon rejects malformed response JSON", async () => { + const d = freshDir(); + const sock = path.join(d, "daemon.sock"); + const bad = net.createServer((socket) => { + socket.on("error", () => {}); + socket.write("not-json\n"); + }); + await new Promise((resolve) => bad.listen(sock, resolve)); + try { + await expect( + callDaemon("ping", {}, { socketPath: sock, timeoutMs: 2000 }), + ).rejects.toThrow(); + } finally { + bad.close(); + try { + fs.unlinkSync(sock); + } catch { + // ignore + } + } + }); + + it("callDaemon ignores mismatched response ids then accepts a match", async () => { + const d = freshDir(); + const sock = path.join(d, "daemon.sock"); + const echo = net.createServer((socket) => { + socket.on("error", () => {}); + socket.once("data", (buf) => { + const req = JSON.parse(String(buf).trim()) as { id: string }; + socket.write( + JSON.stringify({ id: "other", ok: true, result: {} }) + "\n", + ); + socket.write( + JSON.stringify({ id: req.id, ok: true, result: { ok: true } }) + "\n", + ); + }); + }); + await new Promise((resolve) => echo.listen(sock, resolve)); + try { + const result = await callDaemon<{ ok: boolean }>( + "ping", + {}, + { socketPath: sock, timeoutMs: 2000 }, + ); + expect(result.ok).toBe(true); + } finally { + echo.close(); + try { + fs.unlinkSync(sock); + } catch { + // ignore + } + } + }); + + it("callDaemon skips blank lines and defaults missing exitCode", async () => { + const d = freshDir(); + const sock = path.join(d, "daemon.sock"); + const echo = net.createServer((socket) => { + socket.on("error", () => {}); + socket.once("data", (buf) => { + const req = JSON.parse(String(buf).trim()) as { id: string }; + socket.write("\n"); + socket.write( + JSON.stringify({ + id: req.id, + ok: false, + error: { code: "usage", message: "no exit" }, + }) + "\n", + ); + }); + }); + await new Promise((resolve) => echo.listen(sock, resolve)); + try { + await expect( + callDaemon("ping", {}, { socketPath: sock, timeoutMs: 2000 }), + ).rejects.toMatchObject({ exitCode: 1 }); + } finally { + echo.close(); + try { + fs.unlinkSync(sock); + } catch { + // ignore + } + } + }); + + it("stop() without start and with missing lock files is safe", async () => { + const d = freshDir(); + const orphan = new DaemonServer({ dir: d, idleMs: 0 }); + await orphan.stop("stop"); + + server = new DaemonServer({ dir: d, idleMs: 0 }); + await server.start(); + fs.unlinkSync(server.socketPath); + fs.unlinkSync(path.join(d, "daemon.lock")); + await server.stop("stop"); + server = undefined; + }); + + it("callDaemon times out a hung server", async () => { + const d = freshDir(); + const sock = path.join(d, "daemon.sock"); + const hung = net.createServer((socket) => { + socket.on("error", () => {}); + }); + await new Promise((resolve) => hung.listen(sock, resolve)); + try { + await expect( + callDaemon("ping", {}, { socketPath: sock, timeoutMs: 100 }), + ).rejects.toThrow(/timed out/); + } finally { + hung.close(); + try { + fs.unlinkSync(sock); + } catch { + // ignore + } + } + }, 5000); + + it("sessions/use and reconnect replace an existing session", async () => { + const { command, args } = getTestMcpServerCommand(); + const registry = new SessionRegistry(0); + await registry.connect({ + name: "s", + serverConfig: { type: "stdio", command, args }, + serverIdentity: "s", + }); + await registry.connect({ + name: "s", + serverConfig: { type: "stdio", command, args }, + serverIdentity: "s-again", + }); + expect(registry.use("s").serverIdentity).toBe("s-again"); + expect(() => registry.resolve("missing", false)).toThrow(/not found/); + await registry.disconnectAll(); + }); + + it("idle handler fires after last disconnect when idleMs > 0", async () => { + const registry = new SessionRegistry(20); + let idle = false; + registry.setIdleHandler(() => { + idle = true; + }); + const { command, args } = getTestMcpServerCommand(); + await registry.connect({ + name: "s", + serverConfig: { type: "stdio", command, args }, + serverIdentity: "s", + }); + await registry.disconnect("s", false); + await new Promise((r) => setTimeout(r, 60)); + expect(idle).toBe(true); + expect(registry.idleRemainingMs()).toBeNull(); + }); + + it("covers touch/auth/oauth-setup/disconnect-swallow/reconnect-before-idle", async () => { + const { command, args } = getTestMcpServerCommand(); + const registry = new SessionRegistry(0); + registry.touch("missing"); + + await registry.connect({ + name: "s", + serverConfig: { type: "stdio", command, args }, + serverIdentity: "s", + }); + const session = registry.resolve("s", false); + vi.spyOn(session.client, "disconnect").mockRejectedValueOnce( + new Error("teardown boom"), + ); + await expect(registry.disconnect("s", false)).resolves.toEqual({ + name: "s", + }); + expect(registry.getMruName()).toBeNull(); + + const { AuthRecoveryRequiredError } = + await import("@inspector/core/auth/challenge.js"); + const { InspectorClient } = await import("@inspector/core/mcp/index.js"); + vi.spyOn(InspectorClient.prototype, "connect").mockRejectedValueOnce( + new AuthRecoveryRequiredError(new URL("https://as.example/authorize"), { + reason: "login_required", + }), + ); + await expect( + registry.connect({ + name: "auth", + serverConfig: { type: "stdio", command, args }, + serverIdentity: "auth", + }), + ).rejects.toMatchObject({ exitCode: 3 }); + + // SDK token-exchange failure (empty redirectUrl / stale store) must surface + // as auth_required so the front-end can re-prompt — not a hard ErrorEnvelope. + vi.spyOn(InspectorClient.prototype, "connect").mockRejectedValueOnce( + new Error( + "Either provider.prepareTokenRequest() or authorizationCode is required", + ), + ); + await expect( + registry.connect({ + name: "reauth", + serverConfig: { + type: "streamable-http", + url: "https://example.com/mcp", + }, + serverIdentity: "reauth", + }), + ).rejects.toMatchObject({ + exitCode: 3, + envelope: { code: "auth_required" }, + }); + + await expect( + registry.connect({ + name: "http", + serverConfig: { + type: "streamable-http", + url: "http://127.0.0.1:1/mcp", + }, + serverIdentity: "http", + }), + ).rejects.toThrow(); + + const idleReg = new SessionRegistry(80); + const onIdle = vi.fn(); + idleReg.setIdleHandler(onIdle); + await idleReg.connect({ + name: "a", + serverConfig: { type: "stdio", command, args }, + serverIdentity: "a", + }); + await idleReg.disconnect("a", false); + await idleReg.connect({ + name: "b", + serverConfig: { type: "stdio", command, args }, + serverIdentity: "b", + }); + await new Promise((r) => setTimeout(r, 100)); + expect(onIdle).not.toHaveBeenCalled(); + await idleReg.disconnectAll(); + }, 20000); + + it("ensureDaemon reuses a running daemon and resolveDaemonScriptPath finds build", async () => { + const d = freshDir(); + server = new DaemonServer({ dir: d, idleMs: 0 }); + await server.start(); + const ensured = await ensureDaemon({ + dir: d, + daemonScript: resolveDaemonScriptPath(), + }); + expect(ensured.spawned).toBe(false); + expect(ensured.socketPath).toBe(server.socketPath); + }); + + it("ensureDaemon auto-spawns when no daemon is present", async () => { + const d = freshDir(); + const ensured = await ensureDaemon({ + dir: d, + daemonScript: resolveDaemonScriptPath(), + }); + expect(ensured.spawned).toBe(true); + await callDaemon("daemon/stop", {}, { socketPath: ensured.socketPath }); + await new Promise((r) => setTimeout(r, 150)); + }); + + it("ensureDaemon replaces a stale accepting socket", async () => { + const d = freshDir(); + const sock = path.join(d, "daemon.sock"); + const stale = net.createServer((socket) => { + socket.on("error", () => {}); + socket.end(); + }); + await new Promise((resolve) => stale.listen(sock, resolve)); + try { + const ensured = await ensureDaemon({ + dir: d, + daemonScript: resolveDaemonScriptPath(), + }); + expect(ensured.spawned).toBe(true); + await callDaemon("ping", {}, { socketPath: ensured.socketPath }); + await callDaemon("daemon/stop", {}, { socketPath: ensured.socketPath }); + await new Promise((r) => setTimeout(r, 150)); + } finally { + stale.close(); + } + }); + + it("session-less start arms idle and self-reaps", async () => { + const d = freshDir(); + let shut = false; + server = new DaemonServer({ + dir: d, + idleMs: 40, + onShutdown: () => { + shut = true; + }, + }); + await server.start(); + // ensureDaemon from tools/list with no sessions must not leak forever. + expect(server.registry.idleRemainingMs()).not.toBeNull(); + await new Promise((r) => setTimeout(r, 100)); + expect(shut).toBe(true); + server = undefined; + }); + + it("connect failure for a dead stdio command is surfaced and re-arms idle", async () => { + const registry = new SessionRegistry(5_000); + let idle = false; + registry.setIdleHandler(() => { + idle = true; + }); + await expect( + registry.connect({ + name: "dead", + serverConfig: { + type: "stdio", + command: path.join(os.tmpdir(), "no-such-mcp-server-binary"), + args: [], + }, + serverIdentity: "dead", + }), + ).rejects.toThrow(); + expect(registry.idleRemainingMs()).not.toBeNull(); + expect(idle).toBe(false); + }); + + it("re-arms idle when createSessionClient fails before client.connect", async () => { + const registry = new SessionRegistry(5_000); + registry.setIdleHandler(() => {}); + const prev = process.env.MCP_OAUTH_CALLBACK_URL; + process.env.MCP_OAUTH_CALLBACK_URL = "https://example.com/oauth/callback"; + try { + await expect( + registry.connect({ + name: "http", + serverConfig: { + type: "streamable-http", + url: "http://127.0.0.1:1/mcp", + }, + serverIdentity: "http", + }), + ).rejects.toThrow(/http scheme|callback URL/i); + expect(registry.idleRemainingMs()).not.toBeNull(); + } finally { + if (prev === undefined) delete process.env.MCP_OAUTH_CALLBACK_URL; + else process.env.MCP_OAUTH_CALLBACK_URL = prev; + } + }); + + it("callDaemon fails immediately when the peer closes without a response", async () => { + const d = freshDir(); + const sock = path.join(d, "daemon.sock"); + const peer = net.createServer((socket) => { + socket.on("error", () => {}); + // Accept then FIN with no NDJSON reply. + socket.end(); + }); + await new Promise((resolve) => peer.listen(sock, resolve)); + try { + await expect( + callDaemon("ping", {}, { socketPath: sock, timeoutMs: 60_000 }), + ).rejects.toMatchObject({ + envelope: { code: "daemon_unreachable" }, + }); + } finally { + peer.close(); + try { + fs.unlinkSync(sock); + } catch { + // ignore + } + } + }); + + it("sessions/use via handle and blank IPC lines", async () => { + const d = freshDir(); + server = new DaemonServer({ dir: d, idleMs: 60_000 }); + await server.start(); + const { command, args } = getTestMcpServerCommand(); + await callDaemon( + "connect", + { + name: "s", + serverConfig: { type: "stdio", command, args }, + serverIdentity: "s", + }, + { socketPath: server.socketPath, timeoutMs: 15000 }, + ); + const used = await server.handle({ + id: "u", + op: "sessions/use", + params: { name: "s" }, + }); + expect(used.ok).toBe(true); + expect(server.registry.idleRemainingMs()).toBeNull(); + + await new Promise((resolve, reject) => { + const socket = new net.Socket(); + socket.on("error", reject); + socket.connect(server!.socketPath, () => { + socket.write("\n\n"); + socket.end(); + resolve(); + }); + }); + + await callDaemon( + "disconnect", + { name: "s" }, + { socketPath: server.socketPath }, + ); + // Idle timer armed — remaining countdown is positive and ≤ configured idleMs. + const remaining = server.registry.idleRemainingMs(); + expect(remaining).not.toBeNull(); + expect(remaining!).toBeLessThanOrEqual(60_000); + expect(remaining!).toBeGreaterThan(0); + }); +}); + +describe("mcp session coverage", () => { + let configPath: string | undefined; + let storageDir: string | undefined; + + afterEach(async () => { + if (storageDir) { + const socketPath = path.join(storageDir, "daemon.sock"); + if (fs.existsSync(socketPath)) { + try { + await callDaemon("daemon/stop", {}, { socketPath, timeoutMs: 2000 }); + } catch { + // ignore + } + const deadline = Date.now() + 2000; + while (fs.existsSync(socketPath) && Date.now() < deadline) { + await new Promise((r) => setTimeout(r, 50)); + } + } + fs.rmSync(storageDir, { recursive: true, force: true }); + storageDir = undefined; + } + if (configPath) { + deleteConfigFile(configPath); + configPath = undefined; + } + }); + + function env(): Record { + storageDir = fs.mkdtempSync(path.join(os.tmpdir(), "mcp-sess-cov-")); + return { + MCP_STORAGE_DIR: storageDir, + MCP_INSPECTOR_DAEMON_DIR: storageDir, + MCP_ALLOW_DEFAULT_SESSION: "1", + }; + } + + it("covers sessions/use, daemon status, @session connect, and stop no-op", async () => { + configPath = createSampleTestConfig(); + const e = env(); + + const stopIdle = await runMcp(["daemon", "stop", "--format", "json"], { + env: e, + }); + expectCliSuccess(stopIdle); + expect(stopIdle.stdout).toContain("not running"); + + const connected = await runMcp( + [ + "connect", + "@alpha", + "test-stdio", + "--config", + configPath, + "--format", + "json", + ], + { env: e, timeout: 20000 }, + ); + expectCliSuccess(connected); + expect(JSON.parse(connected.stdout).name).toBe("alpha"); + + const used = await runMcp(["sessions/use", "@alpha", "--format", "text"], { + env: e, + }); + expectCliSuccess(used); + expect(used.stdout).toContain("alpha"); + + const status = await runMcp(["daemon", "status"], { env: e }); + expectCliSuccess(status); + + const listed = await runMcp(["sessions/list"], { env: e }); + expectCliSuccess(listed); + + const viaServer = await runMcp( + [ + "connect", + "--server", + "test-stdio", + "--config", + configPath, + "--session", + "via-flag", + "--format", + "json", + ], + { env: e, timeout: 20000 }, + ); + expectCliSuccess(viaServer); + + const stopped = await runMcp(["daemon", "stop", "--format", "json"], { + env: e, + }); + expectCliSuccess(stopped); + expect(stopped.stdout).toContain("stopping"); + }); + + it("rejects connect with no target and invalid --format", async () => { + const e = env(); + const missing = await runMcp(["connect"], { env: e }); + expectCliFailure(missing, 1); + + const badFormat = await runMcp(["servers/list", "--format", "xml"], { + env: e, + }); + expectCliFailure(badFormat, 1); + + const badTransport = await runMcp(["connect", "x", "--transport", "ftp"], { + env: e, + }); + expectCliFailure(badTransport, 1); + + const badTimeout = await runMcp( + ["connect", "x", "--connect-timeout", "-1"], + { env: e }, + ); + expectCliFailure(badTimeout, 1); + + const emptyUse = await runMcp(["sessions/use", ""], { env: e }); + expectCliFailure(emptyUse, 1); + }); + + it("connects an ad-hoc stdio target", async () => { + const { command, args } = getTestMcpServerCommand(); + const e = env(); + // Multi-token positional target → ad-hoc (not a catalog entry name). + const result = await runMcp( + [ + "connect", + "--session", + "adhoc", + "--transport", + "stdio", + "--format", + "json", + command, + ...args, + ], + { env: e, timeout: 20000 }, + ); + expectCliSuccess(result); + expect(JSON.parse(result.stdout).name).toBe("adhoc"); + }); + + it("treats a URL positional as ad-hoc", async () => { + const e = env(); + const result = await runMcp( + [ + "connect", + "http://127.0.0.1:9/mcp", + "--session", + "url", + "--connect-timeout", + "100", + "--format", + "json", + ], + { env: e, timeout: 10000 }, + ); + // Connection should fail (nothing listening) but the ad-hoc URL path ran. + expectCliFailure(result, 1); + }); + + it("requires explicit session in non-interactive mode without opt-in", async () => { + configPath = createSampleTestConfig(); + storageDir = fs.mkdtempSync(path.join(os.tmpdir(), "mcp-sess-ci-")); + const e = { + MCP_STORAGE_DIR: storageDir, + MCP_INSPECTOR_DAEMON_DIR: storageDir, + // no MCP_ALLOW_DEFAULT_SESSION + }; + const connected = await runMcp( + ["connect", "test-stdio", "--config", configPath, "--format", "json"], + { env: e, timeout: 20000 }, + ); + expectCliSuccess(connected); + + // Force requireExplicit by stubbing isTTY false is default in vitest forks. + const disc = await runMcp(["disconnect", "--format", "json"], { env: e }); + expectCliFailure(disc, 1); + expect(disc.stderr).toMatch(/Explicit|--session|non-interactive/i); + + await runMcp(["disconnect", "--session", "test-stdio"], { env: e }); + }); +}); diff --git a/clients/mcpi/__tests__/daemon-paths.test.ts b/clients/mcpi/__tests__/daemon-paths.test.ts new file mode 100644 index 000000000..cffcb086d --- /dev/null +++ b/clients/mcpi/__tests__/daemon-paths.test.ts @@ -0,0 +1,98 @@ +import { describe, it, expect, afterEach } from "vitest"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { + createPrivateDaemonDir, + ensureDaemonDir, + getDaemonDir, + getDaemonLockPath, + getDaemonSocketPath, + getInspectorHome, +} from "../src/daemon/paths.js"; +import { writeFormattedResult } from "@inspector/cli/handlers/format-output.js"; + +describe("daemon paths", () => { + const backup: Record = {}; + + afterEach(() => { + for (const key of ["MCP_INSPECTOR_DAEMON_DIR", "MCP_STORAGE_DIR", "HOME"]) { + if (key in backup) { + if (backup[key] === undefined) delete process.env[key]; + else process.env[key] = backup[key]; + delete backup[key]; + } + } + }); + + function setEnv(key: string, value: string | undefined) { + backup[key] = process.env[key]; + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + + it("prefers MCP_INSPECTOR_DAEMON_DIR over MCP_STORAGE_DIR", () => { + const a = path.join(os.tmpdir(), "daemon-a"); + const b = path.join(os.tmpdir(), "daemon-b"); + setEnv("MCP_STORAGE_DIR", b); + setEnv("MCP_INSPECTOR_DAEMON_DIR", a); + expect(getDaemonDir()).toBe(path.resolve(a)); + expect(getDaemonSocketPath()).toBe( + path.join(path.resolve(a), "daemon.sock"), + ); + expect(getDaemonLockPath()).toBe(path.join(path.resolve(a), "daemon.lock")); + }); + + it("falls back to MCP_STORAGE_DIR then ~/.mcp-inspector", () => { + const storage = path.join(os.tmpdir(), "daemon-storage"); + setEnv("MCP_INSPECTOR_DAEMON_DIR", undefined); + setEnv("MCP_STORAGE_DIR", storage); + expect(getDaemonDir()).toBe(path.resolve(storage)); + setEnv("MCP_STORAGE_DIR", undefined); + expect(getDaemonDir()).toContain(".mcp-inspector"); + }); + + it("creates the daemon directory", () => { + const dir = path.join(os.tmpdir(), `daemon-mkdir-${Date.now()}`); + ensureDaemonDir(dir); + expect(fs.statSync(dir).isDirectory()).toBe(true); + fs.rmSync(dir, { recursive: true, force: true }); + }); + + it("createPrivateDaemonDir nests under ~/.mcp-inspector/private", () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "mcp-home-")); + setEnv("HOME", home); + setEnv("MCP_INSPECTOR_DAEMON_DIR", undefined); + setEnv("MCP_STORAGE_DIR", undefined); + expect(getInspectorHome()).toBe(path.join(home, ".mcp-inspector")); + const dir = createPrivateDaemonDir(); + expect(dir.startsWith(path.join(home, ".mcp-inspector", "private"))).toBe( + true, + ); + expect(fs.statSync(dir).isDirectory()).toBe(true); + fs.rmSync(home, { recursive: true, force: true }); + }); +}); + +describe("writeFormattedResult", () => { + it("writes text and json envelopes", async () => { + let out = ""; + const original = process.stdout.write; + process.stdout.write = ((chunk: unknown, ...rest: unknown[]) => { + out += String(chunk); + const cb = rest.find((x) => typeof x === "function") as + (() => void) | undefined; + cb?.(); + return true; + }) as typeof process.stdout.write; + try { + await writeFormattedResult({ ok: 1 }, "text"); + expect(out).toContain('"ok": 1'); + out = ""; + await writeFormattedResult({ ok: 2 }, "json"); + expect(JSON.parse(out)).toEqual({ result: { ok: 2 } }); + } finally { + process.stdout.write = original; + } + }); +}); diff --git a/clients/mcpi/__tests__/daemon-private.test.ts b/clients/mcpi/__tests__/daemon-private.test.ts new file mode 100644 index 000000000..d36f9cc35 --- /dev/null +++ b/clients/mcpi/__tests__/daemon-private.test.ts @@ -0,0 +1,229 @@ +import { describe, it, expect, afterEach } from "vitest"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { getTestMcpServerCommand } from "@modelcontextprotocol/inspector-test-server"; +import { assertDaemonToken, tokensEqual } from "../src/daemon/auth.js"; +import { callDaemon } from "../src/daemon/client.js"; +import { ensureDaemon } from "../src/daemon/ensure.js"; +import { + createPrivateDaemonDir, + DAEMON_DIR_ENV, + DAEMON_TOKEN_ENV, +} from "../src/daemon/paths.js"; +import { DaemonServer } from "../src/daemon/server.js"; +import { CliExitCodeError } from "@inspector/cli/error-handler.js"; +import { runMcp } from "./helpers/mcp-runner.js"; +import { + expectCliSuccess, + expectCliFailure, +} from "../../cli/__tests__/helpers/assertions.js"; +import { + createSampleTestConfig, + deleteConfigFile, +} from "../../cli/__tests__/helpers/fixtures.js"; +import { + createPrivateBinding, + formatPrivateEnvExports, +} from "../src/session/private-env.js"; + +describe("daemon IPC token", () => { + it("compares tokens in constant time", () => { + expect(tokensEqual("abc", "abc")).toBe(true); + expect(tokensEqual("abc", "abd")).toBe(false); + expect(tokensEqual("abc", "ab")).toBe(false); + expect(tokensEqual(undefined, "x")).toBe(false); + }); + + it("assertDaemonToken allows shared mode and rejects bad private tokens", () => { + expect(() => assertDaemonToken(undefined, undefined)).not.toThrow(); + expect(() => assertDaemonToken(undefined, "x")).not.toThrow(); + expect(() => assertDaemonToken("secret", "secret")).not.toThrow(); + expect(() => assertDaemonToken("secret", "nope")).toThrow(CliExitCodeError); + expect(() => assertDaemonToken("secret", undefined)).toThrow( + CliExitCodeError, + ); + }); +}); + +describe("mcpi private", () => { + let home: string | undefined; + let prevHome: string | undefined; + + afterEach(() => { + if (prevHome === undefined) delete process.env.HOME; + else process.env.HOME = prevHome; + prevHome = undefined; + if (home) { + fs.rmSync(home, { recursive: true, force: true }); + home = undefined; + } + }); + + function useTempHome() { + prevHome = process.env.HOME; + home = fs.mkdtempSync(path.join(os.tmpdir(), "mcp-home-")); + process.env.HOME = home; + } + + it("prints shell exports for a new private binding", async () => { + useTempHome(); + const result = await runMcp(["private"], { + env: { HOME: home! }, + }); + expectCliSuccess(result); + expect(result.stdout).toMatch( + new RegExp(`export ${DAEMON_DIR_ENV}='[^']+/private/[^']+'`), + ); + expect(result.stdout).toMatch( + new RegExp(`export ${DAEMON_TOKEN_ENV}='[^']+'`), + ); + const dirMatch = result.stdout.match( + new RegExp(`${DAEMON_DIR_ENV}='([^']+)'`), + ); + expect(dirMatch?.[1]).toBeTruthy(); + expect(fs.statSync(dirMatch![1]!).isDirectory()).toBe(true); + }); + + it("formatPrivateEnvExports escapes single quotes", () => { + const text = formatPrivateEnvExports({ + dir: "/tmp/o'brian", + token: "t'ok", + }); + expect(text).toContain(`'/tmp/o'\\''brian'`); + expect(text).toContain(`'t'\\''ok'`); + }); + + it("createPrivateBinding allocates under private/", () => { + useTempHome(); + const binding = createPrivateBinding(); + expect(binding.dir).toContain(`${path.sep}private${path.sep}`); + expect(binding.dir.startsWith(home!)).toBe(true); + expect(binding.token.length).toBeGreaterThan(20); + }); +}); + +describe("private daemon end-to-end", () => { + let server: DaemonServer | undefined; + let dir: string | undefined; + + afterEach(async () => { + if (server) { + await server.stop("stop"); + server = undefined; + } + if (dir) { + fs.rmSync(dir, { recursive: true, force: true }); + dir = undefined; + } + }); + + it("rejects IPC without the required token and accepts with it", async () => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), "mcp-priv-")); + const token = "test-token-value"; + server = new DaemonServer({ dir, idleMs: 0, requiredToken: token }); + await server.start(); + + await expect( + callDaemon( + "ping", + {}, + { socketPath: server.socketPath, timeoutMs: 2000 }, + ), + ).rejects.toMatchObject({ envelope: { code: "daemon_auth_failed" } }); + + const pong = await callDaemon<{ pong: boolean }>( + "ping", + {}, + { socketPath: server.socketPath, timeoutMs: 2000, token }, + ); + expect(pong.pong).toBe(true); + }); + + it("session front-end rethrows non-unreachable daemon errors", async () => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), "mcp-priv-rethrow-")); + const token = "good-token"; + server = new DaemonServer({ dir, idleMs: 0, requiredToken: token }); + await server.start(); + + const env = { + MCP_STORAGE_DIR: dir, + [DAEMON_DIR_ENV]: dir, + [DAEMON_TOKEN_ENV]: "wrong-token", + }; + + const listed = await runMcp(["sessions/list"], { env }); + expectCliFailure(listed); + expect(listed.stderr).toMatch(/authentication failed|daemon_auth_failed/i); + + const status = await runMcp(["daemon", "status"], { env }); + expectCliFailure(status); + + const configPath = createSampleTestConfig(); + try { + const servers = await runMcp(["servers/list", "--config", configPath], { + env, + }); + // Optional daemon probe must not swallow auth failures as empty sessions. + expectCliFailure(servers); + } finally { + deleteConfigFile(configPath); + } + }); + + it("ensureDaemon spawns a token-gated daemon from env", async () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "mcp-home-spawn-")); + const prevHome = process.env.HOME; + process.env.HOME = home; + try { + dir = createPrivateDaemonDir(); + const token = "spawn-token-xyz"; + const prevDir = process.env[DAEMON_DIR_ENV]; + const prevTok = process.env[DAEMON_TOKEN_ENV]; + process.env[DAEMON_DIR_ENV] = dir; + process.env[DAEMON_TOKEN_ENV] = token; + try { + const { socketPath, spawned } = await ensureDaemon({ dir, token }); + expect(spawned).toBe(true); + + // Explicit wrong token — do not rely on clearing env (callDaemon + // falls back to MCP_INSPECTOR_DAEMON_TOKEN when options.token omitted). + await expect( + callDaemon( + "ping", + {}, + { socketPath, timeoutMs: 2000, token: "wrong" }, + ), + ).rejects.toMatchObject({ envelope: { code: "daemon_auth_failed" } }); + + const pong = await callDaemon<{ pong: boolean }>( + "ping", + {}, + { socketPath, timeoutMs: 2000, token }, + ); + expect(pong.pong).toBe(true); + + const { command, args } = getTestMcpServerCommand(); + await callDaemon( + "connect", + { + name: "s", + serverConfig: { type: "stdio", command, args }, + serverIdentity: "s", + }, + { socketPath, timeoutMs: 15000, token }, + ); + await callDaemon("daemon/stop", {}, { socketPath, token }); + } finally { + if (prevDir === undefined) delete process.env[DAEMON_DIR_ENV]; + else process.env[DAEMON_DIR_ENV] = prevDir; + if (prevTok === undefined) delete process.env[DAEMON_TOKEN_ENV]; + else process.env[DAEMON_TOKEN_ENV] = prevTok; + } + } finally { + if (prevHome === undefined) delete process.env.HOME; + else process.env.HOME = prevHome; + fs.rmSync(home, { recursive: true, force: true }); + } + }); +}); diff --git a/clients/mcpi/__tests__/daemon-sessions.test.ts b/clients/mcpi/__tests__/daemon-sessions.test.ts new file mode 100644 index 000000000..b91d25b64 --- /dev/null +++ b/clients/mcpi/__tests__/daemon-sessions.test.ts @@ -0,0 +1,252 @@ +import { describe, it, expect, afterEach } from "vitest"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { getTestMcpServerCommand } from "@modelcontextprotocol/inspector-test-server"; +import { DaemonServer } from "../src/daemon/server.js"; +import { callDaemon } from "../src/daemon/client.js"; +import { parseRequestLine, encodeResponse } from "../src/daemon/framing.js"; +import { + DEFAULT_IDLE_MS, + isSessionAuthRequiredError, + SessionRegistry, +} from "../src/daemon/sessions.js"; +import { CliExitCodeError } from "@inspector/cli/error-handler.js"; +import { AuthRecoveryRequiredError } from "@inspector/core/auth/challenge.js"; + +describe("daemon framing", () => { + it("parses and rejects invalid request lines", () => { + expect(parseRequestLine("")).toBeNull(); + expect(parseRequestLine(" ")).toBeNull(); + expect(parseRequestLine('{"id":"1","op":"ping"}')).toEqual({ + id: "1", + op: "ping", + }); + expect(() => parseRequestLine("not-json")).toThrow(); + expect(() => parseRequestLine('{"op":"ping"}')).toThrow(/Invalid daemon/); + expect(encodeResponse({ id: "1", ok: true, result: { pong: true } })).toBe( + '{"id":"1","ok":true,"result":{"pong":true}}\n', + ); + }); +}); + +describe("isSessionAuthRequiredError", () => { + it("recognizes unauthorized, recovery, and SDK token-exchange failures", () => { + expect(isSessionAuthRequiredError(new Error("nope"))).toBe(false); + expect( + isSessionAuthRequiredError( + new AuthRecoveryRequiredError(new URL("https://as.example/a"), { + reason: "unauthorized", + }), + ), + ).toBe(true); + const unauthorized = Object.assign(new Error("boom"), { status: 401 }); + expect(isSessionAuthRequiredError(unauthorized)).toBe(true); + expect( + isSessionAuthRequiredError( + new Error( + "Either provider.prepareTokenRequest() or authorizationCode is required", + ), + ), + ).toBe(true); + expect( + isSessionAuthRequiredError( + new Error("redirectUrl is required for authorization_code flow"), + ), + ).toBe(true); + expect( + isSessionAuthRequiredError( + new Error("No code verifier saved for session"), + ), + ).toBe(true); + }); +}); + +describe("SessionRegistry", () => { + it("requires an explicit session when asked", () => { + const registry = new SessionRegistry(0); + expect(() => registry.resolve(undefined, true)).toThrow(CliExitCodeError); + expect(() => registry.resolve(undefined, false)).toThrow( + /No open sessions/, + ); + }); + + it("tracks MRU across connect/disconnect", async () => { + const { command, args } = getTestMcpServerCommand(); + const registry = new SessionRegistry(0); + const a = await registry.connect({ + name: "a", + serverConfig: { type: "stdio", command, args }, + serverIdentity: `${command} ${args.join(" ")}`, + }); + expect(a.isMru).toBe(true); + const b = await registry.connect({ + name: "b", + serverConfig: { type: "stdio", command, args }, + serverIdentity: `${command} ${args.join(" ")}`, + }); + expect(b.isMru).toBe(true); + expect(registry.getMruName()).toBe("b"); + registry.use("a"); + expect(registry.getMruName()).toBe("a"); + await registry.disconnect("b", false); + expect(registry.list().map((s) => s.name)).toEqual(["a"]); + await registry.disconnect(undefined, false); + expect(registry.sessionCount()).toBe(0); + expect(DEFAULT_IDLE_MS).toBe(60_000); + }); +}); + +describe("DaemonServer IPC", () => { + let server: DaemonServer | undefined; + let dir: string | undefined; + + afterEach(async () => { + if (server) { + await server.stop("stop"); + server = undefined; + } + if (dir) { + fs.rmSync(dir, { recursive: true, force: true }); + dir = undefined; + } + }); + + it("serves ping / connect / sessions/list / disconnect over the socket", async () => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), "mcp-daemon-")); + server = new DaemonServer({ dir, idleMs: 0 }); + await server.start(); + + const pong = await callDaemon<{ pong: boolean }>( + "ping", + {}, + { socketPath: server.socketPath }, + ); + expect(pong.pong).toBe(true); + + const { command, args } = getTestMcpServerCommand(); + const connected = await callDaemon<{ name: string; isMru: boolean }>( + "connect", + { + name: "stdio", + serverConfig: { type: "stdio", command, args }, + serverIdentity: "test-stdio", + }, + { socketPath: server.socketPath, timeoutMs: 15000 }, + ); + expect(connected.name).toBe("stdio"); + expect(connected.isMru).toBe(true); + + const listed = await callDaemon<{ sessions: { name: string }[] }>( + "sessions/list", + {}, + { socketPath: server.socketPath }, + ); + expect(listed.sessions.map((s) => s.name)).toEqual(["stdio"]); + + const status = await callDaemon<{ pid: number; socketPath: string }>( + "daemon/status", + {}, + { socketPath: server.socketPath }, + ); + expect(status.pid).toBe(process.pid); + expect(status.socketPath).toBe(server.socketPath); + + const disc = await callDaemon<{ name: string }>( + "disconnect", + { name: "stdio" }, + { socketPath: server.socketPath }, + ); + expect(disc.name).toBe("stdio"); + }); + + it("runs rpc tools/list and initialize against a live session", async () => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), "mcp-daemon-rpc-")); + server = new DaemonServer({ dir, idleMs: 0 }); + await server.start(); + + const { command, args } = getTestMcpServerCommand(); + await callDaemon( + "connect", + { + name: "stdio", + serverConfig: { type: "stdio", command, args }, + serverIdentity: "test-stdio", + }, + { socketPath: server.socketPath, timeoutMs: 15000 }, + ); + + const listed = await callDaemon<{ + kind: string; + result: { tools: unknown[] }; + }>( + "rpc", + { method: "tools/list", name: "stdio" }, + { socketPath: server.socketPath, timeoutMs: 15000 }, + ); + expect(listed.kind).toBe("result"); + expect(listed.result.tools.length).toBeGreaterThan(0); + + const init = await callDaemon<{ + kind: string; + result: { protocolVersion?: string }; + }>( + "rpc", + { method: "initialize", name: "stdio" }, + { socketPath: server.socketPath, timeoutMs: 15000 }, + ); + expect(init.kind).toBe("result"); + expect(init.result.protocolVersion).toBeTruthy(); + + await callDaemon( + "disconnect", + { name: "stdio" }, + { socketPath: server.socketPath }, + ); + }); + + it("rejects stream methods on rpc and rpc methods on stream", async () => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), "mcp-daemon-ops-")); + server = new DaemonServer({ dir, idleMs: 0 }); + await server.start(); + + const { command, args } = getTestMcpServerCommand(); + await callDaemon( + "connect", + { + name: "stdio", + serverConfig: { type: "stdio", command, args }, + serverIdentity: "test-stdio", + }, + { socketPath: server.socketPath, timeoutMs: 15000 }, + ); + + await expect( + callDaemon( + "rpc", + { method: "logging/tail", name: "stdio" }, + { socketPath: server.socketPath, timeoutMs: 5000 }, + ), + ).rejects.toMatchObject({ envelope: { code: "use_stream_op" } }); + + const badStream = await server.handleOutcome({ + id: "s1", + op: "stream", + params: { method: "tools/list", name: "stdio" }, + }); + expect(badStream.response.ok).toBe(false); + + const noMethod = await server.handle({ + id: "s2", + op: "rpc", + params: { name: "stdio" } as never, + }); + expect(noMethod.ok).toBe(false); + + await callDaemon( + "disconnect", + { name: "stdio" }, + { socketPath: server.socketPath }, + ); + }); +}); diff --git a/clients/mcpi/__tests__/daemon-stream.test.ts b/clients/mcpi/__tests__/daemon-stream.test.ts new file mode 100644 index 000000000..ddf3e9e75 --- /dev/null +++ b/clients/mcpi/__tests__/daemon-stream.test.ts @@ -0,0 +1,312 @@ +import { describe, it, expect, afterEach } from "vitest"; +import * as fs from "node:fs"; +import * as net from "node:net"; +import * as os from "node:os"; +import * as path from "node:path"; +import { streamDaemon } from "../src/daemon/stream-client.js"; +import { + acceptDaemonConnection, + removeStaleDaemonSocket, +} from "../src/daemon/ipc-glue.js"; +import { CliExitCodeError, EXIT_CODES } from "@inspector/cli/error-handler.js"; + +describe("streamDaemon + ipc-glue", () => { + let dir: string | undefined; + let server: net.Server | undefined; + const sockets = new Set(); + + afterEach(async () => { + for (const s of sockets) { + s.destroy(); + } + sockets.clear(); + if (server) { + await new Promise((resolve) => { + server!.close(() => resolve()); + }); + server = undefined; + } + if (dir) { + fs.rmSync(dir, { recursive: true, force: true }); + dir = undefined; + } + }); + + function freshSock(): string { + dir = fs.mkdtempSync(path.join(os.tmpdir(), "mcp-stream-")); + return path.join(dir, "daemon.sock"); + } + + async function listen( + sock: string, + onSocket: (socket: net.Socket) => void, + ): Promise { + server = net.createServer((socket) => { + sockets.add(socket); + socket.on("error", () => {}); + socket.on("close", () => sockets.delete(socket)); + onSocket(socket); + }); + await new Promise((resolve) => server!.listen(sock, resolve)); + } + + it("delivers data frames then end (skips blank/mismatched ids)", async () => { + const sock = freshSock(); + await listen(sock, (socket) => { + socket.once("data", (buf) => { + const req = JSON.parse(String(buf).trim()) as { id: string }; + // Mismatched first response id is ignored; matching ok opens the stream. + socket.write( + JSON.stringify({ id: "wrong", ok: true, result: {} }) + "\n", + ); + socket.write( + JSON.stringify({ id: req.id, ok: true, result: {} }) + "\n", + ); + socket.write("\n"); + socket.write( + JSON.stringify({ id: "other", stream: "data", data: { skip: 1 } }) + + "\n", + ); + socket.write( + JSON.stringify({ id: req.id, stream: "noop", data: 0 }) + "\n", + ); + socket.write( + JSON.stringify({ id: req.id, stream: "data", data: { n: 1 } }) + "\n", + ); + socket.write(JSON.stringify({ id: req.id, stream: "end" }) + "\n"); + }); + }); + + const data: unknown[] = []; + await streamDaemon( + { method: "logging/tail" }, + { socketPath: sock, timeoutMs: 5000, onData: (d) => data.push(d) }, + ); + expect(data).toEqual([{ n: 1 }]); + }); + + it("resolves on socket error after the stream has opened", async () => { + const sock = freshSock(); + await listen(sock, (socket) => { + socket.once("data", (buf) => { + const req = JSON.parse(String(buf).trim()) as { id: string }; + socket.write( + JSON.stringify({ id: req.id, ok: true, result: {} }) + "\n", + ); + setTimeout(() => socket.destroy(), 20); + }); + }); + await streamDaemon( + {}, + { socketPath: sock, timeoutMs: 2000, onData: () => {} }, + ); + }); + + it("rejects malformed stream frames after open", async () => { + const sock = freshSock(); + await listen(sock, (socket) => { + socket.once("data", (buf) => { + const req = JSON.parse(String(buf).trim()) as { id: string }; + socket.write( + JSON.stringify({ id: req.id, ok: true, result: {} }) + "\n", + ); + socket.write("not-a-frame\n"); + }); + }); + await expect( + streamDaemon({}, { socketPath: sock, timeoutMs: 2000, onData: () => {} }), + ).rejects.toThrow(); + }); + + it("rejects error responses without exitCode (defaults USAGE)", async () => { + const sock = freshSock(); + await listen(sock, (socket) => { + socket.once("data", (buf) => { + const req = JSON.parse(String(buf).trim()) as { id: string }; + socket.write( + JSON.stringify({ + id: req.id, + ok: false, + error: { code: "usage", message: "nope" }, + }) + "\n", + ); + }); + }); + + await expect( + streamDaemon({}, { socketPath: sock, timeoutMs: 2000, onData: () => {} }), + ).rejects.toMatchObject({ exitCode: EXIT_CODES.USAGE }); + }); + + it("rejects malformed first-frame JSON", async () => { + const sock = freshSock(); + await listen(sock, (socket) => { + socket.once("data", () => { + socket.write("not-json\n"); + }); + }); + await expect( + streamDaemon({}, { socketPath: sock, timeoutMs: 2000, onData: () => {} }), + ).rejects.toThrow(); + }); + + it("aborts via signal after the stream opens", async () => { + const sock = freshSock(); + await listen(sock, (socket) => { + socket.once("data", (buf) => { + const req = JSON.parse(String(buf).trim()) as { id: string }; + socket.write( + JSON.stringify({ id: req.id, ok: true, result: {} }) + "\n", + ); + }); + }); + + const ac = new AbortController(); + const pending = streamDaemon( + {}, + { + socketPath: sock, + timeoutMs: 5000, + signal: ac.signal, + onData: () => {}, + }, + ); + await new Promise((r) => setTimeout(r, 50)); + ac.abort(); + await pending; + }); + + it("times out a hung stream open", async () => { + const sock = freshSock(); + await listen(sock, (socket) => { + socket.once("data", () => { + // never respond with an ok frame + }); + }); + await expect( + streamDaemon({}, { socketPath: sock, timeoutMs: 80, onData: () => {} }), + ).rejects.toThrow(/timed out/); + }, 5000); + + it("fails when the peer FINs before the stream ok frame", async () => { + const sock = freshSock(); + await listen(sock, (socket) => { + socket.on("error", () => {}); + socket.once("data", () => { + socket.end(); + }); + }); + await expect( + streamDaemon( + {}, + { socketPath: sock, timeoutMs: 60_000, onData: () => {} }, + ), + ).rejects.toMatchObject({ + envelope: { code: "daemon_unreachable" }, + }); + }); + + it("resolves when the peer closes mid-stream", async () => { + const sock = freshSock(); + await listen(sock, (socket) => { + socket.once("data", (buf) => { + const req = JSON.parse(String(buf).trim()) as { id: string }; + socket.write( + JSON.stringify({ id: req.id, ok: true, result: {} }) + "\n", + ); + socket.end(); + }); + }); + await streamDaemon( + {}, + { socketPath: sock, timeoutMs: 2000, onData: () => {} }, + ); + }); + + it("removeStaleDaemonSocket handles absent, dead, and live sockets", async () => { + const sock = freshSock(); + await removeStaleDaemonSocket(sock); + + fs.writeFileSync(sock, ""); + await removeStaleDaemonSocket(sock); + expect(fs.existsSync(sock)).toBe(false); + + await listen(sock, () => {}); + await expect(removeStaleDaemonSocket(sock)).rejects.toThrow( + /already running/, + ); + }); + + it("acceptDaemonConnection rejects invalid request lines", async () => { + const sock = freshSock(); + const chunks: string[] = []; + await listen(sock, (socket) => { + acceptDaemonConnection(socket, async () => ({ + response: { id: "x", ok: true, result: {} }, + })); + }); + + await new Promise((resolve, reject) => { + const client = net.connect(sock, () => { + sockets.add(client); + client.on("data", (c) => chunks.push(String(c))); + client.write('{"op":"ping"}\n'); + setTimeout(() => { + client.destroy(); + resolve(); + }, 50); + }); + client.on("error", reject); + }); + expect(chunks.join("")).toContain("invalid_request"); + }); + + it("acceptDaemonConnection streams via startStream until socket closes", async () => { + const sock = freshSock(); + let stopCalled = false; + await listen(sock, (socket) => { + acceptDaemonConnection(socket, async (req) => ({ + response: { id: req.id, ok: true, result: {} }, + startStream: (writeData) => { + writeData({ a: 1 }); + return () => { + stopCalled = true; + throw new Error("unsubscribe boom"); + }; + }, + })); + }); + + const frames: string[] = []; + await new Promise((resolve) => { + const client = net.connect(sock, () => { + sockets.add(client); + client.on("data", (c) => frames.push(String(c))); + client.on("close", () => resolve()); + client.on("error", () => {}); + client.write( + JSON.stringify({ id: "s1", op: "stream", params: {} }) + "\n", + ); + // Half-close so the server cleanup can still write the end frame. + setTimeout(() => client.end(), 80); + }); + client.on("error", () => {}); + }); + const joined = frames.join(""); + expect(joined).toContain('"stream":"data"'); + expect(stopCalled).toBe(true); + }); + + it("unreachable socket path fails before streaming", async () => { + await expect( + streamDaemon( + {}, + { + socketPath: path.join(os.tmpdir(), "no-such-mcp-daemon.sock"), + timeoutMs: 500, + onData: () => {}, + }, + ), + ).rejects.toBeInstanceOf(CliExitCodeError); + }); +}); diff --git a/clients/mcpi/__tests__/dispatch.test.ts b/clients/mcpi/__tests__/dispatch.test.ts new file mode 100644 index 000000000..1221d171b --- /dev/null +++ b/clients/mcpi/__tests__/dispatch.test.ts @@ -0,0 +1,158 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +const callDaemon = vi.fn(); +const ensureDaemon = vi.fn(); +const streamDaemon = vi.fn(); + +vi.mock("../src/daemon/index.js", () => ({ + callDaemon: (...args: unknown[]) => callDaemon(...args), + ensureDaemon: (...args: unknown[]) => ensureDaemon(...args), + streamDaemon: (...args: unknown[]) => streamDaemon(...args), +})); + +describe("dispatchSessionRpc", () => { + let stdout: string; + let originalWrite: typeof process.stdout.write; + + beforeEach(() => { + stdout = ""; + originalWrite = process.stdout.write; + process.stdout.write = ((chunk: unknown, ...rest: unknown[]) => { + stdout += typeof chunk === "string" ? chunk : String(chunk); + const cb = rest.find((r) => typeof r === "function") as + (() => void) | undefined; + cb?.(); + return true; + }) as typeof process.stdout.write; + ensureDaemon.mockResolvedValue({ socketPath: "/tmp/t.sock" }); + callDaemon.mockReset(); + streamDaemon.mockReset(); + }); + + afterEach(() => { + process.stdout.write = originalWrite; + }); + + it("writes pretty JSON for --format json", async () => { + callDaemon.mockResolvedValue({ + kind: "result", + result: { tools: [] }, + }); + const { dispatchSessionRpc } = await import("../src/session/dispatch.js"); + await dispatchSessionRpc( + "tools/list", + {}, + { format: "json", requireExplicit: false }, + ); + expect(JSON.parse(stdout.trim())).toEqual({ tools: [] }); + expect(stdout).toContain("\n"); + }); + + it("writes human text for tools/list by default", async () => { + callDaemon.mockResolvedValue({ + kind: "result", + result: { + tools: [{ name: "echo", description: "Echo", inputSchema: {} }], + }, + }); + const { dispatchSessionRpc } = await import("../src/session/dispatch.js"); + await dispatchSessionRpc("tools/list", {}, { requireExplicit: false }); + expect(stdout).toContain("Tools (1):"); + expect(stdout).toContain("`echo"); + }); + + it("writes human app-info list for ndjson outcomes", async () => { + callDaemon.mockResolvedValue({ + kind: "ndjson", + lines: [{ hasApp: false, toolName: "a" }], + }); + const { dispatchSessionRpc } = await import("../src/session/dispatch.js"); + await dispatchSessionRpc( + "tools/list", + { appInfo: true }, + { requireExplicit: false }, + ); + expect(stdout).toContain("App info"); + expect(stdout).toContain("`a`"); + }); + + it("opens a stream for logging/tail and wires SIGINT abort", async () => { + streamDaemon.mockImplementation( + async ( + _params: unknown, + opts: { onData: (d: unknown) => void; signal?: AbortSignal }, + ) => { + opts.onData({ + type: "subscribed", + uri: "test://x", + }); + process.emit("SIGINT"); + expect(opts.signal?.aborted).toBe(true); + }, + ); + const { dispatchSessionRpc } = await import("../src/session/dispatch.js"); + await dispatchSessionRpc( + "logging/tail", + {}, + { requireExplicit: false, session: "@s" }, + ); + expect(stdout).toContain("Subscribed:"); + expect(streamDaemon).toHaveBeenCalled(); + }); +}); + +describe("hoistAtSession / stripAt / requireExplicitSession", () => { + it("stripAt removes leading @", async () => { + const { stripAt, requireExplicitSession } = + await import("../src/session/dispatch.js"); + expect(stripAt("@x")).toBe("x"); + expect(stripAt(undefined)).toBeUndefined(); + const prev = process.env.MCP_ALLOW_DEFAULT_SESSION; + process.env.MCP_ALLOW_DEFAULT_SESSION = "1"; + expect(requireExplicitSession()).toBe(false); + if (prev === undefined) delete process.env.MCP_ALLOW_DEFAULT_SESSION; + else process.env.MCP_ALLOW_DEFAULT_SESSION = prev; + }); + + it("requireExplicitSession keys off stdin TTY (piping stdout still OK)", async () => { + const { requireExplicitSession } = + await import("../src/session/dispatch.js"); + const prevEnv = process.env.MCP_ALLOW_DEFAULT_SESSION; + delete process.env.MCP_ALLOW_DEFAULT_SESSION; + const stdinDesc = Object.getOwnPropertyDescriptor(process.stdin, "isTTY"); + const stdoutDesc = Object.getOwnPropertyDescriptor(process.stdout, "isTTY"); + try { + Object.defineProperty(process.stdin, "isTTY", { + configurable: true, + value: true, + }); + Object.defineProperty(process.stdout, "isTTY", { + configurable: true, + value: false, + }); + expect(requireExplicitSession()).toBe(false); + + Object.defineProperty(process.stdin, "isTTY", { + configurable: true, + value: false, + }); + expect(requireExplicitSession()).toBe(true); + } finally { + if (stdinDesc) Object.defineProperty(process.stdin, "isTTY", stdinDesc); + else + Object.defineProperty(process.stdin, "isTTY", { + configurable: true, + value: undefined, + }); + if (stdoutDesc) + Object.defineProperty(process.stdout, "isTTY", stdoutDesc); + else + Object.defineProperty(process.stdout, "isTTY", { + configurable: true, + value: undefined, + }); + if (prevEnv === undefined) delete process.env.MCP_ALLOW_DEFAULT_SESSION; + else process.env.MCP_ALLOW_DEFAULT_SESSION = prevEnv; + } + }); +}); diff --git a/clients/mcpi/__tests__/format-session.test.ts b/clients/mcpi/__tests__/format-session.test.ts new file mode 100644 index 000000000..500405716 --- /dev/null +++ b/clients/mcpi/__tests__/format-session.test.ts @@ -0,0 +1,699 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { + formatCallToolResultHuman, + formatToolsHuman, + formatResourcesHuman, + formatResourceTemplatesHuman, + formatPromptsHuman, + formatResourceReadHuman, + formatPromptResultHuman, + formatCompletionsHuman, + formatTasksHuman, + formatTaskHuman, + formatInitializeHuman, + formatRootsHuman, + formatAuthListHuman, + formatServersListHuman, + formatServerShowHuman, + formatSessionsListHuman, + formatSessionInfoHuman, + formatAppInfoListHuman, + formatAppInfoHuman, + formatStreamEventHuman, + formatRpcResultHuman, +} from "../src/session/format-human.js"; +import { writeSessionOutput } from "../src/session/format-session.js"; +import { CliExitCodeError, EXIT_CODES } from "@inspector/cli/error-handler.js"; +import { createStyle } from "@inspector/cli/style.js"; + +describe("format-human", () => { + it("formats tools with schema variants and empty list", () => { + expect(formatToolsHuman([])).toContain("(none)"); + const text = formatToolsHuman([ + { + name: "echo", + description: "Echo back\nmore", + inputSchema: { + type: "object", + properties: { + message: { type: "string" }, + n: { type: "number" }, + tags: { type: "array", items: { type: "string" } }, + extra: { type: "boolean" }, + }, + required: ["message"], + }, + annotations: { + readOnlyHint: true, + destructiveHint: true, + idempotentHint: true, + openWorldHint: true, + }, + }, + { + name: "types", + inputSchema: { + type: "object", + properties: { + emptyArr: { type: "array" }, + multi: { type: ["string", "number"] }, + bare: {}, + }, + }, + }, + { + name: "more", + inputSchema: { + type: "object", + properties: { + flag: { type: ["boolean", "null"] }, + choice: { enum: ["a", "b"] }, + obj: { type: "object" }, + }, + }, + }, + { + name: "ints", + inputSchema: { + type: "object", + properties: { + i: { type: "integer" }, + unknownType: { type: "custom" }, + nonObjProp: "x", + }, + }, + annotations: {}, + }, + { name: "plain", inputSchema: null }, + { name: "emptyProps", inputSchema: { type: "object", properties: {} } }, + { name: "noProps", inputSchema: { type: "object" } }, + {}, + ]); + expect(text).toContain("Tools (8):"); + expect(text).toContain("`echo(message:str, n?:num, tags?:[str], …)`"); + expect(text).toContain("[read-only, destructive, idempotent, open-world]"); + expect(text).toContain("emptyArr?:[any]"); + expect(text).toContain("multi?:str | num"); + expect(text).toContain("bare?:any"); + expect(text).toContain("flag?:bool"); + expect(text).toContain("choice?:enum"); + expect(text).toContain("`plain()`"); + expect(text).toContain("`?()`"); + }); + + it("formats list helpers for resources, templates, prompts, roots, tasks", () => { + expect( + formatResourcesHuman([ + { name: "r", uri: "u://x", description: "d\n2" }, + { uri: "u://only" }, + { name: "n", uri: 1, description: " " }, + { name: "no-uri" }, + ]), + ).toContain("`r` (u://x)"); + expect(formatResourcesHuman([])).toContain("(none)"); + + expect( + formatResourceTemplatesHuman([ + { name: "t", uriTemplate: "u://{id}", description: "tpl" }, + { description: " " }, + { name: "x", uriTemplate: 1 }, + ]), + ).toContain("u://{id}"); + expect(formatResourceTemplatesHuman([])).toContain("(none)"); + + expect( + formatPromptsHuman([ + { name: "p", description: "hi\nmore" }, + { description: " " }, + {}, + ]), + ).toContain("`p`"); + expect(formatPromptsHuman([])).toContain("(none)"); + + expect( + formatRootsHuman([{ uri: "file:///a", name: "a" }, { uri: "file:///b" }]), + ).toContain("file:///a (a)"); + expect(formatRootsHuman([])).toContain("(none)"); + + expect( + formatTasksHuman([ + { taskId: "1", status: "running", statusMessage: "go" }, + { id: "2", status: "done" }, + {}, + ]), + ).toContain("`1` running"); + expect(formatTasksHuman([])).toContain("(none)"); + + expect( + formatTaskHuman({ + taskId: "t1", + status: "ok", + statusMessage: "fine", + createdAt: "c", + lastUpdatedAt: "u", + }), + ).toContain("Created: c"); + expect(formatTaskHuman(null)).toContain("Task: `?`"); + expect(formatTaskHuman({})).toContain("Status: ?"); + }); + + it("formats call tool results across content block types", () => { + const structured = { ok: true }; + const withDupe = formatCallToolResultHuman({ + isError: true, + content: [ + { type: "text", text: JSON.stringify(structured) }, + { type: "text", text: "hello" }, + { type: "text", text: "{not-json" }, + { + type: "resource_link", + uri: "u://r", + name: "n", + description: "d", + mimeType: "text/plain", + }, + { type: "image", mimeType: "image/png", data: "abc" }, + { type: "audio", mimeType: "audio/wav" }, + { + type: "resource", + resource: { uri: "u://e", mimeType: "text/plain", text: "body" }, + }, + { type: "custom", x: 1 }, + ], + structuredContent: structured, + _meta: { a: 1 }, + }); + expect(withDupe).toContain("Tool error:"); + expect(withDupe).toContain("hello"); + expect(withDupe).toContain("Resource link"); + expect(withDupe).toContain("[Image:"); + expect(withDupe).toContain("[Audio:"); + expect(withDupe).toContain("Embedded resource"); + expect(withDupe).toContain('"x": 1'); + expect(withDupe).not.toContain("Structured content:"); + + expect( + formatCallToolResultHuman({ + isError: true, + structuredContent: { only: true }, + content: [], + }), + ).toContain("Structured content:"); + + expect( + formatCallToolResultHuman({ + content: [{ type: "image" }, { type: "audio", data: "x" }], + }), + ).toContain("[Image: unknown"); + + expect( + formatCallToolResultHuman({ + content: [{ type: "resource" }], + }), + ).toContain("Embedded resource"); + + expect( + formatCallToolResultHuman({ + content: [ + { + type: "resource", + resource: { uri: "u://e" }, + }, + ], + }), + ).toContain("URI: u://e"); + + expect( + formatCallToolResultHuman({ + content: [{ type: "resource_link", uri: "u" }], + }), + ).toContain("Resource link"); + + expect( + formatCallToolResultHuman({ + content: [{ type: "text" }], + structuredContent: {}, + _meta: {}, + }), + ).toContain("Content:"); + + expect(formatCallToolResultHuman({})).toBe("(no content)"); + }); + + it("formats resource read, prompt get, completions, initialize", () => { + expect(formatResourceReadHuman({ contents: [] })).toBe("(empty resource)"); + expect( + formatResourceReadHuman({ + contents: [ + { uri: "u://a", mimeType: "text/plain", text: "hi" }, + { uri: "u://b", blob: "zzzz" }, + ], + }), + ).toContain("[Blob:"); + + expect(formatPromptResultHuman({})).toBe("(empty prompt)"); + expect( + formatPromptResultHuman({ + description: "desc", + messages: [ + { role: "user", content: "plain" }, + { + role: "assistant", + content: [{ type: "text", text: "block" }], + }, + { role: "user", content: { type: "text", text: "obj" } }, + ], + }), + ).toContain("[assistant]"); + + expect(formatCompletionsHuman({ values: ["a"], hasMore: true })).toContain( + "(more available)", + ); + expect(formatCompletionsHuman({ values: [] })).toContain("(none)"); + + expect( + formatInitializeHuman({ + serverInfo: { name: "s", version: "1" }, + protocolVersion: "2025-01-01", + instructions: " use me ", + capabilities: { tools: {} }, + }), + ).toContain("Capabilities: tools"); + expect(formatInitializeHuman({})).toContain("(unknown)"); + expect( + formatInitializeHuman({ + serverInfo: { name: "s" }, + instructions: " ", + capabilities: {}, + }), + ).toContain("Server: s"); + }); + + it("formats admin and app-info helpers", () => { + expect( + formatAuthListHuman({ + oauthStatePath: "/tmp/oauth.json", + servers: [ + { + url: "https://example.com/mcp", + hasTokens: true, + hasRefreshToken: true, + }, + { url: "https://empty.example/mcp" }, + ], + }), + ).toMatch(/Stored auth[\s\S]*example\.com[\s\S]*tokens[\s\S]*no tokens/); + expect( + formatAuthListHuman({ oauthStatePath: "/tmp/x", servers: [] }), + ).toContain("(none)"); + expect(formatServersListHuman([])).toContain("(none)"); + expect( + formatServersListHuman([{ name: "s", type: "stdio", detail: "x" }]), + ).toContain("`s`"); + expect( + formatServersListHuman([ + { + name: "s", + type: "stdio", + detail: "x", + session: "s", + isMru: true, + }, + ]), + ).toMatch(/@s \(MRU\)/); + expect( + formatServerShowHuman({ + name: "s", + type: "stdio", + detail: "node x", + config: { type: "stdio", command: "node" }, + }), + ).toMatch(/Server[\s\S]*`s`[\s\S]*node x/); + + expect(formatSessionsListHuman([])).toContain("connect first"); + expect( + formatSessionsListHuman([ + { name: "a", isMru: true, serverIdentity: "id" }, + ]), + ).toContain("(MRU)"); + expect( + formatSessionInfoHuman({ name: "a", isMru: true, serverIdentity: "id" }), + ).toContain("Session `@a`"); + + expect( + formatAppInfoListHuman([ + { toolName: "with", hasApp: true, resourceUri: "ui://x" }, + { toolName: "err", hasApp: false, resourceError: "boom" }, + { toolName: "no", hasApp: false }, + ]), + ).toContain("no app"); + + expect( + formatAppInfoHuman({ + toolName: "t", + hasApp: true, + resourceUri: "ui://x", + csp: { a: 1 }, + }), + ).toContain("CSP:"); + expect( + formatAppInfoHuman({ toolName: "t", hasApp: false, resourceError: "e" }), + ).toContain("e"); + expect(formatAppInfoHuman({ toolName: "t", hasApp: false })).toContain( + "No MCP App", + ); + }); + + it("formats stream events and rpc dispatch", () => { + expect(formatStreamEventHuman(null)).toBe("null"); + expect(formatStreamEventHuman({ type: "subscribed", uri: "u" })).toBe( + "Subscribed: u", + ); + expect( + formatStreamEventHuman({ type: "resources/updated", uri: "u" }), + ).toBe("Resource updated: u"); + expect( + formatStreamEventHuman({ + direction: "notification", + message: { + method: "notifications/message", + params: { level: "warn", logger: "L", data: "hi" }, + }, + }), + ).toBe("[warn] L: hi"); + expect( + formatStreamEventHuman({ + direction: "notification", + message: { params: { message: "m" } }, + }), + ).toBe("[info] m"); + expect( + formatStreamEventHuman({ + direction: "notification", + message: { params: { nested: true } }, + }), + ).toContain("nested"); + expect( + formatStreamEventHuman({ + direction: "notification", + message: {}, + }), + ).toContain("[info]"); + expect(formatStreamEventHuman({ other: 1 })).toContain('"other": 1'); + expect(formatStreamEventHuman("raw")).toBe("raw"); + + expect(formatRpcResultHuman("tools/list", { tools: [] })).toContain( + "Tools", + ); + expect(formatRpcResultHuman("tools/call", { content: [] })).toBe( + "(no content)", + ); + expect(formatRpcResultHuman("resources/list", { resources: [] })).toContain( + "Resources", + ); + expect(formatRpcResultHuman("resources/read", { contents: [] })).toBe( + "(empty resource)", + ); + expect( + formatRpcResultHuman("resources/templates/list", { + resourceTemplates: [], + }), + ).toContain("templates"); + expect(formatRpcResultHuman("resources/unsubscribe", { uri: "u" })).toBe( + "Unsubscribed: u", + ); + expect(formatRpcResultHuman("prompts/list", { prompts: [] })).toContain( + "Prompts", + ); + expect(formatRpcResultHuman("prompts/get", {})).toBe("(empty prompt)"); + expect(formatRpcResultHuman("prompts/complete", { values: [] })).toContain( + "Completions", + ); + expect( + formatRpcResultHuman("initialize", { serverInfo: { name: "s" } }), + ).toContain("Server: s"); + expect(formatRpcResultHuman("logging/setLevel", {})).toBe( + "Logging level updated.", + ); + expect(formatRpcResultHuman("tasks/list", { tasks: [] })).toContain( + "Tasks", + ); + expect( + formatRpcResultHuman("tasks/get", { task: { taskId: "1", status: "x" } }), + ).toContain("Task: `1`"); + expect(formatRpcResultHuman("tasks/cancel", { taskId: "1" })).toBe( + "Cancelled task: 1", + ); + expect(formatRpcResultHuman("tasks/result", { content: [] })).toBe( + "(no content)", + ); + expect(formatRpcResultHuman("roots/list", { roots: [] })).toContain( + "Roots", + ); + expect(formatRpcResultHuman("roots/set", { roots: [] })).toContain("Roots"); + expect(formatRpcResultHuman("unknown/op", { x: 1 })).toBeNull(); + }); +}); + +describe("writeSessionOutput", () => { + let stdout: string; + let original: typeof process.stdout.write; + + beforeEach(() => { + stdout = ""; + original = process.stdout.write; + process.stdout.write = ((chunk: unknown, ...rest: unknown[]) => { + stdout += typeof chunk === "string" ? chunk : String(chunk); + const cb = rest.find((r) => typeof r === "function") as + (() => void) | undefined; + cb?.(); + return true; + }) as typeof process.stdout.write; + }); + + afterEach(() => { + process.stdout.write = original; + }); + + it("pretty-prints json without a result envelope", async () => { + await writeSessionOutput( + { format: "json" }, + { + kind: "rpc", + method: "tools/list", + result: { tools: [] }, + }, + ); + expect(stdout).toBe('{\n "tools": []\n}\n'); + }); + + it("ignores auto-collected appInfo on tools/call json", async () => { + await writeSessionOutput( + { format: "json" }, + { + kind: "rpc", + method: "tools/call", + result: { content: [{ type: "text", text: "ok" }] }, + appInfo: { hasApp: false, toolName: "echo" }, + }, + ); + expect(JSON.parse(stdout)).toEqual({ + content: [{ type: "text", text: "ok" }], + }); + }); + + it("throws NO_APP after printing app-info text", async () => { + await expect( + writeSessionOutput( + { format: "text" }, + { + kind: "rpc", + method: "tools/call", + result: { hasApp: false, toolName: "x" }, + }, + ), + ).rejects.toMatchObject({ exitCode: EXIT_CODES.NO_APP }); + expect(stdout).toContain("has no MCP App"); + }); + + it("allows hasApp true app-info probes", async () => { + await writeSessionOutput( + { format: "text" }, + { + kind: "rpc", + method: "tools/call", + result: { hasApp: true, toolName: "x", resourceUri: "ui://x" }, + }, + ); + expect(stdout).toContain("has an MCP App"); + }); + + it("throws TOOL_ERROR when isError", async () => { + await expect( + writeSessionOutput( + { format: "json" }, + { + kind: "rpc", + method: "tools/call", + result: { isError: true, content: [] }, + toolName: "echo", + }, + ), + ).rejects.toBeInstanceOf(CliExitCodeError); + await expect( + writeSessionOutput( + { format: "json" }, + { + kind: "rpc", + method: "tools/call", + result: { isError: true, content: [] }, + }, + ), + ).rejects.toMatchObject({ message: expect.stringContaining("tool") }); + }); + + it("falls back to pretty JSON for unknown rpc methods in text mode", async () => { + await writeSessionOutput( + { format: "text" }, + { + kind: "rpc", + method: "custom/x", + result: { ok: 1 }, + }, + ); + expect(stdout).toContain('"ok": 1'); + }); + + it("formats every admin/stream payload kind", async () => { + const kinds = [ + { + kind: "ndjson" as const, + lines: [{ toolName: "a", hasApp: false }], + }, + { kind: "stream-event" as const, data: { type: "subscribed", uri: "u" } }, + { + kind: "servers/list" as const, + servers: [{ name: "s", type: "stdio", detail: "d" }], + }, + { + kind: "servers/show" as const, + server: { + name: "s", + type: "stdio", + detail: "d", + config: { type: "stdio", command: "n" }, + }, + }, + { + kind: "sessions/list" as const, + sessions: [{ name: "a", serverIdentity: "id" }], + }, + { + kind: "session" as const, + session: { name: "a", serverIdentity: "id" }, + }, + { kind: "disconnect" as const, name: "a" }, + { + kind: "daemon/status" as const, + status: { pid: 1, socketPath: "/tmp/s", sessions: [] }, + }, + { + kind: "daemon/status" as const, + status: { pid: 2, sessions: "bad" }, + }, + { + kind: "daemon/stop" as const, + result: { stopping: false }, + }, + { + kind: "daemon/stop" as const, + result: { stopping: true }, + }, + { + kind: "daemon/stop" as const, + result: { stopping: false, message: "was idle" }, + }, + { + kind: "auth/list" as const, + list: { + oauthStatePath: "/tmp/oauth.json", + servers: [ + { + url: "https://example.com/mcp", + hasTokens: true, + hasRefreshToken: false, + }, + ], + }, + }, + { + kind: "auth/clear" as const, + result: { all: true, cleared: 1 }, + }, + { + kind: "auth/clear" as const, + result: { all: true, cleared: 2 }, + }, + { + kind: "auth/clear" as const, + result: { url: "https://example.com/mcp" }, + }, + { kind: "generic" as const, data: { x: 1 }, title: "Title" }, + { kind: "generic" as const, data: { y: 2 } }, + ]; + + for (const payload of kinds) { + stdout = ""; + await writeSessionOutput({ format: "text" }, payload); + expect(stdout.length).toBeGreaterThan(0); + stdout = ""; + await writeSessionOutput({ format: "json" }, payload); + expect(() => JSON.parse(stdout)).not.toThrow(); + } + }); + + it("defaults undefined format to text", async () => { + await writeSessionOutput( + {}, + { + kind: "disconnect", + name: "z", + }, + ); + expect(stdout).toContain("Disconnected `@z`"); + }); +}); + +describe("format-human ANSI styling", () => { + it("styles human tool lists and log levels when enabled", () => { + const s = createStyle(true); + const tools = formatToolsHuman( + [ + { + name: "echo", + description: "hi", + inputSchema: { + type: "object", + properties: { message: { type: "string" } }, + required: ["message"], + }, + }, + ], + s, + ); + expect(tools).toContain("\u001b[1m"); // bold name + expect(tools).toContain("\u001b[36m"); // cyan params + expect(tools).toContain("\u001b[2m"); // dim description + expect(tools).toContain("echo"); + + const log = formatStreamEventHuman( + { + direction: "notification", + message: { params: { level: "error", data: "boom" } }, + }, + s, + ); + expect(log).toContain("\u001b[31m"); + expect(log).toContain("boom"); + }); +}); diff --git a/clients/mcpi/__tests__/helpers/mcp-runner.ts b/clients/mcpi/__tests__/helpers/mcp-runner.ts new file mode 100644 index 000000000..341dd37cd --- /dev/null +++ b/clients/mcpi/__tests__/helpers/mcp-runner.ts @@ -0,0 +1,88 @@ +import { runMcp as invokeMcp } from "../../src/session/mcp.js"; +import { formatErrorOutput } from "@inspector/cli/error-handler.js"; + +export interface McpResult { + exitCode: number | null; + stdout: string; + stderr: string; + output: string; +} + +export interface McpOptions { + timeout?: number; + env?: Record; +} + +type WriteArgs = [ + chunk: unknown, + encoding?: unknown, + callback?: (() => void) | undefined, +]; + +function captureWrite(append: (text: string) => void) { + return (...args: WriteArgs): boolean => { + const [chunk, encoding, callback] = args; + append(typeof chunk === "string" ? chunk : String(chunk)); + const cb = typeof encoding === "function" ? encoding : callback; + if (typeof cb === "function") cb(); + return true; + }; +} + +/** + * In-process runner for `runMcp` (session CLI), mirroring {@link runCli}. + */ +export async function runMcp( + args: string[], + options: McpOptions = {}, +): Promise { + let stdout = ""; + let stderr = ""; + + const originalStdoutWrite = process.stdout.write; + const originalStderrWrite = process.stderr.write; + + const envBackup: Record = {}; + if (options.env) { + for (const [key, value] of Object.entries(options.env)) { + envBackup[key] = process.env[key]; + process.env[key] = value; + } + } + + process.stdout.write = captureWrite((text) => { + stdout += text; + }) as typeof process.stdout.write; + process.stderr.write = captureWrite((text) => { + stderr += text; + }) as typeof process.stderr.write; + + const argv = ["node", "mcpi", ...args]; + const timeoutMs = options.timeout ?? 15000; + let timer: ReturnType | undefined; + const timeout = new Promise((_, reject) => { + timer = setTimeout( + () => reject(new Error(`mcpi command timed out after ${timeoutMs}ms`)), + timeoutMs, + ); + }); + + let exitCode = 0; + try { + await Promise.race([invokeMcp(argv), timeout]); + } catch (error) { + const out = formatErrorOutput(error); + exitCode = out.exitCode; + stderr += out.stderr; + } finally { + if (timer) clearTimeout(timer); + process.stdout.write = originalStdoutWrite; + process.stderr.write = originalStderrWrite; + for (const [key, value] of Object.entries(envBackup)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + } + + return { exitCode, stdout, stderr, output: stdout + stderr }; +} diff --git a/clients/mcpi/__tests__/hoist-session.test.ts b/clients/mcpi/__tests__/hoist-session.test.ts new file mode 100644 index 000000000..19b9b13d1 --- /dev/null +++ b/clients/mcpi/__tests__/hoist-session.test.ts @@ -0,0 +1,22 @@ +import { describe, it, expect } from "vitest"; +import { hoistAtSession } from "../src/session/dispatch.js"; + +describe("hoistAtSession", () => { + it("lifts a leading @name into sessionFromAt", () => { + const { argv, sessionFromAt } = hoistAtSession([ + "node", + "mcpi", + "@alpha", + "tools/list", + "--format", + "json", + ]); + expect(sessionFromAt).toBe("alpha"); + expect(argv).toEqual(["node", "mcpi", "tools/list", "--format", "json"]); + }); + + it("leaves argv unchanged when there is no @name", () => { + const input = ["node", "mcpi", "tools/list"]; + expect(hoistAtSession(input)).toEqual({ argv: input }); + }); +}); diff --git a/clients/mcpi/__tests__/mcp-auth-coverage.test.ts b/clients/mcpi/__tests__/mcp-auth-coverage.test.ts new file mode 100644 index 000000000..104e6b59e --- /dev/null +++ b/clients/mcpi/__tests__/mcp-auth-coverage.test.ts @@ -0,0 +1,211 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { + createSampleTestConfig, + deleteConfigFile, +} from "../../cli/__tests__/helpers/fixtures.js"; +import { CliExitCodeError, EXIT_CODES } from "@inspector/cli/error-handler.js"; + +const callDaemon = vi.fn(); +const ensureDaemon = vi.fn(); +const authorizeInFrontend = vi.fn(); + +vi.mock("../src/daemon/index.js", () => ({ + callDaemon: (...args: unknown[]) => callDaemon(...args), + ensureDaemon: (...args: unknown[]) => ensureDaemon(...args), + streamDaemon: vi.fn(), +})); + +vi.mock("../src/session/authorize.js", () => ({ + authorizeInFrontend: (...args: unknown[]) => authorizeInFrontend(...args), +})); + +describe("mcp.ts auth / daemon error paths", () => { + let configPath: string | undefined; + let stdout: string; + let originalStdoutWrite: typeof process.stdout.write; + let originalStderrWrite: typeof process.stderr.write; + + beforeEach(() => { + stdout = ""; + originalStdoutWrite = process.stdout.write; + originalStderrWrite = process.stderr.write; + process.stdout.write = ((chunk: unknown, ...rest: unknown[]) => { + stdout += typeof chunk === "string" ? chunk : String(chunk); + const cb = rest.find((r) => typeof r === "function") as + (() => void) | undefined; + cb?.(); + return true; + }) as typeof process.stdout.write; + process.stderr.write = ((chunk: unknown, ...rest: unknown[]) => { + const cb = rest.find((r) => typeof r === "function") as + (() => void) | undefined; + cb?.(); + return true; + }) as typeof process.stderr.write; + + ensureDaemon.mockResolvedValue({ socketPath: "/tmp/mcp-auth-cov.sock" }); + callDaemon.mockReset(); + authorizeInFrontend.mockReset(); + authorizeInFrontend.mockResolvedValue(undefined); + }); + + afterEach(() => { + process.stdout.write = originalStdoutWrite; + process.stderr.write = originalStderrWrite; + if (configPath) { + deleteConfigFile(configPath); + configPath = undefined; + } + }); + + it("retries connect after auth_required via authorizeInFrontend", async () => { + configPath = createSampleTestConfig(); + const session = { + name: "test-stdio", + isMru: true, + serverIdentity: "stdio", + }; + callDaemon + .mockRejectedValueOnce( + new CliExitCodeError(EXIT_CODES.AUTH_REQUIRED, "need auth", { + code: "auth_required", + }), + ) + .mockResolvedValueOnce(session); + + const { runMcp } = await import("../src/session/mcp.js"); + await runMcp([ + "node", + "mcpi", + "connect", + "test-stdio", + "--config", + configPath, + "--format", + "json", + ]); + + expect(authorizeInFrontend).toHaveBeenCalledOnce(); + expect(callDaemon).toHaveBeenCalledTimes(2); + expect(JSON.parse(stdout.trim()).name).toBe("test-stdio"); + }); + + it("rejects --relogin with --stored-auth-only", async () => { + configPath = createSampleTestConfig(); + const { runMcp } = await import("../src/session/mcp.js"); + await expect( + runMcp([ + "node", + "mcpi", + "--stored-auth-only", + "connect", + "test-stdio", + "--config", + configPath, + "--relogin", + ]), + ).rejects.toMatchObject({ exitCode: 1 }); + expect(callDaemon).not.toHaveBeenCalled(); + }); + + it("clears stored auth on connect --relogin for HTTP targets", async () => { + const fs = await import("node:fs"); + const os = await import("node:os"); + const path = await import("node:path"); + const { resetNodeOAuthStorageCache } = + await import("@inspector/core/auth/node/storage-node.js"); + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "mcp-relogin-")); + const oauthFile = path.join(dir, "oauth.json"); + fs.writeFileSync( + oauthFile, + JSON.stringify({ + servers: { + "http://example.com/mcp": { + tokens: { access_token: "x", token_type: "Bearer" }, + }, + }, + idpSessions: {}, + }), + "utf8", + ); + const prev = process.env.MCP_INSPECTOR_OAUTH_STATE_PATH; + process.env.MCP_INSPECTOR_OAUTH_STATE_PATH = oauthFile; + resetNodeOAuthStorageCache(); + + callDaemon.mockResolvedValueOnce({ + name: "http", + isMru: true, + serverIdentity: "http://example.com/mcp", + }); + + try { + const { runMcp } = await import("../src/session/mcp.js"); + await runMcp([ + "node", + "mcpi", + "connect", + "--session", + "relogin-http", + "--server-url", + "http://example.com/mcp", + "--transport", + "http", + "--relogin", + "--format", + "json", + ]); + expect(callDaemon).toHaveBeenCalledOnce(); + const after = JSON.parse(fs.readFileSync(oauthFile, "utf8")) as { + servers?: Record; + }; + expect(after.servers?.["http://example.com/mcp"]).toBeUndefined(); + } finally { + if (prev === undefined) delete process.env.MCP_INSPECTOR_OAUTH_STATE_PATH; + else process.env.MCP_INSPECTOR_OAUTH_STATE_PATH = prev; + resetNodeOAuthStorageCache(); + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + it("rethrows auth_required when --stored-auth-only is set", async () => { + configPath = createSampleTestConfig(); + callDaemon.mockRejectedValueOnce( + new CliExitCodeError(EXIT_CODES.AUTH_REQUIRED, "need auth", { + code: "auth_required", + }), + ); + + const { runMcp } = await import("../src/session/mcp.js"); + await expect( + runMcp([ + "node", + "mcpi", + "connect", + "test-stdio", + "--config", + configPath, + "--stored-auth-only", + "--format", + "json", + ]), + ).rejects.toMatchObject({ + exitCode: EXIT_CODES.AUTH_REQUIRED, + envelope: { code: "auth_required" }, + }); + expect(authorizeInFrontend).not.toHaveBeenCalled(); + }); + + it("rethrows unexpected daemon/stop errors", async () => { + callDaemon.mockRejectedValueOnce( + new CliExitCodeError(EXIT_CODES.USAGE, "boom", { code: "usage" }), + ); + + const { runMcp } = await import("../src/session/mcp.js"); + await expect( + runMcp(["node", "mcpi", "daemon", "stop", "--format", "json"]), + ).rejects.toMatchObject({ + exitCode: EXIT_CODES.USAGE, + envelope: { code: "usage" }, + }); + }); +}); diff --git a/clients/mcpi/__tests__/mcp-coverage.test.ts b/clients/mcpi/__tests__/mcp-coverage.test.ts new file mode 100644 index 000000000..d6ed517dc --- /dev/null +++ b/clients/mcpi/__tests__/mcp-coverage.test.ts @@ -0,0 +1,374 @@ +import { describe, it, expect, afterEach, beforeAll } from "vitest"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { getTestMcpServerCommand } from "@modelcontextprotocol/inspector-test-server"; +import { runMcp } from "./helpers/mcp-runner.js"; +import { + createSampleTestConfig, + deleteConfigFile, +} from "../../cli/__tests__/helpers/fixtures.js"; +import { + expectCliSuccess, + expectCliFailure, +} from "../../cli/__tests__/helpers/assertions.js"; +import { resolveDaemonScriptPath } from "../src/daemon/ensure.js"; +import { callDaemon } from "../src/daemon/client.js"; + +describe("mcp.ts coverage", () => { + let configPath: string | undefined; + let storageDir: string | undefined; + + beforeAll(() => { + expect(fs.existsSync(resolveDaemonScriptPath())).toBe(true); + }); + + afterEach(async () => { + if (storageDir) { + const socketPath = path.join(storageDir, "daemon.sock"); + if (fs.existsSync(socketPath)) { + try { + await callDaemon("daemon/stop", {}, { socketPath, timeoutMs: 2000 }); + } catch { + // already stopped + } + const deadline = Date.now() + 2000; + while (fs.existsSync(socketPath) && Date.now() < deadline) { + await new Promise((r) => setTimeout(r, 50)); + } + } + fs.rmSync(storageDir, { recursive: true, force: true }); + storageDir = undefined; + } + if (configPath) { + deleteConfigFile(configPath); + configPath = undefined; + } + }); + + function env(): Record { + storageDir = fs.mkdtempSync(path.join(os.tmpdir(), "mcp-cov-")); + return { + MCP_STORAGE_DIR: storageDir, + MCP_INSPECTOR_DAEMON_DIR: storageDir, + MCP_ALLOW_DEFAULT_SESSION: "1", + }; + } + + it("covers RPC registrations, metadata parse, and --plain", async () => { + configPath = createSampleTestConfig(); + const e = env(); + + const connected = await runMcp( + ["connect", "test-stdio", "--config", configPath, "--format", "json"], + { env: e, timeout: 20000 }, + ); + expectCliSuccess(connected); + + const withMeta = await runMcp( + [ + "tools/list", + "--metadata", + "client=session-cov", + "--metadata", + "count=1", + // Object value must JSON.stringify (not String → "[object Object]"). + "--metadata", + 'nested={"a":1}', + "--plain", + "--format", + "json", + ], + { env: e, timeout: 20000 }, + ); + expectCliSuccess(withMeta); + + const badMeta = await runMcp(["tools/list", "--metadata", "novalue"], { + env: e, + }); + expectCliFailure(badMeta, 1); + + const emptyMeta = await runMcp(["tools/list", "--metadata", "k="], { + env: e, + }); + expectCliFailure(emptyMeta, 1); + + const read = await runMcp( + [ + "resources/read", + "demo://resource/static/document/architecture.md", + "--format", + "json", + ], + { env: e, timeout: 20000 }, + ); + expectCliSuccess(read); + + // Same Commander action as subscribe (uri positional / --uri); prefer + // unsubscribe so we don't open a long-lived stream in this suite. + const unsub = await runMcp( + ["resources/unsubscribe", "test://env", "--format", "json"], + { env: e, timeout: 20000 }, + ); + // Default test server does not advertise subscriptions. + expectCliFailure(unsub); + expect(unsub.stderr).toMatch(/unsubscribe|Method not found/i); + + const prompt = await runMcp( + [ + "prompts/get", + "simple_prompt", + "--prompt-args", + "unused=1", + "--format", + "json", + ], + { env: e, timeout: 20000 }, + ); + expectCliSuccess(prompt); + + const completeBad = await runMcp( + ["prompts/complete", "--complete-ref-type", "nope"], + { env: e }, + ); + expectCliFailure(completeBad, 1); + + const complete = await runMcp( + [ + "prompts/complete", + "--complete-ref-type", + "ref/prompt", + "--complete-ref", + "simple_prompt", + "--complete-arg-name", + "name", + "--complete-arg-value", + "s", + "--format", + "json", + ], + { env: e, timeout: 20000 }, + ); + // Completion support varies; assert the command ran (not a usage parse error). + expect(complete.stderr).not.toMatch(/complete-ref-type/); + expect([0, 1]).toContain(complete.exitCode); + + const logOk = await runMcp( + ["logging/setLevel", "debug", "--format", "json"], + { env: e, timeout: 20000 }, + ); + expectCliSuccess(logOk); + + const logBad = await runMcp(["logging/setLevel", "--log-level", "nope"], { + env: e, + }); + expectCliFailure(logBad, 1); + + const taskGet = await runMcp( + ["tasks/get", "missing-task", "--format", "json"], + { + env: e, + timeout: 20000, + }, + ); + expectCliFailure(taskGet, 1); + + const taskCancel = await runMcp( + ["tasks/cancel", "--task-id", "missing-task", "--format", "json"], + { env: e, timeout: 20000 }, + ); + expectCliFailure(taskCancel, 1); + + const taskResult = await runMcp( + ["tasks/result", "missing-task", "--format", "json"], + { env: e, timeout: 20000 }, + ); + expectCliFailure(taskResult, 1); + + const roots = await runMcp( + ["roots/set", "--roots-json", "[]", "--format", "json"], + { env: e, timeout: 20000 }, + ); + expectCliSuccess(roots); + + const called = await runMcp( + [ + "tools/call", + "--tool-name", + "echo", + "--tool-arg", + "message=cov", + "--tool-metadata", + "src=test", + "--format", + "json", + ], + { env: e, timeout: 20000 }, + ); + expectCliSuccess(called); + + const templates = await runMcp( + ["resources/templates/list", "--format", "json"], + { env: e, timeout: 20000 }, + ); + expectCliSuccess(templates); + + const prompts = await runMcp(["prompts/list", "--format", "json"], { + env: e, + timeout: 20000, + }); + expectCliSuccess(prompts); + + const tasks = await runMcp(["tasks/list", "--format", "json"], { + env: e, + timeout: 20000, + }); + expectCliSuccess(tasks); + expect(JSON.parse(tasks.stdout)).toHaveProperty("tasks"); + + const rootsList = await runMcp(["roots/list", "--format", "json"], { + env: e, + timeout: 20000, + }); + expectCliSuccess(rootsList); + expect(JSON.parse(rootsList.stdout)).toHaveProperty("roots"); + + await runMcp( + ["disconnect", "--session", "test-stdio", "--format", "json"], + { + env: e, + }, + ); + await runMcp(["daemon", "stop", "--format", "json"], { env: e }); + }); + + it("covers ad-hoc connect options and servers/list catalog env", async () => { + configPath = createSampleTestConfig(); + const e = env(); + const { command, args } = getTestMcpServerCommand(); + + const adHoc = await runMcp( + [ + "connect", + "--session", + "opts", + "--transport", + "stdio", + "--cwd", + process.cwd(), + "-e", + "COV_FLAG=1", + "--connect-timeout", + "15000", + "--format", + "json", + command, + ...args, + ], + { env: e, timeout: 20000 }, + ); + expectCliSuccess(adHoc); + + await runMcp(["disconnect", "--session", "opts", "--format", "json"], { + env: e, + }); + + // Ad-hoc HTTP with --server-url and no positional rest (empty-rest branch). + const urlOnly = await runMcp( + [ + "connect", + "--session", + "urlonly", + "--transport", + "http", + "--server-url", + "http://127.0.0.1:9/mcp", + "--header", + "X-Test: 1", + "--connect-timeout", + "100", + "--format", + "json", + ], + { env: e, timeout: 10000 }, + ); + expectCliFailure(urlOnly); + // Unreachable HTTP should classify as exit 4 when the error is network-shaped. + expect([1, 4]).toContain(urlOnly.exitCode); + + const listed = await runMcp(["servers/list", "--format", "json"], { + env: { + ...e, + MCP_CATALOG_PATH: configPath, + }, + }); + expectCliSuccess(listed); + + // Whitespace --config → trim || undefined branch on servers/list. + const emptyConfig = await runMcp( + ["servers/list", "--config", " ", "--format", "json"], + { env: { ...e, MCP_CATALOG_PATH: configPath } }, + ); + expectCliSuccess(emptyConfig); + + await runMcp(["daemon", "stop", "--format", "json"], { env: e }); + }); + + it("bare mcpi / --help print usage without an ErrorEnvelope", async () => { + // Bare invocation: Commander writes help to stderr (help-after-error). + const bare = await runMcp([]); + expectCliSuccess(bare); + expect(bare.stderr).toMatch(/Usage:/i); + expect(bare.stderr).not.toContain('"error"'); + + const help = await runMcp(["--help"]); + expectCliSuccess(help); + expect(help.stdout).toMatch(/Usage:/i); + expect(help.stderr).not.toContain('"error"'); + }); + + it("covers exitOverride (unknown command) and default process.argv", async () => { + // Non-zero CommanderError goes through exitOverride → throw err. + const unknown = await runMcp(["not-a-command"]); + expectCliFailure(unknown); + + configPath = createSampleTestConfig(); + const originalArgv = process.argv; + process.argv = [ + "node", + "mcpi", + "servers/list", + "--config", + configPath, + "--format", + "json", + ]; + try { + const { runMcp: invoke } = await import("../src/session/mcp.js"); + await invoke(); + } finally { + process.argv = originalArgv; + } + }); + + it("sessions/list and daemon status do not auto-spawn the daemon", async () => { + const e = env(); + const listed = await runMcp(["sessions/list", "--format", "json"], { + env: e, + }); + expectCliSuccess(listed); + expect(JSON.parse(listed.stdout)).toEqual({ sessions: [] }); + + const status = await runMcp(["daemon", "status", "--format", "json"], { + env: e, + }); + expectCliSuccess(status); + expect(JSON.parse(status.stdout)).toMatchObject({ + running: false, + message: "Daemon is not running.", + }); + + // Socket must not have been created by status/list. + expect(fs.existsSync(path.join(storageDir!, "daemon.sock"))).toBe(false); + }); +}); diff --git a/clients/mcpi/__tests__/mcp-session.test.ts b/clients/mcpi/__tests__/mcp-session.test.ts new file mode 100644 index 000000000..8d38e1cf1 --- /dev/null +++ b/clients/mcpi/__tests__/mcp-session.test.ts @@ -0,0 +1,207 @@ +import { describe, it, expect, afterEach, beforeAll } from "vitest"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { runMcp } from "./helpers/mcp-runner.js"; +import { runCli } from "../../cli/__tests__/helpers/cli-runner.js"; +import { + createSampleTestConfig, + deleteConfigFile, +} from "../../cli/__tests__/helpers/fixtures.js"; +import { expectCliSuccess } from "../../cli/__tests__/helpers/assertions.js"; +import { resolveDaemonScriptPath } from "../src/daemon/ensure.js"; +import { callDaemon } from "../src/daemon/client.js"; + +describe("mcp session CLI", () => { + let configPath: string | undefined; + let storageDir: string | undefined; + + beforeAll(() => { + // Auto-spawn needs the built daemon bundle. + expect(fs.existsSync(resolveDaemonScriptPath())).toBe(true); + }); + + afterEach(async () => { + if (storageDir) { + const socketPath = path.join(storageDir, "daemon.sock"); + if (fs.existsSync(socketPath)) { + try { + await callDaemon("daemon/stop", {}, { socketPath, timeoutMs: 2000 }); + } catch { + // already stopped + } + const deadline = Date.now() + 2000; + while (fs.existsSync(socketPath) && Date.now() < deadline) { + await new Promise((r) => setTimeout(r, 50)); + } + } + fs.rmSync(storageDir, { recursive: true, force: true }); + storageDir = undefined; + } + if (configPath) { + deleteConfigFile(configPath); + configPath = undefined; + } + }); + + function env(): Record { + storageDir = fs.mkdtempSync(path.join(os.tmpdir(), "mcp-session-")); + return { + MCP_STORAGE_DIR: storageDir, + MCP_INSPECTOR_DAEMON_DIR: storageDir, + MCP_ALLOW_DEFAULT_SESSION: "1", + }; + } + + it("lists servers without a daemon", async () => { + configPath = createSampleTestConfig(); + // No MCP_STORAGE_DIR — this path must not touch the daemon. + const result = await runMcp([ + "servers/list", + "--config", + configPath, + "--format", + "json", + ]); + expectCliSuccess(result); + const body = JSON.parse(result.stdout) as { + servers: { name: string }[]; + }; + expect(body.servers.some((s) => s.name === "test-stdio")).toBe(true); + }); + + it("connects, lists sessions, disconnects via auto-spawned daemon", async () => { + configPath = createSampleTestConfig(); + const e = env(); + + const connected = await runMcp( + ["connect", "test-stdio", "--config", configPath, "--format", "json"], + { env: e, timeout: 20000 }, + ); + expectCliSuccess(connected); + const session = JSON.parse(connected.stdout) as { + name: string; + isMru: boolean; + }; + expect(session.name).toBe("test-stdio"); + expect(session.isMru).toBe(true); + + const listed = await runMcp(["sessions/list", "--format", "json"], { + env: e, + }); + expectCliSuccess(listed); + const sessions = JSON.parse(listed.stdout) as { + sessions: { name: string; isMru: boolean }[]; + }; + expect(sessions.sessions).toHaveLength(1); + expect(sessions.sessions[0]?.name).toBe("test-stdio"); + + const servers = await runMcp( + ["servers/list", "--config", configPath, "--format", "json"], + { env: e }, + ); + expectCliSuccess(servers); + const serverBody = JSON.parse(servers.stdout) as { + servers: { + name: string; + session?: string; + isMru?: boolean; + }[]; + }; + const stdio = serverBody.servers.find((s) => s.name === "test-stdio"); + expect(stdio?.session).toBe("test-stdio"); + expect(stdio?.isMru).toBe(true); + expect( + serverBody.servers.find((s) => s.name === "test-http")?.session, + ).toBeUndefined(); + + const disc = await runMcp( + ["disconnect", "--session", "test-stdio", "--format", "json"], + { env: e }, + ); + expectCliSuccess(disc); + + const stopped = await runMcp(["daemon", "stop", "--format", "json"], { + env: e, + }); + expectCliSuccess(stopped); + }); + + it("one-shot servers/list still works alongside session mode", async () => { + configPath = createSampleTestConfig(); + const result = await runCli([ + "--config", + configPath, + "--method", + "servers/list", + ]); + expectCliSuccess(result); + expect(result.stdout).toContain("test-stdio"); + }); + + it("runs tools/list, tools/call, and initialize over a live session", async () => { + configPath = createSampleTestConfig(); + const e = env(); + + const connected = await runMcp( + ["connect", "test-stdio", "--config", configPath, "--format", "json"], + { env: e, timeout: 20000 }, + ); + expectCliSuccess(connected); + + const tools = await runMcp(["tools/list", "--format", "json"], { + env: e, + timeout: 20000, + }); + expectCliSuccess(tools); + const toolsBody = JSON.parse(tools.stdout) as { + tools: { name: string }[]; + }; + expect(toolsBody.tools.length).toBeGreaterThan(0); + + const toolsText = await runMcp(["tools/list"], { + env: e, + timeout: 20000, + }); + expectCliSuccess(toolsText); + expect(toolsText.stdout).toMatch(/Tools \(\d+\):/); + expect(toolsText.stdout).toContain("`"); + + const called = await runMcp( + ["tools/call", "echo", "message:=session", "--format", "json"], + { env: e, timeout: 20000 }, + ); + expectCliSuccess(called); + + const calledJson = await runMcp( + ["tools/call", "echo", '{"message":"session-json"}', "--format", "json"], + { env: e, timeout: 20000 }, + ); + expectCliSuccess(calledJson); + + const resources = await runMcp(["resources/list", "--format", "json"], { + env: e, + timeout: 20000, + }); + expectCliSuccess(resources); + + const init = await runMcp( + ["@test-stdio", "initialize", "--format", "json"], + { env: e, timeout: 20000 }, + ); + expectCliSuccess(init); + const initBody = JSON.parse(init.stdout) as { + serverInfo?: { name?: string }; + protocolVersion?: string; + }; + expect(initBody.protocolVersion).toBeTruthy(); + + await runMcp( + ["disconnect", "--session", "test-stdio", "--format", "json"], + { + env: e, + }, + ); + await runMcp(["daemon", "stop", "--format", "json"], { env: e }); + }); +}); diff --git a/clients/mcpi/__tests__/parse-tool-args.test.ts b/clients/mcpi/__tests__/parse-tool-args.test.ts new file mode 100644 index 000000000..971fa620d --- /dev/null +++ b/clients/mcpi/__tests__/parse-tool-args.test.ts @@ -0,0 +1,119 @@ +import { describe, it, expect } from "vitest"; +import { + parseToolCallPositionals, + resolveToolCallArgs, +} from "../src/session/parse-tool-args.js"; + +describe("parseToolCallPositionals", () => { + it("parses key:=value with JSON typing", () => { + expect( + parseToolCallPositionals([ + "message:=Foo", + "count:=10", + "enabled:=true", + 'cfg:={"a":1}', + 'id:="012"', + ]), + ).toEqual({ + message: "Foo", + count: 10, + enabled: true, + cfg: { a: 1 }, + id: "012", + }); + }); + + it("parses a single inline JSON object", () => { + expect(parseToolCallPositionals(['{"message":"Foo","count":2}'])).toEqual({ + message: "Foo", + count: 2, + }); + }); + + it("rejects bare values, arrays, and mixed JSON+pairs", () => { + expect(() => parseToolCallPositionals(["foo"])).toThrow(/key:=value/); + expect(() => parseToolCallPositionals(["[1]"])).toThrow(/JSON object/); + expect(() => parseToolCallPositionals(["{not-json"])).toThrow( + /Invalid JSON/, + ); + expect(() => parseToolCallPositionals(['{"a":1}', "b:=2"])).toThrow( + /only one argument/, + ); + expect(() => parseToolCallPositionals([":=x"])).toThrow(/missing key/); + expect(parseToolCallPositionals([])).toEqual({}); + }); +}); + +describe("resolveToolCallArgs", () => { + it("uses positionals as the default style", () => { + expect( + resolveToolCallArgs({ + toolNamePos: "echo", + toolArgsPos: ["message:=hi"], + }), + ).toEqual({ toolName: "echo", toolArg: { message: "hi" } }); + }); + + it("treats the name slot as an arg when --tool-name is set", () => { + expect( + resolveToolCallArgs({ + toolNameFlag: "echo", + toolNamePos: "message:=hi", + }), + ).toEqual({ toolName: "echo", toolArg: { message: "hi" } }); + }); + + it("keeps --tool-arg and --tool-args-json as alternatives", () => { + expect( + resolveToolCallArgs({ + toolNamePos: "echo", + toolArgFlag: { message: "via-flag" }, + }), + ).toEqual({ toolName: "echo", toolArg: { message: "via-flag" } }); + + expect( + resolveToolCallArgs({ + toolNamePos: "echo", + toolArgsJson: '{"message":"json"}', + }), + ).toEqual({ toolName: "echo", toolArg: { message: "json" } }); + }); + + it("rejects mixing argument styles", () => { + expect(() => + resolveToolCallArgs({ + toolNamePos: "echo", + toolArgsPos: ["message:=a"], + toolArgFlag: { message: "b" }, + }), + ).toThrow(/one style/); + expect(() => + resolveToolCallArgs({ + toolNamePos: "echo", + toolArgsPos: ["message:=a"], + toolArgsJson: '{"message":"b"}', + }), + ).toThrow(/one style/); + }); + + it("rejects invalid --tool-args-json", () => { + expect(() => + resolveToolCallArgs({ + toolNamePos: "echo", + toolArgsJson: "{bad", + }), + ).toThrow(/not valid JSON/); + expect(() => + resolveToolCallArgs({ + toolNamePos: "echo", + toolArgsJson: "[]", + }), + ).toThrow(/must be a JSON object/); + expect(() => + resolveToolCallArgs({ + toolNamePos: "echo", + toolArgsJson: "null", + }), + ).toThrow(/must be a JSON object/); + }); +}); diff --git a/clients/mcpi/__tests__/session-stored-auth.test.ts b/clients/mcpi/__tests__/session-stored-auth.test.ts new file mode 100644 index 000000000..11c918d10 --- /dev/null +++ b/clients/mcpi/__tests__/session-stored-auth.test.ts @@ -0,0 +1,249 @@ +import { afterEach, describe, expect, it } from "vitest"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { resetNodeOAuthStorageCache } from "@inspector/core/auth/node/storage-node.js"; +import { + clearAllStoredAuth, + clearStoredAuth, + clearStoredAuthForRelogin, + listStoredAuth, + resolveStoredAuthKey, +} from "../src/session/stored-auth.js"; +import { CliExitCodeError } from "@inspector/cli/error-handler.js"; +import { runMcp } from "./helpers/mcp-runner.js"; +import { + expectCliSuccess, + expectCliFailure, +} from "../../cli/__tests__/helpers/assertions.js"; + +function writeOAuthFixture(dir: string): string { + const file = path.join(dir, "oauth.json"); + fs.writeFileSync( + file, + JSON.stringify({ + servers: { + "https://example.com/mcp": { + byIssuer: { + "https://as.example/": { + tokens: { + access_token: "a", + token_type: "Bearer", + refresh_token: "r", + }, + }, + }, + activeIssuer: "https://as.example/", + }, + "https://other.example/mcp": { + tokens: { access_token: "x", token_type: "Bearer" }, + }, + "https://empty.example/mcp": { + codeVerifier: "cv", + }, + "https://nullish.example/mcp": null, + "https://stringish.example/mcp": "not-an-object", + "https://issuer-empty.example/mcp": { + byIssuer: { + "https://as.example/": {}, + }, + }, + }, + idpSessions: {}, + }), + "utf8", + ); + return file; +} + +describe("session stored-auth helpers", () => { + let dir: string | undefined; + let prevPath: string | undefined; + + afterEach(() => { + if (prevPath === undefined) + delete process.env.MCP_INSPECTOR_OAUTH_STATE_PATH; + else process.env.MCP_INSPECTOR_OAUTH_STATE_PATH = prevPath; + resetNodeOAuthStorageCache(); + if (dir) { + fs.rmSync(dir, { recursive: true, force: true }); + dir = undefined; + } + }); + + function useFixture(): string { + dir = fs.mkdtempSync(path.join(os.tmpdir(), "mcp-stored-auth-")); + const file = writeOAuthFixture(dir); + prevPath = process.env.MCP_INSPECTOR_OAUTH_STATE_PATH; + process.env.MCP_INSPECTOR_OAUTH_STATE_PATH = file; + resetNodeOAuthStorageCache(); + return file; + } + + it("lists byIssuer and legacy tokens", async () => { + const file = useFixture(); + const list = await listStoredAuth(); + expect(list.oauthStatePath).toBe(file); + expect(list.servers.map((s) => s.url)).toEqual([ + "https://empty.example/mcp", + "https://example.com/mcp", + "https://issuer-empty.example/mcp", + "https://nullish.example/mcp", + "https://other.example/mcp", + "https://stringish.example/mcp", + ]); + expect(list.servers.find((s) => s.url.includes("nullish"))).toMatchObject({ + hasTokens: false, + hasRefreshToken: false, + }); + expect(list.servers.find((s) => s.url.includes("stringish"))).toMatchObject( + { hasTokens: false, hasRefreshToken: false }, + ); + expect( + list.servers.find((s) => s.url.includes("issuer-empty")), + ).toMatchObject({ hasTokens: false, hasRefreshToken: false }); + expect( + list.servers.find((s) => s.url.includes("example.com")), + ).toMatchObject({ hasTokens: true, hasRefreshToken: true }); + expect(list.servers.find((s) => s.url.includes("other"))).toMatchObject({ + hasTokens: true, + hasRefreshToken: false, + }); + expect(list.servers.find((s) => s.url.includes("empty"))).toMatchObject({ + hasTokens: false, + hasRefreshToken: false, + }); + }); + + it("clears one key and all keys", async () => { + useFixture(); + const cleared = await clearStoredAuth("https://example.com/mcp"); + expect(cleared.url).toBe("https://example.com/mcp"); + let list = await listStoredAuth(); + expect(list.servers.map((s) => s.url)).not.toContain( + "https://example.com/mcp", + ); + + const all = await clearAllStoredAuth(); + expect(all.cleared).toBe(5); + list = await listStoredAuth(); + expect(list.servers).toEqual([]); + }); + + it("resolveStoredAuthKey rejects unknown non-URL keys", async () => { + useFixture(); + await expect(resolveStoredAuthKey("nope")).rejects.toBeInstanceOf( + CliExitCodeError, + ); + }); + + it("clearStoredAuthForRelogin clears by URL", async () => { + useFixture(); + await clearStoredAuthForRelogin("https://other.example/mcp"); + const list = await listStoredAuth(); + expect(list.servers.map((s) => s.url)).not.toContain( + "https://other.example/mcp", + ); + await clearStoredAuthForRelogin(undefined); + await clearStoredAuthForRelogin(" "); + }); + + it("lists an empty store when the file is missing", async () => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), "mcp-stored-auth-")); + const missing = path.join(dir, "missing-oauth.json"); + prevPath = process.env.MCP_INSPECTOR_OAUTH_STATE_PATH; + process.env.MCP_INSPECTOR_OAUTH_STATE_PATH = missing; + resetNodeOAuthStorageCache(); + expect(await listStoredAuth()).toMatchObject({ + oauthStatePath: missing, + servers: [], + }); + }); + + it("resolves keys by normalisation and rejects blanks", async () => { + useFixture(); + await expect(resolveStoredAuthKey(" ")).rejects.toBeInstanceOf( + CliExitCodeError, + ); + await expect(resolveStoredAuthKey("https://Example.COM/mcp")).resolves.toBe( + "https://example.com/mcp", + ); + await expect( + resolveStoredAuthKey("https://brand-new.example/mcp"), + ).resolves.toBe("https://brand-new.example/mcp"); + }); +}); + +describe("mcp auth/list and auth/clear", () => { + let dir: string | undefined; + + afterEach(() => { + resetNodeOAuthStorageCache(); + if (dir) { + fs.rmSync(dir, { recursive: true, force: true }); + dir = undefined; + } + }); + + it("lists and clears via session commands", async () => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), "mcp-auth-cmd-")); + const file = writeOAuthFixture(dir); + resetNodeOAuthStorageCache(); + + const listed = await runMcp(["auth/list", "--format", "json"], { + env: { MCP_INSPECTOR_OAUTH_STATE_PATH: file }, + }); + expectCliSuccess(listed); + const body = JSON.parse(listed.stdout) as { + servers: { url: string }[]; + }; + expect(body.servers.length).toBe(6); + + const cleared = await runMcp( + ["auth/clear", "https://example.com/mcp", "--format", "json"], + { env: { MCP_INSPECTOR_OAUTH_STATE_PATH: file } }, + ); + expectCliSuccess(cleared); + expect(JSON.parse(cleared.stdout)).toEqual({ + url: "https://example.com/mcp", + }); + + const all = await runMcp( + ["auth/clear", "--all", "--yes", "--format", "json"], + { env: { MCP_INSPECTOR_OAUTH_STATE_PATH: file } }, + ); + expectCliSuccess(all); + expect(JSON.parse(all.stdout)).toMatchObject({ all: true, cleared: 5 }); + }); + + it("rejects --all without --yes when non-interactive", async () => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), "mcp-auth-cmd-")); + const file = writeOAuthFixture(dir); + const result = await runMcp(["auth/clear", "--all"], { + env: { MCP_INSPECTOR_OAUTH_STATE_PATH: file }, + }); + expectCliFailure(result); + expect(result.stderr).toMatch(/--yes/); + }); + + it("rejects auth/clear usage errors", async () => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), "mcp-auth-cmd-")); + const file = writeOAuthFixture(dir); + const none = await runMcp(["auth/clear"], { + env: { MCP_INSPECTOR_OAUTH_STATE_PATH: file }, + }); + expectCliFailure(none); + + const both = await runMcp( + ["auth/clear", "https://example.com/mcp", "--all", "--yes"], + { env: { MCP_INSPECTOR_OAUTH_STATE_PATH: file } }, + ); + expectCliFailure(both); + + const human = await runMcp(["auth/list"], { + env: { MCP_INSPECTOR_OAUTH_STATE_PATH: file }, + }); + expectCliSuccess(human); + expect(human.stdout).toMatch(/Stored auth/); + }); +}); diff --git a/clients/mcpi/eslint.config.js b/clients/mcpi/eslint.config.js new file mode 100644 index 000000000..1c43ee8fd --- /dev/null +++ b/clients/mcpi/eslint.config.js @@ -0,0 +1,17 @@ +import js from "@eslint/js"; +import globals from "globals"; +import tseslint from "typescript-eslint"; +import { defineConfig, globalIgnores } from "eslint/config"; + +export default defineConfig([ + globalIgnores(["build", "coverage"]), + { + files: ["**/*.ts"], + extends: [js.configs.recommended, tseslint.configs.recommended], + languageOptions: { + ecmaVersion: 2022, + sourceType: "module", + globals: globals.node, + }, + }, +]); diff --git a/clients/mcpi/package-lock.json b/clients/mcpi/package-lock.json new file mode 100644 index 000000000..f98171f97 --- /dev/null +++ b/clients/mcpi/package-lock.json @@ -0,0 +1,5891 @@ +{ + "name": "@modelcontextprotocol/mcpi", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@modelcontextprotocol/mcpi", + "license": "MIT", + "dependencies": { + "@modelcontextprotocol/client": "2.0.0-beta.5", + "@modelcontextprotocol/core": "2.0.0-beta.5", + "@modelcontextprotocol/server": "2.0.0-beta.5", + "@modelcontextprotocol/server-legacy": "2.0.0-beta.5", + "@napi-rs/keyring": "^1.3.0", + "ajv": "^8.17.1", + "atomically": "^2.1.1", + "commander": "^13.1.0", + "open": "^10.2.0", + "pino": "^9.14.0", + "undici": "^8.5.0", + "zod": "^4.3.6" + }, + "bin": { + "mcpi": "build/mcp-bin.js" + }, + "devDependencies": { + "@eslint/js": "^9.39.4", + "@types/node": "^24.12.4", + "@vitest/coverage-v8": "^4.1.0", + "eslint": "^9.39.4", + "globals": "^17.4.0", + "prettier": "^3.8.1", + "tsup": "^8.5.0", + "typescript": "~5.9.3", + "typescript-eslint": "^8.56.1", + "vitest": "^4.1.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bcoe/v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@emnapi/core": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", + "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", + "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", + "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", + "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", + "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", + "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", + "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", + "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", + "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", + "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", + "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", + "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", + "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", + "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", + "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", + "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", + "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", + "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", + "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", + "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", + "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", + "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", + "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", + "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", + "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", + "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz", + "integrity": "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.2", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", + "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.5" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.6.tgz", + "integrity": "sha512-l2Ul9PrHsPCKcEY/ac7VgFj9D80C7S68sOKc618SyHDPK36s1XcFebXY0iTzUVn4Yq+YbwvSnDmCz9yxjX+QrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.14.0", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.3.0", + "minimatch": "^3.1.5", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@eslint/eslintrc/node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@eslint/eslintrc/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@eslint/js": { + "version": "9.39.5", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.5.tgz", + "integrity": "sha512-QywQuszQh77pIXCsq998c8hbhSTI/azTty1Z6N53dmAudKHhy573j3yvRLsX2BSp8YpLtoCEG8E9DJe+8zUh4A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@modelcontextprotocol/client": { + "version": "2.0.0-beta.5", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/client/-/client-2.0.0-beta.5.tgz", + "integrity": "sha512-YuuNm5f2TMoFQRje1UqVP8TJRjijCXMz4ckvoVpx1cUXuBEmykWQ2d8R536pek6UKcXT41T5nWc4qR1JFIbEmg==", + "license": "MIT", + "dependencies": { + "@modelcontextprotocol/core": "2.0.0-beta.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "jose": "^6.1.3", + "pkce-challenge": "^5.0.0", + "zod": "^4.2.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@modelcontextprotocol/core": { + "version": "2.0.0-beta.5", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/core/-/core-2.0.0-beta.5.tgz", + "integrity": "sha512-HKbY9XTbsDy1Y6r2I55TGE3JEapM0vg96e1MUmBIF9LGjos5gjhcIrTz1yvBPLg2aFKHjwhUAQfRdrCEnPxNew==", + "license": "MIT", + "dependencies": { + "zod": "^4.2.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@modelcontextprotocol/server": { + "version": "2.0.0-beta.5", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/server/-/server-2.0.0-beta.5.tgz", + "integrity": "sha512-i1E5l75rQKsgY/AKAIspgMBH1vEL7dqiK7tHr0L+raYcb0SWOziqNGJXGIG6NY4AlXDWIKGJQGB7Nqfs3oUi5g==", + "license": "MIT", + "dependencies": { + "@modelcontextprotocol/core": "2.0.0-beta.5", + "zod": "^4.2.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@modelcontextprotocol/server-legacy": { + "version": "2.0.0-beta.5", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/server-legacy/-/server-legacy-2.0.0-beta.5.tgz", + "integrity": "sha512-8BemN4avQnG6Fu660fZCqnPGpeyL7gg5kxUceZQh7JCt8oqzX1bwJkNV+cKS01LPAaPbl93INneD+mBtJWKWvQ==", + "deprecated": "This package is a frozen copy of v1's SSE transport and OAuth Authorization Server helpers for migration purposes only. Use StreamableHTTP from @modelcontextprotocol/server and a dedicated OAuth server in production. Will not receive new features.", + "license": "MIT", + "dependencies": { + "@modelcontextprotocol/core": "2.0.0-beta.5", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "express-rate-limit": "^8.2.1", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^4.2.0" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "express": "^4.18.0 || ^5.0.0" + }, + "peerDependenciesMeta": { + "express": { + "optional": true + } + } + }, + "node_modules/@napi-rs/keyring": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring/-/keyring-1.3.0.tgz", + "integrity": "sha512-WrOw/bcXm0f9qHkumlT1QlArXSTWqaY9sunsDpOk+yCCorCKMxvWT/a3xko4EYHVdeZoh00yI2TydXn6eyICDA==", + "license": "MIT", + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "optionalDependencies": { + "@napi-rs/keyring-darwin-arm64": "1.3.0", + "@napi-rs/keyring-darwin-x64": "1.3.0", + "@napi-rs/keyring-freebsd-x64": "1.3.0", + "@napi-rs/keyring-linux-arm-gnueabihf": "1.3.0", + "@napi-rs/keyring-linux-arm64-gnu": "1.3.0", + "@napi-rs/keyring-linux-arm64-musl": "1.3.0", + "@napi-rs/keyring-linux-riscv64-gnu": "1.3.0", + "@napi-rs/keyring-linux-x64-gnu": "1.3.0", + "@napi-rs/keyring-linux-x64-musl": "1.3.0", + "@napi-rs/keyring-win32-arm64-msvc": "1.3.0", + "@napi-rs/keyring-win32-ia32-msvc": "1.3.0", + "@napi-rs/keyring-win32-x64-msvc": "1.3.0" + } + }, + "node_modules/@napi-rs/keyring-darwin-arm64": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-darwin-arm64/-/keyring-darwin-arm64-1.3.0.tgz", + "integrity": "sha512-pl76hJvdYUBn6I24bXiOBMA9nbDapo3I5B+f3OorjDU4dUMSypXeKbOVehJe8fhgTiH24flMyTS3aAIy43xegQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-darwin-x64": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-darwin-x64/-/keyring-darwin-x64-1.3.0.tgz", + "integrity": "sha512-YcJtEV5LA3cvA4z3BurgxH5IhTsW1JfIvcAAcqcecwk06Si9F9NqkxbZVIfDwQ8oRHgaBmT3zZJnLAotCrVahw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-freebsd-x64": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-freebsd-x64/-/keyring-freebsd-x64-1.3.0.tgz", + "integrity": "sha512-vlLf31TGhfRAaxLDBhg8b89ss0HHD/lyNmL5F3UjSaz5CUXElsJmKYq9fqA/B+cZKUEUcLHHGhF0I/CqcFdaVw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-linux-arm-gnueabihf": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-arm-gnueabihf/-/keyring-linux-arm-gnueabihf-1.3.0.tgz", + "integrity": "sha512-KiWdMMu/Inz/bHHIAGrnF7r54FZDYXuHO6UFF/rhIrshUsxbMG1Rl9lEymNtqqsVo927G0VYcb02FzWQ3iBQRQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-linux-arm64-gnu": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-arm64-gnu/-/keyring-linux-arm64-gnu-1.3.0.tgz", + "integrity": "sha512-eyKGpY40lm9Jvs1aD294XRH4y7+TlJM0YVAryZeXA6TX0mb4gMkxVXwSQv7MCwgah7raeUd0dKUb4BPAYIgcMg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-linux-arm64-musl": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-arm64-musl/-/keyring-linux-arm64-musl-1.3.0.tgz", + "integrity": "sha512-iIK6JWHXAJqDrEyLY3TmswwloVyt2vj+04TZnew+uSJ9gnDO8EwRbp3/iw3LpWaXiDO7VomGO6y8I0Id8uBZSw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-linux-riscv64-gnu": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-riscv64-gnu/-/keyring-linux-riscv64-gnu-1.3.0.tgz", + "integrity": "sha512-/PGqrwn6EwgtK6vccASSXJRfOSP4vN1F4ASsIQ+7MdrK6hNvAJ1FZPrIuD5gGGdxezo3F++To2Wq7DbuGIeuNQ==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-linux-x64-gnu": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-x64-gnu/-/keyring-linux-x64-gnu-1.3.0.tgz", + "integrity": "sha512-2PDK1WKWTu9lBGq9VvNEkSlQD3O7YwVpmnyN2M3cy4v7NJ/8gDMd9GXv3G+FVXN13uhp4gnnPBS+ScefmEeD2A==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-linux-x64-musl": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-x64-musl/-/keyring-linux-x64-musl-1.3.0.tgz", + "integrity": "sha512-oJ2HkX8YUo46QBkn0pG+HuIKQNqr523q6vBobCn+P95s4C4K6/kLBqHY/1bg5J4ap31DzsznhnFKcfBNBsjCnw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-win32-arm64-msvc": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-win32-arm64-msvc/-/keyring-win32-arm64-msvc-1.3.0.tgz", + "integrity": "sha512-tOd3c/uAaeoE4ycVlmAdSvygz0Zt3zdca6Y7gokBeIbaRDWpjDIUOpU3MvML59XAaqyuKGsVVu0F/DZb1lHPmw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-win32-ia32-msvc": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-win32-ia32-msvc/-/keyring-win32-ia32-msvc-1.3.0.tgz", + "integrity": "sha512-sPSqeAFZMGqP1R++M2JTza7GQJJ/TpCo6JU6Vcd4jnebvOaEDs9b7eipakU1PJdSvhpC2yXMCNRk9gXfrhuwHQ==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-win32-x64-msvc": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-win32-x64-msvc/-/keyring-win32-x64-msvc-1.3.0.tgz", + "integrity": "sha512-4DnCWXwDc0HRKwyRlG5y0VhKZW2tNRQfKKfyj6IX/KWfDNyq9hn4n+GL1auyDcOO/v8PwnhmYo2+rOOqCkvvOg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", + "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.3" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.139.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.139.0.tgz", + "integrity": "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@pinojs/redact": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", + "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==", + "license": "MIT" + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz", + "integrity": "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.5.tgz", + "integrity": "sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.5.tgz", + "integrity": "sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.5.tgz", + "integrity": "sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.5.tgz", + "integrity": "sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.5.tgz", + "integrity": "sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.5.tgz", + "integrity": "sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.5.tgz", + "integrity": "sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.5.tgz", + "integrity": "sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.5.tgz", + "integrity": "sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.5.tgz", + "integrity": "sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.5.tgz", + "integrity": "sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.5.tgz", + "integrity": "sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.11.1", + "@emnapi/runtime": "1.11.1", + "@napi-rs/wasm-runtime": "^1.1.6" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.5.tgz", + "integrity": "sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.5.tgz", + "integrity": "sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz", + "integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz", + "integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz", + "integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz", + "integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz", + "integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz", + "integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz", + "integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz", + "integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz", + "integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz", + "integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz", + "integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz", + "integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz", + "integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz", + "integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz", + "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz", + "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz", + "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz", + "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz", + "integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz", + "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz", + "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz", + "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz", + "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz", + "integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz", + "integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "24.13.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz", + "integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.65.0.tgz", + "integrity": "sha512-IEgob78X12rHpUmtcwFsXhZdVGJtwTVP8FiCLZkR6GlYVrl2PcuB+KhCE5BlVC/eQpQnu8WXRtkHZuPar+gCRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.65.0", + "@typescript-eslint/type-utils": "8.65.0", + "@typescript-eslint/utils": "8.65.0", + "@typescript-eslint/visitor-keys": "8.65.0", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.65.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.6.tgz", + "integrity": "sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.65.0.tgz", + "integrity": "sha512-CZ4nMxWwgu1HEEFNkeaCptra9QCtkmKdgf3sWh1rl1trIhmxLilgTV4cwcbQ4wemnT4sWQN8CaKOmdYx+g2gMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.65.0", + "@typescript-eslint/types": "8.65.0", + "@typescript-eslint/typescript-estree": "8.65.0", + "@typescript-eslint/visitor-keys": "8.65.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.65.0.tgz", + "integrity": "sha512-SxnPhbTsGahizDgbu7oqFH/xVtzIqMd/s+WtnSxNxJZJpLbdT5IPdzg8EZxO3+PoKahXmwJLeNQOpKJb3/bi7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.65.0", + "@typescript-eslint/types": "^8.65.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.65.0.tgz", + "integrity": "sha512-Esbl8OSYiVxBokYgWPf7VVWg/BE798wXhimnn9ML9Pt5qoDf8bfQlgjlKXR/k98+AcNzlLKYrpCcrcuZ9DZLgg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.65.0", + "@typescript-eslint/visitor-keys": "8.65.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.65.0.tgz", + "integrity": "sha512-j6GzGqCiRdA7Qhur2VVmKZAkBLfnHFQfx4TaJGL9RMveZqCo48jSHHO0DTgizEnGhtWnqmbtCUSrqSkdiY/0Hg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.65.0.tgz", + "integrity": "sha512-YjaZ7PRI5qY7ax2L3PbvX0rRyGtipAReCWs0mhhDBHjH/vl0g0BonaGXrKdKpMbIIsMIwDgbk/xzkBTyAltS5g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.65.0", + "@typescript-eslint/typescript-estree": "8.65.0", + "@typescript-eslint/utils": "8.65.0", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.65.0.tgz", + "integrity": "sha512-JSSwWNy+H0E/01jJEM+hrX6N0OFDzFzeIhHFSAS01tlVaevpG8cFyYRPhS5yjGOvBUx3sqQHVMjCL1CAZZMxBg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.65.0.tgz", + "integrity": "sha512-JboAE2swaYt4tb1fHhHTABE2K+OLy09XfcTbhnk4Pw96f9dd2e9iYsJ28gBggHlo5z5x1rkyWvcPoTuNTd4oGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.65.0", + "@typescript-eslint/tsconfig-utils": "8.65.0", + "@typescript-eslint/types": "8.65.0", + "@typescript-eslint/visitor-keys": "8.65.0", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.8.tgz", + "integrity": "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.65.0.tgz", + "integrity": "sha512-gXiwIHsYreboxeJucHKPvgwl7dXt50mF8s1/c00cP/WoVTyWKFdtfhRWwZiXYFU5H2O8vVoSLNrexFZjYS/SGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.65.0", + "@typescript-eslint/types": "8.65.0", + "@typescript-eslint/typescript-estree": "8.65.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.65.0.tgz", + "integrity": "sha512-8C71BQkGjiMmXtop7pHVJu1l2NNShFdkCyD6a2ezzs5vU/L3LRtb69EtcteFwz0mYMPzIgOw0n6OV4VBUWZd7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.65.0", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@vitest/coverage-v8": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.10.tgz", + "integrity": "sha512-IM49HmthevbgAO4anp1hwtoT9wYe59w0LR00gr+eagHE+ZJ5lK4sLPeO0ubgoJcwLk6dehU3R24N+FbEEKDc8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bcoe/v8-coverage": "^1.0.2", + "@vitest/utils": "4.1.10", + "ast-v8-to-istanbul": "^1.0.0", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-reports": "^3.2.0", + "magicast": "^0.5.2", + "obug": "^2.1.1", + "std-env": "^4.0.0-rc.1", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "4.1.10", + "vitest": "4.1.10" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, + "node_modules/@vitest/expect": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", + "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz", + "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.10", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", + "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz", + "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.10", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz", + "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "@vitest/utils": "4.1.10", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz", + "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz", + "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "peer": true, + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/acorn": { + "version": "8.17.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", + "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true, + "license": "MIT" + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/ast-v8-to-istanbul": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.5.tgz", + "integrity": "sha512-UPAgKJFSEGMWSDr3LX4tqnAb4f7KGT8O40Tyx8wbYmmZ/yn58lNCm8h3svs3eXgiGd5AXxz8NDOvXWvicq+rJA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.31", + "estree-walker": "^3.0.3", + "js-tokens": "^10.0.0" + } + }, + "node_modules/atomic-sleep": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", + "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/atomically": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/atomically/-/atomically-2.1.1.tgz", + "integrity": "sha512-P4w9o2dqARji6P7MHprklbfiArZAWvo07yW7qs3pdljb3BWr12FIB7W+p0zJiuiVsUpRO0iZn1kFFcpPegg0tQ==", + "license": "MIT", + "dependencies": { + "stubborn-fs": "^2.0.0", + "when-exit": "^2.1.4" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/body-parser": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", + "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", + "license": "MIT", + "peer": true, + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^2.0.0", + "debug": "^4.4.3", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.2", + "on-finished": "^2.4.1", + "qs": "^6.15.2", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/body-parser/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", + "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/bundle-name": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz", + "integrity": "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==", + "license": "MIT", + "dependencies": { + "run-applescript": "^7.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/bundle-require": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/bundle-require/-/bundle-require-5.1.0.tgz", + "integrity": "sha512-3WrrOuZiyaaZPWiEt4G3+IffISVC9HYlWueJEBWED4ZH4aIAC2PnkdnuRrR94M+w6yGWn4AglWtJtBI8YqvgoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "load-tsconfig": "^0.2.3" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "peerDependencies": { + "esbuild": ">=0.18" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "peer": true, + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/commander": { + "version": "13.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-13.1.0.tgz", + "integrity": "sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/confbox": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz", + "integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/consola": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz", + "integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.18.0 || >=16.10.0" + } + }, + "node_modules/content-disposition": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/default-browser": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.5.0.tgz", + "integrity": "sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw==", + "license": "MIT", + "dependencies": { + "bundle-name": "^4.1.0", + "default-browser-id": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/default-browser-id": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/default-browser-id/-/default-browser-id-5.0.1.tgz", + "integrity": "sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/define-lazy-prop": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz", + "integrity": "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "peer": true, + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT", + "peer": true + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-module-lexer": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.1.tgz", + "integrity": "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "peer": true, + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", + "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.7", + "@esbuild/android-arm": "0.27.7", + "@esbuild/android-arm64": "0.27.7", + "@esbuild/android-x64": "0.27.7", + "@esbuild/darwin-arm64": "0.27.7", + "@esbuild/darwin-x64": "0.27.7", + "@esbuild/freebsd-arm64": "0.27.7", + "@esbuild/freebsd-x64": "0.27.7", + "@esbuild/linux-arm": "0.27.7", + "@esbuild/linux-arm64": "0.27.7", + "@esbuild/linux-ia32": "0.27.7", + "@esbuild/linux-loong64": "0.27.7", + "@esbuild/linux-mips64el": "0.27.7", + "@esbuild/linux-ppc64": "0.27.7", + "@esbuild/linux-riscv64": "0.27.7", + "@esbuild/linux-s390x": "0.27.7", + "@esbuild/linux-x64": "0.27.7", + "@esbuild/netbsd-arm64": "0.27.7", + "@esbuild/netbsd-x64": "0.27.7", + "@esbuild/openbsd-arm64": "0.27.7", + "@esbuild/openbsd-x64": "0.27.7", + "@esbuild/openharmony-arm64": "0.27.7", + "@esbuild/sunos-x64": "0.27.7", + "@esbuild/win32-arm64": "0.27.7", + "@esbuild/win32-ia32": "0.27.7", + "@esbuild/win32-x64": "0.27.7" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT", + "peer": true + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.39.5", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.5.tgz", + "integrity": "sha512-DgZS62aPLXKlnxILS/AYCoRvHaZeXceIzlXPkkGGzJWSow1aEk0lbTlxUSlyjC8jcaKxAdOnTDz+o1JFSBsyjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.2", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.6", + "@eslint/js": "9.39.5", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/eslint/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.0.tgz", + "integrity": "sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "peer": true, + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-rate-limit": { + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.6.0.tgz", + "integrity": "sha512-XKJXDsASUOo0LLtFwW5hCcQGH0N4WQc/Rn8/Pvoia+TJFOkkFPvrtW9lZOeeNcxQJspvOIERMwiRLsVFlhHEkA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "ip-address": "^10.2.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.4.tgz", + "integrity": "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "peer": true, + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/fix-dts-default-cjs-exports": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/fix-dts-default-cjs-exports/-/fix-dts-default-cjs-exports-1.0.1.tgz", + "integrity": "sha512-pVIECanWFC61Hzl2+oOCtoJ3F17kglZC/6N94eRWycFgBH35hHx0Li604ZIzhseh97mf2p0cv7vVrOZGoqhlEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "magic-string": "^0.30.17", + "mlly": "^1.7.4", + "rollup": "^4.34.8" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.3.tgz", + "integrity": "sha512-/zipXxyO6rGvuNGDiULY9MvEGSkb2gaG4GGH4ygMi0ZZzyMHdUZBmntJmx5x1G2VuPytCwGN4xsJP6cw+sK+vQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "peer": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "peer": true, + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "17.7.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.7.0.tgz", + "integrity": "sha512-Czmyns5dUsq4seFBR/Kdydhmo8y9kC79hiSkPn0YcGtNnYWnrgt0vjrSjx9tspoDGWm2CMarffRuLjM4xUz8xg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "peer": true, + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ip-address": { + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.3.1.tgz", + "integrity": "sha512-1e9d3kb97NHJTIJDZW9rKqW2h6+dFa50Dy0fpPSMQp2ADje5gvKsXmdiK6dwY5t76TaTt5+P5N1Y/LoToIxP6g==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-docker": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", + "integrity": "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==", + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-inside-container": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz", + "integrity": "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==", + "license": "MIT", + "dependencies": { + "is-docker": "^3.0.0" + }, + "bin": { + "is-inside-container": "cli.js" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT", + "peer": true + }, + "node_modules/is-wsl": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.1.tgz", + "integrity": "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==", + "license": "MIT", + "dependencies": { + "is-inside-container": "^1.0.0" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/jose": { + "version": "6.2.4", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.4.tgz", + "integrity": "sha512-N8acGzVsQy6M/fjFcxtysNc4Q379TcM5dM/qKkNtsHFji88yANnXTr7BLeP75iPnFwBfQzM/jg2BZ9+HZrHCZA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/joycon": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz", + "integrity": "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/js-tokens": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", + "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", + "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/load-tsconfig": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/load-tsconfig/-/load-tsconfig-0.2.5.tgz", + "integrity": "sha512-IXO6OCs9yg8tMKzfPZ1YmheJbZCiEsnBdcB03l0OcfK9prKnJb96siuHCr5Fl37/yo9DnKU+TLpxzTUspw9shg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/magicast": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.3.tgz", + "integrity": "sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.3", + "@babel/types": "^7.29.0", + "source-map-js": "^1.2.1" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.1.tgz", + "integrity": "sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "peer": true, + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/mlly": { + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.2.tgz", + "integrity": "sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.16.0", + "pathe": "^2.0.3", + "pkg-types": "^1.3.1", + "ufo": "^1.6.3" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/obug": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz", + "integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/on-exit-leak-free": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", + "integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "peer": true, + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "peer": true, + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/open": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/open/-/open-10.2.0.tgz", + "integrity": "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA==", + "license": "MIT", + "dependencies": { + "default-browser": "^5.2.1", + "define-lazy-prop": "^3.0.0", + "is-inside-container": "^1.0.0", + "wsl-utils": "^0.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "peer": true, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pino": { + "version": "9.14.0", + "resolved": "https://registry.npmjs.org/pino/-/pino-9.14.0.tgz", + "integrity": "sha512-8OEwKp5juEvb/MjpIc4hjqfgCNysrS94RIOMXYvpYCdm/jglrKEiAYmiumbmGhCvs+IcInsphYDFwqrjr7398w==", + "license": "MIT", + "dependencies": { + "@pinojs/redact": "^0.4.0", + "atomic-sleep": "^1.0.0", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^2.0.0", + "pino-std-serializers": "^7.0.0", + "process-warning": "^5.0.0", + "quick-format-unescaped": "^4.0.3", + "real-require": "^0.2.0", + "safe-stable-stringify": "^2.3.1", + "sonic-boom": "^4.0.1", + "thread-stream": "^3.0.0" + }, + "bin": { + "pino": "bin.js" + } + }, + "node_modules/pino-abstract-transport": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-2.0.0.tgz", + "integrity": "sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw==", + "license": "MIT", + "dependencies": { + "split2": "^4.0.0" + } + }, + "node_modules/pino-std-serializers": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz", + "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==", + "license": "MIT" + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/pkce-challenge": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", + "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", + "license": "MIT", + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/pkg-types": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz", + "integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "confbox": "^0.1.8", + "mlly": "^1.7.4", + "pathe": "^2.0.1" + } + }, + "node_modules/postcss": { + "version": "8.5.23", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.23.tgz", + "integrity": "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.16", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-load-config": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", + "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "lilconfig": "^3.1.1" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "jiti": ">=1.21.0", + "postcss": ">=8.0.9", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + }, + "postcss": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prettier": { + "version": "3.9.6", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.6.tgz", + "integrity": "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/process-warning": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz", + "integrity": "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "peer": true, + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/qs": { + "version": "6.15.3", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", + "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", + "license": "BSD-3-Clause", + "peer": true, + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/quick-format-unescaped": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", + "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==", + "license": "MIT" + }, + "node_modules/range-parser": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz", + "integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/real-require": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz", + "integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==", + "license": "MIT", + "engines": { + "node": ">= 12.13.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/rolldown": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz", + "integrity": "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.139.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.1.5", + "@rolldown/binding-darwin-arm64": "1.1.5", + "@rolldown/binding-darwin-x64": "1.1.5", + "@rolldown/binding-freebsd-x64": "1.1.5", + "@rolldown/binding-linux-arm-gnueabihf": "1.1.5", + "@rolldown/binding-linux-arm64-gnu": "1.1.5", + "@rolldown/binding-linux-arm64-musl": "1.1.5", + "@rolldown/binding-linux-ppc64-gnu": "1.1.5", + "@rolldown/binding-linux-s390x-gnu": "1.1.5", + "@rolldown/binding-linux-x64-gnu": "1.1.5", + "@rolldown/binding-linux-x64-musl": "1.1.5", + "@rolldown/binding-openharmony-arm64": "1.1.5", + "@rolldown/binding-wasm32-wasi": "1.1.5", + "@rolldown/binding-win32-arm64-msvc": "1.1.5", + "@rolldown/binding-win32-x64-msvc": "1.1.5" + } + }, + "node_modules/rollup": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", + "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.62.2", + "@rollup/rollup-android-arm64": "4.62.2", + "@rollup/rollup-darwin-arm64": "4.62.2", + "@rollup/rollup-darwin-x64": "4.62.2", + "@rollup/rollup-freebsd-arm64": "4.62.2", + "@rollup/rollup-freebsd-x64": "4.62.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", + "@rollup/rollup-linux-arm-musleabihf": "4.62.2", + "@rollup/rollup-linux-arm64-gnu": "4.62.2", + "@rollup/rollup-linux-arm64-musl": "4.62.2", + "@rollup/rollup-linux-loong64-gnu": "4.62.2", + "@rollup/rollup-linux-loong64-musl": "4.62.2", + "@rollup/rollup-linux-ppc64-gnu": "4.62.2", + "@rollup/rollup-linux-ppc64-musl": "4.62.2", + "@rollup/rollup-linux-riscv64-gnu": "4.62.2", + "@rollup/rollup-linux-riscv64-musl": "4.62.2", + "@rollup/rollup-linux-s390x-gnu": "4.62.2", + "@rollup/rollup-linux-x64-gnu": "4.62.2", + "@rollup/rollup-linux-x64-musl": "4.62.2", + "@rollup/rollup-openbsd-x64": "4.62.2", + "@rollup/rollup-openharmony-arm64": "4.62.2", + "@rollup/rollup-win32-arm64-msvc": "4.62.2", + "@rollup/rollup-win32-ia32-msvc": "4.62.2", + "@rollup/rollup-win32-x64-gnu": "4.62.2", + "@rollup/rollup-win32-x64-msvc": "4.62.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/run-applescript": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.1.0.tgz", + "integrity": "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/safe-stable-stringify": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", + "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "peer": true, + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "peer": true, + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "peer": true, + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "peer": true, + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/sonic-boom": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz", + "integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==", + "license": "MIT", + "dependencies": { + "atomic-sleep": "^1.0.0" + } + }, + "node_modules/source-map": { + "version": "0.7.6", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz", + "integrity": "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">= 12" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/std-env": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", + "dev": true, + "license": "MIT" + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/stubborn-fs": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/stubborn-fs/-/stubborn-fs-2.0.0.tgz", + "integrity": "sha512-Y0AvSwDw8y+nlSNFXMm2g6L51rBGdAQT20J3YSOqxC53Lo3bjWRtr2BKcfYoAf352WYpsZSTURrA0tqhfgudPA==", + "license": "MIT", + "dependencies": { + "stubborn-utils": "^1.0.1" + } + }, + "node_modules/stubborn-utils": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/stubborn-utils/-/stubborn-utils-1.0.2.tgz", + "integrity": "sha512-zOh9jPYI+xrNOyisSelgym4tolKTJCQd5GBhK0+0xJvcYDcwlOoxF/rnFKQ2KRZknXSG9jWAp66fwP6AxN9STg==", + "license": "MIT" + }, + "node_modules/sucrase": { + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", + "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "tinyglobby": "^0.2.11", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/sucrase/node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/thread-stream": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-3.2.0.tgz", + "integrity": "sha512-zLBvqpwr4Esa0kRjcrzGU6zL25lePWaCLMx0RQFrmteozIfeNdaMLpG5U7PeHzvlFkAWaRKA9/KVW4F60iB+qw==", + "license": "MIT", + "dependencies": { + "real-require": "^0.2.0" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/tree-kill": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", + "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", + "dev": true, + "license": "MIT", + "bin": { + "tree-kill": "cli.js" + } + }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/tsup": { + "version": "8.5.1", + "resolved": "https://registry.npmjs.org/tsup/-/tsup-8.5.1.tgz", + "integrity": "sha512-xtgkqwdhpKWr3tKPmCkvYmS9xnQK3m3XgxZHwSUjvfTjp7YfXe5tT3GgWi0F2N+ZSMsOeWeZFh7ZZFg5iPhing==", + "dev": true, + "license": "MIT", + "dependencies": { + "bundle-require": "^5.1.0", + "cac": "^6.7.14", + "chokidar": "^4.0.3", + "consola": "^3.4.0", + "debug": "^4.4.0", + "esbuild": "^0.27.0", + "fix-dts-default-cjs-exports": "^1.0.0", + "joycon": "^3.1.1", + "picocolors": "^1.1.1", + "postcss-load-config": "^6.0.1", + "resolve-from": "^5.0.0", + "rollup": "^4.34.8", + "source-map": "^0.7.6", + "sucrase": "^3.35.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.11", + "tree-kill": "^1.2.2" + }, + "bin": { + "tsup": "dist/cli-default.js", + "tsup-node": "dist/cli-node.js" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@microsoft/api-extractor": "^7.36.0", + "@swc/core": "^1", + "postcss": "^8.4.12", + "typescript": ">=4.5.0" + }, + "peerDependenciesMeta": { + "@microsoft/api-extractor": { + "optional": true + }, + "@swc/core": { + "optional": true + }, + "postcss": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/tsup/node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/type-is": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "license": "MIT", + "peer": true, + "dependencies": { + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/type-is/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.65.0.tgz", + "integrity": "sha512-/ggrHAwyjENDusvyxbuqxAC2dTnZg/Z8F+fgQtYIz+L6n/9HfSlEZcFGV/NsMNa6CkGk0xUjUAFwC0vHOflvIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.65.0", + "@typescript-eslint/parser": "8.65.0", + "@typescript-eslint/typescript-estree": "8.65.0", + "@typescript-eslint/utils": "8.65.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/ufo": { + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.4.tgz", + "integrity": "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==", + "dev": true, + "license": "MIT" + }, + "node_modules/undici": { + "version": "8.9.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.9.0.tgz", + "integrity": "sha512-aWZpUj7XoGonMClx4gdDRfgBjqeA+F473aDmROQQbM9n6PRfK/u1q/a0X4wMTgcHfT8H6fpbt98PFuDUwFg2YA==", + "license": "MIT", + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vite": { + "version": "8.1.5", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.5.tgz", + "integrity": "sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.17", + "rolldown": "~1.1.5", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.3.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz", + "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.10", + "@vitest/mocker": "4.1.10", + "@vitest/pretty-format": "4.1.10", + "@vitest/runner": "4.1.10", + "@vitest/snapshot": "4.1.10", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.10", + "@vitest/browser-preview": "4.1.10", + "@vitest/browser-webdriverio": "4.1.10", + "@vitest/coverage-istanbul": "4.1.10", + "@vitest/coverage-v8": "4.1.10", + "@vitest/ui": "4.1.10", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/vitest/node_modules/tinyexec": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/when-exit": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/when-exit/-/when-exit-2.1.5.tgz", + "integrity": "sha512-VGkKJ564kzt6Ms1dbgPP/yuIoQCrsFAnRbptpC5wOEsDaNsbCB2bnfnaA8i/vRs5tjUSEOtIuvl9/MyVsvQZCg==", + "license": "MIT" + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC", + "peer": true + }, + "node_modules/wsl-utils": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-0.1.0.tgz", + "integrity": "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==", + "license": "MIT", + "dependencies": { + "is-wsl": "^3.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/clients/mcpi/package.json b/clients/mcpi/package.json new file mode 100644 index 000000000..1dd3b3d49 --- /dev/null +++ b/clients/mcpi/package.json @@ -0,0 +1,53 @@ +{ + "name": "@modelcontextprotocol/mcpi", + "private": true, + "description": "Session-oriented MCP Inspector CLI (mcpi) — connect once, run many commands", + "license": "MIT", + "type": "module", + "main": "build/mcp-bin.js", + "bin": { + "mcpi": "./build/mcp-bin.js" + }, + "files": [ + "build", + "README.md" + ], + "scripts": { + "build": "tsup", + "validate": "npm run format:check && npm run lint && npm run test", + "test": "vitest run", + "test:watch": "vitest", + "test:coverage": "npm run test-servers:build && npm run build && vitest run --coverage", + "test-servers:build": "tsc -p ../../test-servers --noCheck", + "pretest": "npm run test-servers:build && npm run build", + "lint": "eslint .", + "format": "prettier --write src __tests__", + "format:check": "prettier --check src __tests__" + }, + "dependencies": { + "@modelcontextprotocol/client": "2.0.0-beta.5", + "@modelcontextprotocol/core": "2.0.0-beta.5", + "@modelcontextprotocol/server": "2.0.0-beta.5", + "@modelcontextprotocol/server-legacy": "2.0.0-beta.5", + "@napi-rs/keyring": "^1.3.0", + "ajv": "^8.17.1", + "atomically": "^2.1.1", + "commander": "^13.1.0", + "open": "^10.2.0", + "pino": "^9.14.0", + "undici": "^8.5.0", + "zod": "^4.3.6" + }, + "devDependencies": { + "@eslint/js": "^9.39.4", + "@types/node": "^24.12.4", + "@vitest/coverage-v8": "^4.1.0", + "eslint": "^9.39.4", + "globals": "^17.4.0", + "prettier": "^3.8.1", + "tsup": "^8.5.0", + "typescript": "~5.9.3", + "typescript-eslint": "^8.56.1", + "vitest": "^4.1.0" + } +} diff --git a/clients/mcpi/src/daemon/auth.ts b/clients/mcpi/src/daemon/auth.ts new file mode 100644 index 000000000..68996ef55 --- /dev/null +++ b/clients/mcpi/src/daemon/auth.ts @@ -0,0 +1,43 @@ +import { timingSafeEqual } from "node:crypto"; +import { CliExitCodeError, EXIT_CODES } from "@inspector/cli/error-handler.js"; +import { DAEMON_TOKEN_ENV } from "./paths.js"; + +/** + * Read the IPC token from the environment (parent client or daemon child). + * Empty / unset → shared (unauthenticated) mode. + */ +export function getDaemonTokenFromEnv( + env: NodeJS.ProcessEnv = process.env, +): string | undefined { + const token = env[DAEMON_TOKEN_ENV]?.trim(); + return token || undefined; +} + +/** Constant-time compare; false if either side is missing or lengths differ. */ +export function tokensEqual( + expected: string | undefined, + provided: string | undefined, +): boolean { + if (expected === undefined || provided === undefined) return false; + const a = Buffer.from(expected, "utf8"); + const b = Buffer.from(provided, "utf8"); + if (a.length !== b.length) return false; + return timingSafeEqual(a, b); +} + +/** + * When {@link requiredToken} is set, reject requests that omit or mismatch it. + */ +export function assertDaemonToken( + requiredToken: string | undefined, + provided: string | undefined, +): void { + if (requiredToken === undefined) return; + if (!tokensEqual(requiredToken, provided)) { + throw new CliExitCodeError( + EXIT_CODES.USAGE, + "Daemon IPC authentication failed (missing or invalid token).", + { code: "daemon_auth_failed" }, + ); + } +} diff --git a/clients/mcpi/src/daemon/client.ts b/clients/mcpi/src/daemon/client.ts new file mode 100644 index 000000000..2932370fa --- /dev/null +++ b/clients/mcpi/src/daemon/client.ts @@ -0,0 +1,141 @@ +import { randomUUID } from "node:crypto"; +import * as net from "node:net"; +import { CliExitCodeError, EXIT_CODES } from "@inspector/cli/error-handler.js"; +import { getDaemonTokenFromEnv } from "./auth.js"; +import { encodeRequest } from "./framing.js"; +import { getDaemonSocketPath } from "./paths.js"; +import type { DaemonOp, DaemonRequest, DaemonResponse } from "./protocol.js"; + +export type DaemonClientOptions = { + socketPath?: string; + /** Per-request timeout in ms. */ + timeoutMs?: number; + /** IPC token; defaults to `MCP_INSPECTOR_DAEMON_TOKEN` when set. */ + token?: string; +}; + +/** + * Short-lived NDJSON client for one request/response against the daemon. + */ +export async function callDaemon( + op: DaemonOp, + params?: DaemonRequest["params"], + options: DaemonClientOptions = {}, +): Promise { + const socketPath = options.socketPath ?? getDaemonSocketPath(); + const timeoutMs = options.timeoutMs ?? 60_000; + const id = randomUUID(); + const token = options.token ?? getDaemonTokenFromEnv(); + const request: DaemonRequest = { id, op, params }; + if (token !== undefined) request.token = token; + + return new Promise((resolve, reject) => { + let settled = false; + let buffer = ""; + // `let` so settle() can clearTimeout before the assignment if connect fails + // synchronously (prefer-const would put `timer` in the TDZ for that race). + // eslint-disable-next-line prefer-const -- see comment above + let timer: ReturnType | undefined; + const socket = new net.Socket(); + + function settle(fn: () => void) { + /* v8 ignore next -- settle() no-op when already settled (connect/timeout race) */ + if (settled) return; + settled = true; + if (timer !== undefined) clearTimeout(timer); + socket.removeAllListeners(); + socket.on("error", () => {}); + fn(); + } + + function fail(error: unknown) { + settle(() => { + socket.destroy(); + reject(error); + }); + } + + function succeed(value: T) { + settle(() => { + socket.end(); + resolve(value); + }); + } + + function handleLine(line: string) { + const trimmed = line.trim(); + if (!trimmed) return; + let response: DaemonResponse; + try { + response = JSON.parse(trimmed) as DaemonResponse; + } catch (error) { + fail(error); + return; + } + if (response.id !== id && response.id !== "?") { + return; + } + if (!response.ok) { + fail( + new CliExitCodeError( + response.error.exitCode ?? EXIT_CODES.USAGE, + response.error.message, + { code: response.error.code }, + ), + ); + return; + } + succeed(response.result as T); + } + + socket.on("error", (err) => { + fail( + new CliExitCodeError( + EXIT_CODES.UNREACHABLE, + `Cannot reach session daemon at ${socketPath}: ${err.message}`, + { code: "daemon_unreachable" }, + ), + ); + }); + + // Clean FIN with no response must not sit until timeoutMs (mirrors + // streamDaemon's close guard). + socket.on("close", () => { + if (!settled) { + fail( + new CliExitCodeError( + EXIT_CODES.UNREACHABLE, + `Session daemon closed the connection during '${op}'`, + { code: "daemon_unreachable" }, + ), + ); + } + }); + + timer = setTimeout(() => { + fail( + new CliExitCodeError( + EXIT_CODES.UNREACHABLE, + `Daemon request '${op}' timed out after ${timeoutMs}ms`, + { code: "daemon_timeout" }, + ), + ); + }, timeoutMs); + + socket.once("connect", () => { + socket.write(encodeRequest(request)); + }); + + socket.on("data", (chunk) => { + buffer += String(chunk); + let idx: number; + while ((idx = buffer.indexOf("\n")) >= 0) { + const line = buffer.slice(0, idx); + buffer = buffer.slice(idx + 1); + handleLine(line); + } + }); + + socket.connect(socketPath); + }); +} diff --git a/clients/mcpi/src/daemon/ensure.ts b/clients/mcpi/src/daemon/ensure.ts new file mode 100644 index 000000000..69f6b435e --- /dev/null +++ b/clients/mcpi/src/daemon/ensure.ts @@ -0,0 +1,147 @@ +import { spawn } from "node:child_process"; +import * as fs from "node:fs"; +import * as net from "node:net"; +import * as path from "node:path"; +import { fileURLToPath } from "node:url"; +import { CliExitCodeError, EXIT_CODES } from "@inspector/cli/error-handler.js"; +import { getDaemonTokenFromEnv } from "./auth.js"; +import { callDaemon } from "./client.js"; +import { + DAEMON_DIR_ENV, + DAEMON_TOKEN_ENV, + ensureDaemonDir, + getDaemonDir, + getDaemonSocketPath, +} from "./paths.js"; + +const READY_TIMEOUT_MS = 10_000; +const READY_POLL_MS = 50; + +/** + * Resolve the built daemon entry (`build/daemon.js`) next to this package's + * build output. When running from source under vitest, prefer the built file + * if present; otherwise throw a clear error. + */ +export function resolveDaemonScriptPath(): string { + // ensure.ts lives at src/daemon/ensure.ts → ../../build/daemon.js + // In the bundle, import.meta.url is build/daemon-*.js or similar; tsup emits + // ensure into the daemon entry chunk. Prefer an explicit sibling daemon.js. + const here = path.dirname(fileURLToPath(import.meta.url)); + const candidates = [ + path.resolve(here, "daemon.js"), + path.resolve(here, "../daemon.js"), + path.resolve(here, "../../build/daemon.js"), + path.resolve(here, "../build/daemon.js"), + ]; + for (const candidate of candidates) { + if (fs.existsSync(candidate)) return candidate; + } + /* v8 ignore next 6 -- only when clients/cli/build is missing; pretest always + builds, and fs.existsSync cannot be spied in this ESM package under vitest. */ + throw new CliExitCodeError( + EXIT_CODES.USAGE, + `Session daemon bundle not found (looked for daemon.js near ${here}). Run npm run build in clients/mcpi.`, + { code: "daemon_not_built" }, + ); +} + +async function isDaemonReachable(socketPath: string): Promise { + return new Promise((resolve) => { + let settled = false; + const socket = new net.Socket(); + const done = (ok: boolean) => { + /* v8 ignore next -- re-entry when connect and error both fire */ + if (settled) return; + settled = true; + socket.removeAllListeners(); + socket.on("error", () => {}); + socket.destroy(); + resolve(ok); + }; + socket.on("error", () => done(false)); + socket.setTimeout(500); + socket.once("connect", () => done(true)); + /* v8 ignore next -- 500ms probe timeout; ensureDaemon usually connects faster */ + socket.once("timeout", () => done(false)); + socket.connect(socketPath); + }); +} + +async function waitForDaemon( + socketPath: string, + token: string | undefined, +): Promise { + const deadline = Date.now() + READY_TIMEOUT_MS; + while (Date.now() < deadline) { + if (await isDaemonReachable(socketPath)) { + try { + await callDaemon("ping", {}, { socketPath, timeoutMs: 2000, token }); + return; + } catch { + // connected but not ready yet + } + } + await new Promise((r) => setTimeout(r, READY_POLL_MS)); + } + /* v8 ignore next 5 -- requires a stuck spawn */ + throw new CliExitCodeError( + EXIT_CODES.UNREACHABLE, + `Timed out waiting for session daemon at ${socketPath}`, + { code: "daemon_start_timeout" }, + ); +} + +/** + * Ensure a session daemon is running for the current {@link getDaemonDir}. + * Auto-spawns a detached Node process when the socket is not reachable. + * + * When `MCP_INSPECTOR_DAEMON_TOKEN` is set (private mode), the child inherits + * that token and every IPC call must present it. + */ +export async function ensureDaemon(options?: { + dir?: string; + daemonScript?: string; + token?: string; +}): Promise<{ socketPath: string; spawned: boolean }> { + const dir = options?.dir ?? getDaemonDir(); + const token = options?.token ?? getDaemonTokenFromEnv(); + ensureDaemonDir(dir); + const socketPath = getDaemonSocketPath(dir); + + if (await isDaemonReachable(socketPath)) { + try { + await callDaemon("ping", {}, { socketPath, timeoutMs: 2000, token }); + return { socketPath, spawned: false }; + } catch { + // stale socket — fall through to spawn + try { + fs.unlinkSync(socketPath); + } catch { + // ignore + } + } + } + + const script = options?.daemonScript ?? resolveDaemonScriptPath(); + const childEnv: NodeJS.ProcessEnv = { + ...process.env, + // Pin the socket directory explicitly so parent and child agree even when + // MCP_STORAGE_DIR is unset (default ~/.mcp-inspector). + [DAEMON_DIR_ENV]: dir, + }; + if (token !== undefined) { + childEnv[DAEMON_TOKEN_ENV] = token; + } else { + delete childEnv[DAEMON_TOKEN_ENV]; + } + + const child = spawn(process.execPath, [script], { + detached: true, + stdio: "ignore", + env: childEnv, + }); + child.unref(); + + await waitForDaemon(socketPath, token); + return { socketPath, spawned: true }; +} diff --git a/clients/mcpi/src/daemon/framing.ts b/clients/mcpi/src/daemon/framing.ts new file mode 100644 index 000000000..fb9822f00 --- /dev/null +++ b/clients/mcpi/src/daemon/framing.ts @@ -0,0 +1,28 @@ +import type { DaemonRequest, DaemonResponse } from "./protocol.js"; + +/** + * Parse one NDJSON line into a daemon request. Returns null for blank lines. + */ +export function parseRequestLine(line: string): DaemonRequest | null { + const trimmed = line.trim(); + if (!trimmed) return null; + const value: unknown = JSON.parse(trimmed); + if ( + value === null || + typeof value !== "object" || + Array.isArray(value) || + typeof (value as DaemonRequest).id !== "string" || + typeof (value as DaemonRequest).op !== "string" + ) { + throw new Error("Invalid daemon request: expected { id, op, params? }"); + } + return value as DaemonRequest; +} + +export function encodeResponse(response: DaemonResponse): string { + return JSON.stringify(response) + "\n"; +} + +export function encodeRequest(request: DaemonRequest): string { + return JSON.stringify(request) + "\n"; +} diff --git a/clients/mcpi/src/daemon/index.ts b/clients/mcpi/src/daemon/index.ts new file mode 100644 index 000000000..d7526945b --- /dev/null +++ b/clients/mcpi/src/daemon/index.ts @@ -0,0 +1,36 @@ +export { + assertDaemonToken, + getDaemonTokenFromEnv, + tokensEqual, +} from "./auth.js"; +export { callDaemon } from "./client.js"; +export { streamDaemon } from "./stream-client.js"; +export { ensureDaemon, resolveDaemonScriptPath } from "./ensure.js"; +export { encodeRequest, encodeResponse, parseRequestLine } from "./framing.js"; +export { + createPrivateDaemonDir, + DAEMON_DIR_ENV, + DAEMON_TOKEN_ENV, + ensureDaemonDir, + getDaemonDir, + getDaemonLockPath, + getDaemonSocketPath, + getInspectorHome, +} from "./paths.js"; +export type { + ConnectParams, + DaemonOp, + DaemonRequest, + DaemonResponse, + DaemonStatus, + RpcParams, + RpcResult, + SessionInfo, + SessionNameParams, +} from "./protocol.js"; +export { DaemonServer } from "./server.js"; +export { + DEFAULT_IDLE_MS, + isSessionAuthRequiredError, + SessionRegistry, +} from "./sessions.js"; diff --git a/clients/mcpi/src/daemon/ipc-glue.ts b/clients/mcpi/src/daemon/ipc-glue.ts new file mode 100644 index 000000000..64a90c429 --- /dev/null +++ b/clients/mcpi/src/daemon/ipc-glue.ts @@ -0,0 +1,125 @@ +/** + * Low-level Unix-socket accept / stale-socket helpers for {@link DaemonServer}. + * + * Outside the per-file coverage gate (see vitest.config.ts); behavior is + * covered by `__tests__/daemon-stream.test.ts`. + */ +import * as fs from "node:fs"; +import * as net from "node:net"; +import { createInterface } from "node:readline"; +import { encodeResponse, parseRequestLine } from "./framing.js"; +import type { + DaemonRequest, + DaemonResponse, + DaemonStreamFrame, +} from "./protocol.js"; + +export type StreamStarter = (writeData: (data: unknown) => void) => () => void; + +/** Result of handling one daemon request — optional long-lived stream. */ +export type HandleOutcome = { + response: DaemonResponse; + /** When set, keep the socket open and push stream frames until closed. */ + startStream?: StreamStarter; +}; + +export type HandleRequest = (request: DaemonRequest) => Promise; + +export function acceptDaemonConnection( + socket: net.Socket, + handle: HandleRequest, +): void { + const rl = createInterface({ input: socket, crlfDelay: Infinity }); + rl.on("line", (line) => { + void (async () => { + let request: DaemonRequest; + try { + const parsed = parseRequestLine(line); + if (!parsed) return; + request = parsed; + } catch (error) { + socket.write( + encodeResponse({ + id: "?", + ok: false, + error: { + code: "invalid_request", + message: error instanceof Error ? error.message : String(error), + }, + }), + ); + return; + } + const outcome = await handle(request); + if (socket.destroyed) return; + socket.write(encodeResponse(outcome.response)); + + if (!outcome.response.ok || !outcome.startStream) { + return; + } + + const id = request.id; + let stopped = false; + const writeData = (data: unknown) => { + if (stopped || socket.destroyed) return; + const frame: DaemonStreamFrame = { id, stream: "data", data }; + socket.write(JSON.stringify(frame) + "\n"); + }; + const stop = outcome.startStream(writeData); + const cleanup = () => { + if (stopped) return; + stopped = true; + try { + stop(); + } catch { + // ignore unsubscribe errors + } + if (!socket.destroyed) { + const end: DaemonStreamFrame = { id, stream: "end" }; + socket.write(JSON.stringify(end) + "\n"); + socket.end(); + } + }; + socket.once("close", cleanup); + socket.once("error", cleanup); + })(); + }); + socket.on("error", () => { + rl.close(); + }); +} + +export async function removeStaleDaemonSocket( + socketPath: string, +): Promise { + if (!fs.existsSync(socketPath)) return; + const live = await canConnect(socketPath); + if (live) { + throw new Error( + `Daemon already running at ${socketPath}. Use mcpi daemon stop first.`, + ); + } + try { + fs.unlinkSync(socketPath); + } catch { + // ignore + } +} + +async function canConnect(socketPath: string): Promise { + return new Promise((resolve) => { + let settled = false; + const socket = new net.Socket(); + const done = (ok: boolean) => { + if (settled) return; + settled = true; + socket.removeAllListeners(); + socket.on("error", () => {}); + socket.destroy(); + resolve(ok); + }; + socket.once("connect", () => done(true)); + socket.once("error", () => done(false)); + socket.connect(socketPath); + }); +} diff --git a/clients/mcpi/src/daemon/paths.ts b/clients/mcpi/src/daemon/paths.ts new file mode 100644 index 000000000..d850b56af --- /dev/null +++ b/clients/mcpi/src/daemon/paths.ts @@ -0,0 +1,67 @@ +import { randomUUID } from "node:crypto"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; + +/** Env: directory that owns daemon.sock + daemon.lock. */ +export const DAEMON_DIR_ENV = "MCP_INSPECTOR_DAEMON_DIR"; + +/** + * Env: IPC bearer token for private daemons. When set in the daemon process, + * every request must present the same value. When unset, the daemon is shared + * (same-UID filesystem trust only). + */ +export const DAEMON_TOKEN_ENV = "MCP_INSPECTOR_DAEMON_TOKEN"; + +/** + * Directory that owns the daemon socket + lock. + * Precedence: + * 1. `MCP_INSPECTOR_DAEMON_DIR` — explicit (private mode / auto-spawn parent) + * 2. `MCP_STORAGE_DIR` — CI / parallel isolation (same override as oauth.json) + * 3. `~/.mcp-inspector` + */ +export function getDaemonDir(): string { + const daemonDir = process.env[DAEMON_DIR_ENV]?.trim(); + if (daemonDir) return path.resolve(daemonDir); + const storage = process.env.MCP_STORAGE_DIR?.trim(); + if (storage) return path.resolve(storage); + /* v8 ignore next 2 -- USERPROFILE is the Windows fallback; CI/darwin use HOME. */ + const home = process.env.HOME || process.env.USERPROFILE || os.homedir(); + return path.join(home, ".mcp-inspector"); +} + +/** `~/.mcp-inspector` (or HOME-equivalent), ignoring daemon-dir overrides. */ +export function getInspectorHome(): string { + /* v8 ignore next 2 -- USERPROFILE is the Windows fallback; CI/darwin use HOME. */ + const home = process.env.HOME || process.env.USERPROFILE || os.homedir(); + return path.join(home, ".mcp-inspector"); +} + +/** + * Create a new private daemon directory under `~/.mcp-inspector/private//` + * (mode `0700`). Does not start the daemon. + */ +export function createPrivateDaemonDir(): string { + const id = randomUUID(); + const dir = path.join(getInspectorHome(), "private", id); + fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); + try { + fs.chmodSync(dir, 0o700); + } catch { + // best-effort on platforms that ignore mode + } + return dir; +} + +export function getDaemonSocketPath(dir: string = getDaemonDir()): string { + return path.join(dir, "daemon.sock"); +} + +export function getDaemonLockPath(dir: string = getDaemonDir()): string { + return path.join(dir, "daemon.lock"); +} + +/** Ensure the daemon directory exists before binding the socket. */ +export function ensureDaemonDir(dir: string = getDaemonDir()): void { + fs.mkdirSync(dir, { recursive: true }); +} diff --git a/clients/mcpi/src/daemon/protocol.ts b/clients/mcpi/src/daemon/protocol.ts new file mode 100644 index 000000000..2d1241cd4 --- /dev/null +++ b/clients/mcpi/src/daemon/protocol.ts @@ -0,0 +1,96 @@ +import type { + InspectorServerSettings, + MCPServerConfig, +} from "@inspector/core/mcp/types.js"; +import type { + CliAppInfo, + MethodArgs, +} from "@inspector/cli/handlers/method-types.js"; + +/** Operations the session daemon accepts over IPC. */ +export type DaemonOp = + | "ping" + | "connect" + | "disconnect" + | "sessions/list" + | "sessions/use" + | "daemon/status" + | "daemon/stop" + | "rpc" + | "stream"; + +export type ConnectParams = { + name: string; + serverConfig: MCPServerConfig; + serverSettings?: InspectorServerSettings; + /** Human-readable server identity for `sessions/list`. */ + serverIdentity: string; +}; + +export type SessionNameParams = { + /** Omit to target the MRU session (TTY). */ + name?: string; + /** + * When true (non-TTY / CI), omit is an error — require an explicit session. + * Front-end sets this from `!process.stdout.isTTY` unless opted out. + */ + requireExplicit?: boolean; +}; + +/** Params for `rpc` / `stream` — session targeting plus method args. */ +export type RpcParams = SessionNameParams & + MethodArgs & { + method: string; + }; + +export type DaemonRequest = { + id: string; + op: DaemonOp; + /** + * IPC auth token. Required when the daemon was started with + * `MCP_INSPECTOR_DAEMON_TOKEN` set (private mode); omitted for the shared + * default daemon. + */ + token?: string; + params?: + ConnectParams | SessionNameParams | RpcParams | Record; +}; + +export type DaemonErrorBody = { + code: string; + message: string; + /** Suggested CLI exit code when applicable. */ + exitCode?: number; +}; + +export type DaemonResponse = + | { id: string; ok: true; result: unknown } + | { id: string; ok: false; error: DaemonErrorBody }; + +/** Frames after the initial ok response on a `stream` connection. */ +export type DaemonStreamFrame = + { id: string; stream: "data"; data: unknown } | { id: string; stream: "end" }; + +export type SessionInfo = { + name: string; + serverIdentity: string; + connectedAt: number; + lastAccessedAt: number; + isMru: boolean; +}; + +export type DaemonStatus = { + pid: number; + socketPath: string; + sessions: SessionInfo[]; + idleMs: number | null; +}; + +/** Serializable RPC outcome (no live stream callbacks). */ +export type RpcResult = + | { + kind: "result"; + result: Record; + appInfo?: CliAppInfo; + } + | { kind: "ndjson"; lines: unknown[] }; diff --git a/clients/mcpi/src/daemon/run.ts b/clients/mcpi/src/daemon/run.ts new file mode 100644 index 000000000..4b28cd1b2 --- /dev/null +++ b/clients/mcpi/src/daemon/run.ts @@ -0,0 +1,29 @@ +#!/usr/bin/env node +/** + * Session daemon entrypoint. Spawned detached by {@link ensureDaemon}. + * Optional foreground `mcpi daemon run` is not shipped yet (see v2_cli_v2.md). + */ +import { DaemonServer } from "./server.js"; + +async function main(): Promise { + const server = new DaemonServer({ + onShutdown: () => { + // Allow natural exit once the server closes and idle work finishes. + process.exitCode = 0; + }, + }); + + const shutdown = () => { + void server.stop("signal").then(() => process.exit(0)); + }; + process.on("SIGINT", shutdown); + process.on("SIGTERM", shutdown); + + await server.start(); +} + +main().catch((error: unknown) => { + const message = error instanceof Error ? error.message : String(error); + process.stderr.write(`mcpi daemon: ${message}\n`); + process.exit(1); +}); diff --git a/clients/mcpi/src/daemon/server.ts b/clients/mcpi/src/daemon/server.ts new file mode 100644 index 000000000..a1a6578b3 --- /dev/null +++ b/clients/mcpi/src/daemon/server.ts @@ -0,0 +1,342 @@ +import * as fs from "node:fs"; +import * as net from "node:net"; +import { + classifyError, + CliExitCodeError, + EXIT_CODES, +} from "@inspector/cli/error-handler.js"; +import { runMethod } from "@inspector/cli/handlers/run-method.js"; +import type { MethodArgs } from "@inspector/cli/handlers/method-types.js"; +import { + acceptDaemonConnection, + removeStaleDaemonSocket, + type HandleOutcome, +} from "./ipc-glue.js"; +import { assertDaemonToken, getDaemonTokenFromEnv } from "./auth.js"; +import { + ensureDaemonDir, + getDaemonDir, + getDaemonLockPath, + getDaemonSocketPath, +} from "./paths.js"; +import type { + ConnectParams, + DaemonRequest, + DaemonResponse, + DaemonStatus, + RpcParams, + RpcResult, + SessionNameParams, +} from "./protocol.js"; +import { DEFAULT_IDLE_MS, SessionRegistry } from "./sessions.js"; + +export type DaemonServerOptions = { + dir?: string; + idleMs?: number; + /** + * When set, every IPC request must present this token. Defaults to + * `MCP_INSPECTOR_DAEMON_TOKEN` from the environment (private mode). + */ + requiredToken?: string; + /** Called when the daemon should exit (idle timeout or daemon/stop). */ + onShutdown?: () => void; +}; + +/** + * Unix-socket NDJSON daemon that owns {@link SessionRegistry}. + */ +export class DaemonServer { + readonly registry: SessionRegistry; + readonly socketPath: string; + readonly lockPath: string; + readonly dir: string; + private readonly requiredToken: string | undefined; + private server: net.Server | null = null; + private readonly onShutdown: (() => void) | null; + private stopping = false; + + constructor(options: DaemonServerOptions = {}) { + this.dir = options.dir ?? getDaemonDir(); + this.socketPath = getDaemonSocketPath(this.dir); + this.lockPath = getDaemonLockPath(this.dir); + this.requiredToken = options.requiredToken ?? getDaemonTokenFromEnv(); + this.registry = new SessionRegistry(options.idleMs ?? DEFAULT_IDLE_MS); + this.onShutdown = options.onShutdown ?? null; + this.registry.setIdleHandler(() => { + void this.stop("idle"); + }); + } + + async start(): Promise { + ensureDaemonDir(this.dir); + await removeStaleDaemonSocket(this.socketPath); + this.writeLock(); + + this.server = net.createServer((socket) => { + acceptDaemonConnection(socket, (req) => this.handleOutcome(req)); + }); + + await new Promise((resolve, reject) => { + this.server!.once("error", reject); + this.server!.listen(this.socketPath, () => { + this.server!.off("error", reject); + resolve(); + }); + }); + + // Restrict socket + lock to the creating user. Private mode also requires + // an IPC token (see specification/v2_cli_v2.md §5.3). + try { + fs.chmodSync(this.socketPath, 0o600); + fs.chmodSync(this.lockPath, 0o600); + } catch { + // Unsupported on some platforms (e.g. Windows named pipes). + } + + // Session-less spawn (e.g. ensureDaemon from tools/list with no sessions) + // must still self-reap — idle was previously only armed after disconnect. + this.registry.armIdleTimerIfEmpty(); + } + + async stop(reason: "idle" | "stop" | "signal" = "stop"): Promise { + void reason; + if (this.stopping) return; + this.stopping = true; + await this.registry.disconnectAll(); + await new Promise((resolve) => { + if (!this.server) { + resolve(); + return; + } + this.server.close(() => resolve()); + }); + this.server = null; + this.removeLockAndSocket(); + this.onShutdown?.(); + } + + status(): DaemonStatus { + return { + pid: process.pid, + socketPath: this.socketPath, + sessions: this.registry.list(), + idleMs: this.registry.idleRemainingMs(), + }; + } + + /** Handle one request; returns the response body (used by in-process tests). */ + async handle(request: DaemonRequest): Promise { + return (await this.handleOutcome(request)).response; + } + + /** Full handle including optional stream starter (socket accept path). */ + async handleOutcome(request: DaemonRequest): Promise { + try { + assertDaemonToken(this.requiredToken, request.token); + return await this.dispatch(request); + } catch (error) { + if (error instanceof CliExitCodeError) { + return { + response: { + id: request.id, + ok: false, + error: { + code: error.envelope?.code ?? "cli_error", + message: error.message, + exitCode: error.exitCode, + }, + }, + }; + } + // Match one-shot CLI exit codes (e.g. unreachable → 4, not always 1). + const { exitCode, envelope } = classifyError(error); + return { + response: { + id: request.id, + ok: false, + error: { + code: envelope.code, + message: envelope.message, + exitCode, + }, + }, + }; + } + } + + private async dispatch(request: DaemonRequest): Promise { + switch (request.op) { + case "ping": + return { + response: { + id: request.id, + ok: true, + result: { pong: true, pid: process.pid }, + }, + }; + case "connect": { + const params = request.params as ConnectParams; + if (!params?.name || !params.serverConfig || !params.serverIdentity) { + throw new CliExitCodeError( + EXIT_CODES.USAGE, + "connect requires name, serverConfig, and serverIdentity", + { code: "invalid_params" }, + ); + } + return { + response: { + id: request.id, + ok: true, + result: await this.registry.connect(params), + }, + }; + } + case "disconnect": { + const params = (request.params ?? {}) as SessionNameParams; + return { + response: { + id: request.id, + ok: true, + result: await this.registry.disconnect( + params.name, + params.requireExplicit, + ), + }, + }; + } + case "sessions/list": + return { + response: { + id: request.id, + ok: true, + result: { sessions: this.registry.list() }, + }, + }; + case "sessions/use": { + const params = (request.params ?? {}) as SessionNameParams; + if (!params.name) { + throw new CliExitCodeError( + EXIT_CODES.USAGE, + "sessions/use requires a session name", + { code: "invalid_params" }, + ); + } + return { + response: { + id: request.id, + ok: true, + result: this.registry.use(params.name), + }, + }; + } + case "daemon/status": + return { + response: { id: request.id, ok: true, result: this.status() }, + }; + case "daemon/stop": + queueMicrotask(() => { + void this.stop("stop"); + }); + return { + response: { id: request.id, ok: true, result: { stopping: true } }, + }; + case "rpc": + return { + response: { + id: request.id, + ok: true, + result: await this.runRpc(request.params as RpcParams), + }, + }; + case "stream": + return this.openStream(request.id, request.params as RpcParams); + default: + throw new CliExitCodeError( + EXIT_CODES.USAGE, + `Unknown daemon op: ${(request as DaemonRequest).op}`, + { code: "unknown_op" }, + ); + } + } + + private async runRpc(params: RpcParams): Promise { + if (!params?.method) { + throw new CliExitCodeError(EXIT_CODES.USAGE, "rpc requires a method", { + code: "invalid_params", + }); + } + const client = this.registry.clientFor(params.name, params.requireExplicit); + const methodArgs = stripSessionFields(params); + const outcome = await runMethod(client, methodArgs); + if (outcome.kind === "stream") { + throw new CliExitCodeError( + EXIT_CODES.USAGE, + `Method '${params.method}' is a stream; use the stream op.`, + { code: "use_stream_op" }, + ); + } + if (outcome.kind === "ndjson") { + return { kind: "ndjson", lines: outcome.lines }; + } + return { + kind: "result", + result: outcome.result, + appInfo: outcome.appInfo, + }; + } + + private async openStream( + id: string, + params: RpcParams, + ): Promise { + if (!params?.method) { + throw new CliExitCodeError(EXIT_CODES.USAGE, "stream requires a method", { + code: "invalid_params", + }); + } + const client = this.registry.clientFor(params.name, params.requireExplicit); + const methodArgs = stripSessionFields(params); + const outcome = await runMethod(client, methodArgs); + if (outcome.kind !== "stream") { + throw new CliExitCodeError( + EXIT_CODES.USAGE, + `Method '${params.method}' is not a stream; use the rpc op.`, + { code: "use_rpc_op" }, + ); + } + return { + response: { + id, + ok: true, + result: { streaming: true, label: outcome.label }, + }, + startStream: outcome.start, + }; + } + + private writeLock(): void { + fs.writeFileSync(this.lockPath, `${process.pid}\n`, { flag: "w" }); + } + + private removeLockAndSocket(): void { + try { + fs.unlinkSync(this.socketPath); + } catch { + // absent is fine + } + try { + fs.unlinkSync(this.lockPath); + } catch { + // absent is fine + } + } +} + +function stripSessionFields( + params: RpcParams, +): MethodArgs & { method: string } { + const { name, requireExplicit, method, ...rest } = params; + void name; + void requireExplicit; + return { method, ...rest }; +} diff --git a/clients/mcpi/src/daemon/sessions.ts b/clients/mcpi/src/daemon/sessions.ts new file mode 100644 index 000000000..21eebb00e --- /dev/null +++ b/clients/mcpi/src/daemon/sessions.ts @@ -0,0 +1,365 @@ +import { InspectorClient } from "@inspector/core/mcp/index.js"; +import type { InspectorClientEnvironment } from "@inspector/core/mcp/types.js"; +import { + eraToVersionNegotiation, + type InspectorServerSettings, + type MCPServerConfig, +} from "@inspector/core/mcp/types.js"; +import { createTransportNode } from "@inspector/core/mcp/node/index.js"; +import { + ConsoleNavigation, + MutableRedirectUrlProvider, +} from "@inspector/core/auth/index.js"; +import { NodeOAuthStorage } from "@inspector/core/auth/node/index.js"; +import { resetNodeOAuthStorageCache } from "@inspector/core/auth/node/storage-node.js"; +import { + DEFAULT_RUNNER_OAUTH_CALLBACK_URL, + formatRunnerOAuthRedirectUrl, + parseRunnerOAuthCallbackUrl, +} from "@inspector/core/auth/node/runner-oauth-callback.js"; +import { + buildRunnerClientAuthOptions, + isOAuthCapableServerConfig, + loadRunnerClientConfig, +} from "@inspector/core/client/runner.js"; +import { readInspectorVersion } from "@inspector/core/node/version.js"; +import { + AuthRecoveryRequiredError, + isUnauthorizedError, +} from "@inspector/core/auth/index.js"; +import { CliExitCodeError, EXIT_CODES } from "@inspector/cli/error-handler.js"; +import type { SessionInfo } from "./protocol.js"; + +const SESSION_CLIENT_NAME = "inspector-cli"; + +/** Default idle timeout after the last session disconnects (~60s). */ +export const DEFAULT_IDLE_MS = 60_000; + +type LiveSession = { + name: string; + serverIdentity: string; + connectedAt: number; + lastAccessedAt: number; + client: InspectorClient; +}; + +/** + * In-memory registry of live MCP sessions owned by the daemon. + */ +export class SessionRegistry { + private readonly sessions = new Map(); + private mruName: string | null = null; + private idleTimer: ReturnType | null = null; + /** Absolute deadline for idle shutdown while the timer is armed. */ + private idleDeadline: number | null = null; + private onIdle: (() => void) | null = null; + private readonly idleMs: number; + + constructor(idleMs: number = DEFAULT_IDLE_MS) { + this.idleMs = idleMs; + } + + /** Register a callback invoked when the idle timer fires with no sessions. */ + setIdleHandler(handler: (() => void) | null): void { + this.onIdle = handler; + } + + /** + * Arm the idle shutdown timer when there are no sessions. + * Called at daemon start so a spawn that never connects still self-reaps, + * and after a failed connect that left the registry empty. + */ + armIdleTimerIfEmpty(): void { + if (this.sessions.size === 0) { + this.armIdleTimer(); + } + } + + list(): SessionInfo[] { + return [...this.sessions.values()] + .map((s) => ({ + name: s.name, + serverIdentity: s.serverIdentity, + connectedAt: s.connectedAt, + lastAccessedAt: s.lastAccessedAt, + isMru: s.name === this.mruName, + })) + .sort((a, b) => b.lastAccessedAt - a.lastAccessedAt); + } + + getMruName(): string | null { + return this.mruName; + } + + sessionCount(): number { + return this.sessions.size; + } + + /** + * Resolve a session by explicit name or MRU. Throws {@link CliExitCodeError} + * when missing / ambiguous under CI rules. + */ + resolve( + name: string | undefined, + requireExplicit: boolean | undefined, + ): LiveSession { + if (!name) { + if (requireExplicit) { + throw new CliExitCodeError( + EXIT_CODES.USAGE, + "Explicit --session / @name is required in non-interactive mode.", + { code: "session_required" }, + ); + } + if (!this.mruName) { + throw new CliExitCodeError( + EXIT_CODES.USAGE, + "No open sessions. Connect first (e.g. mcpi servers/list, mcpi connect ).", + { code: "no_session" }, + ); + } + name = this.mruName; + } + const session = this.sessions.get(name); + if (!session) { + throw new CliExitCodeError( + EXIT_CODES.USAGE, + `Session '${name}' not found. Use mcpi sessions/list.`, + { code: "session_not_found" }, + ); + } + return session; + } + + touch(name: string): void { + const session = this.sessions.get(name); + if (!session) return; + session.lastAccessedAt = Date.now(); + this.mruName = name; + this.clearIdleTimer(); + } + + /** + * Resolve a session for an RPC/stream, touch MRU, and return its client. + */ + clientFor( + name: string | undefined, + requireExplicit: boolean | undefined, + ): InspectorClient { + const session = this.resolve(name, requireExplicit); + this.touch(session.name); + return session.client; + } + + use(name: string): SessionInfo { + const session = this.resolve(name, true); + this.touch(session.name); + return { + name: session.name, + serverIdentity: session.serverIdentity, + connectedAt: session.connectedAt, + lastAccessedAt: session.lastAccessedAt, + isMru: true, + }; + } + + async connect(params: { + name: string; + serverConfig: MCPServerConfig; + serverSettings?: InspectorServerSettings; + serverIdentity: string; + }): Promise { + this.clearIdleTimer(); + + try { + if (this.sessions.has(params.name)) { + // Reconnect: tear down the previous client first. + await this.disconnect(params.name, false); + } + + // Front-end authorize / auth/clear write oauth.json in another process. + // Drop the daemon's cached store so this connect re-reads disk. + resetNodeOAuthStorageCache(); + + const client = await createSessionClient( + params.serverConfig, + params.serverSettings, + ); + + try { + await client.connect(); + } catch (error) { + await safeDisconnect(client); + if (isSessionAuthRequiredError(error)) { + throw new CliExitCodeError( + EXIT_CODES.AUTH_REQUIRED, + error instanceof Error ? error.message : String(error), + { code: "auth_required" }, + ); + } + throw error; + } + + const now = Date.now(); + this.sessions.set(params.name, { + name: params.name, + serverIdentity: params.serverIdentity, + connectedAt: now, + lastAccessedAt: now, + client, + }); + this.mruName = params.name; + + return { + name: params.name, + serverIdentity: params.serverIdentity, + connectedAt: now, + lastAccessedAt: now, + isMru: true, + }; + } catch (error) { + // Any failure after clearIdleTimer (createSessionClient, reconnect + // disconnect, client.connect, …) must re-arm so a session-less daemon + // still self-reaps. + this.armIdleTimerIfEmpty(); + throw error; + } + } + + async disconnect( + name: string | undefined, + requireExplicit: boolean | undefined, + ): Promise<{ name: string }> { + const session = this.resolve(name, requireExplicit); + const sessionName = session.name; + this.sessions.delete(sessionName); + if (this.mruName === sessionName) { + // Promote the next most-recently-accessed session, if any. + const remaining = [...this.sessions.values()].sort( + (a, b) => b.lastAccessedAt - a.lastAccessedAt, + ); + this.mruName = remaining[0]?.name ?? null; + } + await safeDisconnect(session.client); + if (this.sessions.size === 0) { + this.armIdleTimer(); + } + return { name: sessionName }; + } + + async disconnectAll(): Promise { + const names = [...this.sessions.keys()]; + for (const name of names) { + await this.disconnect(name, false); + } + this.clearIdleTimer(); + } + + private armIdleTimer(): void { + this.clearIdleTimer(); + if (this.idleMs <= 0 || !this.onIdle) return; + this.idleDeadline = Date.now() + this.idleMs; + this.idleTimer = setTimeout(() => { + this.idleTimer = null; + this.idleDeadline = null; + if (this.sessions.size === 0) { + this.onIdle?.(); + } + }, this.idleMs); + // Don't keep the process alive solely for the idle timer when nothing else + // is pending — the socket server keeps the event loop alive. + this.idleTimer.unref?.(); + } + + private clearIdleTimer(): void { + if (this.idleTimer) { + clearTimeout(this.idleTimer); + this.idleTimer = null; + } + this.idleDeadline = null; + } + + /** Remaining ms until idle shutdown, or null if not armed. */ + idleRemainingMs(): number | null { + if (this.idleDeadline === null) return null; + return Math.max(0, this.idleDeadline - Date.now()); + } +} + +/** + * Connect failures that should trigger front-end interactive OAuth (then retry), + * not a hard ErrorEnvelope. Includes SDK token-exchange mistakes that happen when + * stored creds need a full re-auth. + */ +export function isSessionAuthRequiredError(error: unknown): boolean { + if ( + error instanceof AuthRecoveryRequiredError || + isUnauthorizedError(error) + ) { + return true; + } + const message = error instanceof Error ? error.message : String(error); + return ( + /prepareTokenRequest\(\) or authorizationCode is required/i.test(message) || + /redirectUrl is required for authorization_code/i.test(message) || + /No code verifier saved for session/i.test(message) + ); +} + +async function createSessionClient( + serverConfig: MCPServerConfig, + serverSettings: InspectorServerSettings | undefined, +): Promise { + const environment: InspectorClientEnvironment = { + transport: createTransportNode, + }; + const redirectUrlProvider = new MutableRedirectUrlProvider(); + if (isOAuthCapableServerConfig(serverConfig)) { + // Must be non-empty: SDK treats a falsy redirectUrl as "non-interactive" and + // calls fetchToken() without an authorization code (breaking stored-token / + // refresh reconnect). Interactive login still runs in the front-end on + // auth_required; this value only keeps the daemon's silent path correct. + const callbackUrlConfig = parseRunnerOAuthCallbackUrl( + process.env.MCP_OAUTH_CALLBACK_URL ?? DEFAULT_RUNNER_OAUTH_CALLBACK_URL, + ); + redirectUrlProvider.redirectUrl = + formatRunnerOAuthRedirectUrl(callbackUrlConfig); + environment.oauth = { + storage: new NodeOAuthStorage(), + navigation: new ConsoleNavigation(), + redirectUrlProvider, + }; + } + + const clientConfig = await loadRunnerClientConfig({}); + const clientAuthOptions = buildRunnerClientAuthOptions( + clientConfig, + serverSettings, + {}, + ); + + return new InspectorClient(serverConfig, { + environment, + clientIdentity: { + name: SESSION_CLIENT_NAME, + version: readInspectorVersion(import.meta.url), + }, + initialLoggingLevel: "debug", + progress: false, + sample: false, + elicit: false, + serverSettings, + ...(serverSettings?.protocolEra && { + versionNegotiation: eraToVersionNegotiation(serverSettings.protocolEra), + }), + ...clientAuthOptions, + }); +} + +async function safeDisconnect(client: InspectorClient): Promise { + try { + await client.disconnect(); + } catch { + // Best-effort teardown. + } +} diff --git a/clients/mcpi/src/daemon/stream-client.ts b/clients/mcpi/src/daemon/stream-client.ts new file mode 100644 index 000000000..7a2419cd3 --- /dev/null +++ b/clients/mcpi/src/daemon/stream-client.ts @@ -0,0 +1,183 @@ +/** + * Long-lived daemon stream client. + * + * Outside the per-file coverage gate (see vitest.config.ts); behavior is + * covered by `__tests__/daemon-stream.test.ts`. + */ +import { randomUUID } from "node:crypto"; +import * as net from "node:net"; +import { CliExitCodeError, EXIT_CODES } from "@inspector/cli/error-handler.js"; +import { getDaemonTokenFromEnv } from "./auth.js"; +import { encodeRequest } from "./framing.js"; +import { getDaemonSocketPath } from "./paths.js"; +import type { + DaemonRequest, + DaemonResponse, + DaemonStreamFrame, +} from "./protocol.js"; +import type { DaemonClientOptions } from "./client.js"; + +export type StreamDaemonOptions = DaemonClientOptions & { + onData: (data: unknown) => void; + /** Abort / cancel the stream (closes the socket). */ + signal?: AbortSignal; +}; + +/** + * Long-lived `stream` op: first frame is a DaemonResponse; subsequent frames + * are {@link DaemonStreamFrame} until `end` or the socket closes. + */ +export async function streamDaemon( + params: DaemonRequest["params"], + options: StreamDaemonOptions, +): Promise { + const socketPath = options.socketPath ?? getDaemonSocketPath(); + const timeoutMs = options.timeoutMs ?? 60_000; + const id = randomUUID(); + const token = options.token ?? getDaemonTokenFromEnv(); + const request: DaemonRequest = { id, op: "stream", params }; + if (token !== undefined) request.token = token; + + return new Promise((resolve, reject) => { + let settled = false; + let buffer = ""; + let streaming = false; + let timer: ReturnType | undefined; + const socket = new net.Socket(); + + function settle(fn: () => void) { + if (settled) return; + settled = true; + if (timer !== undefined) clearTimeout(timer); + options.signal?.removeEventListener("abort", onAbort); + socket.removeAllListeners(); + socket.on("error", () => {}); + fn(); + } + + function fail(error: unknown) { + settle(() => { + socket.destroy(); + reject(error); + }); + } + + function succeed() { + settle(() => { + socket.destroy(); + resolve(); + }); + } + + function onAbort() { + succeed(); + } + + function handleLine(line: string) { + const trimmed = line.trim(); + if (!trimmed) return; + + if (!streaming) { + let response: DaemonResponse; + try { + response = JSON.parse(trimmed) as DaemonResponse; + } catch (error) { + fail(error); + return; + } + if (response.id !== id && response.id !== "?") return; + if (!response.ok) { + fail( + new CliExitCodeError( + response.error.exitCode ?? EXIT_CODES.USAGE, + response.error.message, + { code: response.error.code }, + ), + ); + return; + } + streaming = true; + if (timer !== undefined) { + clearTimeout(timer); + timer = undefined; + } + return; + } + + let frame: DaemonStreamFrame; + try { + frame = JSON.parse(trimmed) as DaemonStreamFrame; + } catch (error) { + fail(error); + return; + } + if (frame.id !== id) return; + if (frame.stream === "data") { + options.onData(frame.data); + return; + } + if (frame.stream === "end") { + succeed(); + } + } + + socket.on("error", (err) => { + if (streaming) { + succeed(); + return; + } + fail( + new CliExitCodeError( + EXIT_CODES.UNREACHABLE, + `Cannot reach session daemon at ${socketPath}: ${err.message}`, + { code: "daemon_unreachable" }, + ), + ); + }); + + socket.on("close", () => { + if (settled) return; + // Soft-end after the ok frame; pre-response FIN is unreachable (mirrors + // the error handler and callDaemon's close guard). + if (streaming) { + succeed(); + return; + } + fail( + new CliExitCodeError( + EXIT_CODES.UNREACHABLE, + `Session daemon closed the connection before the stream opened`, + { code: "daemon_unreachable" }, + ), + ); + }); + + timer = setTimeout(() => { + fail( + new CliExitCodeError( + EXIT_CODES.UNREACHABLE, + `Daemon stream open timed out after ${timeoutMs}ms`, + { code: "daemon_timeout" }, + ), + ); + }, timeoutMs); + + options.signal?.addEventListener("abort", onAbort, { once: true }); + + socket.once("connect", () => { + socket.write(encodeRequest(request)); + }); + + socket.on("data", (chunk) => { + buffer += String(chunk); + let idx: number; + while ((idx = buffer.indexOf("\n")) >= 0) { + const line = buffer.slice(0, idx); + buffer = buffer.slice(idx + 1); + handleLine(line); + } + }); + + socket.connect(socketPath); + }); +} diff --git a/clients/mcpi/src/mcp-bin.ts b/clients/mcpi/src/mcp-bin.ts new file mode 100644 index 000000000..fcda90325 --- /dev/null +++ b/clients/mcpi/src/mcp-bin.ts @@ -0,0 +1,28 @@ +#!/usr/bin/env node + +import { realpathSync } from "fs"; +import { resolve } from "path"; +import { fileURLToPath } from "url"; +import { handleError } from "@inspector/cli/error-handler.js"; +import { runMcp } from "./session/mcp.js"; + +export { runMcp }; + +const __filename = fileURLToPath(import.meta.url); + +/** True when this file is the process entry (works through npm-link symlinks). */ +function isMainModule(): boolean { + const entry = process.argv[1]; + if (entry === undefined) return false; + try { + return realpathSync(resolve(entry)) === realpathSync(resolve(__filename)); + } catch { + return resolve(entry) === resolve(__filename); + } +} + +if (isMainModule()) { + runMcp(process.argv) + .then(() => process.exit(0)) + .catch(handleError); +} diff --git a/clients/mcpi/src/session/authorize.ts b/clients/mcpi/src/session/authorize.ts new file mode 100644 index 000000000..774d7a31c --- /dev/null +++ b/clients/mcpi/src/session/authorize.ts @@ -0,0 +1,93 @@ +import { MutableRedirectUrlProvider } from "@inspector/core/auth/index.js"; +import { NodeOAuthStorage } from "@inspector/core/auth/node/index.js"; +import { + DEFAULT_RUNNER_OAUTH_CALLBACK_URL, + formatRunnerOAuthRedirectUrl, + parseRunnerOAuthCallbackUrl, +} from "@inspector/core/auth/node/runner-oauth-callback.js"; +import { + buildRunnerClientAuthOptions, + isOAuthCapableServerConfig, + loadRunnerClientConfig, +} from "@inspector/core/client/runner.js"; +import { InspectorClient } from "@inspector/core/mcp/index.js"; +import { createTransportNode } from "@inspector/core/mcp/node/index.js"; +import { + eraToVersionNegotiation, + type InspectorClientEnvironment, + type InspectorServerSettings, + type MCPServerConfig, +} from "@inspector/core/mcp/types.js"; +import { readInspectorVersion } from "@inspector/core/node/version.js"; +import { createCliOAuthNavigation } from "@inspector/cli/cli-oauth-navigation.js"; +import { connectInspectorWithOAuth } from "@inspector/cli/cliOAuth.js"; + +/** + * Run interactive (or stored-auth-only) OAuth in the front-end process so tokens + * land in the shared `oauth.json` store, then the daemon can reconnect. + */ +export async function authorizeInFrontend( + serverConfig: MCPServerConfig, + serverSettings: InspectorServerSettings | undefined, + options?: { storedAuthOnly?: boolean }, +): Promise { + if (!isOAuthCapableServerConfig(serverConfig)) { + return; + } + + const environment: InspectorClientEnvironment = { + transport: createTransportNode, + }; + const redirectUrlProvider = new MutableRedirectUrlProvider(); + const callbackUrlConfig = parseRunnerOAuthCallbackUrl( + process.env.MCP_OAUTH_CALLBACK_URL ?? DEFAULT_RUNNER_OAUTH_CALLBACK_URL, + ); + redirectUrlProvider.redirectUrl = + formatRunnerOAuthRedirectUrl(callbackUrlConfig); + environment.oauth = { + storage: new NodeOAuthStorage(), + navigation: createCliOAuthNavigation(), + redirectUrlProvider, + }; + + const clientConfig = await loadRunnerClientConfig({}); + const clientAuthOptions = buildRunnerClientAuthOptions( + clientConfig, + serverSettings, + {}, + ); + + const client = new InspectorClient(serverConfig, { + environment, + clientIdentity: { + name: "inspector-cli", + version: readInspectorVersion(import.meta.url), + }, + initialLoggingLevel: "debug", + progress: false, + sample: false, + elicit: false, + serverSettings, + ...(serverSettings?.protocolEra && { + versionNegotiation: eraToVersionNegotiation(serverSettings.protocolEra), + }), + ...clientAuthOptions, + }); + + try { + await connectInspectorWithOAuth( + client, + serverConfig, + redirectUrlProvider, + callbackUrlConfig, + serverSettings, + { storedAuthOnly: options?.storedAuthOnly }, + ); + } finally { + try { + await client.disconnect(); + } catch { + // best-effort + } + } +} diff --git a/clients/mcpi/src/session/dispatch.ts b/clients/mcpi/src/session/dispatch.ts new file mode 100644 index 000000000..8cf160000 --- /dev/null +++ b/clients/mcpi/src/session/dispatch.ts @@ -0,0 +1,121 @@ +import { callDaemon, ensureDaemon, streamDaemon } from "../daemon/index.js"; +import type { RpcParams, RpcResult } from "../daemon/protocol.js"; +import type { + CliAppInfo, + MethodArgs, +} from "@inspector/cli/handlers/method-types.js"; +import type { OutputFormat } from "@inspector/cli/handlers/format-output.js"; +import { writeSessionOutput } from "./format-session.js"; +import { styleFromOpts } from "@inspector/cli/style.js"; + +const STREAM_METHODS = new Set(["logging/tail", "resources/subscribe"]); + +export type SessionDispatchOpts = { + format?: OutputFormat; + plain?: boolean; + session?: string; + requireExplicit: boolean; +}; + +/** + * Run one session MCP method via daemon `rpc` or `stream`. + */ +export async function dispatchSessionRpc( + method: string, + methodArgs: MethodArgs, + opts: SessionDispatchOpts, +): Promise { + const format: OutputFormat = opts.format ?? "text"; + const style = styleFromOpts({ plain: opts.plain, format }); + const params: RpcParams = { + ...methodArgs, + format, + method, + name: stripAt(opts.session), + requireExplicit: opts.requireExplicit, + }; + + const { socketPath } = await ensureDaemon(); + + if (STREAM_METHODS.has(method)) { + const ac = new AbortController(); + const onSignal = () => ac.abort(); + process.on("SIGINT", onSignal); + process.on("SIGTERM", onSignal); + try { + await streamDaemon(params, { + socketPath, + signal: ac.signal, + onData: (data) => { + void writeSessionOutput( + { format, style }, + { + kind: "stream-event", + data, + }, + ); + }, + }); + } finally { + process.off("SIGINT", onSignal); + process.off("SIGTERM", onSignal); + } + return; + } + + const outcome = await callDaemon("rpc", params, { socketPath }); + if (outcome.kind === "ndjson") { + await writeSessionOutput( + { format, style }, + { + kind: "ndjson", + lines: outcome.lines, + }, + ); + return; + } + await writeSessionOutput( + { format, style }, + { + kind: "rpc", + method, + result: outcome.result, + appInfo: outcome.appInfo as CliAppInfo | undefined, + toolName: methodArgs.toolName, + }, + ); +} + +export function stripAt(name: string | undefined): string | undefined { + if (!name) return undefined; + return name.startsWith("@") ? name.slice(1) : name; +} + +/** + * Non-interactive runs must pass an explicit session for MRU-targeting ops. + * Key off stdin (not stdout) so piping output (`mcpi tools/list | jq`) still + * uses MRU when a human is at the keyboard. + */ +export function requireExplicitSession(): boolean { + if (process.env.MCP_ALLOW_DEFAULT_SESSION === "1") return false; + return process.stdin.isTTY !== true; +} + +/** + * Hoist a leading `@name` from argv so `mcpi @alpha tools/list` works. + */ +export function hoistAtSession(argv: string[]): { + argv: string[]; + sessionFromAt?: string; +} { + const start = 2; + const user = argv.slice(start); + const token = user[0]; + if (token && /^@[A-Za-z0-9_.-]+$/.test(token)) { + return { + argv: [...argv.slice(0, start), ...user.slice(1)], + sessionFromAt: token.slice(1), + }; + } + return { argv }; +} diff --git a/clients/mcpi/src/session/format-human.ts b/clients/mcpi/src/session/format-human.ts new file mode 100644 index 000000000..13f8902e9 --- /dev/null +++ b/clients/mcpi/src/session/format-human.ts @@ -0,0 +1,689 @@ +/** + * Human-readable (markdown-ish) formatters for the session CLI. + * Styling (color / bold / dim / OSC 8 links) is parameterized via {@link Style}. + */ + +import { PLAIN, type Style } from "@inspector/cli/style.js"; + +type JsonObject = Record; + +function asArray(value: unknown): T[] { + return Array.isArray(value) ? (value as T[]) : []; +} + +function shortType(schema: unknown): string { + if (!schema || typeof schema !== "object") return "any"; + const s = schema as JsonObject; + const t = s.type; + if (t === "array") { + if (s.items) return `[${shortType(s.items)}]`; + return "[any]"; + } + if (Array.isArray(t)) { + const filtered = t.filter((x) => x !== "null"); + if (filtered.length === 1) return shortTypeName(String(filtered[0])); + return filtered.map((x) => shortTypeName(String(x))).join(" | "); + } + if (Array.isArray(s.enum)) return "enum"; + if (typeof t === "string") return shortTypeName(t); + return "any"; +} + +function shortTypeName(type: string): string { + const map: Record = { + string: "str", + number: "num", + integer: "int", + boolean: "bool", + object: "obj", + array: "[any]", + }; + return map[type] ?? type; +} + +function formatToolParamsInline(schema: unknown): string { + if (!schema || typeof schema !== "object") return "()"; + const s = schema as JsonObject; + const properties = s.properties as Record | undefined; + if (!properties || Object.keys(properties).length === 0) return "()"; + const required = new Set(asArray(s.required)); + const names = Object.keys(properties); + const ordered = [ + ...names.filter((n) => required.has(n)), + ...names.filter((n) => !required.has(n)), + ]; + const shown = ordered.slice(0, 3); + const hidden = ordered.length - shown.length; + const parts = shown.map((name) => { + const typeStr = shortType(properties[name]); + return required.has(name) ? `${name}:${typeStr}` : `${name}?:${typeStr}`; + }); + if (hidden > 0) parts.push("…"); + return `(${parts.join(", ")})`; +} + +function toolHints(tool: JsonObject): string | undefined { + const ann = tool.annotations as JsonObject | undefined; + if (!ann) return undefined; + const hints: string[] = []; + if (ann.readOnlyHint === true) hints.push("read-only"); + if (ann.destructiveHint === true) hints.push("destructive"); + if (ann.idempotentHint === true) hints.push("idempotent"); + if (ann.openWorldHint === true) hints.push("open-world"); + return hints.length > 0 ? hints.join(", ") : undefined; +} + +function code(style: Style, name: string): string { + return `\`${style.bold(name)}\``; +} + +function heading(style: Style, text: string): string { + return style.bold(text); +} + +function descSuffix(style: Style, description: unknown): string { + if (typeof description !== "string" || !description.trim()) return ""; + return style.dim(` — ${description.trim().split("\n")[0]}`); +} + +function formatUri(style: Style, uri: string): string { + if (!uri) return uri; + if (uri.includes("://")) return style.link(uri); + return style.cyan(uri); +} + +function colorLevel(style: Style, level: string): string { + switch (level) { + case "error": + case "critical": + case "alert": + case "emergency": + return style.red(level); + case "warning": + return style.yellow(level); + case "debug": + case "notice": + return style.dim(level); + default: + return style.cyan(level); + } +} + +/** Format tools/list for human display. */ +export function formatToolsHuman( + tools: unknown[], + style: Style = PLAIN, +): string { + const lines = [heading(style, `Tools (${tools.length}):`)]; + for (const raw of tools) { + const tool = raw as JsonObject; + const name = String(tool.name ?? "?"); + const params = formatToolParamsInline(tool.inputSchema); + const hints = toolHints(tool); + const hintSuffix = hints ? style.dim(` [${hints}]`) : ""; + lines.push( + `* \`${style.bold(name)}${style.cyan(params)}\`${hintSuffix}${descSuffix(style, tool.description)}`, + ); + } + if (tools.length === 0) lines.push(style.dim("(none)")); + return lines.join("\n"); +} + +/** Format resources/list. */ +export function formatResourcesHuman( + resources: unknown[], + style: Style = PLAIN, +): string { + const lines = [heading(style, `Resources (${resources.length}):`)]; + for (const raw of resources) { + const r = raw as JsonObject; + const name = typeof r.name === "string" ? r.name : String(r.uri ?? "?"); + const uri = typeof r.uri === "string" ? r.uri : ""; + const uriPart = uri ? ` (${formatUri(style, uri)})` : ""; + lines.push( + `* ${code(style, name)}${uriPart}${descSuffix(style, r.description)}`, + ); + } + if (resources.length === 0) lines.push(style.dim("(none)")); + return lines.join("\n"); +} + +/** Format resources/templates/list. */ +export function formatResourceTemplatesHuman( + templates: unknown[], + style: Style = PLAIN, +): string { + const lines = [heading(style, `Resource templates (${templates.length}):`)]; + for (const raw of templates) { + const t = raw as JsonObject; + const name = String(t.name ?? "?"); + const uri = typeof t.uriTemplate === "string" ? t.uriTemplate : ""; + const uriPart = uri ? ` (${formatUri(style, uri)})` : ""; + lines.push( + `* ${code(style, name)}${uriPart}${descSuffix(style, t.description)}`, + ); + } + if (templates.length === 0) lines.push(style.dim("(none)")); + return lines.join("\n"); +} + +/** Format prompts/list. */ +export function formatPromptsHuman( + prompts: unknown[], + style: Style = PLAIN, +): string { + const lines = [heading(style, `Prompts (${prompts.length}):`)]; + for (const raw of prompts) { + const p = raw as JsonObject; + const name = String(p.name ?? "?"); + lines.push(`* ${code(style, name)}${descSuffix(style, p.description)}`); + } + if (prompts.length === 0) lines.push(style.dim("(none)")); + return lines.join("\n"); +} + +function formatContentBlock(block: JsonObject, style: Style): string[] { + const lines: string[] = []; + switch (block.type) { + case "text": + lines.push("````"); + lines.push(String(block.text ?? "")); + lines.push("````"); + break; + case "resource_link": + lines.push(heading(style, "Resource link")); + lines.push(`* URI: ${formatUri(style, String(block.uri ?? ""))}`); + if (block.name) lines.push(`* Name: ${String(block.name)}`); + if (block.description) + lines.push(`* Description: ${String(block.description)}`); + if (block.mimeType) lines.push(`* MIME type: ${String(block.mimeType)}`); + break; + case "image": + lines.push( + style.dim( + `[Image: ${String(block.mimeType ?? "unknown")}${ + typeof block.data === "string" + ? `, ${block.data.length} chars base64` + : "" + }]`, + ), + ); + break; + case "audio": + lines.push( + style.dim( + `[Audio: ${String(block.mimeType ?? "unknown")}${ + typeof block.data === "string" + ? `, ${block.data.length} chars base64` + : "" + }]`, + ), + ); + break; + case "resource": { + lines.push(heading(style, "Embedded resource")); + const res = block.resource as JsonObject | undefined; + if (res) { + lines.push(`* URI: ${formatUri(style, String(res.uri ?? ""))}`); + if (res.mimeType) lines.push(`* MIME type: ${String(res.mimeType)}`); + if (typeof res.text === "string") { + lines.push("````"); + lines.push(res.text); + lines.push("````"); + } + } + break; + } + default: + lines.push(JSON.stringify(block, null, 2)); + } + return lines; +} + +function findDuplicateTextBlocks( + content: JsonObject[], + structuredContent: JsonObject, +): Set { + const dupes = new Set(); + const canonical = JSON.stringify(structuredContent); + for (let i = 0; i < content.length; i++) { + const block = content[i]; + if (!block || block.type !== "text" || typeof block.text !== "string") + continue; + try { + const parsed: unknown = JSON.parse(block.text.trim()); + if (JSON.stringify(parsed) === canonical) dupes.add(i); + } catch { + // keep + } + } + return dupes; +} + +/** + * Format a CallToolResult (also used for tasks/result) for human display. + */ +export function formatCallToolResultHuman( + result: JsonObject, + style: Style = PLAIN, +): string { + const lines: string[] = []; + if (result.isError === true) { + lines.push(style.red(heading(style, "Tool error:"))); + } + + const sc = result.structuredContent as JsonObject | undefined; + const hasStructuredContent = !!sc && Object.keys(sc).length > 0; + const content = asArray(result.content); + const skipIndices = hasStructuredContent + ? findDuplicateTextBlocks(content, sc!) + : new Set(); + const visible = content.filter((_, i) => !skipIndices.has(i)); + + if (visible.length > 0) { + lines.push(heading(style, "Content:")); + for (let i = 0; i < visible.length; i++) { + if (i > 0) lines.push(""); + lines.push(...formatContentBlock(visible[i]!, style)); + } + } + + if (hasStructuredContent && visible.length === 0) { + if (lines.length > 0) lines.push(""); + lines.push(heading(style, "Structured content:")); + lines.push(JSON.stringify(sc, null, 2)); + } + + const meta = result._meta as JsonObject | undefined; + if (meta && Object.keys(meta).length > 0) { + if (lines.length > 0) lines.push(""); + lines.push(style.dim("Metadata:")); + lines.push(style.dim(JSON.stringify(meta, null, 2))); + } + + if (lines.length === 0) return style.dim("(no content)"); + return lines.join("\n"); +} + +/** Format resources/read contents. */ +export function formatResourceReadHuman( + result: JsonObject, + style: Style = PLAIN, +): string { + const contents = asArray(result.contents); + if (contents.length === 0) return style.dim("(empty resource)"); + const lines: string[] = [ + heading(style, `Resource contents (${contents.length}):`), + ]; + for (const c of contents) { + lines.push(""); + lines.push(`URI: ${formatUri(style, String(c.uri ?? ""))}`); + if (c.mimeType) lines.push(style.dim(`MIME: ${String(c.mimeType)}`)); + if (typeof c.text === "string") { + lines.push("````"); + lines.push(c.text); + lines.push("````"); + } else if (typeof c.blob === "string") { + lines.push(style.dim(`[Blob: ${c.blob.length} chars base64]`)); + } + } + return lines.join("\n"); +} + +/** Format prompts/get. */ +export function formatPromptResultHuman( + result: JsonObject, + style: Style = PLAIN, +): string { + const description = + typeof result.description === "string" ? result.description : undefined; + const messages = asArray(result.messages); + const lines: string[] = []; + if (description) { + lines.push(style.dim(description)); + lines.push(""); + } + lines.push(heading(style, `Messages (${messages.length}):`)); + for (const msg of messages) { + const role = String(msg.role ?? "?"); + lines.push(""); + lines.push(style.cyan(`[${role}]`)); + const content = msg.content; + if (typeof content === "string") { + lines.push("````"); + lines.push(content); + lines.push("````"); + } else if (content && typeof content === "object") { + if (Array.isArray(content)) { + for (const block of content as JsonObject[]) { + lines.push(...formatContentBlock(block, style)); + } + } else { + lines.push(...formatContentBlock(content as JsonObject, style)); + } + } + } + if (messages.length === 0 && !description) return style.dim("(empty prompt)"); + return lines.join("\n"); +} + +/** Format prompts/complete. */ +export function formatCompletionsHuman( + result: JsonObject, + style: Style = PLAIN, +): string { + const values = asArray(result.values); + const lines = [heading(style, `Completions (${values.length}):`)]; + for (const v of values) lines.push(`* ${v}`); + if (values.length === 0) lines.push(style.dim("(none)")); + if (result.hasMore === true) lines.push(style.dim("(more available)")); + return lines.join("\n"); +} + +/** Format tasks/list. */ +export function formatTasksHuman( + tasks: unknown[], + style: Style = PLAIN, +): string { + const lines = [heading(style, `Tasks (${tasks.length}):`)]; + for (const raw of tasks) { + const t = raw as JsonObject; + const id = String(t.taskId ?? t.id ?? "?"); + const status = String(t.status ?? "?"); + const msg = + typeof t.statusMessage === "string" + ? style.dim(` — ${t.statusMessage}`) + : ""; + lines.push(`* ${code(style, id)} ${status}${msg}`); + } + if (tasks.length === 0) lines.push(style.dim("(none)")); + return lines.join("\n"); +} + +/** Format tasks/get. */ +export function formatTaskHuman(task: unknown, style: Style = PLAIN): string { + const t = (task ?? {}) as JsonObject; + const lines = [ + `${heading(style, "Task:")} ${code(style, String(t.taskId ?? t.id ?? "?"))}`, + `Status: ${String(t.status ?? "?")}`, + ]; + if (typeof t.statusMessage === "string") { + lines.push(`Message: ${t.statusMessage}`); + } + if (t.createdAt) lines.push(style.dim(`Created: ${String(t.createdAt)}`)); + if (t.lastUpdatedAt) + lines.push(style.dim(`Updated: ${String(t.lastUpdatedAt)}`)); + return lines.join("\n"); +} + +/** Format initialize / server probe. */ +export function formatInitializeHuman( + result: JsonObject, + style: Style = PLAIN, +): string { + const info = (result.serverInfo ?? {}) as JsonObject; + const lines = [ + `${heading(style, "Server:")} ${style.bold(String(info.name ?? "(unknown)"))}${ + info.version ? style.dim(` v${String(info.version)}`) : "" + }`, + ]; + if (result.protocolVersion) { + lines.push(`Protocol: ${String(result.protocolVersion)}`); + } + if (typeof result.instructions === "string" && result.instructions.trim()) { + lines.push(""); + lines.push(heading(style, "Instructions:")); + lines.push(result.instructions.trim()); + } + const caps = result.capabilities; + if (caps && typeof caps === "object") { + const keys = Object.keys(caps as JsonObject); + if (keys.length > 0) { + lines.push(""); + lines.push(`${heading(style, "Capabilities:")} ${keys.join(", ")}`); + } + } + return lines.join("\n"); +} + +/** Format roots/list or roots/set. */ +export function formatRootsHuman( + roots: unknown[], + style: Style = PLAIN, +): string { + const lines = [heading(style, `Roots (${roots.length}):`)]; + for (const raw of roots) { + const r = raw as JsonObject; + const name = typeof r.name === "string" ? style.dim(` (${r.name})`) : ""; + lines.push(`* ${formatUri(style, String(r.uri ?? "?"))}${name}`); + } + if (roots.length === 0) lines.push(style.dim("(none)")); + return lines.join("\n"); +} + +/** Format auth/list. */ +export function formatAuthListHuman( + list: { + oauthStatePath?: string; + servers?: unknown[]; + }, + style: Style = PLAIN, +): string { + const servers = Array.isArray(list.servers) ? list.servers : []; + const lines = [ + heading(style, `Stored auth (${servers.length}):`), + style.dim(String(list.oauthStatePath ?? "")), + ]; + for (const raw of servers) { + const s = raw as JsonObject; + const flags: string[] = []; + if (s.hasTokens === true) flags.push("tokens"); + if (s.hasRefreshToken === true) flags.push("refresh"); + const flagText = + flags.length > 0 + ? style.dim(` (${flags.join(", ")})`) + : style.dim(" (no tokens)"); + lines.push(`* ${code(style, String(s.url))}${flagText}`); + } + if (servers.length === 0) lines.push(style.dim("(none)")); + return lines.join("\n"); +} + +/** Format servers/list. */ +export function formatServersListHuman( + servers: unknown[], + style: Style = PLAIN, +): string { + const lines = [heading(style, `Servers (${servers.length}):`)]; + for (const raw of servers) { + const s = raw as JsonObject; + const sessionName = + typeof s.session === "string" && s.session.length > 0 + ? s.session + : undefined; + const sessionMark = sessionName + ? ` ${style.green(`@${sessionName}`)}${s.isMru === true ? style.green(" (MRU)") : ""}` + : ""; + lines.push( + `* ${code(style, String(s.name))} ${style.dim(`[${String(s.type)}]`)} ${style.dim(String(s.detail ?? ""))}${sessionMark}`, + ); + } + if (servers.length === 0) lines.push(style.dim("(none)")); + return lines.join("\n"); +} + +/** Format servers/show (one catalog entry). */ +export function formatServerShowHuman( + server: JsonObject, + style: Style = PLAIN, +): string { + const name = String(server.name ?? "?"); + const type = String(server.type ?? "?"); + const detail = String(server.detail ?? ""); + const header = `${heading(style, "Server")} ${code(style, name)} ${style.dim(`[${type}]`)}`; + const body: Record = {}; + if (server.config && typeof server.config === "object") { + body.config = server.config; + } + if (server.settings && typeof server.settings === "object") { + body.settings = server.settings; + } + return [ + header, + detail ? style.dim(detail) : style.dim("(no detail)"), + JSON.stringify(body, null, 2), + ].join("\n"); +} + +/** Format sessions/list. */ +export function formatSessionsListHuman( + sessions: unknown[], + style: Style = PLAIN, +): string { + const lines = [heading(style, `Sessions (${sessions.length}):`)]; + for (const raw of sessions) { + const s = raw as JsonObject; + const mru = s.isMru === true ? style.green(" (MRU)") : ""; + lines.push( + `* ${code(style, `@${String(s.name)}`)}${mru}${style.dim(` — ${String(s.serverIdentity ?? "")}`)}`, + ); + } + if (sessions.length === 0) lines.push(style.dim("(none — connect first)")); + return lines.join("\n"); +} + +/** Format a single session info (connect / sessions/use). */ +export function formatSessionInfoHuman( + session: JsonObject, + style: Style = PLAIN, +): string { + const mru = session.isMru === true ? style.green(" (MRU)") : ""; + return [ + `${heading(style, "Session")} ${code(style, `@${String(session.name)}`)}${mru}`, + `Server: ${style.dim(String(session.serverIdentity ?? ""))}`, + ].join("\n"); +} + +/** Format tools/list --app-info lines. */ +export function formatAppInfoListHuman( + lines: unknown[], + style: Style = PLAIN, +): string { + const out = [heading(style, `App info (${lines.length} tools):`)]; + for (const raw of lines) { + const info = raw as JsonObject; + const name = String(info.toolName ?? "?"); + if (info.hasApp === true) { + const uri = String(info.resourceUri ?? "ui://?"); + out.push( + `* ${code(style, name)} — ${style.green("app")} (${formatUri(style, uri)})`, + ); + } else { + const err = + typeof info.resourceError === "string" + ? style.dim(` — ${info.resourceError}`) + : style.dim(" — no app"); + out.push(`* ${code(style, name)}${err}`); + } + } + return out.join("\n"); +} + +/** Format a single app-info probe. */ +export function formatAppInfoHuman( + info: JsonObject, + style: Style = PLAIN, +): string { + const name = String(info.toolName ?? "?"); + if (info.hasApp === true) { + const lines = [ + `Tool ${code(style, name)} ${style.green("has an MCP App")}`, + `Resource: ${formatUri(style, String(info.resourceUri ?? ""))}`, + ]; + if (info.csp) lines.push(style.dim(`CSP: ${JSON.stringify(info.csp)}`)); + return lines.join("\n"); + } + const err = + typeof info.resourceError === "string" + ? info.resourceError + : "No MCP App UI resource (_meta.ui.resourceUri)."; + return `Tool ${code(style, name)} ${style.red("has no MCP App")}\n${style.dim(err)}`; +} + +/** Format a stream event for human display. */ +export function formatStreamEventHuman( + data: unknown, + style: Style = PLAIN, +): string { + if (!data || typeof data !== "object") return String(data); + const ev = data as JsonObject; + if (ev.type === "subscribed") { + return `${heading(style, "Subscribed:")} ${formatUri(style, String(ev.uri ?? ""))}`; + } + if (ev.type === "resources/updated") { + return `${heading(style, "Resource updated:")} ${formatUri(style, String(ev.uri ?? ""))}`; + } + // logging/tail MessageEntry-shaped + if (ev.direction === "notification" && ev.message) { + const msg = ev.message as JsonObject; + const params = (msg.params ?? {}) as JsonObject; + const level = String(params.level ?? "info"); + const logger = params.logger ? style.dim(` ${String(params.logger)}:`) : ""; + const text = String( + params.data ?? params.message ?? JSON.stringify(params), + ); + return `[${colorLevel(style, level)}]${logger} ${text}`; + } + return JSON.stringify(ev, null, 2); +} + +/** + * Dispatch human formatting for an RPC method result. + * Returns null when the caller should fall back to pretty JSON. + */ +export function formatRpcResultHuman( + method: string, + result: JsonObject, + style: Style = PLAIN, +): string | null { + switch (method) { + case "tools/list": + return formatToolsHuman(asArray(result.tools), style); + case "tools/call": + return formatCallToolResultHuman(result, style); + case "resources/list": + return formatResourcesHuman(asArray(result.resources), style); + case "resources/read": + return formatResourceReadHuman(result, style); + case "resources/templates/list": + return formatResourceTemplatesHuman( + asArray(result.resourceTemplates), + style, + ); + case "resources/unsubscribe": + return `${heading(style, "Unsubscribed:")} ${formatUri(style, String(result.uri ?? ""))}`; + case "prompts/list": + return formatPromptsHuman(asArray(result.prompts), style); + case "prompts/get": + return formatPromptResultHuman(result, style); + case "prompts/complete": + return formatCompletionsHuman(result, style); + case "initialize": + return formatInitializeHuman(result, style); + case "logging/setLevel": + return style.green("Logging level updated."); + case "tasks/list": + return formatTasksHuman(asArray(result.tasks), style); + case "tasks/get": + return formatTaskHuman(result.task, style); + case "tasks/cancel": + return `${heading(style, "Cancelled task:")} ${String(result.taskId ?? "")}`; + case "tasks/result": + return formatCallToolResultHuman(result, style); + case "roots/list": + case "roots/set": + return formatRootsHuman(asArray(result.roots), style); + default: + return null; + } +} diff --git a/clients/mcpi/src/session/format-session.ts b/clients/mcpi/src/session/format-session.ts new file mode 100644 index 000000000..db634e44f --- /dev/null +++ b/clients/mcpi/src/session/format-session.ts @@ -0,0 +1,224 @@ +import { awaitableLog } from "@inspector/cli/utils/awaitable-log.js"; +import type { SessionInfo } from "../daemon/protocol.js"; +import { CliExitCodeError, EXIT_CODES } from "@inspector/cli/error-handler.js"; +import type { OutputFormat } from "@inspector/cli/handlers/format-output.js"; +import type { CliAppInfo } from "@inspector/cli/handlers/method-types.js"; +import { + formatAppInfoHuman, + formatAppInfoListHuman, + formatAuthListHuman, + formatRpcResultHuman, + formatServersListHuman, + formatServerShowHuman, + formatSessionInfoHuman, + formatSessionsListHuman, + formatStreamEventHuman, +} from "./format-human.js"; +import { PLAIN, type Style } from "@inspector/cli/style.js"; + +type JsonObject = Record; + +/** + * Pretty-print JSON for session `--format json`. + * Unlike one-shot, this does **not** wrap in `{ result }` — the payload is the + * MCP / admin object itself (convenient for scripting). + */ +export function formatSessionJson(data: unknown): string { + return JSON.stringify(data, null, 2) + "\n"; +} + +export type SessionWriteKind = + | { + kind: "rpc"; + method: string; + result: JsonObject; + /** + * Auto-collected by `runMethod` for `tools/call` + `--format json`. + * Session output ignores this side-channel (no `{ result, appInfo }` + * envelope); only `result` is printed. `--app-info` probes put the + * info object in `result` itself. + */ + appInfo?: CliAppInfo; + /** For exit-code messages when result.isError. */ + toolName?: string; + } + | { kind: "ndjson"; lines: unknown[] } + | { kind: "stream-event"; data: unknown } + | { kind: "servers/list"; servers: unknown[] } + | { kind: "servers/show"; server: JsonObject } + | { kind: "sessions/list"; sessions: unknown[] } + | { kind: "session"; session: SessionInfo | JsonObject } + | { kind: "disconnect"; name: string } + | { kind: "daemon/status"; status: JsonObject } + | { kind: "daemon/stop"; result: JsonObject } + | { + kind: "auth/list"; + list: { oauthStatePath: string; servers: unknown[] }; + } + | { + kind: "auth/clear"; + result: { url?: string; cleared?: number; all?: boolean }; + } + | { kind: "generic"; data: unknown; title?: string }; + +export type SessionWriteOpts = { + format?: OutputFormat; + /** Human-output styling; ignored for `--format json`. Defaults to plain. */ + style?: Style; +}; + +/** + * Write session CLI output honouring `--format text|json`. + * One-shot output paths are unchanged (`emitResult` / `writeFormattedResult`). + */ +export async function writeSessionOutput( + opts: SessionWriteOpts, + payload: SessionWriteKind, +): Promise { + const format: OutputFormat = opts.format === "json" ? "json" : "text"; + const style = opts.style ?? PLAIN; + + if (format === "json") { + await awaitableLog(formatSessionJson(jsonPayload(payload))); + applyExitCodes(payload); + return; + } + + await awaitableLog(humanPayload(payload, style) + "\n"); + applyExitCodes(payload); +} + +function jsonPayload(payload: SessionWriteKind): unknown { + switch (payload.kind) { + case "rpc": + // Pretty payload only — never the one-shot `{ result[, appInfo] }` wrap. + return payload.result; + case "ndjson": + return payload.lines; + case "stream-event": + return payload.data; + case "servers/list": + return { servers: payload.servers }; + case "servers/show": + return payload.server; + case "sessions/list": + return { sessions: payload.sessions }; + case "session": + return payload.session; + case "disconnect": + return { name: payload.name }; + case "daemon/status": + return payload.status; + case "daemon/stop": + return payload.result; + case "auth/list": + return payload.list; + case "auth/clear": + return payload.result; + case "generic": + return payload.data; + } +} + +function humanPayload(payload: SessionWriteKind, style: Style): string { + switch (payload.kind) { + case "rpc": { + if (asAppInfoProbe(payload.result)) { + return formatAppInfoHuman(payload.result, style); + } + const formatted = formatRpcResultHuman( + payload.method, + payload.result, + style, + ); + return formatted ?? JSON.stringify(payload.result, null, 2); + } + case "ndjson": + return formatAppInfoListHuman(payload.lines, style); + case "stream-event": + return formatStreamEventHuman(payload.data, style); + case "servers/list": + return formatServersListHuman(payload.servers, style); + case "servers/show": + return formatServerShowHuman(payload.server, style); + case "sessions/list": + return formatSessionsListHuman(payload.sessions, style); + case "session": + return formatSessionInfoHuman(payload.session as JsonObject, style); + case "disconnect": + return `${style.bold("Disconnected")} ${`\`${style.bold(`@${payload.name}`)}\``}`; + case "daemon/status": { + const s = payload.status; + if (s.running === false) { + return String(s.message ?? "Daemon is not running."); + } + const sessions = Array.isArray(s.sessions) + ? (s.sessions as unknown[]) + : []; + return [ + `${style.bold("Daemon")} pid ${String(s.pid)}`, + style.dim(`Socket: ${String(s.socketPath ?? "")}`), + formatSessionsListHuman(sessions, style), + ].join("\n"); + } + case "daemon/stop": + if (payload.result.stopping === false) { + return String(payload.result.message ?? "Daemon was not running."); + } + return style.green("Daemon stopping."); + case "auth/list": + return formatAuthListHuman(payload.list, style); + case "auth/clear": + if (payload.result.all === true) { + return style.green( + `Cleared ${String(payload.result.cleared ?? 0)} stored auth entr${ + payload.result.cleared === 1 ? "y" : "ies" + }.`, + ); + } + return `${style.green("Cleared")} \`${style.bold(String(payload.result.url ?? ""))}\``; + case "generic": { + if (payload.title) { + return `${style.bold(payload.title)}\n${JSON.stringify(payload.data, null, 2)}`; + } + return JSON.stringify(payload.data, null, 2); + } + } +} + +function asAppInfoProbe(result: JsonObject): CliAppInfo | undefined { + if ( + typeof result.hasApp !== "boolean" || + typeof result.toolName !== "string" || + result.content !== undefined || + result.tools !== undefined + ) { + return undefined; + } + // Narrowed by the structural checks above; CliAppInfo adds optional fields. + return result as CliAppInfo; +} + +function applyExitCodes(payload: SessionWriteKind): void { + if (payload.kind === "rpc") { + // Only `--app-info` probes (result is the info object) map to NO_APP. + // Auto-collected `payload.appInfo` from tools/call+json must not. + const info = asAppInfoProbe(payload.result); + if (info) { + if (!info.hasApp) { + throw new CliExitCodeError( + EXIT_CODES.NO_APP, + `Tool '${info.toolName}' has no MCP App UI resource (_meta.ui.resourceUri).`, + ); + } + return; + } + if (payload.result.isError === true) { + throw new CliExitCodeError( + EXIT_CODES.TOOL_ERROR, + `Tool '${payload.toolName ?? "tool"}' returned isError:true.`, + { code: "tool_is_error" }, + ); + } + } +} diff --git a/clients/mcpi/src/session/mcp.ts b/clients/mcpi/src/session/mcp.ts new file mode 100644 index 000000000..db1fc310b --- /dev/null +++ b/clients/mcpi/src/session/mcp.ts @@ -0,0 +1,825 @@ +import { Command, type Command as CommandType } from "commander"; +import type { JsonValue } from "@inspector/core/mcp/index.js"; +import { + loadServerEntries, + parseHeaderPair, + parseKeyValuePair as parseEnvPair, + selectServerEntry, +} from "@inspector/core/mcp/node/index.js"; +import { type LoggingLevel } from "@modelcontextprotocol/client"; +import { LoggingLevelSchema } from "@modelcontextprotocol/core"; +import { CliExitCodeError, EXIT_CODES } from "@inspector/cli/error-handler.js"; +import { callDaemon, ensureDaemon } from "../daemon/index.js"; +import type { SessionInfo } from "../daemon/protocol.js"; +import { + annotateServerEntriesWithSessions, + listServerEntries, + showServerEntry, + summarizeServerConfig, +} from "@inspector/cli/handlers/servers-list.js"; +import { type OutputFormat } from "@inspector/cli/handlers/format-output.js"; +import { + DEFAULT_CONNECT_TIMEOUT_MS, + withConnectTimeout, +} from "@inspector/cli/handlers/connect-timeout.js"; +import { + metaValueToString, + SESSION_RPC_METHODS, + type MethodArgs, +} from "@inspector/cli/handlers/method-types.js"; +import { authorizeInFrontend } from "./authorize.js"; +import { resolveToolCallArgs } from "./parse-tool-args.js"; +import { + dispatchSessionRpc, + hoistAtSession, + requireExplicitSession, + stripAt, +} from "./dispatch.js"; +import { writeSessionOutput } from "./format-session.js"; +import { + createPrivateBinding, + formatPrivateEnvExports, +} from "./private-env.js"; +import { + clearAllStoredAuth, + clearStoredAuth, + clearStoredAuthForRelogin, + listStoredAuth, +} from "./stored-auth.js"; +import { styleFromOpts } from "@inspector/cli/style.js"; +import { awaitableLog } from "@inspector/cli/utils/awaitable-log.js"; +import { createInterface } from "node:readline/promises"; + +function isDaemonUnreachable(error: unknown): boolean { + return ( + error instanceof CliExitCodeError && + error.envelope?.code === "daemon_unreachable" + ); +} + +/** Commander help/version exits — text already written; not real failures. */ +function isCommanderDisplayOnly(error: unknown): boolean { + if (error == null || typeof error !== "object") return false; + const code = (error as { code?: unknown }).code; + return ( + code === "commander.help" || + code === "commander.helpDisplayed" || + code === "commander.version" + ); +} + +type GlobalOpts = { + format?: OutputFormat; + plain?: boolean; + session?: string; + catalog?: string; + config?: string; + storedAuthOnly?: boolean; +}; + +function outOpts(opts: GlobalOpts) { + return { + format: opts.format, + style: styleFromOpts({ plain: opts.plain === true, format: opts.format }), + }; +} + +const validLogLevels: LoggingLevel[] = Object.values(LoggingLevelSchema.enum); + +/** + * Session-first CLI entry (`mcpi`). Talks to the implicit session daemon over + * IPC for connect/disconnect/sessions and MCP RPCs; `servers/list` and + * `servers/show` are local (no daemon). + */ +export async function runMcp(argv?: string[]): Promise { + const raw = argv ?? process.argv; + const { argv: rewritten, sessionFromAt } = hoistAtSession(raw); + + const program = new Command(); + program.exitOverride((err) => { + // Help/version already printed. Always throw so Commander does not + // process.exit (which would tear down in-process tests); runMcp treats + // these as success. Bare `mcpi` uses code `commander.help` with exitCode 1 + // — must not reach handleError as an ErrorEnvelope. + if (isCommanderDisplayOnly(err)) throw err; + if (err.exitCode !== 0) throw err; + }); + + program + .name("mcpi") + .description( + "MCP Inspector session CLI — connect once, run many commands against a named session.", + ) + .helpOption("-h, --help", "Display help for command") + .helpCommand("help [command]", "Display help for command") + .option( + "--format ", + "Output format: text (default; human-readable) or json (pretty-printed)", + (v: string): OutputFormat => { + if (v !== "text" && v !== "json") { + throw new Error(`--format must be 'text' or 'json'.`); + } + return v; + }, + ) + .option( + "--plain", + "Disable ANSI styling (color, bold/dim, hyperlinks) in human text output", + ) + .option( + "--session ", + "Session name (without required @). Overrides MRU / positional @name.", + ) + .option( + "--catalog ", + "Writable catalog file (default: ~/.mcp-inspector/mcp.json or MCP_CATALOG_PATH)", + ) + .option( + "--config ", + "Read-only session config file (never written or seeded)", + ) + .option( + "--stored-auth-only", + "Never start interactive OAuth; use the shared store if present, otherwise fail.", + ); + + if (sessionFromAt) { + program.setOptionValue("session", sessionFromAt); + } + + program + .command("servers/list") + .description( + "List catalog/config server entries (marks live sessions when the daemon is running; no MCP connection)", + ) + .action(async () => { + const opts = program.opts(); + const envCatalog = process.env.MCP_CATALOG_PATH; + const entries = await listServerEntries({ + catalogPath: opts.catalog?.trim() || envCatalog, + configPath: opts.config?.trim() || undefined, + }); + let sessions: SessionInfo[] = []; + try { + const result = await callDaemon<{ sessions: SessionInfo[] }>( + "sessions/list", + {}, + ); + sessions = result.sessions; + } catch (error) { + if (!isDaemonUnreachable(error)) throw error; + } + await writeSessionOutput(outOpts(opts), { + kind: "servers/list", + servers: annotateServerEntriesWithSessions(entries, sessions), + }); + }); + + program + .command("servers/show") + .description( + "Show one catalog/config entry in detail (no MCP connection; secrets redacted)", + ) + .argument("", "Catalog entry name") + .action(async (name: string) => { + const opts = program.opts(); + const envCatalog = process.env.MCP_CATALOG_PATH; + const entry = await showServerEntry(name, { + catalogPath: opts.catalog?.trim() || envCatalog, + configPath: opts.config?.trim() || undefined, + }); + await writeSessionOutput(outOpts(opts), { + kind: "servers/show", + server: entry, + }); + }); + + registerConnect(program); + registerSessionAdmin(program); + registerAuthCommands(program); + registerRpcCommands(program); + // Keep infra commands last in --help (just before Commander's built-in help). + registerDaemonCommands(program); + registerPrivateCommand(program); + + try { + await program.parseAsync(rewritten); + } catch (error) { + if (isCommanderDisplayOnly(error)) return; + throw error; + } +} + +function registerConnect(program: CommandType): void { + program + .command("connect") + .description("Connect a catalog entry or ad-hoc target as a named session") + .argument( + "[target...]", + "Catalog entry name, or command/URL (use -- for command args)", + ) + .option("--server ", "Server name from catalog/config") + .option( + "-e ", + "Environment variables for the server (KEY=VALUE)", + parseEnvPair, + {}, + ) + .option("--cwd ", "Working directory for stdio server process") + .option( + "--transport ", + "Transport type (sse, http, or stdio)", + (value: string) => { + const valid = ["sse", "http", "stdio"]; + if (!valid.includes(value)) { + throw new Error(`Invalid transport type: ${value}`); + } + return value as "sse" | "http" | "stdio"; + }, + ) + .option("--server-url ", "Server URL for SSE/HTTP transport") + .option( + "--header ", + 'HTTP headers as "HeaderName: Value" pairs', + parseHeaderPair, + {}, + ) + .option( + "--connect-timeout ", + `Connection timeout in ms (default ${DEFAULT_CONNECT_TIMEOUT_MS} for ad-hoc)`, + (v: string) => { + const n = Number(v); + if (!Number.isFinite(n) || n < 0) { + throw new Error(`--connect-timeout must be a non-negative number.`); + } + return n; + }, + ) + .option( + "--relogin", + "Ignore stored OAuth for this connect (HTTP/SSE URL keys only); interactive login runs only if the server requires auth. No-op for stdio / servers with no stored entry", + ) + .action(async (target: string[], cmdOpts) => { + const opts = program.opts(); + const { name: positionalSession, rest } = splitSessionTarget(target); + const sessionName = + stripAt(opts.session) ?? + positionalSession ?? + cmdOpts.server?.trim() ?? + rest[0]; + + if (!sessionName) { + throw new CliExitCodeError( + EXIT_CODES.USAGE, + "connect requires a catalog entry name, --server , or an ad-hoc target.", + { code: "usage" }, + ); + } + + const relogin = cmdOpts.relogin === true; + if (relogin && opts.storedAuthOnly) { + throw new CliExitCodeError( + EXIT_CODES.USAGE, + "--relogin cannot be combined with --stored-auth-only", + { code: "usage" }, + ); + } + + const adHoc = + rest.length > 1 || + Boolean(cmdOpts.transport) || + Boolean(cmdOpts.serverUrl?.trim()) || + (rest.length === 1 && looksLikeUrl(rest[0]!)); + + const envCatalog = adHoc ? undefined : process.env.MCP_CATALOG_PATH; + const serverOptions = { + catalogPath: opts.catalog?.trim() || envCatalog, + configPath: opts.config?.trim() || undefined, + target: adHoc ? (rest.length > 0 ? rest : undefined) : undefined, + transport: cmdOpts.transport as "sse" | "http" | "stdio" | undefined, + serverUrl: cmdOpts.serverUrl as string | undefined, + cwd: cmdOpts.cwd as string | undefined, + env: cmdOpts.e as Record | undefined, + headers: cmdOpts.header as Record | undefined, + }; + + const selectName = adHoc + ? undefined + : ((cmdOpts.server as string | undefined)?.trim() ?? rest[0]); + + const entries = await loadServerEntries(serverOptions); + const selected = selectServerEntry(entries, selectName); + const serverConfig = selected.config; + const serverSettings = withConnectTimeout( + selected.settings, + (cmdOpts.connectTimeout as number | undefined) ?? + (adHoc ? DEFAULT_CONNECT_TIMEOUT_MS : undefined), + ); + const { detail } = summarizeServerConfig(serverConfig); + const name = stripAt(sessionName)!; + + if (relogin && "url" in serverConfig && serverConfig.url) { + await clearStoredAuthForRelogin(serverConfig.url); + } + + const { socketPath } = await ensureDaemon(); + const connectParams = { + name, + serverConfig, + serverSettings, + serverIdentity: detail, + }; + + let result: SessionInfo; + try { + result = await callDaemon("connect", connectParams, { + socketPath, + }); + } catch (error) { + if ( + !(error instanceof CliExitCodeError) || + error.envelope?.code !== "auth_required" + ) { + throw error; + } + if (opts.storedAuthOnly) { + throw error; + } + await authorizeInFrontend(serverConfig, serverSettings, { + storedAuthOnly: false, + }); + result = await callDaemon("connect", connectParams, { + socketPath, + }); + } + await writeSessionOutput(outOpts(opts), { + kind: "session", + session: result, + }); + }); +} + +function registerAuthCommands(program: CommandType): void { + program + .command("auth/list") + .description( + "List server URLs in the shared OAuth store (keys for auth/clear)", + ) + .action(async () => { + const opts = program.opts(); + const list = await listStoredAuth(); + await writeSessionOutput(outOpts(opts), { kind: "auth/list", list }); + }); + + program + .command("auth/clear") + .description( + "Clear stored OAuth state for one server URL (from auth/list) or all entries", + ) + .argument("[key]", "Server URL key from auth/list") + .option("--all", "Clear every stored OAuth server entry") + .option("--yes", "Skip confirmation when using --all") + .action(async (key: string | undefined, cmdOpts) => { + const opts = program.opts(); + const all = cmdOpts.all === true; + if (all && key?.trim()) { + throw new CliExitCodeError( + EXIT_CODES.USAGE, + "auth/clear: pass a key or --all, not both", + { code: "usage" }, + ); + } + if (!all && !key?.trim()) { + throw new CliExitCodeError( + EXIT_CODES.USAGE, + "auth/clear requires a server URL key (from auth/list) or --all", + { code: "usage" }, + ); + } + if (all) { + if (!cmdOpts.yes) { + if (!process.stdin.isTTY || !process.stdout.isTTY) { + throw new CliExitCodeError( + EXIT_CODES.USAGE, + "auth/clear --all requires --yes in non-interactive mode", + { code: "usage" }, + ); + } + /* v8 ignore next 22 -- interactive y/N confirm needs a real TTY */ + const rl = createInterface({ + input: process.stdin, + output: process.stderr, + }); + try { + const answer = await rl.question( + "Clear ALL stored OAuth credentials? [y/N] ", + ); + const ok = + answer.trim().toLowerCase() === "y" || + answer.trim().toLowerCase() === "yes"; + if (!ok) { + throw new CliExitCodeError( + EXIT_CODES.USAGE, + "auth/clear --all cancelled", + { code: "usage" }, + ); + } + } finally { + rl.close(); + } + } + const result = await clearAllStoredAuth(); + await writeSessionOutput(outOpts(opts), { + kind: "auth/clear", + result: { all: true, cleared: result.cleared }, + }); + return; + } + const result = await clearStoredAuth(key!); + await writeSessionOutput(outOpts(opts), { + kind: "auth/clear", + result: { url: result.url }, + }); + }); +} + +function registerSessionAdmin(program: CommandType): void { + program + .command("disconnect") + .description("Disconnect a session (MRU when omitted on a TTY)") + .argument("[session]", "Optional @name / name to disconnect") + .action(async (sessionArg: string | undefined) => { + const opts = program.opts(); + const name = stripAt(opts.session) ?? stripAt(sessionArg); + const { socketPath } = await ensureDaemon(); + const result = await callDaemon<{ name: string }>( + "disconnect", + { + name, + requireExplicit: requireExplicitSession(), + }, + { socketPath }, + ); + await writeSessionOutput(outOpts(opts), { + kind: "disconnect", + name: result.name, + }); + }); + + program + .command("sessions/list") + .description("List open sessions (marks MRU); does not start the daemon") + .action(async () => { + const opts = program.opts(); + try { + const result = await callDaemon<{ sessions: SessionInfo[] }>( + "sessions/list", + {}, + ); + await writeSessionOutput(outOpts(opts), { + kind: "sessions/list", + sessions: result.sessions, + }); + } catch (error) { + if (isDaemonUnreachable(error)) { + await writeSessionOutput(outOpts(opts), { + kind: "sessions/list", + sessions: [], + }); + return; + } + throw error; + } + }); + + program + .command("sessions/use") + .description("Set the MRU session without an MCP RPC") + .argument("", "Session @name / name") + .action(async (sessionArg: string) => { + const opts = program.opts(); + const name = stripAt(opts.session) ?? stripAt(sessionArg); + if (!name) { + throw new CliExitCodeError( + EXIT_CODES.USAGE, + "sessions/use requires a session name", + { code: "usage" }, + ); + } + const { socketPath } = await ensureDaemon(); + const result = await callDaemon( + "sessions/use", + { name }, + { socketPath }, + ); + await writeSessionOutput(outOpts(opts), { + kind: "session", + session: result, + }); + }); +} + +function registerDaemonCommands(program: CommandType): void { + const daemon = program.command("daemon").description("Daemon control"); + + daemon + .command("status") + .description("Show daemon pid, socket, and sessions (does not start it)") + .action(async () => { + const opts = program.opts(); + try { + const result = await callDaemon("daemon/status", {}); + await writeSessionOutput(outOpts(opts), { + kind: "daemon/status", + status: result as Record, + }); + } catch (error) { + if (isDaemonUnreachable(error)) { + await writeSessionOutput(outOpts(opts), { + kind: "daemon/status", + status: { + running: false, + message: "Daemon is not running.", + }, + }); + return; + } + throw error; + } + }); + + daemon + .command("stop") + .description("Stop the daemon and disconnect all sessions") + .action(async () => { + const opts = program.opts(); + try { + const result = await callDaemon("daemon/stop", {}); + await writeSessionOutput(outOpts(opts), { + kind: "daemon/stop", + result: result as Record, + }); + } catch (error) { + if (isDaemonUnreachable(error)) { + await writeSessionOutput(outOpts(opts), { + kind: "daemon/stop", + result: { + stopping: false, + message: "Daemon was not running.", + }, + }); + return; + } + throw error; + } + }); +} + +function registerPrivateCommand(program: CommandType): void { + program + .command("private") + .description( + 'Print shell exports for a private daemon (eval "$(mcpi private)"). ' + + "Later mcpi commands in that shell use an isolated, token-gated daemon.", + ) + .action(async () => { + const binding = createPrivateBinding(); + await awaitableLog(formatPrivateEnvExports(binding)); + }); +} + +function registerRpcCommands(program: CommandType): void { + for (const method of SESSION_RPC_METHODS) { + const cmd = program + .command(method) + .description(`MCP ${method} against the current session`); + + cmd.option( + "--metadata ", + "General metadata as key=value pairs", + parseKeyValue, + {}, + ); + + switch (method) { + case "tools/list": + cmd.option("--app-info", "Emit one NDJSON app-info line per tool"); + cmd.action(async (o) => { + await runRpc(program, method, { + appInfo: o.appInfo === true, + metadata: stringifyMeta(o.metadata), + }); + }); + break; + case "tools/call": + cmd + .argument("[toolName]", "Tool name") + .argument( + "[toolArgs...]", + "Arguments as key:=value pairs or a JSON object", + ) + .option("--tool-name ", "Tool name") + .option( + "--tool-arg ", + "Tool argument as key=value pair (alternative to key:=value positionals)", + parseKeyValue, + {}, + ) + .option( + "--tool-args-json ", + "Tool arguments as a JSON object (alternative to inline JSON positional)", + ) + .option( + "--tool-metadata ", + "Tool-specific metadata", + parseKeyValue, + {}, + ) + .option("--task", "Task-augmented tool call (callToolStream)") + .option("--app-info", "Probe MCP App metadata only"); + cmd.action( + async ( + toolNamePos: string | undefined, + toolArgsPos: string[] | undefined, + o, + ) => { + const { toolName, toolArg } = resolveToolCallArgs({ + toolNameFlag: o.toolName as string | undefined, + toolNamePos, + toolArgsPos, + toolArgFlag: (o.toolArg ?? {}) as Record, + toolArgsJson: o.toolArgsJson as string | undefined, + }); + await runRpc(program, method, { + toolName, + toolArg, + toolMeta: stringifyMeta(o.toolMetadata), + metadata: stringifyMeta(o.metadata), + task: o.task === true, + appInfo: o.appInfo === true, + }); + }, + ); + break; + case "resources/read": + case "resources/subscribe": + case "resources/unsubscribe": + cmd + .argument("[uri]", "Resource URI") + .option("--uri ", "Resource URI"); + cmd.action(async (uriPos: string | undefined, o) => { + await runRpc(program, method, { + uri: (o.uri as string | undefined) ?? uriPos, + metadata: stringifyMeta(o.metadata), + }); + }); + break; + case "prompts/get": + cmd + .argument("[promptName]", "Prompt name") + .option("--prompt-name ", "Prompt name") + .option( + "--prompt-args ", + "Prompt arguments", + parseKeyValue, + {}, + ); + cmd.action(async (promptPos: string | undefined, o) => { + await runRpc(program, method, { + promptName: (o.promptName as string | undefined) ?? promptPos, + promptArgs: (o.promptArgs ?? {}) as Record, + metadata: stringifyMeta(o.metadata), + }); + }); + break; + case "prompts/complete": + cmd + .option("--complete-ref-type ", "ref/prompt or ref/resource") + .option("--complete-ref ", "Prompt name or resource URI") + .option("--complete-arg-name ", "Argument name") + .option("--complete-arg-value ", "Partial value", ""); + cmd.action(async (o) => { + const refType = o.completeRefType as string | undefined; + if (refType !== "ref/prompt" && refType !== "ref/resource") { + throw new CliExitCodeError( + EXIT_CODES.USAGE, + "prompts/complete requires --complete-ref-type ref/prompt|ref/resource", + { code: "usage" }, + ); + } + await runRpc(program, method, { + completeRefType: refType, + completeRef: o.completeRef as string | undefined, + completeArgName: o.completeArgName as string | undefined, + completeArgValue: (o.completeArgValue as string | undefined) ?? "", + metadata: stringifyMeta(o.metadata), + }); + }); + break; + case "logging/setLevel": + cmd + .argument("[level]", "Logging level") + .option("--log-level ", "Logging level"); + cmd.action(async (levelPos: string | undefined, o) => { + const level = (o.logLevel as string | undefined) ?? levelPos; + if (level && !validLogLevels.includes(level as LoggingLevel)) { + throw new Error( + `Invalid log level: ${level}. Valid: ${validLogLevels.join(", ")}`, + ); + } + await runRpc(program, method, { + logLevel: level as LoggingLevel | undefined, + metadata: stringifyMeta(o.metadata), + }); + }); + break; + case "tasks/get": + case "tasks/cancel": + case "tasks/result": + cmd.argument("[taskId]", "Task id").option("--task-id ", "Task id"); + cmd.action(async (taskPos: string | undefined, o) => { + await runRpc(program, method, { + taskId: (o.taskId as string | undefined) ?? taskPos, + metadata: stringifyMeta(o.metadata), + }); + }); + break; + case "roots/set": + cmd.option("--roots-json ", "JSON array of {uri, name?}"); + cmd.action(async (o) => { + await runRpc(program, method, { + rootsJson: o.rootsJson as string | undefined, + metadata: stringifyMeta(o.metadata), + }); + }); + break; + default: + cmd.action(async (o) => { + await runRpc(program, method, { + metadata: stringifyMeta(o.metadata), + }); + }); + break; + } + } +} + +async function runRpc( + program: CommandType, + method: string, + methodArgs: MethodArgs, +): Promise { + const opts = program.opts(); + await dispatchSessionRpc(method, methodArgs, { + format: opts.format, + plain: opts.plain === true, + session: opts.session, + requireExplicit: requireExplicitSession(), + }); +} + +function parseKeyValue( + value: string, + previous: Record = {}, +): Record { + const parts = value.split("="); + const key = parts[0]; + const val = parts.slice(1).join("="); + if (!key || val === undefined || val === "") { + throw new Error( + `Invalid parameter format: ${value}. Use key=value format.`, + ); + } + let parsedValue: JsonValue; + try { + parsedValue = JSON.parse(val) as JsonValue; + } catch { + parsedValue = val; + } + return { ...previous, [key]: parsedValue }; +} + +function stringifyMeta( + meta: Record | undefined, +): Record | undefined { + if (!meta || Object.keys(meta).length === 0) return undefined; + return Object.fromEntries( + Object.entries(meta).map(([k, v]) => [k, metaValueToString(v)]), + ); +} + +function looksLikeUrl(value: string): boolean { + return /^https?:\/\//i.test(value); +} + +function splitSessionTarget(target: string[]): { + name: string | undefined; + rest: string[]; +} { + if (target.length > 0 && target[0]!.startsWith("@")) { + return { name: stripAt(target[0]), rest: target.slice(1) }; + } + return { name: undefined, rest: target }; +} + +export { hoistAtSession } from "./dispatch.js"; diff --git a/clients/mcpi/src/session/parse-tool-args.ts b/clients/mcpi/src/session/parse-tool-args.ts new file mode 100644 index 000000000..6b4bd931f --- /dev/null +++ b/clients/mcpi/src/session/parse-tool-args.ts @@ -0,0 +1,132 @@ +import type { JsonValue } from "@inspector/core/mcp/index.js"; + +/** + * Parse session `tools/call` positionals after the tool name: + * - `key:=value` pairs (JSON-typed when the value parses as JSON, else string) + * - a single inline JSON object (`{"message":"Foo"}`) + */ +export function parseToolCallPositionals( + args: string[], +): Record { + if (args.length === 0) return {}; + + const first = args[0]!; + if (first.startsWith("{") || first.startsWith("[")) { + if (args.length > 1) { + throw new Error( + "When using inline JSON, only one argument is allowed after the tool name.", + ); + } + let parsed: unknown; + try { + parsed = JSON.parse(first); + } catch (e) { + throw new Error( + `Invalid JSON tool arguments: ${e instanceof Error ? e.message : String(e)}`, + ); + } + if ( + parsed === null || + typeof parsed !== "object" || + Array.isArray(parsed) + ) { + throw new Error("Inline JSON tool arguments must be a JSON object."); + } + return parsed as Record; + } + + const out: Record = {}; + for (const pair of args) { + const sep = pair.indexOf(":="); + if (sep === -1) { + throw new Error( + `Invalid tool argument "${pair}". Use key:=value pairs or a JSON object.\n` + + `Examples: message:=hello count:=10 '{"message":"hello"}'`, + ); + } + const key = pair.slice(0, sep); + const rawValue = pair.slice(sep + 2); + if (!key) { + throw new Error( + `Invalid tool argument "${pair}" — missing key before :=`, + ); + } + out[key] = autoParseValue(rawValue); + } + return out; +} + +function autoParseValue(raw: string): JsonValue { + try { + return JSON.parse(raw) as JsonValue; + } catch { + return raw; + } +} + +export type ResolveToolCallArgsInput = { + toolNameFlag?: string; + toolNamePos?: string; + /** Remaining positionals after the tool-name slot. */ + toolArgsPos?: string[]; + toolArgFlag?: Record; + toolArgsJson?: string; +}; + +/** + * Resolve tool name + arguments from positionals and/or legacy flags. + * Styles are mutually exclusive: positionals, `--tool-arg`, or `--tool-args-json`. + */ +export function resolveToolCallArgs(input: ResolveToolCallArgsInput): { + toolName: string | undefined; + toolArg: Record; +} { + const flagArgs = input.toolArgFlag ?? {}; + const hasFlagArgs = Object.keys(flagArgs).length > 0; + const hasJson = input.toolArgsJson !== undefined; + + let toolName = input.toolNameFlag ?? input.toolNamePos; + let positionals = [...(input.toolArgsPos ?? [])]; + + // `tools/call --tool-name echo message:=Foo` — commander puts message:=Foo + // in the toolName slot when the name came from the flag. + if (input.toolNameFlag && input.toolNamePos) { + positionals = [input.toolNamePos, ...positionals]; + toolName = input.toolNameFlag; + } + + const hasPositionals = positionals.length > 0; + const styles = [hasPositionals, hasFlagArgs, hasJson].filter(Boolean).length; + if (styles > 1) { + throw new Error( + "Tool arguments must use one style: key:=value / JSON positionals, " + + "--tool-arg, or --tool-args-json.", + ); + } + + if (hasJson) { + return { + toolName, + toolArg: parseJsonObject(input.toolArgsJson!, "--tool-args-json"), + }; + } + if (hasPositionals) { + return { toolName, toolArg: parseToolCallPositionals(positionals) }; + } + return { toolName, toolArg: flagArgs }; +} + +function parseJsonObject(raw: string, flag: string): Record { + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch (e) { + throw new Error( + `${flag} is not valid JSON: ${e instanceof Error ? e.message : String(e)}`, + ); + } + if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new Error(`${flag} must be a JSON object.`); + } + return parsed as Record; +} diff --git a/clients/mcpi/src/session/private-env.ts b/clients/mcpi/src/session/private-env.ts new file mode 100644 index 000000000..f4b62e26e --- /dev/null +++ b/clients/mcpi/src/session/private-env.ts @@ -0,0 +1,37 @@ +import { randomBytes } from "node:crypto"; +import { + createPrivateDaemonDir, + DAEMON_DIR_ENV, + DAEMON_TOKEN_ENV, +} from "../daemon/paths.js"; + +export type PrivateEnvBinding = { + dir: string; + token: string; +}; + +/** + * Allocate a private daemon directory and mint an IPC token. + * Does not start the daemon (lazy on first `ensureDaemon`). + */ +export function createPrivateBinding(): PrivateEnvBinding { + const dir = createPrivateDaemonDir(); + const token = randomBytes(32).toString("base64url"); + return { dir, token }; +} + +/** + * Shell exports for `eval "$(mcpi private)"` (POSIX sh / bash / zsh). + */ +export function formatPrivateEnvExports(binding: PrivateEnvBinding): string { + return [ + `export ${DAEMON_DIR_ENV}=${shellSingleQuote(binding.dir)}`, + `export ${DAEMON_TOKEN_ENV}=${shellSingleQuote(binding.token)}`, + "", + ].join("\n"); +} + +function shellSingleQuote(value: string): string { + // POSIX-safe: 'foo'\''bar' for embedded quotes. + return `'${value.replace(/'/g, `'\\''`)}'`; +} diff --git a/clients/mcpi/src/session/stored-auth.ts b/clients/mcpi/src/session/stored-auth.ts new file mode 100644 index 000000000..804621275 --- /dev/null +++ b/clients/mcpi/src/session/stored-auth.ts @@ -0,0 +1,151 @@ +import { parseOAuthPersistBlob } from "@inspector/core/auth/oauth-persist.js"; +import { + clearAllOAuthClientState, + getStateFilePath, + NodeOAuthStorage, + resetNodeOAuthStorageCache, +} from "@inspector/core/auth/node/storage-node.js"; +import { CliExitCodeError, EXIT_CODES } from "@inspector/cli/error-handler.js"; + +/** Same canonicalisation as one-shot `normalizeServerUrl` (avoid importing cli.ts). */ +function normalizeServerUrl(serverUrl: string): string { + try { + return new URL(serverUrl).href; + } catch { + return serverUrl; + } +} + +export type StoredAuthEntry = { + url: string; + hasTokens: boolean; + hasRefreshToken: boolean; +}; + +export type StoredAuthList = { + oauthStatePath: string; + servers: StoredAuthEntry[]; +}; + +type TokenBlob = { + access_token?: string; + refresh_token?: string; +}; + +function tokenFlagsFromState(state: unknown): { + hasTokens: boolean; + hasRefreshToken: boolean; +} { + if (state == null || typeof state !== "object") { + return { hasTokens: false, hasRefreshToken: false }; + } + const s = state as { + tokens?: TokenBlob; + byIssuer?: Record; + }; + if (s.tokens?.access_token) { + return { + hasTokens: true, + hasRefreshToken: Boolean(s.tokens.refresh_token), + }; + } + for (const slot of Object.values(s.byIssuer ?? {})) { + if (slot?.tokens?.access_token) { + return { + hasTokens: true, + hasRefreshToken: Boolean(slot.tokens.refresh_token), + }; + } + } + return { hasTokens: false, hasRefreshToken: false }; +} + +async function readServersMap( + statePath: string, +): Promise> { + const { readFile } = await import("node:fs/promises"); + try { + const text = await readFile(statePath, "utf8"); + const snapshot = parseOAuthPersistBlob(text); + if (snapshot?.servers && typeof snapshot.servers === "object") { + return snapshot.servers as Record; + } + } catch { + // absent / unreadable + } + return {}; +} + +/** List every server key in the shared OAuth store (tokens optional). */ +export async function listStoredAuth(): Promise { + const oauthStatePath = getStateFilePath(); + const servers = await readServersMap(oauthStatePath); + const entries = Object.keys(servers) + .sort((a, b) => a.localeCompare(b)) + .map((url) => ({ + url, + ...tokenFlagsFromState(servers[url]), + })); + return { oauthStatePath, servers: entries }; +} + +/** + * Resolve a user-supplied key to a stored server URL (exact, then normalised). + */ +export async function resolveStoredAuthKey(key: string): Promise { + const trimmed = key.trim(); + if (!trimmed) { + throw new CliExitCodeError( + EXIT_CODES.USAGE, + "auth/clear requires a server URL key (from auth/list) or --all", + { code: "usage" }, + ); + } + const { servers } = await listStoredAuth(); + const urls = servers.map((s) => s.url); + if (urls.includes(trimmed)) return trimmed; + const normalized = normalizeServerUrl(trimmed); + if (urls.includes(normalized)) return normalized; + // Allow clearing a key that is not listed (no-op clear) when it normalises + // to a URL — still useful after partial writes. + if (normalized !== trimmed || /^https?:\/\//i.test(trimmed)) { + return normalized; + } + throw new CliExitCodeError( + EXIT_CODES.USAGE, + `No stored auth entry for '${trimmed}'. Use auth/list to see keys.`, + { code: "usage" }, + ); +} + +/** Clear one server's OAuth state from the shared store. */ +export async function clearStoredAuth(key: string): Promise<{ url: string }> { + const url = await resolveStoredAuthKey(key); + const storage = new NodeOAuthStorage(); + await storage.clear(url); + resetNodeOAuthStorageCache(); + return { url }; +} + +/** Clear every server entry in the shared OAuth store. */ +export async function clearAllStoredAuth(): Promise<{ cleared: number }> { + const before = await listStoredAuth(); + await clearAllOAuthClientState(); + resetNodeOAuthStorageCache(); + return { cleared: before.servers.length }; +} + +/** + * Drop stored OAuth state for an HTTP(S) server URL so the next connect cannot + * silently reuse tokens (`--relogin`). No-op when `serverUrl` is missing + * (stdio / no URL-keyed entry) — interactive login still only runs if auth is required. + */ +export async function clearStoredAuthForRelogin( + serverUrl: string | undefined, +): Promise { + if (!serverUrl?.trim()) return; + const url = normalizeServerUrl(serverUrl.trim()); + const storage = new NodeOAuthStorage(); + await storage.clear(url); + resetNodeOAuthStorageCache(); +} diff --git a/clients/mcpi/tsconfig.json b/clients/mcpi/tsconfig.json new file mode 100644 index 000000000..c897207af --- /dev/null +++ b/clients/mcpi/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "noEmit": true, + "paths": { + "@inspector/core/*": ["../../core/*"], + "@inspector/cli/*": ["../cli/src/*"] + } + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "**/*.test.ts", "build"] +} diff --git a/clients/mcpi/tsup.config.ts b/clients/mcpi/tsup.config.ts new file mode 100644 index 000000000..df2a373f2 --- /dev/null +++ b/clients/mcpi/tsup.config.ts @@ -0,0 +1,39 @@ +import { defineConfig } from "tsup"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const dirname = path.dirname(fileURLToPath(import.meta.url)); +const repoRoot = path.resolve(dirname, "../.."); +const cliSrc = path.resolve(dirname, "../cli/src"); + +export default defineConfig({ + entry: { + "mcp-bin": "src/mcp-bin.ts", + daemon: "src/daemon/run.ts", + }, + format: ["esm"], + outDir: "build", + clean: true, + // No source maps in the published bundle — they roughly double the on-disk + // size and aren't needed at runtime (debug via `npm run dev` on the source). + sourcemap: false, + target: "node22", + platform: "node", + // Bundle core + one-shot CLI internals (handlers, error-handler, OAuth helpers). + // Temporary reach-in until a dedicated shared package exists — see README. + noExternal: [/^@inspector\/core/, /^@inspector\/cli/], + external: [ + "@napi-rs/keyring", + "@modelcontextprotocol/client", + "@modelcontextprotocol/core", + "commander", + "pino", + "open", + ], + esbuildOptions(options) { + options.alias = { + "@inspector/core": path.join(repoRoot, "core"), + "@inspector/cli": cliSrc, + }; + }, +}); diff --git a/clients/mcpi/vitest.config.ts b/clients/mcpi/vitest.config.ts new file mode 100644 index 000000000..2663e899f --- /dev/null +++ b/clients/mcpi/vitest.config.ts @@ -0,0 +1,44 @@ +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { defineConfig } from "vitest/config"; +import { vitestSharedPaths } from "../../vitest.shared.mts"; + +const dirname = path.dirname(fileURLToPath(import.meta.url)); +const { projectResolve } = vitestSharedPaths(dirname); +const cliSrc = path.resolve(dirname, "../cli/src"); + +const baseAliases = Array.isArray(projectResolve.alias) + ? projectResolve.alias + : []; + +export default defineConfig({ + resolve: { + ...projectResolve, + alias: [...baseAliases, { find: "@inspector/cli", replacement: cliSrc }], + }, + test: { + globals: false, + environment: "node", + include: ["__tests__/**/*.test.ts"], + testTimeout: 15000, + pool: "forks", + coverage: { + provider: "v8", + reporter: ["text", "html", "json-summary"], + include: ["src/**/*.ts"], + exclude: [ + "src/mcp-bin.ts", + "src/daemon/run.ts", + "src/daemon/ipc-glue.ts", + "src/daemon/stream-client.ts", + ], + thresholds: { + perFile: true, + lines: 90, + statements: 90, + functions: 90, + branches: 90, + }, + }, + }, +}); diff --git a/package.json b/package.json index 8707fd2f1..7f69ef067 100644 --- a/package.json +++ b/package.json @@ -32,25 +32,28 @@ "web": "node clients/launcher/build/index.js --web", "build:web:runner": "cd clients/web && npm run build:runner", "web:dev": "npm run build:web:runner && node clients/launcher/build/index.js --web --dev", - "build": "npm run build:web && npm run build:cli && npm run build:tui && npm run build:launcher", + "build": "npm run build:web && npm run build:cli && npm run build:mcpi && npm run build:tui && npm run build:launcher", "build:cli": "cd clients/cli && npm run build", + "build:mcpi": "cd clients/mcpi && npm run build", "build:tui": "cd clients/tui && npm run build", "build:web": "cd clients/web && npm run build", "build:launcher": "cd clients/launcher && npm run build", "ci": "npm run validate && npm run coverage && npm run smoke && npm run ci:storybook", "ci:storybook": "cd clients/web && npx playwright install chromium && npm run test:storybook", - "validate": "npm run validate:core && npm run validate:web && npm run validate:cli && npm run validate:tui && npm run validate:launcher", + "validate": "npm run validate:core && npm run validate:web && npm run validate:cli && npm run validate:mcpi && npm run validate:tui && npm run validate:launcher", "validate:core": "npm run format:check:core && npm run lint:core", "lint:core": "eslint \"core/**/*.{ts,tsx}\"", "format:core": "prettier --write \"core/**/*.{ts,tsx}\"", "format:check:core": "prettier --check \"core/**/*.{ts,tsx}\"", - "format": "npm run format:core && cd clients/web && npm run format && cd ../cli && npm run format && cd ../tui && npm run format && cd ../launcher && npm run format", + "format": "npm run format:core && cd clients/web && npm run format && cd ../cli && npm run format && cd ../mcpi && npm run format && cd ../tui && npm run format && cd ../launcher && npm run format", "validate:cli": "cd clients/cli && npm run validate", + "validate:mcpi": "cd clients/mcpi && npm run validate", "validate:tui": "cd clients/tui && npm run validate", "validate:web": "cd clients/web && npm run validate", "validate:launcher": "cd clients/launcher && npm run validate", - "coverage": "npm run coverage:web && npm run coverage:cli && npm run coverage:tui && npm run coverage:launcher", + "coverage": "npm run coverage:web && npm run coverage:cli && npm run coverage:mcpi && npm run coverage:tui && npm run coverage:launcher", "coverage:cli": "cd clients/cli && npm run test:coverage", + "coverage:mcpi": "cd clients/mcpi && npm run test:coverage", "coverage:tui": "cd clients/tui && npm run test:coverage", "coverage:web": "cd clients/web && npm run test:coverage", "coverage:launcher": "cd clients/launcher && npm run test:coverage", diff --git a/scripts/install-clients.mjs b/scripts/install-clients.mjs index 94867b0a4..b7cf34f7b 100644 --- a/scripts/install-clients.mjs +++ b/scripts/install-clients.mjs @@ -27,7 +27,7 @@ import { dirname, join, resolve, sep } from "node:path"; import { fileURLToPath } from "node:url"; const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); -const CLIENTS = ["web", "cli", "tui", "launcher"]; +const CLIENTS = ["web", "cli", "mcpi", "tui", "launcher"]; if (process.env.INSPECTOR_SKIP_CLIENT_INSTALL) { console.log( diff --git a/specification/v2_catalog_launch_config.md b/specification/v2_catalog_launch_config.md index 6fa0b9a7e..90ac5e7f0 100644 --- a/specification/v2_catalog_launch_config.md +++ b/specification/v2_catalog_launch_config.md @@ -512,7 +512,7 @@ G1, G4, and launcher details: [v2_cli_tui_launcher.md](v2_cli_tui_launcher.md). | [#1183](https://github.com/modelcontextprotocol/inspector/issues/1183) — auto-connect | Open | UC5 web ergonomics | | [#1348](https://github.com/modelcontextprotocol/inspector/issues/1348) — import from other clients | Open | UC2 web UI | | [#1435](https://github.com/modelcontextprotocol/inspector/issues/1435) — registry import | Open | UC2 registry path | -| [#1432](https://github.com/modelcontextprotocol/inspector/issues/1432) — CLI v2 | Open | Session CLI umbrella | +| [#1432](https://github.com/modelcontextprotocol/inspector/issues/1432) — CLI v2 | Open | Session CLI umbrella — as-built: [v2_cli_v2.md](v2_cli_v2.md) | | [#1352](https://github.com/modelcontextprotocol/inspector/pull/1352) / [#1358](https://github.com/modelcontextprotocol/inspector/pull/1358) | Merged | Flat settings on disk | | [#1356](https://github.com/modelcontextprotocol/inspector/pull/1356) | Merged | Secrets in keychain | diff --git a/specification/v2_cli_tui_launcher.md b/specification/v2_cli_tui_launcher.md index fb42b296b..54f837443 100644 --- a/specification/v2_cli_tui_launcher.md +++ b/specification/v2_cli_tui_launcher.md @@ -20,7 +20,7 @@ This document describes how those clients are built, wired, and tested today, an ## Non-goals -- **CLI v2 sessions** (connect once, many subcommands) — tracked separately in [#1432](https://github.com/modelcontextprotocol/inspector/issues/1432). +- **CLI v2 sessions** (connect once, many subcommands) — as-built in [v2_cli_v2.md](v2_cli_v2.md) (`mcpi` bin session-first; `mcp-inspector --cli` stays one-shot); tracked by [#1432](https://github.com/modelcontextprotocol/inspector/issues/1432). - **npm workspaces** — v2 uses a fat root package plus per-client `package.json` for dev dependencies; the launcher resolves sibling `build/` outputs via relative paths, not workspace hoisting. - _Why not workspaces:_ `core/` is consumed by **bundling** — a Vite alias for the browser, tsup inlining for the Node clients — not by symlinked package resolution, so workspaces' main benefit (cross-package linking) does not apply. Each client also pins `react` / `@modelcontextprotocol/sdk` to its own `node_modules` (see `vitest.shared.mts`) to avoid dual-package-instance hazards, which hoisting works against. And the published `@modelcontextprotocol/inspector` is a single flat fat package that workspaces would complicate rather than simplify. - _Cost (from-source dev only):_ there is no hoisting, so each client keeps its own `node_modules`. A root `postinstall` (`scripts/install-clients.mjs`) cascades `npm install` into every client, so a single `npm install` at the repo root populates them all — re-run it after a pull that changes a client's dependencies. The cascade no-ops outside a source checkout (it exits early when running from `node_modules`, and the published tarball ships only each client's `build/`, no client `package.json`), so end users of the published package are unaffected. Set `INSPECTOR_SKIP_CLIENT_INSTALL=1` to skip the cascade (e.g. CI that installs each client itself). @@ -34,7 +34,8 @@ This document describes how those clients are built, wired, and tested today, an | Artifact | Path | Build | Published bin | | ---------- | ------------------------------- | ------------------------------------------------------ | -------------------------------------------------------- | | Launcher | `clients/launcher/` | `tsc` → `build/index.js` | Root `mcp-inspector` → `clients/launcher/build/index.js` | -| CLI | `clients/cli/` | `tsup` → `build/index.js` | `mcp-inspector-cli` (client package only) | +| CLI | `clients/cli/` | `tsup` → `build/index.js` | `mcp-inspector-cli` (client package only; one-shot) | +| mcpi | `clients/mcpi/` | `tsup` → `build/mcp-bin.js` + `build/daemon.js` | `mcpi` (experimental; not shipped in inspector package) | | TUI | `clients/tui/` | `tsup` → `build/index.js` | `mcp-inspector-tui` (client package only) | | Web runner | `clients/web/server/run-web.ts` | `tsup` (`build:runner`) → `clients/web/build/index.js` | `mcp-inspector-web` (client package only) | @@ -94,7 +95,7 @@ All three clients import from `@inspector/core/...` (mapped to `../../core/` sou ## CLI -**Model:** one-shot — each invocation connects, runs a single `--method`, prints JSON to stdout, disconnects, exits. Same surface as v1.5; session-oriented CLI v2 is future work ([#1432](https://github.com/modelcontextprotocol/inspector/issues/1432)). +**Model:** one-shot — each invocation connects, runs a single `--method`, prints a result to stdout, disconnects, exits. Same surface as v1.5. Session-oriented CLI v2 (`mcpi`) is documented as-built in [v2_cli_v2.md](v2_cli_v2.md) ([#1432](https://github.com/modelcontextprotocol/inspector/issues/1432)). **Entry:** `clients/cli/src/index.ts` exports `runCli(argv)`; `src/cli.ts` owns Commander parsing and `InspectorClient` orchestration. diff --git a/specification/v2_cli_v2.md b/specification/v2_cli_v2.md new file mode 100644 index 000000000..68bac9211 --- /dev/null +++ b/specification/v2_cli_v2.md @@ -0,0 +1,185 @@ +# Inspector CLI v2 (session-oriented) + +### [Brief](README.md) | [V1 Problems](v1_problems.md) | [V2 Scope](v2_scope.md) | [V2 Tech Stack](v2_web_client.md) | [V2 UX](v2_ux.md) | [V2 Auth](v2_auth.md) | [V2 New Spec Impact](v2_new_spec_impact.md) + +#### [CLI, TUI, Launcher](v2_cli_tui_launcher.md) | CLI v2 | [Catalog / launch config](v2_catalog_launch_config.md) + +Documentation of the **experimental** session-oriented Inspector CLI (`mcpi`) and how it relates to the frozen one-shot path (`mcp-inspector --cli`). Tracked by [#1432](https://github.com/modelcontextprotocol/inspector/issues/1432). `mcpi` is a separate client under `clients/mcpi/` and is **not** shipped in `@modelcontextprotocol/inspector`. + +**Related:** [CLI, TUI, and Launcher](v2_cli_tui_launcher.md), [Catalog and Launch Configuration](v2_catalog_launch_config.md), [Storage](v2_storage.md), [Auth](v2_auth.md), [`clients/mcpi/README.md`](../clients/mcpi/README.md), [`clients/cli/README.md`](../clients/cli/README.md) (one-shot). + +--- + +## Overview + +| | **One-shot** | **Session** | +| --- | --- | --- | +| Entrypoint | `mcp-inspector --cli` | `mcpi` | +| Lifecycle | Connect → one `--method` → disconnect | Connect once → many subcommands → disconnect | +| Process | In-process only | Short-lived front-end + implicit session daemon (IPC) | +| Package | `clients/cli` (ships with `@modelcontextprotocol/inspector`) | `clients/mcpi` (experimental separate client; not shipped in the inspector package) | + +Both use `@inspector/core` `InspectorClient` and shared `clients/cli/src/handlers/run-method.ts` (mcpi reaches in via a temporary `@inspector/cli` build alias). One-shot never starts the daemon. `mcpi` does not accept `--method`. + +```bash +mcpi servers/list --config mcp.json +mcpi servers/show my-server --config mcp.json +mcpi connect myserver --config mcp.json +mcpi tools/list +mcpi tools/call search query:=hello +mcpi @other resources/list +mcpi disconnect +``` + +Optional private daemon for one shell (`ssh-agent` style): + +```bash +eval "$(mcpi private)" +mcpi connect myserver --config mcp.json +mcpi tools/list +``` + +--- + +## As-built + +### Entrypoints and layout + +| Piece | Location | +| --- | --- | +| One-shot | `clients/cli/src/cli.ts`, `cliOAuth.ts`, `index.ts` | +| Session front-end | `clients/mcpi/src/session/` (`mcp.ts`, `dispatch.ts`, `authorize.ts`, `format-*.ts`, `private-env.ts`) + `mcp-bin.ts` | +| Daemon | `clients/mcpi/src/daemon/` → `clients/mcpi/build/daemon.js` | +| Shared handlers | `clients/cli/src/handlers/` (`run-method.ts`, `method-types.ts`, `servers-list.ts`, `emit-result.ts`, …) | + +``` +mcp-inspector --cli … mcpi … + │ │ + ▼ ▼ + clients/cli clients/mcpi + cli.ts session/mcp.ts + │ │ NDJSON IPC + │ daemon (build/daemon.js) + └──────────┬─────────────┘ + ▼ + clients/cli handlers/run-method.ts → InspectorClient +``` + +### One-shot (`mcp-inspector --cli`) + +Frozen automation contract. Each invocation: resolve server → connect → `runMethod` → print → disconnect. Never uses the session daemon. + +| `--method` | Notes | +| --- | --- | +| `initialize`, `tools/list`, `tools/call`, `resources/list`, `resources/read`, `resources/templates/list`, `prompts/list`, `prompts/get`, `logging/setLevel` | Core one-shot surface (`ONE_SHOT_METHODS`) | +| `servers/list`, `servers/show` | Catalog only (no MCP connect); `servers/show` needs `--server` | + +Anything else (e.g. `logging/tail`, `resources/subscribe`, `tasks/*`, `roots/*`) is a **usage error before connect** — one-shot must not hang on stream outcomes. + +**Output:** `--format text` = pretty JSON of bare result; `json` = `{ result[, appInfo] }` envelope. Exit codes `0`–`5` + stderr `ErrorEnvelope`. + +**Auth:** Interactive OAuth + mid-session recovery in-process (`cliOAuth.ts`); `--stored-auth-only`, `--use-stored-auth`, handoff flags. See [clients/cli/README.md](../clients/cli/README.md). + +### Session CLI (`mcpi`) + +#### Commands + +| Category | Commands | +| --- | --- | +| Catalog | `servers/list`, `servers/show ` | +| Session | `connect` (`--relogin`), `disconnect`, `sessions/list`, `sessions/use` | +| Auth store | `auth/list`, `auth/clear` / `auth/clear --all` | +| Daemon | `private`, `daemon status`, `daemon stop` | +| MCP | `initialize`, `tools/list`, `tools/call`, `resources/*`, `prompts/*`, `logging/setLevel`, `logging/tail`, `tasks/*`, `roots/list`, `roots/set` | + +**Globals (before subcommand):** `--format text|json`, `--plain`, `--session `, `--catalog` / `--config`, `--stored-auth-only`. + +**Session select:** leading `@name` and/or `--session `. Tool args: `key:=value`, inline JSON, or `--tool-arg` / `--tool-args-json`. + +**Connect forms:** catalog entry / `--server` / ad-hoc URL or command; optional `@name` to override session name (default = entry id). + +#### Output + +| Flag | Behaviour | +| --- | --- | +| `--format text` (default) | Human-readable. On a TTY: ANSI color / bold / dim / OSC 8 links unless `--plain` or `NO_COLOR`. | +| `--format json` | Pretty-printed payload (**no** `{ result }` envelope; never ANSI). | +| Streams | Long-lived until Ctrl-C; human lines or pretty JSON events per `--format`. | + +#### Default session (MRU) + +- Omit `@name` / `--session` → MRU (TTY). +- Explicit `@name` / `--session` always wins. +- Non-TTY: require explicit session unless `MCP_ALLOW_DEFAULT_SESSION=1`. +- `sessions/list`, `sessions/use `; `daemon status` / `sessions/list` do **not** auto-spawn the daemon. + +#### Daemon + +**IPC ops:** `ping`, `connect`, `disconnect`, `sessions/list`, `sessions/use`, `daemon/status`, `daemon/stop`, `rpc`, `stream`. + +- One `InspectorClient` per named session; auto-spawn on first need; idle exit ~60s after last disconnect **or** after a session-less spawn with no successful connect; `daemon stop` tears down immediately. +- Socket/lock mode `0600` (best-effort). Config (incl. secrets) over IPC after listen — not on daemon argv. +- Errors that are not already `CliExitCodeError` go through `classifyError` (exit-code parity with one-shot). + +| Context | Path | +| --- | --- | +| Shared default | `~/.mcp-inspector/daemon.sock` (+ lock) | +| `MCP_STORAGE_DIR` | Socket/lock under that dir (CI isolation; same family as `oauth.json`) | +| `MCP_INSPECTOR_DAEMON_DIR` | Wins over storage dir when set (spawn pin / private) | +| Private | `~/.mcp-inspector/private//` from `mcpi private` | + +| Mode | Trust | +| --- | --- | +| **Shared (default)** | No token. Same-UID peer that can open the socket can drive sessions (intentional cross-terminal share). | +| **Private** | `eval "$(mcpi private)"` exports `MCP_INSPECTOR_DAEMON_DIR` + `MCP_INSPECTOR_DAEMON_TOKEN`. Daemon requires the token on every request. OAuth store remains shared unless the user also sets `MCP_STORAGE_DIR`. Daemon starts lazily on first IPC. | + +#### Auth (session) + +- Same `oauth.json` store as other Inspector clients. +- **Connect-time:** daemon connect → on `auth_required`, front-end `authorizeInFrontend()` (unless `--stored-auth-only`) → retry connect. +- **`--relogin`:** clear any stored OAuth for the server URL before connect; interactive login still runs only if auth is required afterward. No-op for stdio / targets with no URL-keyed store entry (do not reject — same semantics, nothing to clear). +- **Mid-session** step-up during `rpc` / `stream`: **not implemented** (see To-do). Use one-shot, or disconnect / re-auth / reconnect. +- Session `connect` does not expose one-shot OAuth flags (`--client-id`, `--callback-url`, …); env / defaults / `MCP_OAUTH_CALLBACK_URL` only. + +#### One-shot ↔ session mapping + +| One-shot | Session | +| --- | --- | +| `… --catalog mcp.json --server s --method tools/list` | `mcpi connect --catalog mcp.json s` then `mcpi tools/list` | +| `… --method tools/call --tool-name X --tool-args-json '…'` | `mcpi tools/call X key:=val` / `'{"…"}'` | +| `… --method servers/list` | `mcpi servers/list` | +| `… --method servers/show --server ` | `mcpi servers/show ` | + +### Testing + +| Client | Runner | Coverage | +| --- | --- | --- | +| One-shot (`clients/cli`) | In-process `runCli()`; thin binary e2e | Per-file ≥90 on `clients/cli/src`. Exclusion: `src/index.ts`. | +| Session (`clients/mcpi`) | In-process `runMcp()`; daemon IPC + stream + private-token tests | Per-file ≥90 on `clients/mcpi/src`. Exclusions: `mcp-bin.ts`, `daemon/run.ts`, `ipc-glue.ts`, `stream-client.ts`. | + +Both are wired into root `validate` / `coverage`. + +--- + +## To-do + +| Item | Notes | +| --- | --- | +| **Mid-session auth over IPC** | Challenge + step-up UX on the invoking `mcpi` during `rpc`/`stream`. Connect-time only today. | +| **Daemon singleton / exclusive lock** | `daemon.lock` writes a PID but does not enforce exclusive spawn or stale-PID reclaim. Concurrent `ensureDaemon` can race. | +| **Windows daemon transport** | Unix-domain sockets only; named pipes on `win32` when needed. | +| **Per-socket request serialization** | Accept handler is unbounded per NDJSON line; safe while clients use one request per connection. | +| **Per-session RPC mutex** | Parallel `mcpi` processes against one session can interleave on one `InspectorClient`. | +| **`streamDaemon` post-open errors** | Socket errors after the initial ok frame are treated as soft end. | +| **Coverage gate for `ipc-glue` / `stream-client`** | Behavioral tests exist; files excluded until the race matrix is stably ≥90. | +| **Shared `createCliInspectorClient`** | Daemon / authorize / one-shot construct clients separately. | +| **Split `registerRpcCommands`** | Large Commander switch in `session/mcp.ts`. | +| **`mcpi daemon run`** | Optional foreground debug (not a Commander subcommand; `build/daemon.js` works today). | +| **Launcher help polish** | Make `mcpi` vs `--cli` unmistakable in launcher `--help` / docs. | +| **Session `connect` OAuth flag parity** | One-shot has `--client-id` / `--callback-url` / handoff; session authorize uses defaults / env only. | +| **Peer-cred / stronger private IPC** | Private mode uses bearer token; optional OS peer checks beyond that. | +| **Stream fan-out / `mcpi attach`** | One consumer per stream invocation today. | +| **Sampling / elicitation CLI** | Still TUI/web. | +| **Ephemeral no-`connect` shortcuts on `mcpi`** | Out of scope (keep two mental models). | +| **`MCP_SESSION` env** | Superseded by require-explicit-on-non-TTY + `MCP_ALLOW_DEFAULT_SESSION=1`. | +| **Human `--full` schema dumps** | Optional formatter polish. |