From 2bb98b0639ad5c49df08cd7de0b39c840d65e7b0 Mon Sep 17 00:00:00 2001 From: Bob Dickinson Date: Sat, 25 Jul 2026 16:38:24 -0700 Subject: [PATCH 01/23] feat(cli): catalog listing, auth flags, and OAuth browser UX Add servers/list and servers/show, --relogin / --stored-auth-only, browser OAuth navigation with OSC 8 links, and extract method handlers under clients/cli/src/handlers. Tracks #1781. Co-authored-by: Cursor --- clients/cli/README.md | 31 +- clients/cli/__tests__/README.md | 72 --- .../clear-stored-auth-for-relogin.test.ts | 68 +++ .../__tests__/cli-oauth-navigation.test.ts | 114 +++++ clients/cli/__tests__/cliOAuth.test.ts | 71 +++ clients/cli/__tests__/emit-result.test.ts | 35 ++ clients/cli/__tests__/format-output.test.ts | 50 ++ clients/cli/__tests__/metadata.test.ts | 37 ++ clients/cli/__tests__/method-types.test.ts | 36 ++ clients/cli/__tests__/methods.test.ts | 33 +- clients/cli/__tests__/open-url.test.ts | 28 ++ .../__tests__/programmatic-ergonomics.test.ts | 46 ++ .../cli/__tests__/run-method-mocks.test.ts | 232 +++++++++ clients/cli/__tests__/run-method.test.ts | 249 +++++++++ clients/cli/__tests__/servers-list.test.ts | 286 +++++++++++ clients/cli/__tests__/style.test.ts | 40 ++ clients/cli/package-lock.json | 149 ++++++ clients/cli/package.json | 1 + .../cli/src/clear-stored-auth-for-relogin.ts | 27 + clients/cli/src/cli-oauth-navigation.ts | 46 ++ clients/cli/src/cli.ts | 476 ++++-------------- clients/cli/src/cliOAuth.ts | 36 ++ clients/cli/src/handlers/collect-app-info.ts | 35 ++ clients/cli/src/handlers/connect-timeout.ts | 35 ++ clients/cli/src/handlers/consume-outcome.ts | 37 ++ clients/cli/src/handlers/emit-result.ts | 46 ++ clients/cli/src/handlers/format-output.ts | 18 + clients/cli/src/handlers/method-types.ts | 116 +++++ clients/cli/src/handlers/run-method.ts | 310 ++++++++++++ clients/cli/src/handlers/servers-list.ts | 212 ++++++++ clients/cli/src/open-url.ts | 6 + clients/cli/src/style.ts | 76 +++ clients/cli/tsup.config.ts | 4 +- 33 files changed, 2585 insertions(+), 473 deletions(-) create mode 100644 clients/cli/__tests__/clear-stored-auth-for-relogin.test.ts create mode 100644 clients/cli/__tests__/cli-oauth-navigation.test.ts create mode 100644 clients/cli/__tests__/format-output.test.ts create mode 100644 clients/cli/__tests__/method-types.test.ts create mode 100644 clients/cli/__tests__/open-url.test.ts create mode 100644 clients/cli/__tests__/run-method-mocks.test.ts create mode 100644 clients/cli/__tests__/run-method.test.ts create mode 100644 clients/cli/__tests__/servers-list.test.ts create mode 100644 clients/cli/__tests__/style.test.ts create mode 100644 clients/cli/src/clear-stored-auth-for-relogin.ts create mode 100644 clients/cli/src/cli-oauth-navigation.ts create mode 100644 clients/cli/src/handlers/collect-app-info.ts create mode 100644 clients/cli/src/handlers/connect-timeout.ts create mode 100644 clients/cli/src/handlers/consume-outcome.ts create mode 100644 clients/cli/src/handlers/emit-result.ts create mode 100644 clients/cli/src/handlers/format-output.ts create mode 100644 clients/cli/src/handlers/method-types.ts create mode 100644 clients/cli/src/handlers/run-method.ts create mode 100644 clients/cli/src/handlers/servers-list.ts create mode 100644 clients/cli/src/open-url.ts create mode 100644 clients/cli/src/style.ts diff --git a/clients/cli/README.md b/clients/cli/README.md index 7c49d3a352..8c75df3ef2 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. +CLI for the Inspector: connect, run a `--method`, disconnect. Invoked as `mcp-inspector --cli`. ## Running the CLI -You can run the CLI client directly via `npx`: +You can run the 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,10 +142,12 @@ 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). + +**CLI (`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). @@ -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 09aec386fc..ca7793506c 100644 --- a/clients/cli/__tests__/README.md +++ b/clients/cli/__tests__/README.md @@ -1,73 +1 @@ # CLI Tests - -## Running Tests - -```bash -# Run all tests (pretest builds test-servers + the CLI binary) -npm test - -# Run all tests under the per-file coverage gate -npm run test:coverage - -# Run in watch mode (useful for test file changes) -npm run test:watch - -# Run specific test file -npm run test:cli # cli.test.ts -npm run test:cli-tools # tools.test.ts -npm run test:cli-headers # headers.test.ts -npm run test:cli-metadata # metadata.test.ts -npx vitest run oauth-interactive.test.ts cliOAuth.test.ts # OAuth interactive smoke parity -``` - -## How the CLI is exercised - -Most tests run the CLI **in-process**: `helpers/cli-runner.ts` imports `runCli()` -from `../src/cli.ts` and invokes it directly, capturing `process.stdout`/`stderr` -and mapping thrown errors to an exit code. This is what lets vitest's v8 coverage -instrument `clients/cli/src` (#1484). Because `commander` uses `.exitOverride()`, -a parse/usage error throws instead of calling `process.exit` and tearing down the -worker. - -`e2e.test.ts` is the deliberately thin **out-of-process** layer: it spawns the -**built** `build/index.js` as a real subprocess to cover the shebang, the -`index.ts` `isMain` bootstrap, and the actual `process.exit` codes — the only -parts the in-process runner can't reach. (`scripts/smoke-cli.mjs` at the repo -root provides a further end-to-end check of the binary.) - -## Test Files - -- `cli.test.ts` - Basic CLI functionality: CLI mode, environment variables, config files, resources, prompts, logging, transport types -- `tools.test.ts` - Tool-related tests: Tool discovery, JSON argument parsing, error handling, prompts -- `headers.test.ts` - Header parsing and validation -- `metadata.test.ts` - Metadata functionality: General metadata, tool-specific metadata, parsing, merging, validation -- `methods.test.ts` - Method/option-validation paths not covered elsewhere (resource templates, missing/invalid options, the `--` target separator) -- `error-handler.test.ts` - The binary's `handleError` error sink, exercised in-process with `process.exit` stubbed -- `oauth-runner.test.ts` - OAuth flag wiring (`--client-config`, `--callback-url`, CIMD overrides) -- `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) - -## Helpers - -The `helpers/` directory contains shared utilities: - -- `cli-runner.ts` - Invokes `runCli()` in-process and captures stdout/stderr + exit code -- `assertions.ts` - Custom assertion helpers for CLI output validation -- `fixtures.ts` - Test config/catalog file generators and temporary directory management - -HTTP/SSE test servers (for transport tests) come from the -`@modelcontextprotocol/inspector-test-server` package (`createTestServerHttp`), -and the bundled stdio server is launched via `getTestMcpServerCommand()`. - -## Notes - -- Test files run in separate forked processes (`pool: 'forks'`), so the in-process - runner's `process.std*.write` patching never overlaps across files; tests within - 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 -- 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 0000000000..0e4ecee93d --- /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 0000000000..4cc9a2dbf6 --- /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 f9d191539d..f075f13ca0 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 df4e8fc6f8..dfa16b379d 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 0000000000..14d633207a --- /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 ac2876f6c5..b5867d50ec 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 0000000000..42c710256c --- /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 full RPC method set supported by runMethod", () => { + 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 0fb720b362..a414595865 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 0000000000..304dd25709 --- /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 63ea39cbd2..17888498e8 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("CLI --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 0000000000..4fda0c5197 --- /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 0000000000..242e0cd030 --- /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 0000000000..6860e29f0f --- /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 --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 --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 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 0000000000..f9b8cfeffd --- /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 2760136d3a..36d3aad62e 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 9ef0e9bd89..2f277877cf 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 0000000000..850c14f997 --- /dev/null +++ b/clients/cli/src/clear-stored-auth-for-relogin.ts @@ -0,0 +1,27 @@ +import { + NodeOAuthStorage, + resetNodeOAuthStorageCache, +} from "@inspector/core/auth/node/storage-node.js"; + +/** Same canonicalisation as CLI `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. + */ +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 0000000000..03352a6976 --- /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 f63815bca6..b48bfb7cb2 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}. Supported --cli methods: ${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 24aa60582d..a15bc0c4f2 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 0000000000..aa2df1fc1b --- /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 0000000000..7d5a7b2cc4 --- /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 0000000000..89b4ac7a88 --- /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 0000000000..b4d370deba --- /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 0000000000..d6ceaa9757 --- /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 CLI `--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 0000000000..ce5344a210 --- /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 via {@link runMethod}. */ +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 `mcp-inspector --cli` (plus catalog-only + * `servers/list` / `servers/show`, handled before connect). Stream methods and + * long-lived RPCs (`logging/tail`, `tasks/*`, …) are rejected so the CLI + * 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 0000000000..8d94e2978d --- /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}. + * Core method dispatch used by the CLI (and other Inspector Node runners). + */ +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 0000000000..8188f70353 --- /dev/null +++ b/clients/cli/src/handlers/servers-list.ts @@ -0,0 +1,212 @@ +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; + /** + * Optional live-session name when a caller annotates catalog entries + * with connected sessions (omitted for plain catalog listing). + */ + session?: string; + /** True when that session is the most-recently-used connected session. */ + 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. + * 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. + */ +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 0000000000..f5b42a8aca --- /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 0000000000..315edb381f --- /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 d65d37ec3b..5a58c1ab84 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, From a6bc7e6357e520bbfc0b743ddd78dd1ba280533d Mon Sep 17 00:00:00 2001 From: Bob Dickinson Date: Sat, 25 Jul 2026 16:39:01 -0700 Subject: [PATCH 02/23] feat(mcpi): add experimental session CLI client Introduce clients/mcpi (session front-end + local daemon), wire it into monorepo validate/build/coverage/install, and document the design. Not shipped in the published inspector tarball. Closes #1432. Co-authored-by: Cursor --- AGENTS.md | 23 +- README.md | 4 +- clients/mcpi/README.md | 78 + clients/mcpi/__tests__/authorize.test.ts | 66 + .../mcpi/__tests__/daemon-coverage.test.ts | 791 +++ clients/mcpi/__tests__/daemon-paths.test.ts | 98 + clients/mcpi/__tests__/daemon-private.test.ts | 229 + .../mcpi/__tests__/daemon-sessions.test.ts | 252 + clients/mcpi/__tests__/daemon-stream.test.ts | 312 + clients/mcpi/__tests__/dispatch.test.ts | 158 + clients/mcpi/__tests__/format-session.test.ts | 699 ++ clients/mcpi/__tests__/helpers/mcp-runner.ts | 88 + clients/mcpi/__tests__/hoist-session.test.ts | 22 + .../mcpi/__tests__/mcp-auth-coverage.test.ts | 211 + clients/mcpi/__tests__/mcp-coverage.test.ts | 374 ++ clients/mcpi/__tests__/mcp-session.test.ts | 207 + .../mcpi/__tests__/parse-tool-args.test.ts | 119 + .../__tests__/session-stored-auth.test.ts | 249 + clients/mcpi/eslint.config.js | 17 + clients/mcpi/package-lock.json | 5891 +++++++++++++++++ clients/mcpi/package.json | 53 + clients/mcpi/src/daemon/auth.ts | 43 + clients/mcpi/src/daemon/client.ts | 141 + clients/mcpi/src/daemon/ensure.ts | 147 + clients/mcpi/src/daemon/framing.ts | 28 + clients/mcpi/src/daemon/index.ts | 36 + clients/mcpi/src/daemon/ipc-glue.ts | 125 + clients/mcpi/src/daemon/paths.ts | 67 + clients/mcpi/src/daemon/protocol.ts | 96 + clients/mcpi/src/daemon/run.ts | 29 + clients/mcpi/src/daemon/server.ts | 342 + clients/mcpi/src/daemon/sessions.ts | 365 + clients/mcpi/src/daemon/stream-client.ts | 183 + clients/mcpi/src/mcp-bin.ts | 28 + clients/mcpi/src/session/authorize.ts | 93 + clients/mcpi/src/session/dispatch.ts | 121 + clients/mcpi/src/session/format-human.ts | 689 ++ clients/mcpi/src/session/format-session.ts | 224 + clients/mcpi/src/session/mcp.ts | 825 +++ clients/mcpi/src/session/parse-tool-args.ts | 132 + clients/mcpi/src/session/private-env.ts | 37 + clients/mcpi/src/session/stored-auth.ts | 151 + clients/mcpi/tsconfig.json | 12 + clients/mcpi/tsup.config.ts | 39 + clients/mcpi/vitest.config.ts | 44 + package.json | 11 +- scripts/install-clients.mjs | 2 +- specification/v2_catalog_launch_config.md | 2 +- specification/v2_cli_tui_launcher.md | 7 +- specification/v2_cli_v2.md | 185 + 50 files changed, 14126 insertions(+), 19 deletions(-) create mode 100644 clients/mcpi/README.md create mode 100644 clients/mcpi/__tests__/authorize.test.ts create mode 100644 clients/mcpi/__tests__/daemon-coverage.test.ts create mode 100644 clients/mcpi/__tests__/daemon-paths.test.ts create mode 100644 clients/mcpi/__tests__/daemon-private.test.ts create mode 100644 clients/mcpi/__tests__/daemon-sessions.test.ts create mode 100644 clients/mcpi/__tests__/daemon-stream.test.ts create mode 100644 clients/mcpi/__tests__/dispatch.test.ts create mode 100644 clients/mcpi/__tests__/format-session.test.ts create mode 100644 clients/mcpi/__tests__/helpers/mcp-runner.ts create mode 100644 clients/mcpi/__tests__/hoist-session.test.ts create mode 100644 clients/mcpi/__tests__/mcp-auth-coverage.test.ts create mode 100644 clients/mcpi/__tests__/mcp-coverage.test.ts create mode 100644 clients/mcpi/__tests__/mcp-session.test.ts create mode 100644 clients/mcpi/__tests__/parse-tool-args.test.ts create mode 100644 clients/mcpi/__tests__/session-stored-auth.test.ts create mode 100644 clients/mcpi/eslint.config.js create mode 100644 clients/mcpi/package-lock.json create mode 100644 clients/mcpi/package.json create mode 100644 clients/mcpi/src/daemon/auth.ts create mode 100644 clients/mcpi/src/daemon/client.ts create mode 100644 clients/mcpi/src/daemon/ensure.ts create mode 100644 clients/mcpi/src/daemon/framing.ts create mode 100644 clients/mcpi/src/daemon/index.ts create mode 100644 clients/mcpi/src/daemon/ipc-glue.ts create mode 100644 clients/mcpi/src/daemon/paths.ts create mode 100644 clients/mcpi/src/daemon/protocol.ts create mode 100644 clients/mcpi/src/daemon/run.ts create mode 100644 clients/mcpi/src/daemon/server.ts create mode 100644 clients/mcpi/src/daemon/sessions.ts create mode 100644 clients/mcpi/src/daemon/stream-client.ts create mode 100644 clients/mcpi/src/mcp-bin.ts create mode 100644 clients/mcpi/src/session/authorize.ts create mode 100644 clients/mcpi/src/session/dispatch.ts create mode 100644 clients/mcpi/src/session/format-human.ts create mode 100644 clients/mcpi/src/session/format-session.ts create mode 100644 clients/mcpi/src/session/mcp.ts create mode 100644 clients/mcpi/src/session/parse-tool-args.ts create mode 100644 clients/mcpi/src/session/private-env.ts create mode 100644 clients/mcpi/src/session/stored-auth.ts create mode 100644 clients/mcpi/tsconfig.json create mode 100644 clients/mcpi/tsup.config.ts create mode 100644 clients/mcpi/vitest.config.ts create mode 100644 specification/v2_cli_v2.md diff --git a/AGENTS.md b/AGENTS.md index ed03359f1c..d4927fade9 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 709637e5fb..7ef5d0ca6c 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/mcpi/README.md b/clients/mcpi/README.md new file mode 100644 index 0000000000..13724be944 --- /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 0000000000..a96f5b3a0e --- /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 0000000000..41d5c82a4a --- /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 0000000000..cffcb086d6 --- /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 0000000000..d36f9cc35c --- /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 0000000000..b91d25b648 --- /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 0000000000..ddf3e9e751 --- /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 0000000000..1221d171be --- /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 0000000000..5004057168 --- /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 0000000000..341dd37cd7 --- /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 0000000000..19b9b13d1a --- /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 0000000000..104e6b59ea --- /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 0000000000..d6ed517dc2 --- /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 0000000000..8d38e1cf13 --- /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 0000000000..971fa620d0 --- /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 0000000000..11c918d10b --- /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 0000000000..1c43ee8fdb --- /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 0000000000..f98171f975 --- /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 0000000000..1dd3b3d49c --- /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 0000000000..68996ef556 --- /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 0000000000..2932370faa --- /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 0000000000..69f6b435e3 --- /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 0000000000..fb9822f008 --- /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 0000000000..d7526945bb --- /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 0000000000..64a90c4298 --- /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 0000000000..d850b56af3 --- /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 0000000000..2d1241cd4c --- /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 0000000000..4b28cd1b24 --- /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 0000000000..a1a6578b30 --- /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 0000000000..21eebb00eb --- /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 0000000000..7a2419cd37 --- /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 0000000000..fcda903255 --- /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 0000000000..774d7a31c1 --- /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 0000000000..8cf160000a --- /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 0000000000..13f8902e94 --- /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 0000000000..db634e44fc --- /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 0000000000..db1fc310b4 --- /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 0000000000..6b4bd931fe --- /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 0000000000..f4b62e26ec --- /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 0000000000..8046212757 --- /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 0000000000..c897207af9 --- /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 0000000000..df2a373f22 --- /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 0000000000..2663e899f0 --- /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 8707fd2f10..7f69ef067a 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 94867b0a45..b7cf34f7b6 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 6fa0b9a7e1..90ac5e7f04 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 fb42b296bd..54f8374435 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 0000000000..68bac9211d --- /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. | From 24f39b3b5141c5d0980238f95c151553745cf0fa Mon Sep 17 00:00:00 2001 From: Bob Dickinson Date: Sun, 26 Jul 2026 14:15:04 -0700 Subject: [PATCH 03/23] chore(mcpi): format and pin prettier after v2/main merge Co-authored-by: Cursor --- clients/launcher/package-lock.json | 30 ------------------- clients/mcpi/__tests__/daemon-paths.test.ts | 3 +- clients/mcpi/__tests__/dispatch.test.ts | 3 +- clients/mcpi/__tests__/format-session.test.ts | 3 +- .../mcpi/__tests__/mcp-auth-coverage.test.ts | 6 ++-- clients/mcpi/package-lock.json | 8 ++--- clients/mcpi/src/daemon/protocol.ts | 8 +++-- 7 files changed, 20 insertions(+), 41 deletions(-) diff --git a/clients/launcher/package-lock.json b/clients/launcher/package-lock.json index 4c112ddc47..ecf296c179 100644 --- a/clients/launcher/package-lock.json +++ b/clients/launcher/package-lock.json @@ -440,9 +440,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -460,9 +457,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -480,9 +474,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -500,9 +491,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -520,9 +508,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -540,9 +525,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1825,9 +1807,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -1849,9 +1828,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -1873,9 +1849,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -1897,9 +1870,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ diff --git a/clients/mcpi/__tests__/daemon-paths.test.ts b/clients/mcpi/__tests__/daemon-paths.test.ts index cffcb086d6..5636493bd6 100644 --- a/clients/mcpi/__tests__/daemon-paths.test.ts +++ b/clients/mcpi/__tests__/daemon-paths.test.ts @@ -81,7 +81,8 @@ describe("writeFormattedResult", () => { process.stdout.write = ((chunk: unknown, ...rest: unknown[]) => { out += String(chunk); const cb = rest.find((x) => typeof x === "function") as - (() => void) | undefined; + | (() => void) + | undefined; cb?.(); return true; }) as typeof process.stdout.write; diff --git a/clients/mcpi/__tests__/dispatch.test.ts b/clients/mcpi/__tests__/dispatch.test.ts index 1221d171be..77d1f9c629 100644 --- a/clients/mcpi/__tests__/dispatch.test.ts +++ b/clients/mcpi/__tests__/dispatch.test.ts @@ -20,7 +20,8 @@ describe("dispatchSessionRpc", () => { 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; + | (() => void) + | undefined; cb?.(); return true; }) as typeof process.stdout.write; diff --git a/clients/mcpi/__tests__/format-session.test.ts b/clients/mcpi/__tests__/format-session.test.ts index 5004057168..7490581bec 100644 --- a/clients/mcpi/__tests__/format-session.test.ts +++ b/clients/mcpi/__tests__/format-session.test.ts @@ -465,7 +465,8 @@ describe("writeSessionOutput", () => { 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; + | (() => void) + | undefined; cb?.(); return true; }) as typeof process.stdout.write; diff --git a/clients/mcpi/__tests__/mcp-auth-coverage.test.ts b/clients/mcpi/__tests__/mcp-auth-coverage.test.ts index 104e6b59ea..c7acafcfd7 100644 --- a/clients/mcpi/__tests__/mcp-auth-coverage.test.ts +++ b/clients/mcpi/__tests__/mcp-auth-coverage.test.ts @@ -32,13 +32,15 @@ describe("mcp.ts auth / daemon error paths", () => { 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; + | (() => 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; + | (() => void) + | undefined; cb?.(); return true; }) as typeof process.stderr.write; diff --git a/clients/mcpi/package-lock.json b/clients/mcpi/package-lock.json index f98171f975..fbc6aaeeb3 100644 --- a/clients/mcpi/package-lock.json +++ b/clients/mcpi/package-lock.json @@ -29,7 +29,7 @@ "@vitest/coverage-v8": "^4.1.0", "eslint": "^9.39.4", "globals": "^17.4.0", - "prettier": "^3.8.1", + "prettier": "3.8.4", "tsup": "^8.5.0", "typescript": "~5.9.3", "typescript-eslint": "^8.56.1", @@ -4722,9 +4722,9 @@ } }, "node_modules/prettier": { - "version": "3.9.6", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.6.tgz", - "integrity": "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==", + "version": "3.8.4", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.4.tgz", + "integrity": "sha512-N2MylSdi48+5N/6S5j+maeHbUSIzzZ5uOcX5Hm4QpV8Dkb1HFjfAKTKX6yNPJQD9AhcT3ifHNB66tWTTJDi11Q==", "dev": true, "license": "MIT", "bin": { diff --git a/clients/mcpi/src/daemon/protocol.ts b/clients/mcpi/src/daemon/protocol.ts index 2d1241cd4c..ba925f3346 100644 --- a/clients/mcpi/src/daemon/protocol.ts +++ b/clients/mcpi/src/daemon/protocol.ts @@ -53,7 +53,10 @@ export type DaemonRequest = { */ token?: string; params?: - ConnectParams | SessionNameParams | RpcParams | Record; + | ConnectParams + | SessionNameParams + | RpcParams + | Record; }; export type DaemonErrorBody = { @@ -69,7 +72,8 @@ export type DaemonResponse = /** Frames after the initial ok response on a `stream` connection. */ export type DaemonStreamFrame = - { id: string; stream: "data"; data: unknown } | { id: string; stream: "end" }; + | { id: string; stream: "data"; data: unknown } + | { id: string; stream: "end" }; export type SessionInfo = { name: string; From e79dea7f7d9193040c753daf9b14d0cdbb4cfcbd Mon Sep 17 00:00:00 2001 From: Bob Dickinson Date: Sun, 13 Sep 2026 21:49:08 -0700 Subject: [PATCH 04/23] chore(mcpi): re-add build:dev tooling after v2/main merge --- clients/mcpi/README.md | 17 +++++++++++++++++ clients/mcpi/package.json | 1 + package.json | 1 + 3 files changed, 19 insertions(+) diff --git a/clients/mcpi/README.md b/clients/mcpi/README.md index 13724be944..a107946dd7 100644 --- a/clients/mcpi/README.md +++ b/clients/mcpi/README.md @@ -21,6 +21,23 @@ 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. +### Development loop + +`mcpi` itself is a short-lived process re-executed on every invocation, so a +plain rebuild is enough for its changes to take effect on the next command. +The **session daemon** (`build/daemon.js`) is different: `ensureDaemon` (see +`src/daemon/ensure.ts`) reuses an already-running daemon without checking its +code version, so a daemon started before your rebuild keeps running stale +code indefinitely. + +Use `npm run build:dev` instead of `npm run build` while iterating: it runs +`mcpi daemon stop` first (harmless/no-op if no daemon is running — it treats +"daemon not running" as success) and then `tsup`, so the next daemon-backed +command (`connect`, `tools/list`, …) spawns a fresh daemon from the code you +just built. Commands that never touch the daemon (`servers/list`, +`servers/show`, `--help`) don't need this — a plain `npm run build` is enough +for those. + Without linking, run the built file directly: ```bash diff --git a/clients/mcpi/package.json b/clients/mcpi/package.json index c20f5ff010..cab641d2eb 100644 --- a/clients/mcpi/package.json +++ b/clients/mcpi/package.json @@ -14,6 +14,7 @@ ], "scripts": { "build": "tsup", + "build:dev": "node build/mcp-bin.js daemon stop >/dev/null 2>&1; tsup", "validate": "npm run format:check && npm run lint && npm run test", "test": "vitest run", "test:watch": "vitest", diff --git a/package.json b/package.json index 03d89d171f..c0665f273c 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,7 @@ "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:mcpi:dev": "cd clients/mcpi && npm run build:dev", "build:tui": "cd clients/tui && npm run build", "build:web": "cd clients/web && npm run build", "build:launcher": "cd clients/launcher && npm run build", From fd64afffce3650e05f9bd598a8f3282792d155f8 Mon Sep 17 00:00:00 2001 From: Bob Dickinson Date: Sun, 13 Sep 2026 22:01:40 -0700 Subject: [PATCH 05/23] fix(mcpi): restore build after v2/main merge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two interface-drift breaks surfaced only at build/runtime (the git merge itself was clean): - clients/cli/handlers/method-types.ts dropped metaValueToString when _meta was widened to RequestMetadata (JsonValue, not string) in #1910. mcpi's stringifyMeta was flattening to strings for a type that no longer exists; pass the already-JSON metadata through directly instead. - core/ gained proper-lockfile (auth/node/file-lock.ts) and yaml (mcp/skillFile.ts) as new dependencies. Both are bundled into mcpi's daemon via the noExternal @inspector/core reach-in, and both do a dynamic require() of a Node builtin in their CJS internals, which esbuild's ESM bundle output can't satisfy — the daemon threw on startup with 'Dynamic require of "process"/"path" is not supported' and every session-CLI test that needed a live daemon timed out waiting for it. Externalize both packages (already root dependencies, resolvable from node_modules at runtime). Verified: npm run build:mcpi succeeds, npm run validate:mcpi (lint + typecheck + 117 tests, coverage gate) passes clean. --- clients/mcpi/src/session/mcp.ts | 30 ++++++++++-------------------- clients/mcpi/tsup.config.ts | 2 ++ 2 files changed, 12 insertions(+), 20 deletions(-) diff --git a/clients/mcpi/src/session/mcp.ts b/clients/mcpi/src/session/mcp.ts index db1fc310b4..bd724e0709 100644 --- a/clients/mcpi/src/session/mcp.ts +++ b/clients/mcpi/src/session/mcp.ts @@ -23,7 +23,6 @@ import { withConnectTimeout, } from "@inspector/cli/handlers/connect-timeout.js"; import { - metaValueToString, SESSION_RPC_METHODS, type MethodArgs, } from "@inspector/cli/handlers/method-types.js"; @@ -607,7 +606,7 @@ function registerRpcCommands(program: CommandType): void { cmd.action(async (o) => { await runRpc(program, method, { appInfo: o.appInfo === true, - metadata: stringifyMeta(o.metadata), + metadata: o.metadata, }); }); break; @@ -653,8 +652,8 @@ function registerRpcCommands(program: CommandType): void { await runRpc(program, method, { toolName, toolArg, - toolMeta: stringifyMeta(o.toolMetadata), - metadata: stringifyMeta(o.metadata), + toolMeta: o.toolMetadata, + metadata: o.metadata, task: o.task === true, appInfo: o.appInfo === true, }); @@ -670,7 +669,7 @@ function registerRpcCommands(program: CommandType): void { cmd.action(async (uriPos: string | undefined, o) => { await runRpc(program, method, { uri: (o.uri as string | undefined) ?? uriPos, - metadata: stringifyMeta(o.metadata), + metadata: o.metadata, }); }); break; @@ -688,7 +687,7 @@ function registerRpcCommands(program: CommandType): void { await runRpc(program, method, { promptName: (o.promptName as string | undefined) ?? promptPos, promptArgs: (o.promptArgs ?? {}) as Record, - metadata: stringifyMeta(o.metadata), + metadata: o.metadata, }); }); break; @@ -712,7 +711,7 @@ function registerRpcCommands(program: CommandType): void { completeRef: o.completeRef as string | undefined, completeArgName: o.completeArgName as string | undefined, completeArgValue: (o.completeArgValue as string | undefined) ?? "", - metadata: stringifyMeta(o.metadata), + metadata: o.metadata, }); }); break; @@ -729,7 +728,7 @@ function registerRpcCommands(program: CommandType): void { } await runRpc(program, method, { logLevel: level as LoggingLevel | undefined, - metadata: stringifyMeta(o.metadata), + metadata: o.metadata, }); }); break; @@ -740,7 +739,7 @@ function registerRpcCommands(program: CommandType): void { cmd.action(async (taskPos: string | undefined, o) => { await runRpc(program, method, { taskId: (o.taskId as string | undefined) ?? taskPos, - metadata: stringifyMeta(o.metadata), + metadata: o.metadata, }); }); break; @@ -749,14 +748,14 @@ function registerRpcCommands(program: CommandType): void { cmd.action(async (o) => { await runRpc(program, method, { rootsJson: o.rootsJson as string | undefined, - metadata: stringifyMeta(o.metadata), + metadata: o.metadata, }); }); break; default: cmd.action(async (o) => { await runRpc(program, method, { - metadata: stringifyMeta(o.metadata), + metadata: o.metadata, }); }); break; @@ -799,15 +798,6 @@ function parseKeyValue( 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); } diff --git a/clients/mcpi/tsup.config.ts b/clients/mcpi/tsup.config.ts index df2a373f22..a73e742ec7 100644 --- a/clients/mcpi/tsup.config.ts +++ b/clients/mcpi/tsup.config.ts @@ -29,6 +29,8 @@ export default defineConfig({ "commander", "pino", "open", + "yaml", + "proper-lockfile", ], esbuildOptions(options) { options.alias = { From f2fc1a2cf7b839a03f904c5084d6a1c3456c201a Mon Sep 17 00:00:00 2001 From: Bob Dickinson Date: Sun, 13 Sep 2026 22:28:16 -0700 Subject: [PATCH 06/23] feat(mcpi): add --era override to connect for ad-hoc targets Ad-hoc connect targets (bare URL/stdio, no catalog entry) had no way to request auto/modern era negotiation - loadServerEntries/ headersToServerSettings never populate protocolEra, so an ad-hoc connect always defaulted to legacy with no override, unlike a catalog entry with a protocolEra field. Add --era to mcpi connect, applied via a new withEraOverride() helper (mirrors the existing withConnectTimeout() pattern) that overrides protocolEra on the resolved settings, or synthesizes a bare-defaults settings object carrying just the override when the target had none. --- clients/mcpi/src/session/mcp.ts | 61 ++++++++++++++++++++++++++++++--- 1 file changed, 57 insertions(+), 4 deletions(-) diff --git a/clients/mcpi/src/session/mcp.ts b/clients/mcpi/src/session/mcp.ts index bd724e0709..ce0447d4e3 100644 --- a/clients/mcpi/src/session/mcp.ts +++ b/clients/mcpi/src/session/mcp.ts @@ -1,5 +1,13 @@ import { Command, type Command as CommandType } from "commander"; import type { JsonValue } from "@inspector/core/mcp/index.js"; +import type { + InspectorServerSettings, + ServerProtocolEra, +} from "@inspector/core/mcp/types.js"; +import { + DEFAULT_MAX_FETCH_REQUESTS, + DEFAULT_TASK_TTL_MS, +} from "@inspector/core/mcp/types.js"; import { loadServerEntries, parseHeaderPair, @@ -254,6 +262,21 @@ function registerConnect(program: CommandType): void { return n; }, ) + .option( + "--era ", + "Protocol era to negotiate: legacy (default), auto, or modern. " + + "Overrides the catalog/config entry's protocolEra; the only way to " + + "set it for an ad-hoc target, which has no config entry of its own.", + (value: string) => { + const valid: ServerProtocolEra[] = ["legacy", "auto", "modern"]; + if (!valid.includes(value as ServerProtocolEra)) { + throw new Error( + `Invalid --era: ${value}. Use legacy, auto, or modern.`, + ); + } + return value as ServerProtocolEra; + }, + ) .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", @@ -309,10 +332,13 @@ function registerConnect(program: CommandType): void { 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 serverSettings = withEraOverride( + withConnectTimeout( + selected.settings, + (cmdOpts.connectTimeout as number | undefined) ?? + (adHoc ? DEFAULT_CONNECT_TIMEOUT_MS : undefined), + ), + cmdOpts.era as ServerProtocolEra | undefined, ); const { detail } = summarizeServerConfig(serverConfig); const name = stripAt(sessionName)!; @@ -777,6 +803,33 @@ async function runRpc( }); } +/** + * Overlay `--era` onto the settings lifted from the file/ad-hoc target. + * Mirrors `withConnectTimeout`'s shape: only `protocolEra` is overridden, and a + * bare-defaults settings object is synthesized when the target had none (the + * common ad-hoc case, which otherwise has no way to request `auto`/`modern`). + */ +function withEraOverride( + settings: InspectorServerSettings | undefined, + era: ServerProtocolEra | undefined, +): InspectorServerSettings | undefined { + if (era === undefined) return settings; + if (settings) return { ...settings, protocolEra: era }; + return { + headers: [], + metadata: {}, + env: [], + connectionTimeout: DEFAULT_CONNECT_TIMEOUT_MS, + requestTimeout: 0, + taskTtl: DEFAULT_TASK_TTL_MS, + maxFetchRequests: DEFAULT_MAX_FETCH_REQUESTS, + autoRefreshOnListChanged: false, + paginatedLists: false, + roots: [], + protocolEra: era, + }; +} + function parseKeyValue( value: string, previous: Record = {}, From 9550b32c121bb94c3b772fc5b37d26abd8ea12cc Mon Sep 17 00:00:00 2001 From: Bob Dickinson Date: Sun, 13 Sep 2026 22:43:42 -0700 Subject: [PATCH 07/23] mcpi: replace initialize with sessions/show (adds era details) mcpi initialize never sent a live initialize request - it replayed InspectorClient's cached connect-time state, which is populated the same way regardless of era (legacy initialize response vs. modern server/discover). The name was misleading, and it omitted the two fields that do differ by era: protocolEra and (when probed) supportedVersions. Remove "initialize" from SESSION_RPC_METHODS (kept in ONE_SHOT_METHODS - the one-shot CLI's --method initialize still matches the literal wire method name on purpose). Add mcpi sessions/show [session], combining daemon session bookkeeping (name, serverIdentity, timestamps, isMru) with live connection state (serverInfo, protocolVersion, protocolEra, capabilities, instructions, supportedVersions). New daemon op sessions/show plus SessionRegistry.sessionFor() backing both it and the existing clientFor(). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- clients/cli/src/handlers/method-types.ts | 8 +++- clients/mcpi/__tests__/mcp-session.test.ts | 15 +++++--- clients/mcpi/src/daemon/protocol.ts | 23 +++++++++++ clients/mcpi/src/daemon/server.ts | 25 ++++++++++++ clients/mcpi/src/daemon/sessions.ts | 17 +++++++-- clients/mcpi/src/session/format-human.ts | 44 ++++++++++++++++++++-- clients/mcpi/src/session/mcp.ts | 23 ++++++++++- 7 files changed, 141 insertions(+), 14 deletions(-) diff --git a/clients/cli/src/handlers/method-types.ts b/clients/cli/src/handlers/method-types.ts index 958552dcea..acf5e290b4 100644 --- a/clients/cli/src/handlers/method-types.ts +++ b/clients/cli/src/handlers/method-types.ts @@ -91,9 +91,15 @@ export type MethodOutcome = * TODO(#1432): several of these (subscribe, tasks, roots, logging/tail, …) are * not exposed by `mcp-inspector --cli` today; they exist for the experimental * session CLI (`mcpi`) and other Node runners that share this dispatcher. + * + * Deliberately excludes `"initialize"` — that's still a valid {@link + * ONE_SHOT_METHODS} entry (scripting parity with the literal wire method + * name), but for `mcpi` it read as "send another initialize", which it never + * did (it only replays cached connect-time state). `mcpi sessions/show` + * covers the same data (server info, capabilities, negotiated era) alongside + * daemon session bookkeeping instead. */ export const SESSION_RPC_METHODS = [ - "initialize", "tools/list", "tools/call", "resources/list", diff --git a/clients/mcpi/__tests__/mcp-session.test.ts b/clients/mcpi/__tests__/mcp-session.test.ts index 8d38e1cf13..072d19371d 100644 --- a/clients/mcpi/__tests__/mcp-session.test.ts +++ b/clients/mcpi/__tests__/mcp-session.test.ts @@ -139,7 +139,7 @@ describe("mcp session CLI", () => { expect(result.stdout).toContain("test-stdio"); }); - it("runs tools/list, tools/call, and initialize over a live session", async () => { + it("runs tools/list, tools/call, and sessions/show over a live session", async () => { configPath = createSampleTestConfig(); const e = env(); @@ -185,16 +185,19 @@ describe("mcp session CLI", () => { }); expectCliSuccess(resources); - const init = await runMcp( - ["@test-stdio", "initialize", "--format", "json"], + const shown = await runMcp( + ["@test-stdio", "sessions/show", "--format", "json"], { env: e, timeout: 20000 }, ); - expectCliSuccess(init); - const initBody = JSON.parse(init.stdout) as { + expectCliSuccess(shown); + const shownBody = JSON.parse(shown.stdout) as { + name?: string; serverInfo?: { name?: string }; protocolVersion?: string; + protocolEra?: string; }; - expect(initBody.protocolVersion).toBeTruthy(); + expect(shownBody.protocolVersion).toBeTruthy(); + expect(shownBody.protocolEra).toBeTruthy(); await runMcp( ["disconnect", "--session", "test-stdio", "--format", "json"], diff --git a/clients/mcpi/src/daemon/protocol.ts b/clients/mcpi/src/daemon/protocol.ts index ba925f3346..6a63994b2c 100644 --- a/clients/mcpi/src/daemon/protocol.ts +++ b/clients/mcpi/src/daemon/protocol.ts @@ -6,6 +6,11 @@ import type { CliAppInfo, MethodArgs, } from "@inspector/cli/handlers/method-types.js"; +import type { + Implementation, + ProtocolEra, + ServerCapabilities, +} from "@modelcontextprotocol/client"; /** Operations the session daemon accepts over IPC. */ export type DaemonOp = @@ -14,6 +19,7 @@ export type DaemonOp = | "disconnect" | "sessions/list" | "sessions/use" + | "sessions/show" | "daemon/status" | "daemon/stop" | "rpc" @@ -83,6 +89,23 @@ export type SessionInfo = { isMru: boolean; }; +/** + * `sessions/show` result: daemon bookkeeping ({@link SessionInfo}) plus the + * live MCP connection state — era-agnostic (`serverInfo`/`capabilities`/ + * `instructions`/`protocolVersion` are populated the same way whether they + * came from a legacy `initialize` response or a modern `server/discover`) + * and era-specific (`protocolEra`, `supportedVersions` — the latter only set + * when the connect actually probed `server/discover`, i.e. `auto`/`modern`). + */ +export type SessionShowResult = SessionInfo & { + serverInfo?: Implementation; + protocolVersion?: string; + protocolEra?: ProtocolEra; + capabilities?: ServerCapabilities; + instructions?: string; + supportedVersions?: string[]; +}; + export type DaemonStatus = { pid: number; socketPath: string; diff --git a/clients/mcpi/src/daemon/server.ts b/clients/mcpi/src/daemon/server.ts index a1a6578b30..4ee748e18c 100644 --- a/clients/mcpi/src/daemon/server.ts +++ b/clients/mcpi/src/daemon/server.ts @@ -27,6 +27,7 @@ import type { RpcParams, RpcResult, SessionNameParams, + SessionShowResult, } from "./protocol.js"; import { DEFAULT_IDLE_MS, SessionRegistry } from "./sessions.js"; @@ -229,6 +230,30 @@ export class DaemonServer { }, }; } + case "sessions/show": { + const params = (request.params ?? {}) as SessionNameParams; + const session = this.registry.sessionFor( + params.name, + params.requireExplicit, + ); + const client = session.client; + const result: SessionShowResult = { + name: session.name, + serverIdentity: session.serverIdentity, + connectedAt: session.connectedAt, + lastAccessedAt: session.lastAccessedAt, + isMru: true, + serverInfo: client.getServerInfo(), + protocolVersion: client.getProtocolVersion(), + protocolEra: client.getProtocolEra(), + capabilities: client.getCapabilities(), + instructions: client.getInstructions(), + supportedVersions: client.getDiscoverResult()?.supportedVersions, + }; + return { + response: { id: request.id, ok: true, result }, + }; + } case "daemon/status": return { response: { id: request.id, ok: true, result: this.status() }, diff --git a/clients/mcpi/src/daemon/sessions.ts b/clients/mcpi/src/daemon/sessions.ts index 21eebb00eb..7875906b44 100644 --- a/clients/mcpi/src/daemon/sessions.ts +++ b/clients/mcpi/src/daemon/sessions.ts @@ -139,6 +139,19 @@ export class SessionRegistry { this.clearIdleTimer(); } + /** + * Resolve a session for an RPC/stream/show, touch MRU, and return the + * live session (name/serverIdentity/timestamps plus the client). + */ + sessionFor( + name: string | undefined, + requireExplicit: boolean | undefined, + ): LiveSession { + const session = this.resolve(name, requireExplicit); + this.touch(session.name); + return session; + } + /** * Resolve a session for an RPC/stream, touch MRU, and return its client. */ @@ -146,9 +159,7 @@ export class SessionRegistry { name: string | undefined, requireExplicit: boolean | undefined, ): InspectorClient { - const session = this.resolve(name, requireExplicit); - this.touch(session.name); - return session.client; + return this.sessionFor(name, requireExplicit).client; } use(name: string): SessionInfo { diff --git a/clients/mcpi/src/session/format-human.ts b/clients/mcpi/src/session/format-human.ts index 13f8902e94..ed78f4615b 100644 --- a/clients/mcpi/src/session/format-human.ts +++ b/clients/mcpi/src/session/format-human.ts @@ -552,16 +552,54 @@ export function formatSessionsListHuman( return lines.join("\n"); } -/** Format a single session info (connect / sessions/use). */ +/** Format a single session info (connect / sessions/use / sessions/show). */ export function formatSessionInfoHuman( session: JsonObject, style: Style = PLAIN, ): string { const mru = session.isMru === true ? style.green(" (MRU)") : ""; - return [ + const lines = [ `${heading(style, "Session")} ${code(style, `@${String(session.name)}`)}${mru}`, `Server: ${style.dim(String(session.serverIdentity ?? ""))}`, - ].join("\n"); + ]; + + // Connection details (`sessions/show` only — plain `use`/`connect` + // results don't carry these). + const era = session.protocolEra; + const protocolVersion = session.protocolVersion; + if (era !== undefined || protocolVersion !== undefined) { + const versionSuffix = + protocolVersion !== undefined ? ` (${String(protocolVersion)})` : ""; + lines.push( + `Era: ${style.dim(`${String(era ?? "unknown")}${versionSuffix}`)}`, + ); + } + const serverInfo = session.serverInfo as JsonObject | undefined; + if (serverInfo?.name !== undefined) { + const version = + serverInfo.version !== undefined ? ` v${String(serverInfo.version)}` : ""; + lines.push( + `Server info: ${style.dim(`${String(serverInfo.name)}${version}`)}`, + ); + } + const capabilities = session.capabilities as JsonObject | undefined; + if (capabilities !== undefined) { + const keys = Object.keys(capabilities); + lines.push( + `Capabilities: ${style.dim(keys.length > 0 ? keys.join(", ") : "(none)")}`, + ); + } + const supportedVersions = session.supportedVersions; + if (Array.isArray(supportedVersions) && supportedVersions.length > 0) { + lines.push( + `Supported versions: ${style.dim(supportedVersions.join(", "))}`, + ); + } + if (typeof session.instructions === "string" && session.instructions !== "") { + lines.push(`Instructions: ${style.dim(session.instructions)}`); + } + + return lines.join("\n"); } /** Format tools/list --app-info lines. */ diff --git a/clients/mcpi/src/session/mcp.ts b/clients/mcpi/src/session/mcp.ts index ce0447d4e3..c6111ed71d 100644 --- a/clients/mcpi/src/session/mcp.ts +++ b/clients/mcpi/src/session/mcp.ts @@ -18,7 +18,7 @@ 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 type { SessionInfo, SessionShowResult } from "../daemon/protocol.js"; import { annotateServerEntriesWithSessions, listServerEntries, @@ -542,6 +542,27 @@ function registerSessionAdmin(program: CommandType): void { session: result, }); }); + + program + .command("sessions/show") + .description( + "Show session + connection details: server info, capabilities, negotiated protocol era (defaults to MRU)", + ) + .argument("[session]", "Session @name / name (defaults to MRU)") + .action(async (sessionArg: string | undefined) => { + const opts = program.opts(); + const name = stripAt(opts.session) ?? stripAt(sessionArg); + const { socketPath } = await ensureDaemon(); + const result = await callDaemon( + "sessions/show", + { name, requireExplicit: requireExplicitSession() }, + { socketPath }, + ); + await writeSessionOutput(outOpts(opts), { + kind: "session", + session: result, + }); + }); } function registerDaemonCommands(program: CommandType): void { From 54d00b919f7f3ba44f9d266d12bd724d9c48ba8e Mon Sep 17 00:00:00 2001 From: Bob Dickinson Date: Sun, 13 Sep 2026 23:10:06 -0700 Subject: [PATCH 08/23] Bring mcpi into parity with repo-wide validate guards CI (npm run validate) failed on verify:typecheck-coverage: mcpi had no typecheck script wired into validate, unlike cli/tui/launcher. Fixing that surfaced further gaps once mcpi's own toolchain was actually exercised end-to-end: - clients/mcpi: add typecheck/check scripts (validate -> check -> typecheck), tsconfig.test.json (mirrors cli's pattern), and the same compilerOptions overrides cli/tui use so core/'s noUncheckedIndexedAccess strictness doesn't spuriously fail here. - clients/mcpi/package.json: bump @modelcontextprotocol/client, core, server, and server-legacy from a stale 2.0.0-beta.5 to 2.0.0 (real SDK version skew vs. the rest of the monorepo, caught by verify:dep-lockstep). Per AGENTS.md, the shared toolchain (eslint, vitest, typescript, etc.) is declared once at the repo root and in no client manifest, so drop mcpi's own copies of all of it instead of keeping them in sync by hand - matching cli/tui/launcher exactly, it now resolves the root copies via npm/Node's directory walk-up. Keep only what's genuinely client-specific: tsup (its bundler) and @types/express (needed transitively by the test-server barrel import, same reason cli has it). That transitive import also drags in a hoisted @types/node that drifts from root's, so pin it via overrides the same way clients/cli does. - Fix real bugs typecheck caught: an unsound cast in format-session.ts, wrong-arity expectCliFailure() calls and an invalid AuthChallengeReason literal in two test files, and two throws missing an Error cause (preserve-caught-error) once the root's current eslint actually ran against this client for the first time. - Register mcpi with the repo's cross-cutting guards that assumed a fixed client list: verify-test-timeouts.mjs (CONFIG_ROOTS / EXPECTED_PROJECTS plus its own test fixtures), verify-bundle- externals.mjs (BUNDLED_CLIENTS, since mcpi ships a tsup bundle), workflow-gate.test.mjs (local:validate must reach mcpi the same way it reaches its siblings), and root package.json's local:validate script. Verified: npm run validate passes clean end-to-end (guards, core, and all six clients). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../mcpi/__tests__/daemon-coverage.test.ts | 16 +- clients/mcpi/__tests__/mcp-coverage.test.ts | 14 +- clients/mcpi/package-lock.json | 3350 +++-------------- clients/mcpi/package.json | 33 +- clients/mcpi/src/session/format-session.ts | 4 +- clients/mcpi/src/session/parse-tool-args.ts | 2 + clients/mcpi/tsconfig.json | 13 +- clients/mcpi/tsconfig.test.json | 29 + clients/mcpi/vitest.config.ts | 10 +- package.json | 2 +- scripts/lib/workflow-gate.test.mjs | 4 +- scripts/verify-bundle-externals.mjs | 5 + scripts/verify-test-timeouts.mjs | 2 + scripts/verify-test-timeouts.test.mjs | 3 +- 14 files changed, 519 insertions(+), 2968 deletions(-) create mode 100644 clients/mcpi/tsconfig.test.json diff --git a/clients/mcpi/__tests__/daemon-coverage.test.ts b/clients/mcpi/__tests__/daemon-coverage.test.ts index 41d5c82a4a..225b94f0b4 100644 --- a/clients/mcpi/__tests__/daemon-coverage.test.ts +++ b/clients/mcpi/__tests__/daemon-coverage.test.ts @@ -366,7 +366,7 @@ describe("daemon coverage", () => { 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", + reason: "unauthorized", }), ); await expect( @@ -704,26 +704,26 @@ describe("mcp session coverage", () => { it("rejects connect with no target and invalid --format", async () => { const e = env(); const missing = await runMcp(["connect"], { env: e }); - expectCliFailure(missing, 1); + expectCliFailure(missing); const badFormat = await runMcp(["servers/list", "--format", "xml"], { env: e, }); - expectCliFailure(badFormat, 1); + expectCliFailure(badFormat); const badTransport = await runMcp(["connect", "x", "--transport", "ftp"], { env: e, }); - expectCliFailure(badTransport, 1); + expectCliFailure(badTransport); const badTimeout = await runMcp( ["connect", "x", "--connect-timeout", "-1"], { env: e }, ); - expectCliFailure(badTimeout, 1); + expectCliFailure(badTimeout); const emptyUse = await runMcp(["sessions/use", ""], { env: e }); - expectCliFailure(emptyUse, 1); + expectCliFailure(emptyUse); }); it("connects an ad-hoc stdio target", async () => { @@ -764,7 +764,7 @@ describe("mcp session coverage", () => { { env: e, timeout: 10000 }, ); // Connection should fail (nothing listening) but the ad-hoc URL path ran. - expectCliFailure(result, 1); + expectCliFailure(result); }); it("requires explicit session in non-interactive mode without opt-in", async () => { @@ -783,7 +783,7 @@ describe("mcp session coverage", () => { // Force requireExplicit by stubbing isTTY false is default in vitest forks. const disc = await runMcp(["disconnect", "--format", "json"], { env: e }); - expectCliFailure(disc, 1); + expectCliFailure(disc); expect(disc.stderr).toMatch(/Explicit|--session|non-interactive/i); await runMcp(["disconnect", "--session", "test-stdio"], { env: e }); diff --git a/clients/mcpi/__tests__/mcp-coverage.test.ts b/clients/mcpi/__tests__/mcp-coverage.test.ts index d6ed517dc2..6d9604d9fb 100644 --- a/clients/mcpi/__tests__/mcp-coverage.test.ts +++ b/clients/mcpi/__tests__/mcp-coverage.test.ts @@ -86,12 +86,12 @@ describe("mcp.ts coverage", () => { const badMeta = await runMcp(["tools/list", "--metadata", "novalue"], { env: e, }); - expectCliFailure(badMeta, 1); + expectCliFailure(badMeta); const emptyMeta = await runMcp(["tools/list", "--metadata", "k="], { env: e, }); - expectCliFailure(emptyMeta, 1); + expectCliFailure(emptyMeta); const read = await runMcp( [ @@ -131,7 +131,7 @@ describe("mcp.ts coverage", () => { ["prompts/complete", "--complete-ref-type", "nope"], { env: e }, ); - expectCliFailure(completeBad, 1); + expectCliFailure(completeBad); const complete = await runMcp( [ @@ -162,7 +162,7 @@ describe("mcp.ts coverage", () => { const logBad = await runMcp(["logging/setLevel", "--log-level", "nope"], { env: e, }); - expectCliFailure(logBad, 1); + expectCliFailure(logBad); const taskGet = await runMcp( ["tasks/get", "missing-task", "--format", "json"], @@ -171,19 +171,19 @@ describe("mcp.ts coverage", () => { timeout: 20000, }, ); - expectCliFailure(taskGet, 1); + expectCliFailure(taskGet); const taskCancel = await runMcp( ["tasks/cancel", "--task-id", "missing-task", "--format", "json"], { env: e, timeout: 20000 }, ); - expectCliFailure(taskCancel, 1); + expectCliFailure(taskCancel); const taskResult = await runMcp( ["tasks/result", "missing-task", "--format", "json"], { env: e, timeout: 20000 }, ); - expectCliFailure(taskResult, 1); + expectCliFailure(taskResult); const roots = await runMcp( ["roots/set", "--roots-json", "[]", "--format", "json"], diff --git a/clients/mcpi/package-lock.json b/clients/mcpi/package-lock.json index fbc6aaeeb3..c78f268c9a 100644 --- a/clients/mcpi/package-lock.json +++ b/clients/mcpi/package-lock.json @@ -7,127 +7,25 @@ "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", + "@modelcontextprotocol/client": "2.0.0", + "@modelcontextprotocol/core": "2.0.0", + "@modelcontextprotocol/server": "2.0.0", + "@modelcontextprotocol/server-legacy": "2.0.0", "@napi-rs/keyring": "^1.3.0", - "ajv": "^8.17.1", + "ajv": "8.18.0", "atomically": "^2.1.1", "commander": "^13.1.0", "open": "^10.2.0", "pino": "^9.14.0", - "undici": "^8.5.0", - "zod": "^4.3.6" + "undici": "8.9.0", + "zod": "4.4.3" }, "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.4", - "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" + "@types/express": "^5.0.6", + "tsup": "^8.5.0" } }, "node_modules/@esbuild/aix-ppc64": { @@ -572,253 +470,6 @@ "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", @@ -841,9 +492,9 @@ } }, "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==", + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", "dev": true, "license": "MIT" }, @@ -859,12 +510,12 @@ } }, "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==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/client/-/client-2.0.0.tgz", + "integrity": "sha512-8f1OghQ2rjzIOfqgUCP+8GiUWqRs89njoWLNqAe8kWmDePv3s1fZXseej+QXemssEuuOvLLmLO/kqM3IQHtISw==", "license": "MIT", "dependencies": { - "@modelcontextprotocol/core": "2.0.0-beta.5", + "@modelcontextprotocol/core": "2.0.0", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", @@ -877,9 +528,9 @@ } }, "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==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/core/-/core-2.0.0.tgz", + "integrity": "sha512-pJCEwGG7Lfr/+PQp9ZTwKXNeO5wzbfKL7H3MYpCorM4oFBoQrdjnBgEoqG+RjhsvS1FKrDbKux+M1HhlnGWqcA==", "license": "MIT", "dependencies": { "zod": "^4.2.0" @@ -889,12 +540,12 @@ } }, "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==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/server/-/server-2.0.0.tgz", + "integrity": "sha512-YhHWdHfpFMQfd0prsEnxKeS3Qz3ytIGmsS0sth4KDjnacIT7hxk6hXHkJ9KysxlkvTM+WZAtQbbcUhdoP4Hvtw==", "license": "MIT", "dependencies": { - "@modelcontextprotocol/core": "2.0.0-beta.5", + "@modelcontextprotocol/core": "2.0.0", "zod": "^4.2.0" }, "engines": { @@ -902,13 +553,13 @@ } }, "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==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/server-legacy/-/server-legacy-2.0.0.tgz", + "integrity": "sha512-LnffC1BSqFMHtMQxEz92lqDpHWma+ErV3ghdHDgdkCyYzVcCYKcUT5loq4kflty+Bf9C9qjJqbnphyBWyCqo8Q==", "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", + "@modelcontextprotocol/core": "2.0.0", "content-type": "^1.0.5", "cors": "^2.8.5", "express-rate-limit": "^8.2.1", @@ -1147,33 +798,21 @@ "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==", + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], "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" + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" } }, "node_modules/@pinojs/redact": { @@ -1182,27 +821,38 @@ "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==", + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.63.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.63.2.tgz", + "integrity": "sha512-Xa6RDoWa+hNiX6PgsljlH6W75RaONx3y6PVlbLhkEWW+GaPQ3dP5gwbL/erAzQHWwkvW5UxdD5l87Qx2FAQ/4A==", "cpu": [ - "arm64" + "arm" ], "dev": true, "license": "MIT", "optional": true, "os": [ "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.63.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.63.2.tgz", + "integrity": "sha512-vNASxsghMfQ5s+v3PrpnJd+ryL/26lxCCaGI+sDJ7VzmHiYXIrrVltsDhaawxLM1WcoMU2oYlbPHLaYQtBzhcg==", + "cpu": [ + "arm64" ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] }, - "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==", + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.63.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.63.2.tgz", + "integrity": "sha512-0dWDjmlrpZAgjPD/aPzUDhBW8APLRjAni5bOrM76wiiZm+E+KTMVKNhAzaTBohz8UyO2fKNAl0+fygbe2HZXOA==", "cpu": [ "arm64" ], @@ -1211,15 +861,12 @@ "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==", + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.63.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.63.2.tgz", + "integrity": "sha512-N58uktcwzk3+qT4KHEuNdIxX1N01RWrkfVoml69EAbSaNDL+sbNVLx2RMl4Qd23lpA0fgPvyh5hHb4weD5WKmg==", "cpu": [ "x64" ], @@ -1228,15 +875,26 @@ "optional": true, "os": [ "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.63.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.63.2.tgz", + "integrity": "sha512-HWF2zH8EAp2scWRpt2PGe6iUGz7zi04waXsdRr3zb4DWCk2ImIo5FZu0jjmD53nP/DGSvnW0e7/1ToCNZs2lZw==", + "cpu": [ + "arm64" ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] }, - "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==", + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.63.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.63.2.tgz", + "integrity": "sha512-MkvcwHMnzPSMOQEwB6wHnLzmc+hT8BGc5bW/Mhmjjgx3wbj6VBnlc47XsK74kD0K9MikFfXpQqyz4NUXaUW62A==", "cpu": [ "x64" ], @@ -1245,15 +903,12 @@ "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==", + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.63.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.63.2.tgz", + "integrity": "sha512-xe1bCKPJaKsD0tfd7Rb6bGfUogJTpKbTEEthsfdb7hTfTRNJVQTdirabQx0o6ERVba/smkM720soMY+0QnrlSQ==", "cpu": [ "arm" ], @@ -1262,32 +917,26 @@ "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==", + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.63.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.63.2.tgz", + "integrity": "sha512-yOM7LdK0p6gk6+Q773OEwtlsikT1TL3yMmYsTtRlDRPha5vV2DC5x7LqRWDr6f3cSYNMKVqxzffXv8ivxNBIFQ==", "cpu": [ - "arm64" + "arm" ], "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==", + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.63.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.63.2.tgz", + "integrity": "sha512-qiWuJJV3DybA2IfzvRimeKXGrGuVPv1zobSY/26KnP3HbV0VcNb3ECzgvtbvF3xjSMkcooou6HASXZuLdjnhpQ==", "cpu": [ "arm64" ], @@ -1296,288 +945,14 @@ "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==", + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.63.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.63.2.tgz", + "integrity": "sha512-akcZquRzCY/KpUoZAMBhGf7oi4LmXq1BzRA5CPAC3rkUf28Y/sAYV3jSL+JKd7cwEyFvR5G0XVZ0gaMedP+60A==", "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" + "arm64" ], "dev": true, "license": "MIT", @@ -1587,9 +962,9 @@ ] }, "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==", + "version": "4.63.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.63.2.tgz", + "integrity": "sha512-fNwYHrPyYyxauPzX/cpYw8Z7LQpp+DGA0KCoswA0aVFBpmdMil9XgjB8V3Ny64Ihu797+GKcuJqnsOKEmor7fA==", "cpu": [ "loong64" ], @@ -1601,9 +976,9 @@ ] }, "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==", + "version": "4.63.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.63.2.tgz", + "integrity": "sha512-XfvsgzR7DZqREdst7K1Mj3ilSUM5xLAHJcIMDFPKdxTs9q5VHOT8aMA+a683fqBu7DQl8+Sd9HCsQYL8EMY9qA==", "cpu": [ "loong64" ], @@ -1615,9 +990,9 @@ ] }, "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==", + "version": "4.63.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.63.2.tgz", + "integrity": "sha512-Pp7gVZggEFlbcuztay+/U0gVG9S1XAh8i7I1Re/htbAzo43P5wHZHw6pTyzotISqlKohoh9RpIfnOz3RbemK1w==", "cpu": [ "ppc64" ], @@ -1629,9 +1004,9 @@ ] }, "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==", + "version": "4.63.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.63.2.tgz", + "integrity": "sha512-zkgL2xff6i7u5hau/m6FGeS8gRkLEdgLw522WGmdWWlLd9btmNl3S80mcEjtGq+kvgUekQ3+BOYLLLcPlS2LIA==", "cpu": [ "ppc64" ], @@ -1643,9 +1018,9 @@ ] }, "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==", + "version": "4.63.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.63.2.tgz", + "integrity": "sha512-qOheJomrkVCbbHFJ7L3J97cnhfogKqguAQphv26+3ZsAQIF1L19b+dArl//s8rjJHJLz9byykyM8NBP4nmSa1g==", "cpu": [ "riscv64" ], @@ -1657,9 +1032,9 @@ ] }, "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==", + "version": "4.63.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.63.2.tgz", + "integrity": "sha512-XlxLD54wQhH3FciCgMofxBw27NzUe818gJH410qWvc41UT0ZFcgxVjyX5/EK8MPTupjeVWqN5oy+9pCA9mqfCA==", "cpu": [ "riscv64" ], @@ -1671,9 +1046,9 @@ ] }, "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==", + "version": "4.63.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.63.2.tgz", + "integrity": "sha512-vdryWeRb2bLJZf0Fv/W8se6nvsHe2PkTCxV0meheK3nQE+G90VCJcke51Miy1yQRsfm2uqIyjXOu4wmUzbTtkQ==", "cpu": [ "s390x" ], @@ -1685,9 +1060,9 @@ ] }, "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==", + "version": "4.63.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.63.2.tgz", + "integrity": "sha512-bcq2h2pkKmH2po4cZV8VWzO4lL40STyu/nLoFpYMQp9C2tCVNTdcVv86MwSsn3D5s1FBe2Ty1atqvVAUTMimNg==", "cpu": [ "x64" ], @@ -1699,9 +1074,9 @@ ] }, "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==", + "version": "4.63.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.63.2.tgz", + "integrity": "sha512-EGoo5DMVMRkTId8fuTDaoxVlR5ZTsKULUezRjd9gCw5eeY+DjCvDpZAOlNUvKPGX+7rS1RWx6j+yOpNPx0cUgQ==", "cpu": [ "x64" ], @@ -1713,9 +1088,9 @@ ] }, "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==", + "version": "4.63.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.63.2.tgz", + "integrity": "sha512-MErl12k7BFHZG1TI9QF/3lSSZARzq9KgNy/FjnqFMCkv+N4RSSzoUCA5h2mqHX4Mox3WaTVKblyzhQ1zRb2ZuQ==", "cpu": [ "x64" ], @@ -1727,9 +1102,9 @@ ] }, "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==", + "version": "4.63.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.63.2.tgz", + "integrity": "sha512-ILs8k07Wh4p0PsNY4wYLEaXZKMOpVhrG5QDB0yHhGhuzOfDlnyHN6sflL4El/MpUP1y8uY2lUZrv4oBS6pTT3g==", "cpu": [ "arm64" ], @@ -1741,9 +1116,9 @@ ] }, "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==", + "version": "4.63.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.63.2.tgz", + "integrity": "sha512-hKgB3nz/TKD3Wv78XEsyXzQsNjvhOHmwKQTvXADGOyU/cIClZDO7DsoggbdmJDPGp5V80tA3Vfv61PaKTLH3LA==", "cpu": [ "arm64" ], @@ -1755,9 +1130,9 @@ ] }, "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==", + "version": "4.63.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.63.2.tgz", + "integrity": "sha512-T4wf1mudIDxN8Q/CWIBJC1u5gQUc+r5mPvlwoSbIvNkyVTP2TAFeobEmst5AQ4gMyAz4sSByVdoTDfvTmGK/8g==", "cpu": [ "ia32" ], @@ -1769,9 +1144,9 @@ ] }, "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==", + "version": "4.63.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.63.2.tgz", + "integrity": "sha512-tC3IY7qoaD9Ll3/8WJQn49j5V2f/NuI9S41NOE2iM5MPs3sPIvOkVToLcz/7Bz4pyF7PSvrtwu8I/pUrGOSecQ==", "cpu": [ "x64" ], @@ -1783,9 +1158,9 @@ ] }, "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==", + "version": "4.63.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.63.2.tgz", + "integrity": "sha512-6NHnk/K3eq2ZFYcU1X8g67s9qIJRCOTT92gwLMVBp08dB2uuuwI1/Q/empzL2Bfr2f2WRLJVwpp90RmacQyFkw==", "cpu": [ "x64" ], @@ -1796,39 +1171,58 @@ "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==", + "node_modules/@types/body-parser": { + "version": "1.19.6", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", + "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "node_modules/@types/body-parser/node_modules/@types/node": { + "version": "24.13.4", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.4.tgz", + "integrity": "sha512-YJ7EqCstVTzIr0fMr7qul/977en+pQHrfmuKIo6Zr9i75Be21dr3MovcfvGtyvi2HAUrRerWps5sMO9I7WaxDw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/@types/body-parser/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/@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==", + "node_modules/@types/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", "dev": true, "license": "MIT", - "optional": true, "dependencies": { - "tslib": "^2.4.0" + "@types/node": "*" } }, - "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==", + "node_modules/@types/connect/node_modules/@types/node": { + "version": "24.13.4", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.4.tgz", + "integrity": "sha512-YJ7EqCstVTzIr0fMr7qul/977en+pQHrfmuKIo6Zr9i75Be21dr3MovcfvGtyvi2HAUrRerWps5sMO9I7WaxDw==", "dev": true, "license": "MIT", "dependencies": { - "@types/deep-eql": "*", - "assertion-error": "^2.0.1" + "undici-types": "~7.18.0" } }, - "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==", + "node_modules/@types/connect/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" }, @@ -1839,448 +1233,123 @@ "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==", + "node_modules/@types/express": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.6.tgz", + "integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==", "dev": true, "license": "MIT", "dependencies": { - "undici-types": "~7.18.0" + "@types/body-parser": "*", + "@types/express-serve-static-core": "^5.0.0", + "@types/serve-static": "^2" } }, - "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==", + "node_modules/@types/express-serve-static-core": { + "version": "5.1.3", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.3.tgz", + "integrity": "sha512-dPfW8NFiOF4wOHc7+N/QSxlY9cfSsenewGbAz8C8U/MULPd/YZ27LvJUIlzaXie7e6Ove9YunJGgC9tbHD2cKw==", "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" + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" } }, - "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==", + "node_modules/@types/express-serve-static-core/node_modules/@types/node": { + "version": "24.13.4", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.4.tgz", + "integrity": "sha512-YJ7EqCstVTzIr0fMr7qul/977en+pQHrfmuKIo6Zr9i75Be21dr3MovcfvGtyvi2HAUrRerWps5sMO9I7WaxDw==", "dev": true, "license": "MIT", - "engines": { - "node": ">= 4" + "dependencies": { + "undici-types": "~7.18.0" } }, - "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==", + "node_modules/@types/express-serve-static-core/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/@types/http-errors": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", + "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/qs": { + "version": "6.15.1", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.1.tgz", + "integrity": "sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/range-parser": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", + "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", "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" + "@types/node": "*" } }, - "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==", + "node_modules/@types/send/node_modules/@types/node": { + "version": "24.13.4", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.4.tgz", + "integrity": "sha512-YJ7EqCstVTzIr0fMr7qul/977en+pQHrfmuKIo6Zr9i75Be21dr3MovcfvGtyvi2HAUrRerWps5sMO9I7WaxDw==", "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 - } + "undici-types": "~7.18.0" } }, - "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==", + "node_modules/@types/send/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", - "dependencies": { - "tinyrainbow": "^3.1.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } + "license": "MIT" }, - "node_modules/@vitest/runner": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz", - "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==", + "node_modules/@types/serve-static": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-2.2.0.tgz", + "integrity": "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "4.1.10", - "pathe": "^2.0.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" + "@types/http-errors": "*", + "@types/node": "*" } }, - "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==", + "node_modules/@types/serve-static/node_modules/@types/node": { + "version": "24.13.4", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.4.tgz", + "integrity": "sha512-YJ7EqCstVTzIr0fMr7qul/977en+pQHrfmuKIo6Zr9i75Be21dr3MovcfvGtyvi2HAUrRerWps5sMO9I7WaxDw==", "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" + "undici-types": "~7.18.0" } }, - "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==", + "node_modules/@types/serve-static/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", - "dependencies": { - "@vitest/pretty-format": "4.1.10", - "convert-source-map": "^2.0.0", - "tinyrainbow": "^3.1.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } + "license": "MIT" }, "node_modules/accepts": { "version": "2.0.0", @@ -2297,9 +1366,9 @@ } }, "node_modules/acorn": { - "version": "8.17.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", - "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", + "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", "dev": true, "license": "MIT", "bin": { @@ -2309,20 +1378,10 @@ "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==", + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.3", @@ -2335,22 +1394,6 @@ "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", @@ -2358,35 +1401,6 @@ "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", @@ -2406,13 +1420,6 @@ "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", @@ -2439,9 +1446,9 @@ } }, "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==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", "license": "MIT", "peer": true, "engines": { @@ -2452,17 +1459,6 @@ "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", @@ -2544,43 +1540,6 @@ "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", @@ -2597,26 +1556,6 @@ "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", @@ -2626,13 +1565,6 @@ "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", @@ -2673,13 +1605,6 @@ "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", @@ -2748,17 +1673,10 @@ } } }, - "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==", + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.5.1.tgz", + "integrity": "sha512-m1pAzaJgZ/gssEqlOhJkPJp8Xly7QyW6xcrkUa2KKcDeDSEMP7X8xipU3snUcfisTQx0w1AGae+9UtJSfVnXGw==", "license": "MIT", "dependencies": { "bundle-name": "^4.1.0", @@ -2804,16 +1722,6 @@ "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", @@ -2866,13 +1774,6 @@ "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", @@ -2935,207 +1836,6 @@ "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", @@ -3159,24 +1859,14 @@ } }, "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==", + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.1.tgz", + "integrity": "sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ==", "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", @@ -3222,9 +1912,9 @@ } }, "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==", + "version": "8.7.0", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.7.0.tgz", + "integrity": "sha512-hOwV7WOxXfjRpAM1DSJWZDXx3GhplwD8IfwuwvogD8i1Qnkgosw/H45s4ZnFAUHDAhPjlY9hLBvJhKmGMyY26g==", "license": "MIT", "dependencies": { "debug": "^4.4.3", @@ -3246,24 +1936,10 @@ "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==", + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.7.tgz", + "integrity": "sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==", "funding": [ { "type": "github", @@ -3294,19 +1970,6 @@ } } }, - "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", @@ -3329,23 +1992,6 @@ "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", @@ -3358,27 +2004,6 @@ "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", @@ -3463,32 +2088,6 @@ "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", @@ -3502,16 +2101,6 @@ "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", @@ -3538,13 +2127,6 @@ "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", @@ -3581,43 +2163,6 @@ "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", @@ -3625,9 +2170,9 @@ "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==", + "version": "10.7.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.0.tgz", + "integrity": "sha512-BGFsyJd5mpXp3rK6jIdADLNgpJUK1jnjzvYF8lK+VyDab9JAmqN0YOKDdP17HlgKb2+ehPgDc8EtnRLbGCAMhA==", "license": "MIT", "engines": { "node": ">= 12" @@ -3648,478 +2193,87 @@ "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" - ], + "bin": { + "is-docker": "cli.js" + }, "engines": { - "node": ">= 12.0.0" + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" + "url": "https://github.com/sponsors/sindresorhus" } }, - "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" - ], + "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": ">= 12.0.0" + "node": ">=14.16" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" + "url": "https://github.com/sponsors/sindresorhus" } }, - "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" - ], + "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": ">= 12.0.0" + "node": ">=16" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" + "url": "https://github.com/sponsors/sindresorhus" } }, - "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" - }, + "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/jose": { + "version": "6.2.12", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.12.tgz", + "integrity": "sha512-9NiFmJEex0sy2Dk58j2UGBSHgUs2ypF9eZSu4L6vjOX3Dp96Sw1F3uL+H+D1sx02jZZdzUT0HgvCy59CuvXcWw==", + "license": "MIT", "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" + "url": "https://github.com/sponsors/panva" } }, - "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" - ], + "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": "MPL-2.0", - "optional": true, - "os": [ - "win32" - ], + "license": "MIT", "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" + "node": ">=10" } }, + "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/lilconfig": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", @@ -4150,29 +2304,6 @@ "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", @@ -4183,34 +2314,6 @@ "@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", @@ -4275,19 +2378,6 @@ "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", @@ -4319,40 +2409,35 @@ "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" - } - ], + "node_modules/negotiator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.1.0.tgz", + "integrity": "sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg==", "license": "MIT", - "bin": { - "nanoid": "bin/nanoid.cjs" + "peer": true, + "dependencies": { + "content-type": "^2.1.0" }, "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, - "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==", + "node_modules/negotiator/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", "license": "MIT", "peer": true, "engines": { - "node": ">= 0.6" + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/object-assign": { @@ -4377,20 +2462,6 @@ "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", @@ -4441,69 +2512,6 @@ "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", @@ -4514,16 +2522,6 @@ "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", @@ -4559,9 +2557,9 @@ "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==", + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", "dev": true, "license": "MIT", "engines": { @@ -4639,35 +2637,6 @@ "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", @@ -4706,41 +2675,15 @@ "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.8.4", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.4.tgz", - "integrity": "sha512-N2MylSdi48+5N/6S5j+maeHbUSIzzZ5uOcX5Hm4QpV8Dkb1HFjfAKTKX6yNPJQD9AhcT3ifHNB66tWTTJDi11Q==", - "dev": true, - "license": "MIT", - "bin": { - "prettier": "bin/prettier.cjs" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/prettier/prettier?sponsor=1" + "yaml": { + "optional": true + } } }, "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==", + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.1.0.tgz", + "integrity": "sha512-jQSaVHsPgtyw60e1rQ/A+/ArPEj/S8pS/vFnyGa/gYFXrKk/6RuDkoqVDQ5NI5MmS01698ltlAk0NoDBNLujRw==", "funding": [ { "type": "github", @@ -4767,20 +2710,10 @@ "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==", + "version": "6.16.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz", + "integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==", "license": "BSD-3-Clause", "peer": true, "dependencies": { @@ -4862,53 +2795,19 @@ } }, "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==", + "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", - "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": ">=8" } }, "node_modules/rollup": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", - "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==", + "version": "4.63.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.63.2.tgz", + "integrity": "sha512-l5eyksV4tPBj6lJyEa37YzIOCSOV7lkZzEHUdpjWZbtD7wTcFYmEYXSgm5bT4vV+dZLb9rBG1W9GROOG4NS4Ew==", "dev": true, "license": "MIT", "dependencies": { @@ -4922,31 +2821,32 @@ "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", + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.63.2", + "@rollup/rollup-android-arm64": "4.63.2", + "@rollup/rollup-darwin-arm64": "4.63.2", + "@rollup/rollup-darwin-x64": "4.63.2", + "@rollup/rollup-freebsd-arm64": "4.63.2", + "@rollup/rollup-freebsd-x64": "4.63.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.63.2", + "@rollup/rollup-linux-arm-musleabihf": "4.63.2", + "@rollup/rollup-linux-arm64-gnu": "4.63.2", + "@rollup/rollup-linux-arm64-musl": "4.63.2", + "@rollup/rollup-linux-loong64-gnu": "4.63.2", + "@rollup/rollup-linux-loong64-musl": "4.63.2", + "@rollup/rollup-linux-ppc64-gnu": "4.63.2", + "@rollup/rollup-linux-ppc64-musl": "4.63.2", + "@rollup/rollup-linux-riscv64-gnu": "4.63.2", + "@rollup/rollup-linux-riscv64-musl": "4.63.2", + "@rollup/rollup-linux-s390x-gnu": "4.63.2", + "@rollup/rollup-linux-x64-gnu": "4.63.2", + "@rollup/rollup-linux-x64-musl": "4.63.2", + "@rollup/rollup-openbsd-x64": "4.63.2", + "@rollup/rollup-openharmony-arm64": "4.63.2", + "@rollup/rollup-win32-arm64-msvc": "4.63.2", + "@rollup/rollup-win32-ia32-msvc": "4.63.2", + "@rollup/rollup-win32-x64-gnu": "4.63.2", + "@rollup/rollup-win32-x64-msvc": "4.63.2", "fsevents": "~2.3.2" } }, @@ -4994,19 +2894,6 @@ "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", @@ -5157,13 +3044,6 @@ "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", @@ -5183,16 +3063,6 @@ "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", @@ -5202,13 +3072,6 @@ "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", @@ -5218,26 +3081,6 @@ "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", @@ -5286,19 +3129,6 @@ "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", @@ -5331,13 +3161,6 @@ "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", @@ -5362,16 +3185,6 @@ "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", @@ -5391,19 +3204,6 @@ "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", @@ -5411,14 +3211,6 @@ "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", @@ -5472,29 +3264,6 @@ } } }, - "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", @@ -5515,9 +3284,9 @@ } }, "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==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", "license": "MIT", "peer": true, "engines": { @@ -5528,44 +3297,6 @@ "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", @@ -5582,13 +3313,6 @@ "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", @@ -5598,16 +3322,6 @@ "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", @@ -5617,184 +3331,6 @@ "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", @@ -5816,33 +3352,6 @@ "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", @@ -5865,19 +3374,6 @@ "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", diff --git a/clients/mcpi/package.json b/clients/mcpi/package.json index cab641d2eb..59043726c0 100644 --- a/clients/mcpi/package.json +++ b/clients/mcpi/package.json @@ -15,7 +15,9 @@ "scripts": { "build": "tsup", "build:dev": "node build/mcp-bin.js daemon stop >/dev/null 2>&1; tsup", - "validate": "npm run format:check && npm run lint && npm run test", + "typecheck": "tsc --noEmit -p tsconfig.json && tsc --noEmit -p tsconfig.test.json", + "check": "npm run format:check && npm run lint && npm run typecheck", + "validate": "npm run check && npm run test", "test": "vitest run", "test:watch": "vitest", "test:coverage": "npm run test-servers:build && npm run build && vitest run --coverage", @@ -26,29 +28,24 @@ "format:check": "prettier --check src __tests__ \"*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}\"" }, "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", + "@modelcontextprotocol/client": "2.0.0", + "@modelcontextprotocol/core": "2.0.0", + "@modelcontextprotocol/server": "2.0.0", + "@modelcontextprotocol/server-legacy": "2.0.0", "@napi-rs/keyring": "^1.3.0", - "ajv": "^8.17.1", + "ajv": "8.18.0", "atomically": "^2.1.1", "commander": "^13.1.0", "open": "^10.2.0", "pino": "^9.14.0", - "undici": "^8.5.0", - "zod": "^4.3.6" + "undici": "8.9.0", + "zod": "4.4.3" }, "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.4", - "tsup": "^8.5.0", - "typescript": "~5.9.3", - "typescript-eslint": "^8.56.1", - "vitest": "^4.1.0" + "@types/express": "^5.0.6", + "tsup": "^8.5.0" + }, + "overrides": { + "@types/node": "^24.12.4" } } diff --git a/clients/mcpi/src/session/format-session.ts b/clients/mcpi/src/session/format-session.ts index db634e44fc..ce1c185bcf 100644 --- a/clients/mcpi/src/session/format-session.ts +++ b/clients/mcpi/src/session/format-session.ts @@ -196,7 +196,9 @@ function asAppInfoProbe(result: JsonObject): CliAppInfo | undefined { return undefined; } // Narrowed by the structural checks above; CliAppInfo adds optional fields. - return result as CliAppInfo; + // `JsonObject`'s index signature doesn't structurally overlap with + // `CliAppInfo`'s concrete shape, so `as` needs the `unknown` bridge. + return result as unknown as CliAppInfo; } function applyExitCodes(payload: SessionWriteKind): void { diff --git a/clients/mcpi/src/session/parse-tool-args.ts b/clients/mcpi/src/session/parse-tool-args.ts index 6b4bd931fe..74315516ca 100644 --- a/clients/mcpi/src/session/parse-tool-args.ts +++ b/clients/mcpi/src/session/parse-tool-args.ts @@ -23,6 +23,7 @@ export function parseToolCallPositionals( } catch (e) { throw new Error( `Invalid JSON tool arguments: ${e instanceof Error ? e.message : String(e)}`, + { cause: e }, ); } if ( @@ -123,6 +124,7 @@ function parseJsonObject(raw: string, flag: string): Record { } catch (e) { throw new Error( `${flag} is not valid JSON: ${e instanceof Error ? e.message : String(e)}`, + { cause: e }, ); } if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) { diff --git a/clients/mcpi/tsconfig.json b/clients/mcpi/tsconfig.json index c897207af9..b192b9b1eb 100644 --- a/clients/mcpi/tsconfig.json +++ b/clients/mcpi/tsconfig.json @@ -2,11 +2,22 @@ "extends": "../../tsconfig.base.json", "compilerOptions": { "noEmit": true, + // Match clients/cli/tsconfig.json's module/lib *resolution* options (mcpi + // reaches into @inspector/cli/* and @inspector/core/* the same way cli + // does) so core/ and cli/ are validated the same way their own gates + // validate them, rather than under base's stricter + // noUncheckedIndexedAccess, which core/cli were never written against. + "lib": ["ES2023", "DOM", "DOM.Iterable"], + "types": ["node"], + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "module": "ESNext", + "noUncheckedIndexedAccess": false, "paths": { "@inspector/core/*": ["../../core/*"], "@inspector/cli/*": ["../cli/src/*"] } }, - "include": ["src/**/*"], + "include": ["src/**/*", "vitest.config.ts", "tsup.config.ts"], "exclude": ["node_modules", "**/*.test.ts", "build"] } diff --git a/clients/mcpi/tsconfig.test.json b/clients/mcpi/tsconfig.test.json new file mode 100644 index 0000000000..823eed9662 --- /dev/null +++ b/clients/mcpi/tsconfig.test.json @@ -0,0 +1,29 @@ +{ + // Typecheck the __tests__ dir (the src-only tsconfig.json excludes tests). + // Mirrors clients/cli/tsconfig.test.json (and clients/web's): the test-server + // barrel is aliased to its source and the module paths below resolve what + // vitest resolves via vitest.shared.mts's projectResolve, so tsc validates + // the tests against the same graph the runner executes. See #1791. + "extends": "./tsconfig.json", + "compilerOptions": { + "types": ["node", "express"], + "paths": { + "@inspector/core/*": ["../../core/*"], + "@inspector/cli/*": ["../cli/src/*"], + "@modelcontextprotocol/inspector-test-server": [ + "../../test-servers/src/index.ts" + ], + "express": ["./node_modules/@types/express"], + "vitest": ["./node_modules/vitest"] + } + }, + // Some tests import cli's own test helpers by relative path + // (../../cli/__tests__/helpers/*) — tsc follows those transitively, no + // separate include entry needed. + // + // Only the tests root the project; tsc pulls in the `src` they import. The + // src-only tsconfig.json already validates all of `src` (without the + // test-only aliases), so listing it here too would just check it twice. + "include": ["__tests__/**/*"], + "exclude": ["node_modules", "build"] +} diff --git a/clients/mcpi/vitest.config.ts b/clients/mcpi/vitest.config.ts index 2663e899f0..08e3063393 100644 --- a/clients/mcpi/vitest.config.ts +++ b/clients/mcpi/vitest.config.ts @@ -1,7 +1,11 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; import { defineConfig } from "vitest/config"; -import { vitestSharedPaths } from "../../vitest.shared.mts"; +import { + NO_RETRY_SETUP, + TIMEOUTS, + vitestSharedPaths, +} from "../../vitest.shared.mts"; const dirname = path.dirname(fileURLToPath(import.meta.url)); const { projectResolve } = vitestSharedPaths(dirname); @@ -20,7 +24,9 @@ export default defineConfig({ globals: false, environment: "node", include: ["__tests__/**/*.test.ts"], - testTimeout: 15000, + setupFiles: [NO_RETRY_SETUP], + // Shared budgets (#2323). + ...TIMEOUTS, pool: "forks", coverage: { provider: "v8", diff --git a/package.json b/package.json index c0665f273c..7110a7f5a1 100644 --- a/package.json +++ b/package.json @@ -42,7 +42,7 @@ "build:launcher": "cd clients/launcher && npm run build", "local:gate": "node scripts/gate-lease.mjs npm run local:gate:stages", "local:gate:stages": "npm run local:validate && npm run verify:skills:cli && npm run coverage && npm run verify:build-gate && npm run verify:bundle-externals && npm run smoke && npm run smoke:web:firefox && npm run local:storybook", - "local:validate": "npm run validate:guards && npm run validate:core && cd clients/web && npm run check && cd ../cli && npm run check && cd ../tui && npm run check && cd ../launcher && npm run check", + "local:validate": "npm run validate:guards && npm run validate:core && cd clients/web && npm run check && cd ../cli && npm run check && cd ../mcpi && npm run check && cd ../tui && npm run check && cd ../launcher && npm run check", "local:storybook": "cd clients/web && npx playwright install chromium && npm run test:storybook", "verify:build-gate": "node scripts/verify-build-gate.mjs", "verify:bundle-externals": "node scripts/verify-bundle-externals.mjs", diff --git a/scripts/lib/workflow-gate.test.mjs b/scripts/lib/workflow-gate.test.mjs index 5ddfc395ce..8bee6115b2 100644 --- a/scripts/lib/workflow-gate.test.mjs +++ b/scripts/lib/workflow-gate.test.mjs @@ -639,7 +639,7 @@ describe("the gate's name", () => { // that keep it honest: the gate no longer reaches a client's bare `test`, // it still reaches every non-test check `validate` reaches, and `validate` // itself (CI's inner loop) is untouched. - const clients = ["web", "cli", "tui", "launcher"]; + const clients = ["web", "cli", "mcpi", "tui", "launcher"]; const clientScripts = Object.fromEntries( clients.map((c) => [ c, @@ -693,7 +693,7 @@ describe("the gate's name", () => { for (const name of inner) if ( name !== "validate" && - !/^validate:(web|cli|tui|launcher)$/.test(name) + !/^validate:(web|cli|mcpi|tui|launcher)$/.test(name) ) assert.ok(gate.has(name), `local:validate must reach ${name}`); }); diff --git a/scripts/verify-bundle-externals.mjs b/scripts/verify-bundle-externals.mjs index 8b2774f8e6..c5af74f824 100644 --- a/scripts/verify-bundle-externals.mjs +++ b/scripts/verify-bundle-externals.mjs @@ -53,6 +53,11 @@ export const BUNDLED_CLIENTS = [ config: "clients/tui/tsup.config.ts", build: "clients/tui/build", }, + { + name: "mcpi", + config: "clients/mcpi/tsup.config.ts", + build: "clients/mcpi/build", + }, ]; /** diff --git a/scripts/verify-test-timeouts.mjs b/scripts/verify-test-timeouts.mjs index 59f0394ac8..121a40682d 100644 --- a/scripts/verify-test-timeouts.mjs +++ b/scripts/verify-test-timeouts.mjs @@ -93,6 +93,7 @@ export const EXPECTED_PROJECTS = Object.freeze({ cli: EXPECTED_TIMEOUTS, tui: EXPECTED_TIMEOUTS, launcher: EXPECTED_TIMEOUTS, + mcpi: EXPECTED_TIMEOUTS, }); /** @@ -108,6 +109,7 @@ export const CONFIG_ROOTS = Object.freeze([ { root: "clients/cli", projects: ["cli"] }, { root: "clients/tui", projects: ["tui"] }, { root: "clients/launcher", projects: ["launcher"] }, + { root: "clients/mcpi", projects: ["mcpi"] }, ]); /** diff --git a/scripts/verify-test-timeouts.test.mjs b/scripts/verify-test-timeouts.test.mjs index 8c5a727857..d4c5bdec3e 100644 --- a/scripts/verify-test-timeouts.test.mjs +++ b/scripts/verify-test-timeouts.test.mjs @@ -123,6 +123,7 @@ test("a Vitest config this guard does not check is an error", () => { "clients/cli", "clients/tui", "clients/launcher", + "clients/mcpi", "clients/desktop", ]); assert.equal(failures.length, 1); @@ -131,7 +132,7 @@ test("a Vitest config this guard does not check is an error", () => { test("a stale row naming a config that no longer exists is an error", () => { const failures = checkConfigRootCoverage(["clients/web", "clients/cli"]); - assert.equal(failures.length, 2); + assert.equal(failures.length, 3); for (const f of failures) assert.match(f, /has no Vitest config on disk/); }); From aabc19fae98ad854a475fe94b02714f226e904a0 Mon Sep 17 00:00:00 2001 From: Bob Dickinson Date: Sun, 13 Sep 2026 23:47:10 -0700 Subject: [PATCH 09/23] mcpi: close CI coverage/build gaps found after typecheck fix unblocked them The previous commit fixed verify:typecheck-coverage, which unblocked two CI jobs that had never run to completion on this branch before: the per-file branch-coverage gate and the externalized-dependency check. Coverage (clients/mcpi, >=90% branches per file): - server.ts, format-human.ts, and mcp.ts had branch coverage below the 90% gate. The gaps traced to two features added this session that had thin or subprocess-only test coverage: sessions/show's enrichment fields (era, serverInfo, capabilities, supportedVersions, instructions) and the --era flag (previously untested at all). - Added in-process tests (server.handle()/callDaemon() against a daemon constructed directly in the test file) instead of runMcp() subprocess calls, since subprocess code isn't visible to coverage instrumentation. Covers sessions/show's absent-field fallback paths and full success path, the --era auto/invalid-value paths, and the mcp.ts positional sessionArg fallback for sessions/show. Build gate (verify:bundle-externals): - mcpi's tsup entry is named mcp-bin.js (multi-entry config), not index.js like web/cli/tui, so the script's hardcoded index.js existence check reported "build/index.js is missing" for mcpi even after a real build. Added a per-client `entry` override (defaults to index.js) so mcpi's real entry file is checked. - Once buildable, the scan found @modelcontextprotocol/ext-apps (a root runtime dependency reachable through cli's noExternal reach-in) had been inlined into mcpi's bundle instead of staying external. cli and tui already list it as external; added the same entry to mcpi's tsup config. npm run validate and npm run coverage both pass cleanly at the repo root after these changes. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../mcpi/__tests__/daemon-coverage.test.ts | 20 ++++++++++++++ clients/mcpi/__tests__/format-session.test.ts | 26 +++++++++++++++++++ clients/mcpi/__tests__/mcp-coverage.test.ts | 10 +++++++ clients/mcpi/__tests__/mcp-session.test.ts | 8 ++++++ clients/mcpi/tsup.config.ts | 1 + scripts/verify-bundle-externals.mjs | 14 +++++++--- 6 files changed, 75 insertions(+), 4 deletions(-) diff --git a/clients/mcpi/__tests__/daemon-coverage.test.ts b/clients/mcpi/__tests__/daemon-coverage.test.ts index 225b94f0b4..ee4a1faebb 100644 --- a/clients/mcpi/__tests__/daemon-coverage.test.ts +++ b/clients/mcpi/__tests__/daemon-coverage.test.ts @@ -61,6 +61,12 @@ describe("daemon coverage", () => { }); expect(badUse.ok).toBe(false); + // sessions/show with no `params` at all exercises the `request.params ?? + // {}` fallback; with no active session it still fails, same shape as + // sessions/use above. + const badShow = await server.handle({ id: "2b", op: "sessions/show" }); + expect(badShow.ok).toBe(false); + const unknown = await server.handle({ id: "3", op: "nope" as never, @@ -582,6 +588,20 @@ describe("daemon coverage", () => { expect(used.ok).toBe(true); expect(server.registry.idleRemainingMs()).toBeNull(); + // sessions/show over the same live session — exercises the full case + // body (serverInfo/protocolVersion/protocolEra/capabilities lookups) + // in-process, where coverage instrumentation can see it. + const shown = await server.handle({ + id: "s2", + op: "sessions/show", + params: { name: "s" }, + }); + expect(shown.ok).toBe(true); + if (shown.ok) { + const result = shown.result as { protocolVersion?: string }; + expect(result.protocolVersion).toBeTruthy(); + } + await new Promise((resolve, reject) => { const socket = new net.Socket(); socket.on("error", reject); diff --git a/clients/mcpi/__tests__/format-session.test.ts b/clients/mcpi/__tests__/format-session.test.ts index 7490581bec..93286e59c1 100644 --- a/clients/mcpi/__tests__/format-session.test.ts +++ b/clients/mcpi/__tests__/format-session.test.ts @@ -339,6 +339,32 @@ describe("format-human", () => { expect( formatSessionInfoHuman({ name: "a", isMru: true, serverIdentity: "id" }), ).toContain("Session `@a`"); + // sessions/show enrichment: era without a protocolVersion, serverInfo + // without a version, empty capabilities, an empty/non-array + // supportedVersions, and blank instructions each take the "nothing to + // append" branch rather than the populated one exercised elsewhere. + expect( + formatSessionInfoHuman({ + name: "a", + protocolEra: "legacy", + serverInfo: { name: "demo" }, + capabilities: {}, + supportedVersions: [], + instructions: "", + }), + ).toMatch(/Era: legacy\nServer info: demo\nCapabilities: \(none\)/); + expect( + formatSessionInfoHuman({ + name: "a", + protocolEra: undefined, + protocolVersion: "2025-11-25", + serverInfo: { name: "demo", version: "1.2.3" }, + supportedVersions: ["2025-11-25", "2025-06-18"], + instructions: "Say hi.", + }), + ).toMatch( + /Era: unknown \(2025-11-25\)[\s\S]*demo v1\.2\.3[\s\S]*Supported versions: 2025-11-25, 2025-06-18[\s\S]*Instructions: Say hi\./, + ); expect( formatAppInfoListHuman([ diff --git a/clients/mcpi/__tests__/mcp-coverage.test.ts b/clients/mcpi/__tests__/mcp-coverage.test.ts index 6d9604d9fb..9c4d16c93c 100644 --- a/clients/mcpi/__tests__/mcp-coverage.test.ts +++ b/clients/mcpi/__tests__/mcp-coverage.test.ts @@ -260,6 +260,8 @@ describe("mcp.ts coverage", () => { "COV_FLAG=1", "--connect-timeout", "15000", + "--era", + "auto", "--format", "json", command, @@ -269,6 +271,14 @@ describe("mcp.ts coverage", () => { ); expectCliSuccess(adHoc); + // Invalid --era is rejected before any connection is attempted. + const badEra = await runMcp( + ["connect", "--era", "bogus", "--format", "json", command, ...args], + { env: e, timeout: 20000 }, + ); + expectCliFailure(badEra); + expect(badEra.stderr).toMatch(/Invalid --era/); + await runMcp(["disconnect", "--session", "opts", "--format", "json"], { env: e, }); diff --git a/clients/mcpi/__tests__/mcp-session.test.ts b/clients/mcpi/__tests__/mcp-session.test.ts index 072d19371d..e20e192234 100644 --- a/clients/mcpi/__tests__/mcp-session.test.ts +++ b/clients/mcpi/__tests__/mcp-session.test.ts @@ -199,6 +199,14 @@ describe("mcp session CLI", () => { expect(shownBody.protocolVersion).toBeTruthy(); expect(shownBody.protocolEra).toBeTruthy(); + // `sessions/show ` (positional, no `@name`/--session) exercises + // the opts.session-absent fallback to the command's own argument. + const shownByArg = await runMcp( + ["sessions/show", "test-stdio", "--format", "json"], + { env: e, timeout: 20000 }, + ); + expectCliSuccess(shownByArg); + await runMcp( ["disconnect", "--session", "test-stdio", "--format", "json"], { diff --git a/clients/mcpi/tsup.config.ts b/clients/mcpi/tsup.config.ts index a73e742ec7..c3ee701765 100644 --- a/clients/mcpi/tsup.config.ts +++ b/clients/mcpi/tsup.config.ts @@ -26,6 +26,7 @@ export default defineConfig({ "@napi-rs/keyring", "@modelcontextprotocol/client", "@modelcontextprotocol/core", + "@modelcontextprotocol/ext-apps", "commander", "pino", "open", diff --git a/scripts/verify-bundle-externals.mjs b/scripts/verify-bundle-externals.mjs index c5af74f824..60d0e8ed19 100644 --- a/scripts/verify-bundle-externals.mjs +++ b/scripts/verify-bundle-externals.mjs @@ -34,8 +34,12 @@ const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); /** * Clients that ship a tsup bundle, with the config to read `external` from and - * the build directory to inspect. `clients/launcher` is plain `tsc` — it emits - * no bundle and inlines nothing — so it has nothing to check. + * the build directory to inspect. `entry` names the file whose presence + * proves a build actually ran; it defaults to `index.js` (what web/cli/tui + * each name their single tsup entry) and is overridden only when a client's + * tsup config uses a different entry name, like mcpi's multi-entry `mcp-bin`. + * `clients/launcher` is plain `tsc` — it emits no bundle and inlines nothing — + * so it has nothing to check. */ export const BUNDLED_CLIENTS = [ { @@ -57,6 +61,7 @@ export const BUNDLED_CLIENTS = [ name: "mcpi", config: "clients/mcpi/tsup.config.ts", build: "clients/mcpi/build", + entry: "mcp-bin.js", }, ]; @@ -210,10 +215,11 @@ function main() { ); for (const client of BUNDLED_CLIENTS) { const buildDir = join(repoRoot, client.build); - const entry = join(buildDir, "index.js"); + const entryName = client.entry ?? "index.js"; + const entry = join(buildDir, entryName); if (!existsSync(entry)) { failures.push( - `${client.name}: ${client.build}/index.js is missing — run \`npm run build\` first.`, + `${client.name}: ${client.build}/${entryName} is missing — run \`npm run build\` first.`, ); continue; } From 40b4f4416a1295d9488501713347ac4d343d8815 Mon Sep 17 00:00:00 2001 From: Bob Dickinson Date: Mon, 14 Sep 2026 11:25:10 -0700 Subject: [PATCH 10/23] fix(mcpi): implement skills/list and skills/get RPC commands Previously both fell into registerRpcCommands()'s argument-less default case (a side effect of the generic loop over SESSION_RPC_METHODS), so skills/get could parse and run but could never succeed: there was no way to pass --uri. Add dedicated Commander cases for skills/list and skills/get (positional/--uri for the latter), both with --verify. --verify's NDJSON report, stderr summary, and non-zero exit code were also being silently dropped crossing the daemon socket (RpcResult only carried lines) and, in text mode, forced through the tools/list --app-info human formatter (wrong shape). Thread summary/exitCode through the daemon protocol and dispatch layer, add a dedicated formatSkillVerifyListHuman, and apply the verify exit code the same way NO_APP/TOOL_ERROR already are. Verified end-to-end against a live skills-enabled server: skills/list, skills/list --verify (exit 7 nonconformant / 0 clean), skills/get , skills/get --uri --verify, in both --format text and json. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- clients/mcpi/__tests__/format-session.test.ts | 85 +++++++++++++++++++ clients/mcpi/src/daemon/protocol.ts | 14 ++- clients/mcpi/src/daemon/server.ts | 7 +- clients/mcpi/src/session/dispatch.ts | 11 +++ clients/mcpi/src/session/format-human.ts | 48 +++++++++++ clients/mcpi/src/session/format-session.ts | 47 +++++++++- clients/mcpi/src/session/mcp.ts | 28 ++++++ 7 files changed, 235 insertions(+), 5 deletions(-) diff --git a/clients/mcpi/__tests__/format-session.test.ts b/clients/mcpi/__tests__/format-session.test.ts index 93286e59c1..3b99fadec6 100644 --- a/clients/mcpi/__tests__/format-session.test.ts +++ b/clients/mcpi/__tests__/format-session.test.ts @@ -19,6 +19,7 @@ import { formatSessionInfoHuman, formatAppInfoListHuman, formatAppInfoHuman, + formatSkillVerifyListHuman, formatStreamEventHuman, formatRpcResultHuman, } from "../src/session/format-human.js"; @@ -374,6 +375,31 @@ describe("format-human", () => { ]), ).toContain("no app"); + const verifyText = formatSkillVerifyListHuman([ + { name: "ok-skill", uri: "skill://ok/SKILL.md", outcome: "verified" }, + { + name: "bad-skill", + uri: "skill://bad/SKILL.md", + outcome: "failed", + conformance: [{ severity: "error" }], + files: [{ status: "mismatch" }], + }, + { + name: "cut-short", + uri: "skill://cut/SKILL.md", + outcome: "incomplete", + incomplete: "read bounds hit", + }, + ]); + expect(verifyText).toContain("Skill verification (3):"); + expect(verifyText).toContain("`ok-skill`"); + expect(verifyText).toContain("verified"); + expect(verifyText).toContain( + "`bad-skill` (skill://bad/SKILL.md) — failed — 1 issue(s), 1 file mismatch(es)", + ); + expect(verifyText).toContain("`cut-short`"); + expect(verifyText).toContain("read bounds hit"); + expect( formatAppInfoHuman({ toolName: "t", @@ -483,11 +509,15 @@ describe("format-human", () => { describe("writeSessionOutput", () => { let stdout: string; + let stderr: string; let original: typeof process.stdout.write; + let originalErr: typeof process.stderr.write; beforeEach(() => { stdout = ""; + stderr = ""; original = process.stdout.write; + originalErr = 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 @@ -496,10 +526,19 @@ describe("writeSessionOutput", () => { cb?.(); return true; }) as typeof process.stdout.write; + process.stderr.write = ((chunk: unknown, ...rest: unknown[]) => { + stderr += typeof chunk === "string" ? chunk : String(chunk); + const cb = rest.find((r) => typeof r === "function") as + | (() => void) + | undefined; + cb?.(); + return true; + }) as typeof process.stderr.write; }); afterEach(() => { process.stdout.write = original; + process.stderr.write = originalErr; }); it("pretty-prints json without a result envelope", async () => { @@ -591,6 +630,52 @@ describe("writeSessionOutput", () => { expect(stdout).toContain('"ok": 1'); }); + it("renders skill-verify NDJSON with its own formatter, not app-info's", async () => { + await writeSessionOutput( + { format: "text" }, + { + kind: "ndjson", + variant: "skill-verify", + lines: [ + { name: "ok-skill", uri: "skill://ok/SKILL.md", outcome: "verified" }, + ], + summary: "Verified 1 skill and 0 files: no conformance errors.", + }, + ); + expect(stdout).toContain("Skill verification (1):"); + expect(stdout).not.toContain("App info"); + expect(stderr).toBe( + "Verified 1 skill and 0 files: no conformance errors.\n", + ); + }); + + it("throws with the verify exit code after printing the report and summary", async () => { + await expect( + writeSessionOutput( + { format: "json" }, + { + kind: "ndjson", + variant: "skill-verify", + lines: [ + { + name: "bad-skill", + uri: "skill://bad/SKILL.md", + outcome: "failed", + }, + ], + summary: "1 of 1 skill failed verification.", + exitCode: EXIT_CODES.SKILL_NONCONFORMANT, + }, + ), + ).rejects.toMatchObject({ + exitCode: EXIT_CODES.SKILL_NONCONFORMANT, + envelope: { code: "skills_nonconformant" }, + }); + // Report already on stdout, summary on stderr — both happen before the throw. + expect(stdout).toContain("bad-skill"); + expect(stderr).toBe("1 of 1 skill failed verification.\n"); + }); + it("formats every admin/stream payload kind", async () => { const kinds = [ { diff --git a/clients/mcpi/src/daemon/protocol.ts b/clients/mcpi/src/daemon/protocol.ts index 6a63994b2c..393df9380b 100644 --- a/clients/mcpi/src/daemon/protocol.ts +++ b/clients/mcpi/src/daemon/protocol.ts @@ -120,4 +120,16 @@ export type RpcResult = result: Record; appInfo?: CliAppInfo; } - | { kind: "ndjson"; lines: unknown[] }; + | { + kind: "ndjson"; + lines: unknown[]; + /** + * `skills/list --verify` / `skills/get --verify` one-line stderr + * verdict (#2248). Carried across the daemon socket so the session CLI + * can report the same summary the one-shot CLI does, rather than + * silently dropping it the way an earlier pass through this file did. + */ + summary?: string; + /** Non-zero when the emitted report is itself a failure (`--verify`). */ + exitCode?: number; + }; diff --git a/clients/mcpi/src/daemon/server.ts b/clients/mcpi/src/daemon/server.ts index 4ee748e18c..69d5bbc91b 100644 --- a/clients/mcpi/src/daemon/server.ts +++ b/clients/mcpi/src/daemon/server.ts @@ -301,7 +301,12 @@ export class DaemonServer { ); } if (outcome.kind === "ndjson") { - return { kind: "ndjson", lines: outcome.lines }; + return { + kind: "ndjson", + lines: outcome.lines, + summary: outcome.summary, + exitCode: outcome.exitCode, + }; } return { kind: "result", diff --git a/clients/mcpi/src/session/dispatch.ts b/clients/mcpi/src/session/dispatch.ts index 8cf160000a..0ef022c519 100644 --- a/clients/mcpi/src/session/dispatch.ts +++ b/clients/mcpi/src/session/dispatch.ts @@ -10,6 +10,14 @@ import { styleFromOpts } from "@inspector/cli/style.js"; const STREAM_METHODS = new Set(["logging/tail", "resources/subscribe"]); +/** + * The only two methods whose NDJSON output is a `--verify` conformance report + * rather than `tools/list --app-info` probe lines. Everything else that ever + * returns `kind: "ndjson"` is the app-info shape, so this is a short + * allow-list rather than the other way round. + */ +const NDJSON_VARIANTS = new Set(["skills/list", "skills/get"]); + export type SessionDispatchOpts = { format?: OutputFormat; plain?: boolean; @@ -70,6 +78,9 @@ export async function dispatchSessionRpc( { kind: "ndjson", lines: outcome.lines, + variant: NDJSON_VARIANTS.has(method) ? "skill-verify" : "app-info", + summary: outcome.summary, + exitCode: outcome.exitCode, }, ); return; diff --git a/clients/mcpi/src/session/format-human.ts b/clients/mcpi/src/session/format-human.ts index ed78f4615b..32f58281eb 100644 --- a/clients/mcpi/src/session/format-human.ts +++ b/clients/mcpi/src/session/format-human.ts @@ -627,6 +627,54 @@ export function formatAppInfoListHuman( return out.join("\n"); } +/** + * Format `skills/list --verify` / `skills/get --verify` NDJSON lines. + * Each line is a {@link SkillVerifyReport}; the caller already computed the + * one-line stderr summary (`summarizeSkillVerification`) shared with the + * one-shot CLI, so this only renders the per-skill breakdown. + */ +export function formatSkillVerifyListHuman( + lines: unknown[], + style: Style = PLAIN, +): string { + const out = [heading(style, `Skill verification (${lines.length}):`)]; + for (const raw of lines) { + const report = raw as JsonObject; + const name = String(report.name ?? "?"); + const uri = String(report.uri ?? ""); + const outcome = report.outcome as string | undefined; + const conformance = asArray(report.conformance); + const frontmatter = asArray(report.frontmatter); + const files = asArray(report.files); + const errorCount = [...conformance, ...frontmatter].filter( + (issue) => issue.severity === "error", + ).length; + const mismatchCount = files.filter( + (file) => file.status === "mismatch" || file.status === "read-error", + ).length; + const verdict = + outcome === "verified" + ? style.green("verified") + : outcome === "incomplete" + ? style.dim("incomplete") + : style.red("failed"); + const detail = + outcome === "verified" + ? "" + : outcome === "incomplete" + ? style.dim( + ` — ${String(report.incomplete ?? "read bounds cut the walk short")}`, + ) + : style.dim( + ` — ${errorCount} issue(s), ${mismatchCount} file mismatch(es)`, + ); + out.push( + `* ${code(style, name)} (${formatUri(style, uri)}) — ${verdict}${detail}`, + ); + } + return out.join("\n"); +} + /** Format a single app-info probe. */ export function formatAppInfoHuman( info: JsonObject, diff --git a/clients/mcpi/src/session/format-session.ts b/clients/mcpi/src/session/format-session.ts index ce1c185bcf..23afaf9b1d 100644 --- a/clients/mcpi/src/session/format-session.ts +++ b/clients/mcpi/src/session/format-session.ts @@ -1,4 +1,7 @@ -import { awaitableLog } from "@inspector/cli/utils/awaitable-log.js"; +import { + awaitableError, + 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"; @@ -12,6 +15,7 @@ import { formatServerShowHuman, formatSessionInfoHuman, formatSessionsListHuman, + formatSkillVerifyListHuman, formatStreamEventHuman, } from "./format-human.js"; import { PLAIN, type Style } from "@inspector/cli/style.js"; @@ -42,7 +46,16 @@ export type SessionWriteKind = /** For exit-code messages when result.isError. */ toolName?: string; } - | { kind: "ndjson"; lines: unknown[] } + | { + kind: "ndjson"; + lines: unknown[]; + /** Distinguishes `tools/list --app-info` probe lines from a `--verify` report. */ + variant?: "app-info" | "skill-verify"; + /** `--verify` one-line stderr verdict; absent for `--app-info`. */ + summary?: string; + /** Non-zero when the emitted `--verify` report is itself a failure. */ + exitCode?: number; + } | { kind: "stream-event"; data: unknown } | { kind: "servers/list"; servers: unknown[] } | { kind: "servers/show"; server: JsonObject } @@ -80,14 +93,29 @@ export async function writeSessionOutput( if (format === "json") { await awaitableLog(formatSessionJson(jsonPayload(payload))); + await writeNdjsonSummary(payload); applyExitCodes(payload); return; } await awaitableLog(humanPayload(payload, style) + "\n"); + await writeNdjsonSummary(payload); applyExitCodes(payload); } +/** + * `skills/list --verify` / `skills/get --verify`: the one-line verdict goes to + * **stderr**, after the report, in both `--format text` and `--format json` — + * mirrors the one-shot CLI (`consumeMethodOutcome`), so a reader piping stdout + * into `jq` still sees it and a `--format json` caller isn't left without one + * just because the report itself is already structured. + */ +async function writeNdjsonSummary(payload: SessionWriteKind): Promise { + if (payload.kind === "ndjson" && payload.summary) { + await awaitableError(`${payload.summary}\n`); + } +} + function jsonPayload(payload: SessionWriteKind): unknown { switch (payload.kind) { case "rpc": @@ -134,7 +162,9 @@ function humanPayload(payload: SessionWriteKind, style: Style): string { return formatted ?? JSON.stringify(payload.result, null, 2); } case "ndjson": - return formatAppInfoListHuman(payload.lines, style); + return payload.variant === "skill-verify" + ? formatSkillVerifyListHuman(payload.lines, style) + : formatAppInfoListHuman(payload.lines, style); case "stream-event": return formatStreamEventHuman(payload.data, style); case "servers/list": @@ -202,6 +232,17 @@ function asAppInfoProbe(result: JsonObject): CliAppInfo | undefined { } function applyExitCodes(payload: SessionWriteKind): void { + if (payload.kind === "ndjson" && payload.exitCode) { + // Report already written above; thrown last so it routes through the + // session CLI's single exit path, same as the one-shot CLI's + // `consumeMethodOutcome` (Copilot). + throw new CliExitCodeError(payload.exitCode, payload.summary ?? "", { + code: + payload.exitCode === EXIT_CODES.SKILL_INCOMPLETE + ? "skills_incomplete" + : "skills_nonconformant", + }); + } 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. diff --git a/clients/mcpi/src/session/mcp.ts b/clients/mcpi/src/session/mcp.ts index c6111ed71d..5a303dfcec 100644 --- a/clients/mcpi/src/session/mcp.ts +++ b/clients/mcpi/src/session/mcp.ts @@ -720,6 +720,34 @@ function registerRpcCommands(program: CommandType): void { }); }); break; + case "skills/list": + cmd.option( + "--verify", + "Run the SEP-2640 conformance and digest checks over every skill returned", + ); + cmd.action(async (o) => { + await runRpc(program, method, { + verify: o.verify === true, + metadata: o.metadata, + }); + }); + break; + case "skills/get": + cmd + .argument("[uri]", "Skill URI") + .option("--uri ", "Skill URI") + .option( + "--verify", + "Run the SEP-2640 conformance and digest checks over this skill", + ); + cmd.action(async (uriPos: string | undefined, o) => { + await runRpc(program, method, { + uri: (o.uri as string | undefined) ?? uriPos, + verify: o.verify === true, + metadata: o.metadata, + }); + }); + break; case "prompts/get": cmd .argument("[promptName]", "Prompt name") From 380dd3e7762c80e8446f74c04653f7cb352c2ba2 Mon Sep 17 00:00:00 2001 From: Bob Dickinson Date: Mon, 14 Sep 2026 11:31:48 -0700 Subject: [PATCH 11/23] feat(mcpi): surface protocolEra on every session, not just sessions/show sessions/list, connect, and sessions/use all returned SessionInfo with no era field, so seeing which era a session negotiated required a separate sessions/show lookup per session - awkward with several open sessions against different servers, and the exact visibility dual-era support is supposed to give. Move protocolEra onto the base SessionInfo type (SessionShowResult already had it; it is now inherited rather than redeclared) and populate it from client.getProtocolEra() - already connected, so free - at all three SessionInfo construction sites in SessionRegistry (list/use/connect). formatSessionsListHuman renders it inline (@name (MRU) - server [era]); formatSessionInfoHuman already handled protocolEra being present without a protocolVersion, so connect/ sessions/use output picks it up with no further changes. Verified end-to-end: connect, sessions/list (text + json), and sessions/use against a live server all show [legacy]/Era: legacy. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- clients/mcpi/__tests__/format-session.test.ts | 13 ++++++++++ clients/mcpi/src/daemon/protocol.ts | 25 +++++++++++++------ clients/mcpi/src/daemon/sessions.ts | 3 +++ clients/mcpi/src/session/format-human.ts | 11 +++++--- 4 files changed, 42 insertions(+), 10 deletions(-) diff --git a/clients/mcpi/__tests__/format-session.test.ts b/clients/mcpi/__tests__/format-session.test.ts index 3b99fadec6..2c5ba2e76d 100644 --- a/clients/mcpi/__tests__/format-session.test.ts +++ b/clients/mcpi/__tests__/format-session.test.ts @@ -337,6 +337,19 @@ describe("format-human", () => { { name: "a", isMru: true, serverIdentity: "id" }, ]), ).toContain("(MRU)"); + // protocolEra is on every SessionInfo now (#2298 follow-up), not just + // sessions/show — sessions/list renders it inline; its absence (an older + // daemon reply, hypothetically) must not print a bare "[undefined]". + expect( + formatSessionsListHuman([ + { name: "a", isMru: true, serverIdentity: "id", protocolEra: "modern" }, + ]), + ).toContain("— id [modern]"); + expect( + formatSessionsListHuman([ + { name: "a", isMru: false, serverIdentity: "id" }, + ]), + ).not.toContain("["); expect( formatSessionInfoHuman({ name: "a", isMru: true, serverIdentity: "id" }), ).toContain("Session `@a`"); diff --git a/clients/mcpi/src/daemon/protocol.ts b/clients/mcpi/src/daemon/protocol.ts index 393df9380b..5a589ed10c 100644 --- a/clients/mcpi/src/daemon/protocol.ts +++ b/clients/mcpi/src/daemon/protocol.ts @@ -87,20 +87,31 @@ export type SessionInfo = { connectedAt: number; lastAccessedAt: number; isMru: boolean; + /** + * Negotiated era for this session's connection — legacy `initialize` vs. + * modern `server/discover` (#2298 follow-up). Present everywhere a live + * session is reported (`connect`, `sessions/list`, `sessions/use`), not + * just `sessions/show`, so a user with several open sessions can see which + * era each negotiated without querying them one at a time. Absent only if + * the client hasn't connected (never observed in practice — every code + * path constructing a `SessionInfo` does so from an already-connected + * session). + */ + protocolEra?: ProtocolEra; }; /** - * `sessions/show` result: daemon bookkeeping ({@link SessionInfo}) plus the - * live MCP connection state — era-agnostic (`serverInfo`/`capabilities`/ - * `instructions`/`protocolVersion` are populated the same way whether they - * came from a legacy `initialize` response or a modern `server/discover`) - * and era-specific (`protocolEra`, `supportedVersions` — the latter only set - * when the connect actually probed `server/discover`, i.e. `auto`/`modern`). + * `sessions/show` result: daemon bookkeeping ({@link SessionInfo}, which as of + * #2298 already carries `protocolEra`) plus the live MCP connection state — + * era-agnostic (`serverInfo`/`capabilities`/`instructions`/`protocolVersion` + * are populated the same way whether they came from a legacy `initialize` + * response or a modern `server/discover`) and era-specific (`supportedVersions`, + * only set when the connect actually probed `server/discover`, i.e. + * `auto`/`modern`). */ export type SessionShowResult = SessionInfo & { serverInfo?: Implementation; protocolVersion?: string; - protocolEra?: ProtocolEra; capabilities?: ServerCapabilities; instructions?: string; supportedVersions?: string[]; diff --git a/clients/mcpi/src/daemon/sessions.ts b/clients/mcpi/src/daemon/sessions.ts index 7875906b44..b982b15c53 100644 --- a/clients/mcpi/src/daemon/sessions.ts +++ b/clients/mcpi/src/daemon/sessions.ts @@ -83,6 +83,7 @@ export class SessionRegistry { connectedAt: s.connectedAt, lastAccessedAt: s.lastAccessedAt, isMru: s.name === this.mruName, + protocolEra: s.client.getProtocolEra(), })) .sort((a, b) => b.lastAccessedAt - a.lastAccessedAt); } @@ -171,6 +172,7 @@ export class SessionRegistry { connectedAt: session.connectedAt, lastAccessedAt: session.lastAccessedAt, isMru: true, + protocolEra: session.client.getProtocolEra(), }; } @@ -227,6 +229,7 @@ export class SessionRegistry { connectedAt: now, lastAccessedAt: now, isMru: true, + protocolEra: client.getProtocolEra(), }; } catch (error) { // Any failure after clearIdleTimer (createSessionClient, reconnect diff --git a/clients/mcpi/src/session/format-human.ts b/clients/mcpi/src/session/format-human.ts index 32f58281eb..d50b41e99f 100644 --- a/clients/mcpi/src/session/format-human.ts +++ b/clients/mcpi/src/session/format-human.ts @@ -544,8 +544,12 @@ export function formatSessionsListHuman( for (const raw of sessions) { const s = raw as JsonObject; const mru = s.isMru === true ? style.green(" (MRU)") : ""; + const era = + s.protocolEra !== undefined + ? style.dim(` [${String(s.protocolEra)}]`) + : ""; lines.push( - `* ${code(style, `@${String(s.name)}`)}${mru}${style.dim(` — ${String(s.serverIdentity ?? "")}`)}`, + `* ${code(style, `@${String(s.name)}`)}${mru}${style.dim(` — ${String(s.serverIdentity ?? "")}`)}${era}`, ); } if (sessions.length === 0) lines.push(style.dim("(none — connect first)")); @@ -563,8 +567,9 @@ export function formatSessionInfoHuman( `Server: ${style.dim(String(session.serverIdentity ?? ""))}`, ]; - // Connection details (`sessions/show` only — plain `use`/`connect` - // results don't carry these). + // Connection details. `protocolEra` is now on every `SessionInfo` (#2298 + // follow-up), so it renders for plain `connect`/`sessions/use` results too; + // `protocolVersion` and everything below it are `sessions/show`-only. const era = session.protocolEra; const protocolVersion = session.protocolVersion; if (era !== undefined || protocolVersion !== undefined) { From 22d5c9f4acfc4e363181ddec5babc4f7b7f07d92 Mon Sep 17 00:00:00 2001 From: Bob Dickinson Date: Mon, 14 Sep 2026 11:38:38 -0700 Subject: [PATCH 12/23] feat(mcpi): implement tasks/update to resume paused modern tasks Add the one remaining concrete gap flagged in the era-support review: tasks/update was absent from SESSION_RPC_METHODS entirely, so a modern (SEP-2663) task paused on input_required could be observed (tasks/get) or killed (tasks/cancel) from mcpi but never resumed by hand. - method-types.ts: add "tasks/update" to SESSION_RPC_METHODS; add inputResponsesJson to MethodArgs (mirrors roots/set's rootsJson JSON-blob convention). - run-method.ts: add a tasks/update case validating --task-id and parsing --input-responses as a JSON object, then calling InspectorClient.updateRequestorTask; echoes {updated, taskId} since the ack is empty and status only advances on a later tasks/get poll. - mcp.ts: dedicated Commander case (positional [taskId] / --task-id, --input-responses ), consistent with the existing tasks/get, tasks/cancel, tasks/result case. - mcp-coverage.test.ts: cover the missing-input-responses error, invalid-JSON error, and the call-through path. Verified against a live tasks-modern-http.json server: a task-augmented tools/call paused on input_required, tasks/update with the confirm input resumed it successfully; missing/invalid --input-responses both fail with clear errors. Full mcpi suite (120 tests) and coverage gate pass; cli suite (384 tests) and typecheck pass in both packages. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- clients/cli/src/handlers/method-types.ts | 9 +++++- clients/cli/src/handlers/run-method.ts | 32 +++++++++++++++++++ clients/mcpi/__tests__/mcp-coverage.test.ts | 34 +++++++++++++++++++++ clients/mcpi/src/session/mcp.ts | 19 ++++++++++++ 4 files changed, 93 insertions(+), 1 deletion(-) diff --git a/clients/cli/src/handlers/method-types.ts b/clients/cli/src/handlers/method-types.ts index acf5e290b4..9c5260b94c 100644 --- a/clients/cli/src/handlers/method-types.ts +++ b/clients/cli/src/handlers/method-types.ts @@ -30,7 +30,7 @@ export type MethodArgs = { */ strict?: boolean; format?: OutputFormat; - /** Task id for tasks/get, tasks/cancel, tasks/result. */ + /** Task id for tasks/get, tasks/cancel, tasks/result, tasks/update. */ taskId?: string; /** When true, tools/call uses callToolStream (task-augmented). */ task?: boolean; @@ -48,6 +48,12 @@ export type MethodArgs = { cursor?: string; /** roots/set payload (JSON array of {uri, name?}). */ rootsJson?: string; + /** + * tasks/update payload (JSON object keyed by the server's `inputRequests` + * ids). Resumes a modern (SEP-2663) task paused on `input_required` — + * modern-only, symmetric with `roots/set`'s JSON-blob convention. + */ + inputResponsesJson?: string; /** prompts/complete: argument name / value / ref. */ completeRefType?: "ref/prompt" | "ref/resource"; completeRef?: string; @@ -117,6 +123,7 @@ export const SESSION_RPC_METHODS = [ "tasks/get", "tasks/cancel", "tasks/result", + "tasks/update", "roots/list", "roots/set", "skills/list", diff --git a/clients/cli/src/handlers/run-method.ts b/clients/cli/src/handlers/run-method.ts index f3d883e00e..1d30e5be51 100644 --- a/clients/cli/src/handlers/run-method.ts +++ b/clients/cli/src/handlers/run-method.ts @@ -314,6 +314,38 @@ export async function runMethod( result = (await inspectorClient.getRequestorTaskResult( args.taskId, )) as McpResponse; + } else if (args.method === "tasks/update") { + if (!args.taskId) { + throw new Error("Task id is required for tasks/update. Use --task-id."); + } + if (!args.inputResponsesJson) { + throw new Error( + "tasks/update requires --input-responses ''.", + ); + } + let inputResponses: Record; + try { + const parsed: unknown = JSON.parse(args.inputResponsesJson); + if ( + typeof parsed !== "object" || + parsed === null || + Array.isArray(parsed) + ) { + throw new Error("must be a JSON object"); + } + inputResponses = parsed as Record; + } catch (e) { + throw new Error( + `--input-responses is invalid: ${e instanceof Error ? e.message : String(e)}`, + { cause: e }, + ); + } + await inspectorClient.updateRequestorTask(args.taskId, inputResponses); + // The server acks with an empty result and the task's status advances + // only on a subsequent tasks/get poll (updateRequestorTask says so) — + // so echo back what was actually sent rather than imply a fresher + // status is available here. + result = { updated: true, taskId: args.taskId }; } else if (args.method === "skills/list") { // The store's cursor walk is reused rather than re-implemented — it // carries the repeated-cursor and page-cap guards, and a second copy of diff --git a/clients/mcpi/__tests__/mcp-coverage.test.ts b/clients/mcpi/__tests__/mcp-coverage.test.ts index 9c4d16c93c..f72b404426 100644 --- a/clients/mcpi/__tests__/mcp-coverage.test.ts +++ b/clients/mcpi/__tests__/mcp-coverage.test.ts @@ -185,6 +185,40 @@ describe("mcp.ts coverage", () => { ); expectCliFailure(taskResult); + const taskUpdateNoBody = await runMcp( + ["tasks/update", "missing-task", "--format", "json"], + { env: e, timeout: 20000 }, + ); + expectCliFailure(taskUpdateNoBody); + expect(taskUpdateNoBody.stderr).toMatch(/--input-responses/); + + const taskUpdateBadJson = await runMcp( + [ + "tasks/update", + "missing-task", + "--input-responses", + "not-json", + "--format", + "json", + ], + { env: e, timeout: 20000 }, + ); + expectCliFailure(taskUpdateBadJson); + expect(taskUpdateBadJson.stderr).toMatch(/--input-responses is invalid/); + + const taskUpdate = await runMcp( + [ + "tasks/update", + "missing-task", + "--input-responses", + '{"req-1":"answer"}', + "--format", + "json", + ], + { env: e, timeout: 20000 }, + ); + expectCliFailure(taskUpdate); + const roots = await runMcp( ["roots/set", "--roots-json", "[]", "--format", "json"], { env: e, timeout: 20000 }, diff --git a/clients/mcpi/src/session/mcp.ts b/clients/mcpi/src/session/mcp.ts index 5a303dfcec..129d6b4687 100644 --- a/clients/mcpi/src/session/mcp.ts +++ b/clients/mcpi/src/session/mcp.ts @@ -818,6 +818,25 @@ function registerRpcCommands(program: CommandType): void { }); }); break; + case "tasks/update": + // Modern-only (SEP-2663): resumes a task paused on `input_required`. + // `--input-responses` mirrors `roots/set`'s JSON-blob convention + // rather than trying to model arbitrary per-request shapes as flags. + cmd + .argument("[taskId]", "Task id") + .option("--task-id ", "Task id") + .option( + "--input-responses ", + "JSON object keyed by the server's inputRequests id", + ); + cmd.action(async (taskPos: string | undefined, o) => { + await runRpc(program, method, { + taskId: (o.taskId as string | undefined) ?? taskPos, + inputResponsesJson: o.inputResponses as string | undefined, + metadata: o.metadata, + }); + }); + break; case "roots/set": cmd.option("--roots-json ", "JSON array of {uri, name?}"); cmd.action(async (o) => { From 2c3d6ce5bfab936f21521c6e51027991664a966c Mon Sep 17 00:00:00 2001 From: Bob Dickinson Date: Mon, 14 Sep 2026 13:28:29 -0700 Subject: [PATCH 13/23] test(cli): cover tasks/update success and error paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit run-method.ts's tasks/update case (added in 22d5c9f4) wasn't exercised by the cli package's own test suite — only through mcpi's build, which doesn't count toward this package's coverage gate. That dropped run-method.ts branch coverage to 85.81%, below the 90% per-file threshold, and failed CI's coverage job. Add updateRequestorTask to the mocked client and cover: the success path (result shape, args forwarded to updateRequestorTask), missing taskId, missing --input-responses, invalid JSON, and non-object JSON (array). run-method.ts is back to 93.24% branch coverage; full cli suite (384 tests) passes. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../cli/__tests__/run-method-mocks.test.ts | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/clients/cli/__tests__/run-method-mocks.test.ts b/clients/cli/__tests__/run-method-mocks.test.ts index 4fda0c5197..222cf09020 100644 --- a/clients/cli/__tests__/run-method-mocks.test.ts +++ b/clients/cli/__tests__/run-method-mocks.test.ts @@ -12,6 +12,7 @@ function mockClient(overrides: Partial = {}): InspectorClient { getRequestorTask: vi.fn().mockResolvedValue({ taskId: "t1" }), cancelRequestorTask: vi.fn().mockResolvedValue(undefined), getRequestorTaskResult: vi.fn().mockResolvedValue({ content: [] }), + updateRequestorTask: vi.fn().mockResolvedValue(undefined), getRoots: vi.fn().mockReturnValue([]), setRoots: vi.fn().mockResolvedValue(undefined), setLoggingLevel: vi.fn().mockResolvedValue(undefined), @@ -133,6 +134,19 @@ describe("runMethod (mocked client)", () => { }); expect(result.kind).toBe("result"); + const updated = await runMethod(client, { + method: "tasks/update", + taskId: "t1", + inputResponsesJson: '{"confirm":{"approved":true}}', + }); + expect(updated.kind).toBe("result"); + if (updated.kind === "result") { + expect(updated.result).toMatchObject({ updated: true, taskId: "t1" }); + } + expect(client.updateRequestorTask).toHaveBeenCalledWith("t1", { + confirm: { approved: true }, + }); + const complete = await runMethod(client, { method: "prompts/complete", completeRefType: "ref/prompt", @@ -191,6 +205,27 @@ describe("runMethod (mocked client)", () => { /tasks\/result/, ); + await expect(runMethod(client, { method: "tasks/update" })).rejects.toThrow( + /tasks\/update/, + ); + await expect( + runMethod(client, { method: "tasks/update", taskId: "t1" }), + ).rejects.toThrow(/--input-responses/); + await expect( + runMethod(client, { + method: "tasks/update", + taskId: "t1", + inputResponsesJson: "not-json", + }), + ).rejects.toThrow(/--input-responses is invalid/); + await expect( + runMethod(client, { + method: "tasks/update", + taskId: "t1", + inputResponsesJson: "[1,2,3]", + }), + ).rejects.toThrow(/--input-responses is invalid/); + await expect( runMethod(client, { method: "roots/set", From 2a61f427edb7aea21d457a0c0b4d26937a719132 Mon Sep 17 00:00:00 2001 From: Bob Dickinson Date: Mon, 14 Sep 2026 13:46:01 -0700 Subject: [PATCH 14/23] docs(mcpi): document protocol era support (--era, sessions/show, tasks/update) Closes the last open item from the mcpi era-support review: add a 'Protocol era support' section to the mcpi README covering the --era connect flag (legacy/auto/modern, overrides catalog/config, only way to set it for an ad-hoc target), the [era] annotation now shown inline in sessions/list, sessions/use, and connect output, sessions/show's fuller era/version/capabilities/supported-versions detail, and how to resume a paused modern task with tasks/update. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- clients/mcpi/README.md | 49 +++++++++++++++++++++++++++++++++++++----- 1 file changed, 44 insertions(+), 5 deletions(-) diff --git a/clients/mcpi/README.md b/clients/mcpi/README.md index a107946dd7..72cf5171ec 100644 --- a/clients/mcpi/README.md +++ b/clients/mcpi/README.md @@ -84,12 +84,51 @@ mcpi tools/list See [`specification/v2_cli_v2.md`](../../specification/v2_cli_v2.md) for the as-built design and to-do list. +## Protocol era support + +mcpi shares `core`'s `InspectorClient`, so it negotiates whichever era +(`legacy` 2025-03-26-style vs. `modern`/2026-era, e.g. task-augmented calls, +`server/discover`) the target actually speaks — no extra flags needed for +that to work. Two things are mcpi-specific: + +- **`--era ` on `connect`**: `legacy` (default), `auto` (probe via + `server/discover` before connecting), or `modern`. Overrides whatever a + catalog/config entry's `protocolEra` says, and is the only way to set it + for an ad-hoc target (no config entry to read one from). + + ```bash + mcpi connect my-modern-server --config path/to/mcp.json --era modern + mcpi connect https://example.com/mcp --era auto + ``` + +- **Era visibility in session output**: `sessions/list`, `sessions/use`, and + `connect` all show the negotiated era inline (`@name (MRU) — server +[modern]`). `sessions/show ` gives the full picture — era, negotiated + protocol version, server info, capabilities, and (when the connect probed + `server/discover`) the server's supported-versions list: + + ``` + $ mcpi sessions/show my-modern-server + Session: my-modern-server + Server: https://example.com/mcp + Era: modern (2026-06-18) + Supported versions: 2025-03-26, 2026-06-18 + ... + ``` + +A paused modern (SEP-2663) task — one whose `tasks/get` shows +`status: "input_required"` — can be resumed with `tasks/update`: + +```bash +mcpi tasks/update --input-responses '{"":{"approved":true}}' +``` + ## 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 | 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). From ac7e4bf1f05dd5b460fb527958e76875fb75a352 Mon Sep 17 00:00:00 2001 From: Bob Dickinson Date: Mon, 14 Sep 2026 15:40:28 -0700 Subject: [PATCH 15/23] mcpi: support URL-mode elicitation (Phase 1) Adds duplex daemon-IPC framing so a pending elicitation on the InspectorClient can pause an in-flight tools/call over the daemon socket, prompt the user in mcpi, and resume the call with the answer. - protocol.ts: new ElicitationRequestFrame/ElicitationResponseFrame - ipc-glue.ts: ElicitationChannel + ConnectionElicitationChannel for per-connection pause/resume duplex exchange - elicitation-bridge.ts (new): wires InspectorClient's newPendingElicitation events to the daemon's ElicitationChannel, skipping task-input-required origin (out of scope for Phase 1) - server.ts: threads the channel through handle/handleOutcome/ dispatch/runRpc, wrapping runMethod() with the bridge - sessions.ts: advertise elicit: { url: true } capability - client.ts (callDaemon): duplex line handling for elicitation request/response frames, with an onElicitation callback - elicitation-prompt.ts (new): terminal UI for URL-mode accept/ cancel; form-mode auto-declines (not yet implemented) and non-interactive callers auto-cancel - dispatch.ts: wires prompting in for interactive text-format sessions only Covers legacy elicitInput({mode:"url"}) and modern non-task MRTR elicitation. Task-augmented MRTR elicitation and form-mode rendering are deferred (see work/mcpi-era-support.md). Manually verified end-to-end against test-servers/configs/ url-elicitation-form.json: accept, cancel, and non-interactive auto-cancel paths all round-trip correctly through the daemon. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- clients/mcpi/__tests__/dispatch.test.ts | 63 ++++ .../mcpi/__tests__/elicitation-bridge.test.ts | 182 +++++++++++ .../mcpi/__tests__/elicitation-client.test.ts | 288 ++++++++++++++++++ .../mcpi/__tests__/elicitation-prompt.test.ts | 144 +++++++++ clients/mcpi/src/daemon/client.ts | 70 ++++- clients/mcpi/src/daemon/elicitation-bridge.ts | 100 ++++++ clients/mcpi/src/daemon/ipc-glue.ts | 84 ++++- clients/mcpi/src/daemon/protocol.ts | 41 +++ clients/mcpi/src/daemon/server.ts | 62 +++- clients/mcpi/src/daemon/sessions.ts | 7 +- clients/mcpi/src/session/dispatch.ts | 13 +- .../mcpi/src/session/elicitation-prompt.ts | 113 +++++++ 12 files changed, 1146 insertions(+), 21 deletions(-) create mode 100644 clients/mcpi/__tests__/elicitation-bridge.test.ts create mode 100644 clients/mcpi/__tests__/elicitation-client.test.ts create mode 100644 clients/mcpi/__tests__/elicitation-prompt.test.ts create mode 100644 clients/mcpi/src/daemon/elicitation-bridge.ts create mode 100644 clients/mcpi/src/session/elicitation-prompt.ts diff --git a/clients/mcpi/__tests__/dispatch.test.ts b/clients/mcpi/__tests__/dispatch.test.ts index 77d1f9c629..1cf1f750b3 100644 --- a/clients/mcpi/__tests__/dispatch.test.ts +++ b/clients/mcpi/__tests__/dispatch.test.ts @@ -3,6 +3,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; const callDaemon = vi.fn(); const ensureDaemon = vi.fn(); const streamDaemon = vi.fn(); +const promptElicitation = vi.fn(); vi.mock("../src/daemon/index.js", () => ({ callDaemon: (...args: unknown[]) => callDaemon(...args), @@ -10,6 +11,10 @@ vi.mock("../src/daemon/index.js", () => ({ streamDaemon: (...args: unknown[]) => streamDaemon(...args), })); +vi.mock("../src/session/elicitation-prompt.js", () => ({ + promptElicitation: (...args: unknown[]) => promptElicitation(...args), +})); + describe("dispatchSessionRpc", () => { let stdout: string; let originalWrite: typeof process.stdout.write; @@ -28,6 +33,7 @@ describe("dispatchSessionRpc", () => { ensureDaemon.mockResolvedValue({ socketPath: "/tmp/t.sock" }); callDaemon.mockReset(); streamDaemon.mockReset(); + promptElicitation.mockReset(); }); afterEach(() => { @@ -100,6 +106,63 @@ describe("dispatchSessionRpc", () => { expect(stdout).toContain("Subscribed:"); expect(streamDaemon).toHaveBeenCalled(); }); + + it("wires onElicitation as interactive when text format + TTY stdin/stdout", async () => { + callDaemon.mockResolvedValue({ kind: "result", result: {} }); + const stdinDesc = Object.getOwnPropertyDescriptor(process.stdin, "isTTY"); + const stdoutDesc = Object.getOwnPropertyDescriptor(process.stdout, "isTTY"); + Object.defineProperty(process.stdin, "isTTY", { + configurable: true, + value: true, + }); + Object.defineProperty(process.stdout, "isTTY", { + configurable: true, + value: true, + }); + try { + const { dispatchSessionRpc } = await import( + "../src/session/dispatch.js" + ); + await dispatchSessionRpc( + "tools/call", + {}, + { format: "text", requireExplicit: false }, + ); + const opts = callDaemon.mock.calls[0][2] as { + onElicitation: (frame: unknown) => unknown; + }; + expect(opts.onElicitation).toBeInstanceOf(Function); + promptElicitation.mockResolvedValue({ action: "cancel" }); + await opts.onElicitation({ id: "x" }); + expect(promptElicitation).toHaveBeenCalledWith( + { id: "x" }, + expect.objectContaining({ interactive: true }), + ); + } finally { + if (stdinDesc) Object.defineProperty(process.stdin, "isTTY", stdinDesc); + if (stdoutDesc) + Object.defineProperty(process.stdout, "isTTY", stdoutDesc); + } + }); + + it("wires onElicitation as non-interactive for --format json", async () => { + callDaemon.mockResolvedValue({ kind: "result", result: {} }); + const { dispatchSessionRpc } = await import("../src/session/dispatch.js"); + await dispatchSessionRpc( + "tools/call", + {}, + { format: "json", requireExplicit: false }, + ); + const opts = callDaemon.mock.calls[0][2] as { + onElicitation: (frame: unknown) => unknown; + }; + promptElicitation.mockResolvedValue({ action: "cancel" }); + await opts.onElicitation({ id: "x" }); + expect(promptElicitation).toHaveBeenCalledWith( + { id: "x" }, + expect.objectContaining({ interactive: false }), + ); + }); }); describe("hoistAtSession / stripAt / requireExplicitSession", () => { diff --git a/clients/mcpi/__tests__/elicitation-bridge.test.ts b/clients/mcpi/__tests__/elicitation-bridge.test.ts new file mode 100644 index 0000000000..a639ea9052 --- /dev/null +++ b/clients/mcpi/__tests__/elicitation-bridge.test.ts @@ -0,0 +1,182 @@ +import { describe, it, expect, vi } from "vitest"; +import { wireElicitationBridge } from "../src/daemon/elicitation-bridge.js"; +import type { ElicitationChannel } from "../src/daemon/ipc-glue.js"; +import type { ElicitationResponseFrame } from "../src/daemon/protocol.js"; +import type { InspectorClient } from "@inspector/core/mcp/inspectorClient.js"; + +/** + * Covers `wireElicitationBridge`'s event routing: origin filtering + * (task-input-required elicitations are left for a future tasks/-based + * command, not answered here), URL vs form mode frame shaping, and the + * channel-failure fallback to `cancel()` (since some construction sites, + * notably legacy URL-mode, never wire a reject callback). + */ +function fakeClient(): { + client: InspectorClient; + emit: (detail: unknown) => void; +} { + const target = new EventTarget(); + const client = { + addEventListener: (type: string, listener: EventListener) => + target.addEventListener(type, listener), + removeEventListener: (type: string, listener: EventListener) => + target.removeEventListener(type, listener), + } as unknown as InspectorClient; + return { + client, + emit: (detail: unknown) => + target.dispatchEvent(new CustomEvent("newPendingElicitation", { detail })), + }; +} + +function fakeMessage(overrides: Partial> = {}) { + return { + id: "elicitation-x", + origin: "server-request", + request: { method: "elicitation/create", params: { message: "hi" } }, + respond: vi.fn().mockResolvedValue(undefined), + cancel: vi.fn(), + reject: vi.fn(), + ...overrides, + }; +} + +describe("wireElicitationBridge", () => { + it("skips task-input-required origin elicitations entirely", () => { + const { client, emit } = fakeClient(); + const channel: ElicitationChannel = { request: vi.fn() }; + const unwire = wireElicitationBridge(client, channel, "req-1"); + const message = fakeMessage({ origin: "task-input-required" }); + emit(message); + expect(channel.request).not.toHaveBeenCalled(); + expect(message.respond).not.toHaveBeenCalled(); + unwire(); + }); + + it("builds a url-mode frame and responds with the channel's answer", async () => { + const { client, emit } = fakeClient(); + const answer: ElicitationResponseFrame = { + id: "req-1", + kind: "elicitation-response", + elicitationId: "elicitation-x", + action: "accept", + }; + const request = vi.fn().mockResolvedValue(answer); + const channel: ElicitationChannel = { request }; + const unwire = wireElicitationBridge(client, channel, "req-1"); + const message = fakeMessage({ + request: { + method: "elicitation/create", + params: { message: "Please visit", url: "https://example.com" }, + }, + }); + emit(message); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + + expect(request).toHaveBeenCalledWith( + expect.objectContaining({ + kind: "elicitation-request", + mode: "url", + url: "https://example.com", + elicitationId: "elicitation-x", + origin: "server-request", + }), + ); + expect(message.respond).toHaveBeenCalledWith({ + action: "accept", + content: undefined, + }); + unwire(); + }); + + it("builds a form-mode frame with requestedSchema", async () => { + const { client, emit } = fakeClient(); + const answer: ElicitationResponseFrame = { + id: "req-1", + kind: "elicitation-response", + elicitationId: "elicitation-x", + action: "decline", + }; + const request = vi.fn().mockResolvedValue(answer); + const channel: ElicitationChannel = { request }; + const unwire = wireElicitationBridge(client, channel, "req-1"); + const message = fakeMessage({ + request: { + method: "elicitation/create", + params: { + message: "Confirm?", + requestedSchema: { type: "object", properties: {} }, + }, + }, + }); + emit(message); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + + expect(request).toHaveBeenCalledWith( + expect.objectContaining({ + mode: "form", + requestedSchema: { type: "object", properties: {} }, + url: undefined, + }), + ); + expect(message.respond).toHaveBeenCalledWith({ + action: "decline", + content: undefined, + }); + unwire(); + }); + + it("cancels the pending elicitation when the channel rejects", async () => { + const { client, emit } = fakeClient(); + const request = vi.fn().mockRejectedValue(new Error("disconnected")); + const channel: ElicitationChannel = { request }; + const unwire = wireElicitationBridge(client, channel, "req-1"); + const message = fakeMessage(); + emit(message); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + + expect(message.cancel).toHaveBeenCalled(); + expect(message.respond).not.toHaveBeenCalled(); + unwire(); + }); + + it("processes multiple elicitations in arrival order (serialized)", async () => { + const { client, emit } = fakeClient(); + const order: string[] = []; + const request = vi.fn().mockImplementation(async (frame) => { + order.push(`start:${frame.elicitationId}`); + await Promise.resolve(); + order.push(`end:${frame.elicitationId}`); + return { + id: frame.id, + kind: "elicitation-response", + elicitationId: frame.elicitationId, + action: "cancel", + } satisfies ElicitationResponseFrame; + }); + const channel: ElicitationChannel = { request }; + const unwire = wireElicitationBridge(client, channel, "req-1"); + emit(fakeMessage({ id: "e1" })); + emit(fakeMessage({ id: "e2" })); + await vi.waitFor(() => { + expect(order).toEqual(["start:e1", "end:e1", "start:e2", "end:e2"]); + }); + + unwire(); + }); + + it("unwire stops the listener from reacting to further events", () => { + const { client, emit } = fakeClient(); + const channel: ElicitationChannel = { request: vi.fn() }; + const unwire = wireElicitationBridge(client, channel, "req-1"); + unwire(); + emit(fakeMessage()); + expect(channel.request).not.toHaveBeenCalled(); + }); +}); diff --git a/clients/mcpi/__tests__/elicitation-client.test.ts b/clients/mcpi/__tests__/elicitation-client.test.ts new file mode 100644 index 0000000000..c39bbd3424 --- /dev/null +++ b/clients/mcpi/__tests__/elicitation-client.test.ts @@ -0,0 +1,288 @@ +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 { callDaemon } from "../src/daemon/client.js"; +import type { + ElicitationRequestFrame, + ElicitationResponseFrame, +} from "../src/daemon/protocol.js"; + +/** + * Covers `callDaemon`'s duplex elicitation handling (dual-era support, phase + * 1): a mid-`rpc` `elicitation-request` frame arriving before the final + * response, answered via `onElicitation` (or auto-cancelled without one), + * with the connect timeout cleared once the exchange starts. + */ +describe("callDaemon elicitation duplex", () => { + 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-elicit-client-")); + 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("routes an elicitation-request frame to onElicitation and writes its answer", async () => { + const sock = freshSock(); + let receivedAnswer: ElicitationResponseFrame | undefined; + await listen(sock, (socket) => { + let buffer = ""; + 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); + if (!line.trim()) continue; + const msg = JSON.parse(line) as { id: string; kind?: string }; + if (msg.kind === "elicitation-response") { + receivedAnswer = msg as ElicitationResponseFrame; + socket.write( + JSON.stringify({ id: msg.id, ok: true, result: { done: true } }) + + "\n", + ); + continue; + } + const frame: ElicitationRequestFrame = { + id: msg.id, + kind: "elicitation-request", + elicitationId: "elicitation-1", + mode: "url", + message: "Please confirm", + url: "https://example.com/confirm", + origin: "server-request", + }; + socket.write(JSON.stringify(frame) + "\n"); + } + }); + }); + + const seenFrames: ElicitationRequestFrame[] = []; + const result = await callDaemon<{ done: boolean }>( + "rpc", + { method: "tools/call" }, + { + socketPath: sock, + timeoutMs: 5000, + onElicitation: async (frame) => { + seenFrames.push(frame); + return { + id: frame.id, + kind: "elicitation-response", + elicitationId: frame.elicitationId, + action: "accept", + }; + }, + }, + ); + + expect(result).toEqual({ done: true }); + expect(seenFrames).toHaveLength(1); + expect(seenFrames[0].mode).toBe("url"); + expect(seenFrames[0].url).toBe("https://example.com/confirm"); + expect(receivedAnswer?.action).toBe("accept"); + expect(receivedAnswer?.elicitationId).toBe("elicitation-1"); + }); + + it("auto-cancels when no onElicitation callback is provided", async () => { + const sock = freshSock(); + let receivedAnswer: ElicitationResponseFrame | undefined; + await listen(sock, (socket) => { + let buffer = ""; + 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); + if (!line.trim()) continue; + const msg = JSON.parse(line) as { id: string; kind?: string }; + if (msg.kind === "elicitation-response") { + receivedAnswer = msg as ElicitationResponseFrame; + socket.write( + JSON.stringify({ id: msg.id, ok: true, result: { done: true } }) + + "\n", + ); + continue; + } + const frame: ElicitationRequestFrame = { + id: msg.id, + kind: "elicitation-request", + elicitationId: "elicitation-2", + mode: "url", + message: "Please confirm", + url: "https://example.com/confirm", + origin: "input-required", + }; + socket.write(JSON.stringify(frame) + "\n"); + } + }); + }); + + const result = await callDaemon<{ done: boolean }>( + "rpc", + { method: "tools/call" }, + { socketPath: sock, timeoutMs: 5000 }, + ); + + expect(result).toEqual({ done: true }); + expect(receivedAnswer?.action).toBe("cancel"); + expect(receivedAnswer?.elicitationId).toBe("elicitation-2"); + }); + + it("ignores an elicitation-request frame whose id doesn't match this call", async () => { + const sock = freshSock(); + await listen(sock, (socket) => { + let buffer = ""; + let answered = false; + 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); + if (!line.trim()) continue; + const msg = JSON.parse(line) as { id: string }; + if (!answered) { + answered = true; + const frame: ElicitationRequestFrame = { + id: "not-this-call", + kind: "elicitation-request", + elicitationId: "elicitation-3", + mode: "url", + message: "stray frame", + url: "https://example.com", + origin: "server-request", + }; + socket.write(JSON.stringify(frame) + "\n"); + socket.write( + JSON.stringify({ id: msg.id, ok: true, result: { done: true } }) + + "\n", + ); + } + } + }); + }); + + const onElicitation = async () => + ({ + id: "n/a", + kind: "elicitation-response", + elicitationId: "n/a", + action: "cancel", + }) satisfies ElicitationResponseFrame; + + const result = await callDaemon<{ done: boolean }>( + "rpc", + { method: "tools/call" }, + { socketPath: sock, timeoutMs: 5000, onElicitation }, + ); + expect(result).toEqual({ done: true }); + }); + + it("fails the call if onElicitation itself throws", async () => { + const sock = freshSock(); + await listen(sock, (socket) => { + let buffer = ""; + 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); + if (!line.trim()) continue; + const msg = JSON.parse(line) as { id: string; kind?: string }; + if (msg.kind === "elicitation-response") continue; + const frame: ElicitationRequestFrame = { + id: msg.id, + kind: "elicitation-request", + elicitationId: "elicitation-4", + mode: "url", + message: "boom", + url: "https://example.com", + origin: "server-request", + }; + socket.write(JSON.stringify(frame) + "\n"); + } + }); + }); + + await expect( + callDaemon<{ done: boolean }>( + "rpc", + { method: "tools/call" }, + { + socketPath: sock, + timeoutMs: 5000, + onElicitation: async () => { + throw new Error("prompt blew up"); + }, + }, + ), + ).rejects.toThrow("prompt blew up"); + }); + + it("silently swallows a post-settle socket error (e.g. late ECONNRESET)", async () => { + const sock = freshSock(); + let serverSocket: net.Socket | undefined; + await listen(sock, (socket) => { + serverSocket = socket; + let buffer = ""; + 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); + if (!line.trim()) continue; + const msg = JSON.parse(line) as { id: string }; + socket.write( + JSON.stringify({ id: msg.id, ok: true, result: { done: true } }) + + "\n", + ); + } + }); + }); + + const result = await callDaemon<{ done: boolean }>( + "rpc", + { method: "tools/call" }, + { socketPath: sock, timeoutMs: 5000 }, + ); + expect(result).toEqual({ done: true }); + // Force a client-side 'error' after the call already settled; the + // no-op listener installed by settle() must swallow it without + // rethrowing or crashing the test. + serverSocket?.destroy(new Error("late reset")); + await new Promise((resolve) => setTimeout(resolve, 50)); + }); +}); diff --git a/clients/mcpi/__tests__/elicitation-prompt.test.ts b/clients/mcpi/__tests__/elicitation-prompt.test.ts new file mode 100644 index 0000000000..ce26dd5ca2 --- /dev/null +++ b/clients/mcpi/__tests__/elicitation-prompt.test.ts @@ -0,0 +1,144 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { createStyle } from "@inspector/cli/style.js"; +import type { ElicitationRequestFrame } from "../src/daemon/protocol.js"; + +const question = vi.fn(); +const close = vi.fn(); + +vi.mock("node:readline/promises", () => ({ + createInterface: () => ({ question, close }), +})); + +/** + * Covers `promptElicitation`'s terminal UI: form mode always declines + * (rendering isn't built yet), non-interactive callers auto-cancel with a + * clear message instead of hanging, and interactive URL mode reads the + * user's accept/cancel choice. + */ +describe("promptElicitation", () => { + let stderr: string; + let originalWrite: typeof process.stderr.write; + + beforeEach(() => { + stderr = ""; + originalWrite = process.stderr.write; + process.stderr.write = ((chunk: unknown, ...rest: unknown[]) => { + stderr += typeof chunk === "string" ? chunk : String(chunk); + const cb = rest.find((r) => typeof r === "function") as + | (() => void) + | undefined; + cb?.(); + return true; + }) as typeof process.stderr.write; + question.mockReset(); + close.mockReset(); + }); + + afterEach(() => { + process.stderr.write = originalWrite; + }); + + const style = createStyle(false); + + function urlFrame( + overrides: Partial = {}, + ): ElicitationRequestFrame { + return { + id: "req-1", + kind: "elicitation-request", + elicitationId: "elicitation-1", + mode: "url", + message: "Please confirm", + url: "https://example.com/confirm", + origin: "server-request", + ...overrides, + }; + } + + it("declines form-mode elicitations without prompting (not yet rendered)", async () => { + const { promptElicitation } = await import( + "../src/session/elicitation-prompt.js" + ); + const frame = urlFrame({ mode: "form", url: undefined }); + const answer = await promptElicitation(frame, { interactive: true, style }); + expect(answer).toEqual({ + id: "req-1", + kind: "elicitation-response", + elicitationId: "elicitation-1", + action: "decline", + }); + expect(question).not.toHaveBeenCalled(); + expect(stderr).toContain("doesn't support"); + }); + + it("cancels non-interactively (e.g. --format json or non-TTY) without prompting", async () => { + const { promptElicitation } = await import( + "../src/session/elicitation-prompt.js" + ); + const frame = urlFrame(); + const answer = await promptElicitation(frame, { + interactive: false, + style, + }); + expect(answer).toEqual({ + id: "req-1", + kind: "elicitation-response", + elicitationId: "elicitation-1", + action: "cancel", + }); + expect(question).not.toHaveBeenCalled(); + expect(stderr).toContain("requires an interactive terminal"); + }); + + it("cancels non-interactively without a url line when the frame has none", async () => { + const { promptElicitation } = await import( + "../src/session/elicitation-prompt.js" + ); + const frame = urlFrame({ url: undefined }); + const answer = await promptElicitation(frame, { + interactive: false, + style, + }); + expect(answer.action).toBe("cancel"); + expect(stderr).not.toContain("undefined"); + }); + + it("accepts when the interactive user confirms completion", async () => { + question.mockResolvedValue(""); + const { promptElicitation } = await import( + "../src/session/elicitation-prompt.js" + ); + const frame = urlFrame(); + const answer = await promptElicitation(frame, { interactive: true, style }); + expect(answer).toEqual({ + id: "req-1", + kind: "elicitation-response", + elicitationId: "elicitation-1", + action: "accept", + }); + expect(close).toHaveBeenCalled(); + expect(stderr).toContain("Please confirm"); + expect(stderr).toContain("https://example.com/confirm"); + }); + + it("cancels when the interactive user types 'c'", async () => { + question.mockResolvedValue("c"); + const { promptElicitation } = await import( + "../src/session/elicitation-prompt.js" + ); + const frame = urlFrame(); + const answer = await promptElicitation(frame, { interactive: true, style }); + expect(answer.action).toBe("cancel"); + }); + + it("falls back to cancel if reading input throws", async () => { + question.mockRejectedValue(new Error("stdin closed")); + const { promptElicitation } = await import( + "../src/session/elicitation-prompt.js" + ); + const frame = urlFrame(); + const answer = await promptElicitation(frame, { interactive: true, style }); + expect(answer.action).toBe("cancel"); + expect(close).toHaveBeenCalled(); + }); +}); diff --git a/clients/mcpi/src/daemon/client.ts b/clients/mcpi/src/daemon/client.ts index 2932370faa..aa8f560c6c 100644 --- a/clients/mcpi/src/daemon/client.ts +++ b/clients/mcpi/src/daemon/client.ts @@ -4,7 +4,13 @@ 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"; +import type { + DaemonOp, + DaemonRequest, + DaemonResponse, + ElicitationRequestFrame, + ElicitationResponseFrame, +} from "./protocol.js"; export type DaemonClientOptions = { socketPath?: string; @@ -12,6 +18,17 @@ export type DaemonClientOptions = { timeoutMs?: number; /** IPC token; defaults to `MCP_INSPECTOR_DAEMON_TOKEN` when set. */ token?: string; + /** + * Called when the in-flight `rpc` call surfaces a legacy or modern + * non-task MRTR elicitation mid-call (dual-era support, phase 1). Omit to + * auto-answer `{action: "cancel"}` — appropriate for non-interactive + * callers (e.g. `--format json`, non-TTY) that shouldn't hang waiting on a + * human. The connect timeout is cleared once the first such frame arrives, + * so an interactive prompt isn't bounded by the original request timeout. + */ + onElicitation?: ( + frame: ElicitationRequestFrame, + ) => Promise; }; /** @@ -32,9 +49,9 @@ export async function callDaemon( return new Promise((resolve, reject) => { let settled = false; let buffer = ""; + let queue: Promise = Promise.resolve(); // `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(); @@ -62,16 +79,51 @@ export async function callDaemon( }); } - function handleLine(line: string) { + function handleLine(line: string): Promise { const trimmed = line.trim(); - if (!trimmed) return; - let response: DaemonResponse; + if (!trimmed) return Promise.resolve(); + let parsed: DaemonResponse | ElicitationRequestFrame; try { - response = JSON.parse(trimmed) as DaemonResponse; + parsed = JSON.parse(trimmed) as DaemonResponse | ElicitationRequestFrame; } catch (error) { fail(error); - return; + return Promise.resolve(); + } + if ( + parsed !== null && + typeof parsed === "object" && + "kind" in parsed && + parsed.kind === "elicitation-request" + ) { + return handleElicitationRequest(parsed as ElicitationRequestFrame); } + handleResponse(parsed as DaemonResponse); + return Promise.resolve(); + } + + async function handleElicitationRequest( + frame: ElicitationRequestFrame, + ): Promise { + if (frame.id !== id) return; + // A human (or a multi-round MRTR exchange) answering this shouldn't be + // bounded by the original fixed request timeout. + if (timer !== undefined) { + clearTimeout(timer); + timer = undefined; + } + const answer = options.onElicitation + ? await options.onElicitation(frame) + : ({ + id: frame.id, + kind: "elicitation-response", + elicitationId: frame.elicitationId, + action: "cancel", + } satisfies ElicitationResponseFrame); + if (settled || socket.destroyed) return; + socket.write(JSON.stringify(answer) + "\n"); + } + + function handleResponse(response: DaemonResponse) { if (response.id !== id && response.id !== "?") { return; } @@ -132,7 +184,9 @@ export async function callDaemon( while ((idx = buffer.indexOf("\n")) >= 0) { const line = buffer.slice(0, idx); buffer = buffer.slice(idx + 1); - handleLine(line); + // Sequential so an awaited onElicitation prompt fully settles (and + // its answer is written) before the next buffered line is handled. + queue = queue.then(() => handleLine(line)).catch((error) => fail(error)); } }); diff --git a/clients/mcpi/src/daemon/elicitation-bridge.ts b/clients/mcpi/src/daemon/elicitation-bridge.ts new file mode 100644 index 0000000000..6731bc1b66 --- /dev/null +++ b/clients/mcpi/src/daemon/elicitation-bridge.ts @@ -0,0 +1,100 @@ +/** + * Bridges `InspectorClient`'s `newPendingElicitation` events to a mid-`rpc` + * duplex exchange with the CLI, for legacy and modern non-task MRTR + * elicitations (dual-era support, phase 1). Task-augmented MRTR elicitation + * (SEP-2663 `origin: "task-input-required"`) is out of scope here — those + * calls already return immediately, so they never need this bridge to keep a + * blocking `rpc` call alive; they'll get their own `tasks/get`-driven + * discoverability + answer commands in a follow-up phase. + */ +import type { InspectorClient } from "@inspector/core/mcp/inspectorClient.js"; +import type { ElicitationCreateMessage } from "@inspector/core/mcp/elicitationCreateMessage.js"; +import type { TypedEventGeneric } from "@inspector/core/mcp/typedEventTarget.js"; +import type { InspectorClientEventMap } from "@inspector/core/mcp/inspectorClientEventTarget.js"; +import type { ElicitationChannel } from "./ipc-glue.js"; +import type { ElicitationRequestFrame } from "./protocol.js"; + +/** + * Wires `client`'s pending-elicitation events to `channel` for the duration + * of one in-flight call. Returns a cleanup function that must be called + * (typically in a `finally`) once the call settles, so the listener doesn't + * outlive the request. + * + * Core resolves elicitations sequentially — never more than one pending at a + * time (see `inspectorClient.ts`'s `fulfilInputRequests` and + * `requestWithInputRequired`'s retry loop) — but a single call can pause and + * resume through several of these in turn across MRTR rounds. The `queue` + * here is a defensive belt-and-suspenders in case that guarantee ever + * changes; each event is still handled one at a time, in arrival order. + */ +export function wireElicitationBridge( + client: InspectorClient, + channel: ElicitationChannel, + requestId: string, +): () => void { + let queue: Promise = Promise.resolve(); + + const onNewPendingElicitation = ( + event: TypedEventGeneric, + ) => { + const message = event.detail; + if (message.origin === "task-input-required") { + // Task-augmented — the originating call already returned; nothing here + // is awaiting this elicitation, so leave it pending for a future + // tasks/-based command to answer. + return; + } + queue = queue.then(() => handleOne(channel, requestId, message)); + }; + + client.addEventListener("newPendingElicitation", onNewPendingElicitation); + + return () => { + client.removeEventListener( + "newPendingElicitation", + onNewPendingElicitation, + ); + }; +} + +async function handleOne( + channel: ElicitationChannel, + requestId: string, + message: ElicitationCreateMessage, +): Promise { + const params = message.request.params; + const isUrlMode = params != null && "url" in params; + const frame: ElicitationRequestFrame = { + id: requestId, + kind: "elicitation-request", + elicitationId: message.id, + mode: isUrlMode ? "url" : "form", + message: params?.message ?? "", + requestedSchema: isUrlMode + ? undefined + : (params as { requestedSchema?: Record }) + .requestedSchema, + url: isUrlMode ? (params as { url?: string }).url : undefined, + origin: message.origin, + }; + + try { + const answer = await channel.request(frame); + // Defensive: if the answer's elicitationId somehow doesn't match what we + // asked for, proceed with it anyway (single connection, single pending + // exchange at a time — this should never happen in practice) rather than + // hang the call. + await message.respond({ + action: answer.action, + content: answer.content as + | { [x: string]: string | number | boolean | string[] } + | undefined, + }); + } catch { + // Channel failure (e.g. CLI disconnected mid-prompt). `cancel()` settles + // the pending elicitation regardless of origin/mode — some construction + // sites (notably legacy URL-mode's `awaitUrlElicitation`) never wire a + // reject callback, so `reject()` alone would leave the call hanging. + message.cancel(); + } +} diff --git a/clients/mcpi/src/daemon/ipc-glue.ts b/clients/mcpi/src/daemon/ipc-glue.ts index 64a90c4298..17e21afa93 100644 --- a/clients/mcpi/src/daemon/ipc-glue.ts +++ b/clients/mcpi/src/daemon/ipc-glue.ts @@ -12,6 +12,8 @@ import type { DaemonRequest, DaemonResponse, DaemonStreamFrame, + ElicitationRequestFrame, + ElicitationResponseFrame, } from "./protocol.js"; export type StreamStarter = (writeData: (data: unknown) => void) => () => void; @@ -23,15 +25,93 @@ export type HandleOutcome = { startStream?: StreamStarter; }; -export type HandleRequest = (request: DaemonRequest) => Promise; +/** + * Bridges a single in-flight `rpc` call to its owning connection so it can + * pause mid-call for a legacy/modern-non-task elicitation, and resume once + * the CLI answers. See `ElicitationRequestFrame`'s doc comment in + * `protocol.ts` for why one exchange (repeatable) is all a single connection + * ever needs. + */ +export type ElicitationChannel = { + request(frame: ElicitationRequestFrame): Promise; +}; + +export type HandleRequest = ( + request: DaemonRequest, + elicitation: ElicitationChannel, +) => Promise; + +/** + * Per-connection {@link ElicitationChannel}. Writes an elicitation-request + * frame straight onto the socket (ahead of the eventual `DaemonResponse`) and + * waits for the next line to answer it; `acceptDaemonConnection`'s line + * handler gives that next line to {@link tryConsumeLine} instead of parsing + * it as a new top-level request. Rejects any pending exchange if the socket + * disconnects, so a dropped client can't hang the daemon-side call forever. + */ +class ConnectionElicitationChannel implements ElicitationChannel { + private pending: { + resolve: (frame: ElicitationResponseFrame) => void; + reject: (error: Error) => void; + } | null = null; + + constructor(private readonly socket: net.Socket) { + const onDisconnect = () => this.rejectPending("Connection closed"); + socket.once("close", onDisconnect); + socket.once("error", onDisconnect); + } + + request(frame: ElicitationRequestFrame): Promise { + if (this.pending) { + return Promise.reject( + new Error( + "Another elicitation is already pending on this connection", + ), + ); + } + return new Promise((resolve, reject) => { + this.pending = { resolve, reject }; + if (this.socket.destroyed) { + this.rejectPending("Connection closed"); + return; + } + this.socket.write(JSON.stringify(frame) + "\n"); + }); + } + + /** Returns true if this line was consumed as a pending elicitation answer. */ + tryConsumeLine(line: string): boolean { + if (!this.pending) return false; + let parsed: ElicitationResponseFrame; + try { + parsed = JSON.parse(line); + } catch { + return false; + } + if (!parsed || parsed.kind !== "elicitation-response") return false; + const { resolve } = this.pending; + this.pending = null; + resolve(parsed); + return true; + } + + private rejectPending(message: string): void { + if (!this.pending) return; + const { reject } = this.pending; + this.pending = null; + reject(new Error(message)); + } +} export function acceptDaemonConnection( socket: net.Socket, handle: HandleRequest, ): void { const rl = createInterface({ input: socket, crlfDelay: Infinity }); + const elicitationChannel = new ConnectionElicitationChannel(socket); rl.on("line", (line) => { void (async () => { + if (elicitationChannel.tryConsumeLine(line)) return; let request: DaemonRequest; try { const parsed = parseRequestLine(line); @@ -50,7 +130,7 @@ export function acceptDaemonConnection( ); return; } - const outcome = await handle(request); + const outcome = await handle(request, elicitationChannel); if (socket.destroyed) return; socket.write(encodeResponse(outcome.response)); diff --git a/clients/mcpi/src/daemon/protocol.ts b/clients/mcpi/src/daemon/protocol.ts index 5a589ed10c..8ae7dd0743 100644 --- a/clients/mcpi/src/daemon/protocol.ts +++ b/clients/mcpi/src/daemon/protocol.ts @@ -1,6 +1,7 @@ import type { InspectorServerSettings, MCPServerConfig, + PendingRequestOrigin, } from "@inspector/core/mcp/types.js"; import type { CliAppInfo, @@ -81,6 +82,46 @@ export type DaemonStreamFrame = | { id: string; stream: "data"; data: unknown } | { id: string; stream: "end" }; +/** + * One elicitation request/answer exchange, carried mid-`rpc` call when the + * in-flight tool/prompt/resource call surfaces a legacy or modern non-task + * MRTR elicitation (dual-era support, phase 1 — task-augmented MRTR + * elicitation is a separate follow-up, since that call already returns + * immediately and never blocks a `rpc` round-trip in the first place). + * + * Written by the daemon onto the SAME connection as the originating `rpc` + * request, before its `DaemonResponse`; the CLI answers on that same + * connection with an {@link ElicitationResponseFrame}, and the daemon resumes + * the (still in-flight) call. See `ipc-glue.ts`'s `acceptDaemonConnection` for + * why this needs no new channel: each `rpc` request already owns its + * connection exclusively, and core itself never has more than one elicitation + * pending at a time (sequential by design) — though a single call can + * pause/resume through several of these exchanges before its final response. + */ +export type ElicitationRequestFrame = { + id: string; + kind: "elicitation-request"; + /** `ElicitationCreateMessage.id` — echoed back so the answer can be matched. */ + elicitationId: string; + mode: "form" | "url"; + message: string; + /** Form mode only. */ + requestedSchema?: Record; + /** URL mode only. */ + url?: string; + /** Legacy server→client request vs. modern non-task MRTR round. */ + origin: PendingRequestOrigin; +}; + +export type ElicitationResponseFrame = { + id: string; + kind: "elicitation-response"; + elicitationId: string; + action: "accept" | "decline" | "cancel"; + /** Form mode `action: "accept"` only. */ + content?: Record; +}; + export type SessionInfo = { name: string; serverIdentity: string; diff --git a/clients/mcpi/src/daemon/server.ts b/clients/mcpi/src/daemon/server.ts index 69d5bbc91b..0a54e6e989 100644 --- a/clients/mcpi/src/daemon/server.ts +++ b/clients/mcpi/src/daemon/server.ts @@ -10,8 +10,10 @@ import type { MethodArgs } from "@inspector/cli/handlers/method-types.js"; import { acceptDaemonConnection, removeStaleDaemonSocket, + type ElicitationChannel, type HandleOutcome, } from "./ipc-glue.js"; +import { wireElicitationBridge } from "./elicitation-bridge.js"; import { assertDaemonToken, getDaemonTokenFromEnv } from "./auth.js"; import { ensureDaemonDir, @@ -31,6 +33,23 @@ import type { } from "./protocol.js"; import { DEFAULT_IDLE_MS, SessionRegistry } from "./sessions.js"; +/** + * Default channel used when a caller doesn't wire a real one (in-process + * `handle`/`handleOutcome` test call sites that predate elicitation support). + * Immediately cancels any elicitation, matching `elicit: false` behavior — + * these callers never advertise elicitation support to the server anyway. + */ +const autoCancelElicitationChannel: ElicitationChannel = { + request(frame) { + return Promise.resolve({ + id: frame.id, + kind: "elicitation-response", + elicitationId: frame.elicitationId, + action: "cancel", + }); + }, +}; + export type DaemonServerOptions = { dir?: string; idleMs?: number; @@ -74,7 +93,9 @@ export class DaemonServer { this.writeLock(); this.server = net.createServer((socket) => { - acceptDaemonConnection(socket, (req) => this.handleOutcome(req)); + acceptDaemonConnection(socket, (req, elicitation) => + this.handleOutcome(req, elicitation), + ); }); await new Promise((resolve, reject) => { @@ -126,15 +147,21 @@ export class DaemonServer { } /** Handle one request; returns the response body (used by in-process tests). */ - async handle(request: DaemonRequest): Promise { - return (await this.handleOutcome(request)).response; + async handle( + request: DaemonRequest, + elicitation: ElicitationChannel = autoCancelElicitationChannel, + ): Promise { + return (await this.handleOutcome(request, elicitation)).response; } /** Full handle including optional stream starter (socket accept path). */ - async handleOutcome(request: DaemonRequest): Promise { + async handleOutcome( + request: DaemonRequest, + elicitation: ElicitationChannel = autoCancelElicitationChannel, + ): Promise { try { assertDaemonToken(this.requiredToken, request.token); - return await this.dispatch(request); + return await this.dispatch(request, elicitation); } catch (error) { if (error instanceof CliExitCodeError) { return { @@ -165,7 +192,10 @@ export class DaemonServer { } } - private async dispatch(request: DaemonRequest): Promise { + private async dispatch( + request: DaemonRequest, + elicitation: ElicitationChannel, + ): Promise { switch (request.op) { case "ping": return { @@ -270,7 +300,11 @@ export class DaemonServer { response: { id: request.id, ok: true, - result: await this.runRpc(request.params as RpcParams), + result: await this.runRpc( + request.id, + request.params as RpcParams, + elicitation, + ), }, }; case "stream": @@ -284,7 +318,11 @@ export class DaemonServer { } } - private async runRpc(params: RpcParams): Promise { + private async runRpc( + requestId: string, + params: RpcParams, + elicitation: ElicitationChannel, + ): Promise { if (!params?.method) { throw new CliExitCodeError(EXIT_CODES.USAGE, "rpc requires a method", { code: "invalid_params", @@ -292,7 +330,13 @@ export class DaemonServer { } const client = this.registry.clientFor(params.name, params.requireExplicit); const methodArgs = stripSessionFields(params); - const outcome = await runMethod(client, methodArgs); + const unwire = wireElicitationBridge(client, elicitation, requestId); + let outcome; + try { + outcome = await runMethod(client, methodArgs); + } finally { + unwire(); + } if (outcome.kind === "stream") { throw new CliExitCodeError( EXIT_CODES.USAGE, diff --git a/clients/mcpi/src/daemon/sessions.ts b/clients/mcpi/src/daemon/sessions.ts index b982b15c53..245a8d017e 100644 --- a/clients/mcpi/src/daemon/sessions.ts +++ b/clients/mcpi/src/daemon/sessions.ts @@ -361,7 +361,12 @@ async function createSessionClient( initialLoggingLevel: "debug", progress: false, sample: false, - elicit: false, + // Phase 1 of dual-era elicitation support: advertise URL-mode only. Form + // mode isn't rendered yet (that's a follow-up phase), so it stays + // unadvertised here — a form-mode elicitation arriving anyway (a server + // ignoring our capabilities) is defensively auto-declined by the daemon's + // elicitation prompt. + elicit: { url: true }, serverSettings, ...(serverSettings?.protocolEra && { versionNegotiation: eraToVersionNegotiation(serverSettings.protocolEra), diff --git a/clients/mcpi/src/session/dispatch.ts b/clients/mcpi/src/session/dispatch.ts index 0ef022c519..7de50f791c 100644 --- a/clients/mcpi/src/session/dispatch.ts +++ b/clients/mcpi/src/session/dispatch.ts @@ -7,6 +7,7 @@ import type { import type { OutputFormat } from "@inspector/cli/handlers/format-output.js"; import { writeSessionOutput } from "./format-session.js"; import { styleFromOpts } from "@inspector/cli/style.js"; +import { promptElicitation } from "./elicitation-prompt.js"; const STREAM_METHODS = new Set(["logging/tail", "resources/subscribe"]); @@ -71,7 +72,17 @@ export async function dispatchSessionRpc( return; } - const outcome = await callDaemon("rpc", params, { socketPath }); + const outcome = await callDaemon("rpc", params, { + socketPath, + onElicitation: (frame) => + promptElicitation(frame, { + style, + interactive: + format === "text" && + process.stdin.isTTY === true && + process.stdout.isTTY === true, + }), + }); if (outcome.kind === "ndjson") { await writeSessionOutput( { format, style }, diff --git a/clients/mcpi/src/session/elicitation-prompt.ts b/clients/mcpi/src/session/elicitation-prompt.ts new file mode 100644 index 0000000000..ebf1564fe6 --- /dev/null +++ b/clients/mcpi/src/session/elicitation-prompt.ts @@ -0,0 +1,113 @@ +/** + * Terminal UI for a mid-`rpc` elicitation exchange (dual-era support, phase + * 1 — legacy + modern non-task MRTR, URL mode only; form-mode rendering is a + * follow-up phase). Mirrors the web client's URL-mode convention + * (`InlineElicitationRequest`/`PendingClientRequestModal`): the actual + * out-of-band completion can't be observed here, so the user self-reports it + * by answering a confirm prompt — there is no "decline" for URL mode, only + * accept (they say they finished) or cancel. + */ +import { createInterface } from "node:readline/promises"; +import type { Style } from "@inspector/cli/style.js"; +import type { + ElicitationRequestFrame, + ElicitationResponseFrame, +} from "../daemon/protocol.js"; + +export type PromptElicitationOpts = { + /** False for non-interactive callers (e.g. `--format json`, non-TTY). */ + interactive: boolean; + style: Style; +}; + +function cancelResponse( + frame: ElicitationRequestFrame, +): ElicitationResponseFrame { + return { + id: frame.id, + kind: "elicitation-response", + elicitationId: frame.elicitationId, + action: "cancel", + }; +} + +function declineResponse( + frame: ElicitationRequestFrame, +): ElicitationResponseFrame { + return { + id: frame.id, + kind: "elicitation-response", + elicitationId: frame.elicitationId, + action: "decline", + }; +} + +/** + * Prompt the user for one elicitation exchange and return their answer. + * Never throws — falls back to cancel/decline on any failure to read input, + * so a mid-prompt problem always unblocks the daemon-side call. + */ +export async function promptElicitation( + frame: ElicitationRequestFrame, + opts: PromptElicitationOpts, +): Promise { + const { style } = opts; + + if (frame.mode === "form") { + // Form rendering isn't built yet (a follow-up phase); decline clearly + // rather than silently guessing at field values or hanging. + process.stderr.write( + style.yellow( + "This server is asking for form input, which mcpi doesn't support " + + "yet — declining.\n", + ) + ` ${frame.message}\n`, + ); + return declineResponse(frame); + } + + if (!opts.interactive) { + process.stderr.write( + style.yellow( + "This server is asking for input via a URL (elicitation), which " + + "requires an interactive terminal — cancelling.\n", + ) + + ` ${frame.message}\n` + + (frame.url ? ` ${frame.url}\n` : ""), + ); + return cancelResponse(frame); + } + + process.stderr.write( + "\n" + + style.bold("Action required: ") + + frame.message + + "\n" + + " " + + style.link(frame.url ?? "", frame.url) + + "\n\n", + ); + + const rl = createInterface({ + input: process.stdin, + output: process.stderr, + }); + try { + const answer = await rl.question( + "Open the URL above, complete it, then press Enter to continue " + + "(or type 'c' to cancel): ", + ); + if (answer.trim().toLowerCase() === "c") { + return cancelResponse(frame); + } + return { + id: frame.id, + kind: "elicitation-response", + elicitationId: frame.elicitationId, + action: "accept", + }; + } catch { + return cancelResponse(frame); + } finally { + rl.close(); + } +} From 258d789f0693253fe3b912997289a3c7a5bf8ab1 Mon Sep 17 00:00:00 2001 From: Bob Dickinson Date: Mon, 14 Sep 2026 15:53:42 -0700 Subject: [PATCH 16/23] mcpi: support form-mode elicitation (Phase 3) Adds terminal rendering for form-mode elicitation, building on the Phase 1 duplex daemon-IPC framing. - session/form-schema.ts (new): parses a requestedSchema into an ordered list of typed fields, per the MRTR elicitation spec's restricted primitive-field shape (string/number/integer/boolean, single-select enum via enum or titled oneOf, multi-select enum via array + items.enum/items.anyOf). Returns null for anything outside that shape so callers can fall back to a clear decline. - session/form-prompt.ts (new): prompts once per field (text/numeric/ y-n/numbered single-select/numbered comma-separated multi-select), pre-fills defaults, validates required/length/range/min-max-items with a retry loop, then a review step (submit / re-edit a field by name / cancel) before returning the answer. - session/elicitation-prompt.ts: form-mode branch now renders via form-schema/form-prompt instead of always declining; still declines when non-interactive or when the schema doesn't parse. - daemon/sessions.ts: advertise elicit: { url: true, form: true }. Also fixes prettier formatting on the Phase 1 files that hadn't been run through npm run format:check before pushing (client.ts, ipc-glue.ts, and a few test files) - this is what broke the build check on ac7e4bf1. Manually verified end-to-end against test-servers/configs/ modern-mrtr-http.json (boolean field, mrtr_confirm) and mrtr-showcase-http.json (string field, multi-round MRTR, mrtr_two_step): accept, cancel-at-review, edit-a-field-at-review, and non-interactive auto-decline all round-trip correctly through the daemon. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- clients/mcpi/__tests__/dispatch.test.ts | 4 +- .../mcpi/__tests__/elicitation-bridge.test.ts | 4 +- .../mcpi/__tests__/elicitation-prompt.test.ts | 129 +++++- clients/mcpi/__tests__/form-prompt.test.ts | 377 ++++++++++++++++++ clients/mcpi/__tests__/form-schema.test.ts | 284 +++++++++++++ clients/mcpi/src/daemon/client.ts | 8 +- clients/mcpi/src/daemon/ipc-glue.ts | 4 +- clients/mcpi/src/daemon/sessions.ts | 2 +- .../mcpi/src/session/elicitation-prompt.ts | 77 +++- clients/mcpi/src/session/form-prompt.ts | 216 ++++++++++ clients/mcpi/src/session/form-schema.ts | 176 ++++++++ 11 files changed, 1236 insertions(+), 45 deletions(-) create mode 100644 clients/mcpi/__tests__/form-prompt.test.ts create mode 100644 clients/mcpi/__tests__/form-schema.test.ts create mode 100644 clients/mcpi/src/session/form-prompt.ts create mode 100644 clients/mcpi/src/session/form-schema.ts diff --git a/clients/mcpi/__tests__/dispatch.test.ts b/clients/mcpi/__tests__/dispatch.test.ts index 1cf1f750b3..4df91816a2 100644 --- a/clients/mcpi/__tests__/dispatch.test.ts +++ b/clients/mcpi/__tests__/dispatch.test.ts @@ -120,9 +120,7 @@ describe("dispatchSessionRpc", () => { value: true, }); try { - const { dispatchSessionRpc } = await import( - "../src/session/dispatch.js" - ); + const { dispatchSessionRpc } = await import("../src/session/dispatch.js"); await dispatchSessionRpc( "tools/call", {}, diff --git a/clients/mcpi/__tests__/elicitation-bridge.test.ts b/clients/mcpi/__tests__/elicitation-bridge.test.ts index a639ea9052..0e9b5ef4a9 100644 --- a/clients/mcpi/__tests__/elicitation-bridge.test.ts +++ b/clients/mcpi/__tests__/elicitation-bridge.test.ts @@ -25,7 +25,9 @@ function fakeClient(): { return { client, emit: (detail: unknown) => - target.dispatchEvent(new CustomEvent("newPendingElicitation", { detail })), + target.dispatchEvent( + new CustomEvent("newPendingElicitation", { detail }), + ), }; } diff --git a/clients/mcpi/__tests__/elicitation-prompt.test.ts b/clients/mcpi/__tests__/elicitation-prompt.test.ts index ce26dd5ca2..17f0f164ad 100644 --- a/clients/mcpi/__tests__/elicitation-prompt.test.ts +++ b/clients/mcpi/__tests__/elicitation-prompt.test.ts @@ -4,11 +4,16 @@ import type { ElicitationRequestFrame } from "../src/daemon/protocol.js"; const question = vi.fn(); const close = vi.fn(); +const promptFormMock = vi.fn(); vi.mock("node:readline/promises", () => ({ createInterface: () => ({ question, close }), })); +vi.mock("../src/session/form-prompt.js", () => ({ + promptForm: (...args: unknown[]) => promptFormMock(...args), +})); + /** * Covers `promptElicitation`'s terminal UI: form mode always declines * (rendering isn't built yet), non-interactive callers auto-cancel with a @@ -32,6 +37,7 @@ describe("promptElicitation", () => { }) as typeof process.stderr.write; question.mockReset(); close.mockReset(); + promptFormMock.mockReset(); }); afterEach(() => { @@ -55,10 +61,28 @@ describe("promptElicitation", () => { }; } - it("declines form-mode elicitations without prompting (not yet rendered)", async () => { - const { promptElicitation } = await import( - "../src/session/elicitation-prompt.js" - ); + function formFrame( + overrides: Partial = {}, + ): ElicitationRequestFrame { + return { + id: "req-1", + kind: "elicitation-request", + elicitationId: "elicitation-1", + mode: "form", + message: "Please provide your name", + requestedSchema: { + type: "object", + properties: { name: { type: "string" } }, + required: ["name"], + }, + origin: "server-request", + ...overrides, + }; + } + + it("declines form-mode elicitations whose schema isn't the restricted primitive shape", async () => { + const { promptElicitation } = + await import("../src/session/elicitation-prompt.js"); const frame = urlFrame({ mode: "form", url: undefined }); const answer = await promptElicitation(frame, { interactive: true, style }); expect(answer).toEqual({ @@ -71,10 +95,34 @@ describe("promptElicitation", () => { expect(stderr).toContain("doesn't support"); }); + it("declines form-mode elicitations non-interactively without prompting", async () => { + const { promptElicitation } = + await import("../src/session/elicitation-prompt.js"); + const frame = urlFrame({ + mode: "form", + url: undefined, + requestedSchema: { + type: "object", + properties: { name: { type: "string" } }, + }, + }); + const answer = await promptElicitation(frame, { + interactive: false, + style, + }); + expect(answer).toEqual({ + id: "req-1", + kind: "elicitation-response", + elicitationId: "elicitation-1", + action: "decline", + }); + expect(question).not.toHaveBeenCalled(); + expect(stderr).toContain("requires an interactive terminal"); + }); + it("cancels non-interactively (e.g. --format json or non-TTY) without prompting", async () => { - const { promptElicitation } = await import( - "../src/session/elicitation-prompt.js" - ); + const { promptElicitation } = + await import("../src/session/elicitation-prompt.js"); const frame = urlFrame(); const answer = await promptElicitation(frame, { interactive: false, @@ -91,9 +139,8 @@ describe("promptElicitation", () => { }); it("cancels non-interactively without a url line when the frame has none", async () => { - const { promptElicitation } = await import( - "../src/session/elicitation-prompt.js" - ); + const { promptElicitation } = + await import("../src/session/elicitation-prompt.js"); const frame = urlFrame({ url: undefined }); const answer = await promptElicitation(frame, { interactive: false, @@ -105,9 +152,8 @@ describe("promptElicitation", () => { it("accepts when the interactive user confirms completion", async () => { question.mockResolvedValue(""); - const { promptElicitation } = await import( - "../src/session/elicitation-prompt.js" - ); + const { promptElicitation } = + await import("../src/session/elicitation-prompt.js"); const frame = urlFrame(); const answer = await promptElicitation(frame, { interactive: true, style }); expect(answer).toEqual({ @@ -123,9 +169,8 @@ describe("promptElicitation", () => { it("cancels when the interactive user types 'c'", async () => { question.mockResolvedValue("c"); - const { promptElicitation } = await import( - "../src/session/elicitation-prompt.js" - ); + const { promptElicitation } = + await import("../src/session/elicitation-prompt.js"); const frame = urlFrame(); const answer = await promptElicitation(frame, { interactive: true, style }); expect(answer.action).toBe("cancel"); @@ -133,12 +178,58 @@ describe("promptElicitation", () => { it("falls back to cancel if reading input throws", async () => { question.mockRejectedValue(new Error("stdin closed")); - const { promptElicitation } = await import( - "../src/session/elicitation-prompt.js" - ); + const { promptElicitation } = + await import("../src/session/elicitation-prompt.js"); const frame = urlFrame(); const answer = await promptElicitation(frame, { interactive: true, style }); expect(answer.action).toBe("cancel"); expect(close).toHaveBeenCalled(); }); + + it("accepts an interactive form submission and returns its content", async () => { + promptFormMock.mockResolvedValue({ + action: "accept", + content: { name: "octocat" }, + }); + const { promptElicitation } = + await import("../src/session/elicitation-prompt.js"); + const frame = formFrame(); + const answer = await promptElicitation(frame, { interactive: true, style }); + expect(answer).toEqual({ + id: "req-1", + kind: "elicitation-response", + elicitationId: "elicitation-1", + action: "accept", + content: { name: "octocat" }, + }); + expect(close).toHaveBeenCalled(); + }); + + it("declines an interactive form when promptForm reports decline", async () => { + promptFormMock.mockResolvedValue({ action: "decline" }); + const { promptElicitation } = + await import("../src/session/elicitation-prompt.js"); + const frame = formFrame(); + const answer = await promptElicitation(frame, { interactive: true, style }); + expect(answer.action).toBe("decline"); + }); + + it("cancels an interactive form when promptForm reports cancel", async () => { + promptFormMock.mockResolvedValue({ action: "cancel" }); + const { promptElicitation } = + await import("../src/session/elicitation-prompt.js"); + const frame = formFrame(); + const answer = await promptElicitation(frame, { interactive: true, style }); + expect(answer.action).toBe("cancel"); + }); + + it("falls back to cancel if promptForm throws", async () => { + promptFormMock.mockRejectedValue(new Error("stdin closed")); + const { promptElicitation } = + await import("../src/session/elicitation-prompt.js"); + const frame = formFrame(); + const answer = await promptElicitation(frame, { interactive: true, style }); + expect(answer.action).toBe("cancel"); + expect(close).toHaveBeenCalled(); + }); }); diff --git a/clients/mcpi/__tests__/form-prompt.test.ts b/clients/mcpi/__tests__/form-prompt.test.ts new file mode 100644 index 0000000000..c5f318f405 --- /dev/null +++ b/clients/mcpi/__tests__/form-prompt.test.ts @@ -0,0 +1,377 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { createStyle } from "@inspector/cli/style.js"; +import { promptForm } from "../src/session/form-prompt.js"; +import type { FormField } from "../src/session/form-schema.js"; + +/** + * Covers `promptForm`'s field-by-field prompting (one branch per + * `FormField.kind`, including validation retry loops and defaults) and the + * review step (submit / edit-by-name / cancel). + */ +describe("promptForm", () => { + let stderr: string; + let originalWrite: typeof process.stderr.write; + const style = createStyle(false); + + beforeEach(() => { + stderr = ""; + originalWrite = process.stderr.write; + process.stderr.write = ((chunk: unknown, ...rest: unknown[]) => { + stderr += typeof chunk === "string" ? chunk : String(chunk); + const cb = rest.find((r) => typeof r === "function") as + | (() => void) + | undefined; + cb?.(); + return true; + }) as typeof process.stderr.write; + }); + + afterEach(() => { + process.stderr.write = originalWrite; + }); + + function fakeRl(answers: string[]) { + let i = 0; + return { + question: vi.fn(async () => { + const answer = answers[i]; + i += 1; + if (answer === undefined) { + throw new Error("no more scripted answers"); + } + return answer; + }), + } as unknown as Parameters[0]; + } + + const stringField: FormField = { + name: "name", + required: true, + title: "Name", + kind: "string", + }; + + it("collects a required string field and submits on blank review answer", async () => { + const rl = fakeRl(["octocat", ""]); + const outcome = await promptForm( + rl, + "Enter your name", + [stringField], + style, + ); + expect(outcome).toEqual({ action: "accept", content: { name: "octocat" } }); + expect(stderr).toContain("Enter your name"); + }); + + it("re-prompts a required string field left blank, then accepts a default", async () => { + const field: FormField = { + ...stringField, + required: false, + default: "anon", + }; + const rl = fakeRl(["", ""]); + const outcome = await promptForm(rl, "msg", [field], style); + expect(outcome).toEqual({ action: "accept", content: { name: "anon" } }); + }); + + it("omits an optional string field left blank with no default", async () => { + const field: FormField = { ...stringField, required: false }; + const rl = fakeRl(["", ""]); + const outcome = await promptForm(rl, "msg", [field], style); + expect(outcome).toEqual({ action: "accept", content: {} }); + }); + + it("re-prompts a required string field until non-blank", async () => { + const rl = fakeRl(["", "octocat", ""]); + const outcome = await promptForm(rl, "msg", [stringField], style); + expect(outcome).toEqual({ action: "accept", content: { name: "octocat" } }); + expect(stderr).toContain("This field is required"); + }); + + it("enforces minLength/maxLength on a string field", async () => { + const field: FormField = { ...stringField, minLength: 3, maxLength: 5 }; + const rl = fakeRl(["ab", "toolong", "oka", ""]); + const outcome = await promptForm(rl, "msg", [field], style); + expect(outcome).toEqual({ action: "accept", content: { name: "oka" } }); + expect(stderr).toContain("at least 3"); + expect(stderr).toContain("at most 5"); + }); + + it("collects a required number field with range validation", async () => { + const field: FormField = { + name: "age", + required: true, + title: "Age", + kind: "number", + integer: false, + minimum: 18, + maximum: 100, + }; + const rl = fakeRl(["notanumber", "5", "30", ""]); + const outcome = await promptForm(rl, "msg", [field], style); + expect(outcome).toEqual({ action: "accept", content: { age: 30 } }); + expect(stderr).toContain("Enter a valid number"); + }); + + it("rejects a non-integer value for an integer field", async () => { + const field: FormField = { + name: "count", + required: true, + title: "Count", + kind: "number", + integer: true, + }; + const rl = fakeRl(["1.5", "3", ""]); + const outcome = await promptForm(rl, "msg", [field], style); + expect(outcome).toEqual({ action: "accept", content: { count: 3 } }); + expect(stderr).toContain("Enter a valid integer"); + }); + + it("uses a number field's default on blank, or omits when optional with none", async () => { + const withDefault: FormField = { + name: "age", + required: false, + title: "Age", + kind: "number", + integer: false, + default: 21, + }; + const rl1 = fakeRl(["", ""]); + expect(await promptForm(rl1, "msg", [withDefault], style)).toEqual({ + action: "accept", + content: { age: 21 }, + }); + + const noDefault: FormField = { + name: "age", + required: false, + title: "Age", + kind: "number", + integer: false, + }; + const rl2 = fakeRl(["", ""]); + expect(await promptForm(rl2, "msg", [noDefault], style)).toEqual({ + action: "accept", + content: {}, + }); + }); + + it("re-prompts a required number field left blank", async () => { + const field: FormField = { + name: "age", + required: true, + title: "Age", + kind: "number", + integer: false, + }; + const rl = fakeRl(["", "42", ""]); + const outcome = await promptForm(rl, "msg", [field], style); + expect(outcome).toEqual({ action: "accept", content: { age: 42 } }); + }); + + it("collects a boolean field via y/n, defaulting on blank", async () => { + const field: FormField = { + name: "confirm", + required: false, + title: "Confirm", + kind: "boolean", + default: true, + }; + const rl = fakeRl(["", ""]); + const outcome = await promptForm(rl, "msg", [field], style); + expect(outcome).toEqual({ action: "accept", content: { confirm: true } }); + }); + + it("re-prompts on an invalid boolean answer and accepts yes/no variants", async () => { + const field: FormField = { + name: "confirm", + required: true, + title: "Confirm", + kind: "boolean", + }; + const rl = fakeRl(["maybe", "yes", ""]); + const outcome = await promptForm(rl, "msg", [field], style); + expect(outcome).toEqual({ action: "accept", content: { confirm: true } }); + expect(stderr).toContain("Please answer y or n"); + + const rl2 = fakeRl(["no", ""]); + expect( + await promptForm(rl2, "msg", [{ ...field, required: false }], style), + ).toEqual({ action: "accept", content: { confirm: false } }); + }); + + it("omits an optional boolean field left blank with no default", async () => { + const field: FormField = { + name: "confirm", + required: false, + title: "Confirm", + kind: "boolean", + }; + const rl = fakeRl(["", ""]); + const outcome = await promptForm(rl, "msg", [field], style); + expect(outcome).toEqual({ action: "accept", content: {} }); + }); + + it("collects a single-select enum by number, and accepts a default on blank", async () => { + const field: FormField = { + name: "color", + required: true, + title: "Color", + kind: "enum", + choices: [ + { value: "red", label: "Red" }, + { value: "blue", label: "Blue" }, + ], + }; + const rl = fakeRl(["2", ""]); + const outcome = await promptForm(rl, "msg", [field], style); + expect(outcome).toEqual({ action: "accept", content: { color: "blue" } }); + + const withDefault: FormField = { ...field, default: "red" }; + const rl2 = fakeRl(["", ""]); + expect(await promptForm(rl2, "msg", [withDefault], style)).toEqual({ + action: "accept", + content: { color: "red" }, + }); + }); + + it("re-prompts on an out-of-range enum choice and a required blank", async () => { + const field: FormField = { + name: "color", + required: true, + title: "Color", + kind: "enum", + choices: [{ value: "red", label: "Red" }], + }; + const rl = fakeRl(["", "9", "1", ""]); + const outcome = await promptForm(rl, "msg", [field], style); + expect(outcome).toEqual({ action: "accept", content: { color: "red" } }); + expect(stderr).toContain("This field is required"); + expect(stderr).toContain("Enter a number between 1 and 1"); + }); + + it("omits an optional enum field left blank with no default", async () => { + const field: FormField = { + name: "color", + required: false, + title: "Color", + kind: "enum", + choices: [{ value: "red", label: "Red" }], + }; + const rl = fakeRl(["", ""]); + const outcome = await promptForm(rl, "msg", [field], style); + expect(outcome).toEqual({ action: "accept", content: {} }); + }); + + it("collects a multi-select enum via comma-separated numbers, enforcing minItems/maxItems", async () => { + const field: FormField = { + name: "colors", + required: true, + title: "Colors", + kind: "multiselect", + choices: [ + { value: "red", label: "Red" }, + { value: "green", label: "Green" }, + { value: "blue", label: "Blue" }, + ], + minItems: 1, + maxItems: 2, + }; + const rl = fakeRl(["1,2,3", "1,2", ""]); + const outcome = await promptForm(rl, "msg", [field], style); + expect(outcome).toEqual({ + action: "accept", + content: { colors: ["red", "green"] }, + }); + expect(stderr).toContain("Select at most 2"); + }); + + it("enforces minItems on a multi-select enum", async () => { + const field: FormField = { + name: "colors", + required: true, + title: "Colors", + kind: "multiselect", + choices: [ + { value: "red", label: "Red" }, + { value: "green", label: "Green" }, + ], + minItems: 2, + }; + const rl = fakeRl(["1", "1,2", ""]); + const outcome = await promptForm(rl, "msg", [field], style); + expect(outcome).toEqual({ + action: "accept", + content: { colors: ["red", "green"] }, + }); + expect(stderr).toContain("Select at least 2"); + }); + + it("uses a multi-select default on blank, formatted in the field description", async () => { + const field: FormField = { + name: "colors", + required: false, + title: "Colors", + kind: "multiselect", + choices: [{ value: "red", label: "Red" }], + default: ["red"], + }; + const rl = fakeRl(["", ""]); + const outcome = await promptForm(rl, "msg", [field], style); + expect(outcome).toEqual({ action: "accept", content: { colors: ["red"] } }); + expect( + (rl.question as ReturnType).mock.calls[0][0], + ).toContain("[default: red]"); + }); + + it("omits an optional multi-select field left blank with no default", async () => { + const field: FormField = { + name: "colors", + required: false, + title: "Colors", + kind: "multiselect", + choices: [{ value: "red", label: "Red" }], + }; + const rl = fakeRl(["", ""]); + const outcome = await promptForm(rl, "msg", [field], style); + expect(outcome).toEqual({ action: "accept", content: {} }); + }); + + it("shows a description when the field has one", async () => { + const field: FormField = { + ...stringField, + description: "Your full display name", + }; + const rl = fakeRl(["octocat", ""]); + await promptForm(rl, "msg", [field], style); + expect( + (rl.question as ReturnType).mock.calls[0][0], + ).toContain("Your full display name"); + }); + + it("cancels from the review step", async () => { + const rl = fakeRl(["octocat", "c"]); + const outcome = await promptForm(rl, "msg", [stringField], style); + expect(outcome).toEqual({ action: "cancel" }); + }); + + it("re-prompts a review answer that doesn't name a known field", async () => { + const rl = fakeRl(["octocat", "bogus", "c"]); + const outcome = await promptForm(rl, "msg", [stringField], style); + expect(outcome).toEqual({ action: "cancel" }); + expect(stderr).toContain('Unknown field "bogus"'); + }); + + it("lets the review step re-edit a named field before submitting", async () => { + const rl = fakeRl(["octocat", "name", "edited", ""]); + const outcome = await promptForm(rl, "msg", [stringField], style); + expect(outcome).toEqual({ action: "accept", content: { name: "edited" } }); + }); + + it("shows '(none)' in the review for a field with no value", async () => { + const field: FormField = { ...stringField, required: false }; + const rl = fakeRl(["", ""]); + await promptForm(rl, "msg", [field], style); + expect(stderr).toContain("(none)"); + }); +}); diff --git a/clients/mcpi/__tests__/form-schema.test.ts b/clients/mcpi/__tests__/form-schema.test.ts new file mode 100644 index 0000000000..facee5b402 --- /dev/null +++ b/clients/mcpi/__tests__/form-schema.test.ts @@ -0,0 +1,284 @@ +import { describe, it, expect } from "vitest"; +import { parseFormSchema } from "../src/session/form-schema.js"; + +describe("parseFormSchema", () => { + it("returns null for a non-object schema", () => { + expect(parseFormSchema(undefined)).toBeNull(); + expect(parseFormSchema({ type: "string" })).toBeNull(); + }); + + it("returns null when properties is missing or not an object", () => { + expect(parseFormSchema({ type: "object" })).toBeNull(); + expect(parseFormSchema({ type: "object", properties: "nope" })).toBeNull(); + }); + + it("parses a string field with title/description/length/format/default", () => { + const fields = parseFormSchema({ + type: "object", + properties: { + name: { + type: "string", + title: "Display Name", + description: "Your name", + minLength: 2, + maxLength: 20, + format: "email", + default: "octocat", + }, + }, + required: ["name"], + }); + expect(fields).toEqual([ + { + name: "name", + required: true, + title: "Display Name", + description: "Your name", + kind: "string", + minLength: 2, + maxLength: 20, + format: "email", + default: "octocat", + }, + ]); + }); + + it("parses a number field, distinguishing integer from number", () => { + const fields = parseFormSchema({ + type: "object", + properties: { + age: { type: "number", minimum: 18, maximum: 100, default: 30 }, + count: { type: "integer" }, + }, + properties2: undefined, + } as Record); + expect(fields).toEqual([ + { + name: "age", + required: false, + title: "age", + description: undefined, + kind: "number", + integer: false, + minimum: 18, + maximum: 100, + default: 30, + }, + { + name: "count", + required: false, + title: "count", + description: undefined, + kind: "number", + integer: true, + minimum: undefined, + maximum: undefined, + default: undefined, + }, + ]); + }); + + it("parses a boolean field with a default", () => { + const fields = parseFormSchema({ + type: "object", + properties: { confirm: { type: "boolean", default: false } }, + }); + expect(fields).toEqual([ + { + name: "confirm", + required: false, + title: "confirm", + description: undefined, + kind: "boolean", + default: false, + }, + ]); + }); + + it("parses a single-select enum without titles", () => { + const fields = parseFormSchema({ + type: "object", + properties: { + color: { + type: "string", + title: "Color", + enum: ["Red", "Green", "Blue"], + default: "Red", + }, + }, + }); + expect(fields).toEqual([ + { + name: "color", + required: false, + title: "Color", + description: undefined, + kind: "enum", + choices: [ + { value: "Red", label: "Red" }, + { value: "Green", label: "Green" }, + { value: "Blue", label: "Blue" }, + ], + default: "Red", + }, + ]); + }); + + it("parses a single-select enum with titled oneOf", () => { + const fields = parseFormSchema({ + type: "object", + properties: { + color: { + type: "string", + oneOf: [{ const: "#FF0000", title: "Red" }, { const: "#00FF00" }], + }, + }, + }); + expect(fields).toEqual([ + { + name: "color", + required: false, + title: "color", + description: undefined, + kind: "enum", + choices: [ + { value: "#FF0000", label: "Red" }, + { value: "#00FF00", label: "#00FF00" }, + ], + default: undefined, + }, + ]); + }); + + it("returns null when oneOf entries are malformed", () => { + expect( + parseFormSchema({ + type: "object", + properties: { + color: { type: "string", oneOf: [{ notConst: true }] }, + }, + }), + ).toBeNull(); + expect( + parseFormSchema({ + type: "object", + properties: { color: { type: "string", oneOf: "nope" } }, + }), + ).toBeNull(); + }); + + it("parses a multi-select enum without titles, with min/maxItems and default", () => { + const fields = parseFormSchema({ + type: "object", + properties: { + colors: { + type: "array", + title: "Colors", + minItems: 1, + maxItems: 2, + items: { type: "string", enum: ["Red", "Green", "Blue"] }, + default: ["Red", "Green"], + }, + }, + }); + expect(fields).toEqual([ + { + name: "colors", + required: false, + title: "Colors", + description: undefined, + kind: "multiselect", + choices: [ + { value: "Red", label: "Red" }, + { value: "Green", label: "Green" }, + { value: "Blue", label: "Blue" }, + ], + minItems: 1, + maxItems: 2, + default: ["Red", "Green"], + }, + ]); + }); + + it("parses a multi-select enum with titled anyOf", () => { + const fields = parseFormSchema({ + type: "object", + properties: { + colors: { + type: "array", + items: { + anyOf: [ + { const: "#FF0000", title: "Red" }, + { const: "#00FF00", title: "Green" }, + ], + }, + }, + }, + }); + expect(fields?.[0]).toMatchObject({ + kind: "multiselect", + choices: [ + { value: "#FF0000", label: "Red" }, + { value: "#00FF00", label: "Green" }, + ], + }); + }); + + it("returns null for an array field without items or without enum/anyOf", () => { + expect( + parseFormSchema({ + type: "object", + properties: { colors: { type: "array" } }, + }), + ).toBeNull(); + expect( + parseFormSchema({ + type: "object", + properties: { + colors: { type: "array", items: { type: "string" } }, + }, + }), + ).toBeNull(); + }); + + it("ignores a non-string-array default on a multiselect field", () => { + const fields = parseFormSchema({ + type: "object", + properties: { + colors: { + type: "array", + items: { type: "string", enum: ["Red"] }, + default: [1, 2], + }, + }, + }); + expect(fields?.[0]).toMatchObject({ default: undefined }); + }); + + it("returns null for an unsupported/unknown property type", () => { + expect( + parseFormSchema({ + type: "object", + properties: { nested: { type: "object", properties: {} } }, + }), + ).toBeNull(); + }); + + it("returns null when a property isn't an object", () => { + expect( + parseFormSchema({ + type: "object", + properties: { name: "not-a-schema" }, + }), + ).toBeNull(); + }); + + it("treats non-array/malformed required as no required fields", () => { + const fields = parseFormSchema({ + type: "object", + properties: { name: { type: "string" } }, + required: "name", + }); + expect(fields?.[0].required).toBe(false); + }); +}); diff --git a/clients/mcpi/src/daemon/client.ts b/clients/mcpi/src/daemon/client.ts index aa8f560c6c..a49ed82f48 100644 --- a/clients/mcpi/src/daemon/client.ts +++ b/clients/mcpi/src/daemon/client.ts @@ -84,7 +84,9 @@ export async function callDaemon( if (!trimmed) return Promise.resolve(); let parsed: DaemonResponse | ElicitationRequestFrame; try { - parsed = JSON.parse(trimmed) as DaemonResponse | ElicitationRequestFrame; + parsed = JSON.parse(trimmed) as + | DaemonResponse + | ElicitationRequestFrame; } catch (error) { fail(error); return Promise.resolve(); @@ -186,7 +188,9 @@ export async function callDaemon( buffer = buffer.slice(idx + 1); // Sequential so an awaited onElicitation prompt fully settles (and // its answer is written) before the next buffered line is handled. - queue = queue.then(() => handleLine(line)).catch((error) => fail(error)); + queue = queue + .then(() => handleLine(line)) + .catch((error) => fail(error)); } }); diff --git a/clients/mcpi/src/daemon/ipc-glue.ts b/clients/mcpi/src/daemon/ipc-glue.ts index 17e21afa93..92c5bfa3c4 100644 --- a/clients/mcpi/src/daemon/ipc-glue.ts +++ b/clients/mcpi/src/daemon/ipc-glue.ts @@ -64,9 +64,7 @@ class ConnectionElicitationChannel implements ElicitationChannel { request(frame: ElicitationRequestFrame): Promise { if (this.pending) { return Promise.reject( - new Error( - "Another elicitation is already pending on this connection", - ), + new Error("Another elicitation is already pending on this connection"), ); } return new Promise((resolve, reject) => { diff --git a/clients/mcpi/src/daemon/sessions.ts b/clients/mcpi/src/daemon/sessions.ts index 245a8d017e..64aeaf30c0 100644 --- a/clients/mcpi/src/daemon/sessions.ts +++ b/clients/mcpi/src/daemon/sessions.ts @@ -366,7 +366,7 @@ async function createSessionClient( // unadvertised here — a form-mode elicitation arriving anyway (a server // ignoring our capabilities) is defensively auto-declined by the daemon's // elicitation prompt. - elicit: { url: true }, + elicit: { url: true, form: true }, serverSettings, ...(serverSettings?.protocolEra && { versionNegotiation: eraToVersionNegotiation(serverSettings.protocolEra), diff --git a/clients/mcpi/src/session/elicitation-prompt.ts b/clients/mcpi/src/session/elicitation-prompt.ts index ebf1564fe6..c4de3d3d3f 100644 --- a/clients/mcpi/src/session/elicitation-prompt.ts +++ b/clients/mcpi/src/session/elicitation-prompt.ts @@ -1,11 +1,17 @@ /** - * Terminal UI for a mid-`rpc` elicitation exchange (dual-era support, phase - * 1 — legacy + modern non-task MRTR, URL mode only; form-mode rendering is a - * follow-up phase). Mirrors the web client's URL-mode convention - * (`InlineElicitationRequest`/`PendingClientRequestModal`): the actual - * out-of-band completion can't be observed here, so the user self-reports it - * by answering a confirm prompt — there is no "decline" for URL mode, only - * accept (they say they finished) or cancel. + * Terminal UI for a mid-`rpc` elicitation exchange (dual-era support). Covers + * both delivery mechanisms (legacy server→client request, modern non-task + * MRTR round) and both modes: + * + * - **URL mode** mirrors the web client's convention + * (`InlineElicitationRequest`/`PendingClientRequestModal`): the actual + * out-of-band completion can't be observed here, so the user self-reports + * it by answering a confirm prompt — there is no "decline" for URL mode, + * only accept (they say they finished) or cancel. + * - **Form mode** renders one prompt per field from the schema (see + * `form-schema.ts`/`form-prompt.ts`), with a review step before submitting. + * Schemas outside the spec's restricted primitive-field shape (should + * never happen from a well-behaved server) fall back to a clear decline. */ import { createInterface } from "node:readline/promises"; import type { Style } from "@inspector/cli/style.js"; @@ -13,6 +19,8 @@ import type { ElicitationRequestFrame, ElicitationResponseFrame, } from "../daemon/protocol.js"; +import { parseFormSchema } from "./form-schema.js"; +import { promptForm } from "./form-prompt.js"; export type PromptElicitationOpts = { /** False for non-interactive callers (e.g. `--format json`, non-TTY). */ @@ -54,15 +62,52 @@ export async function promptElicitation( const { style } = opts; if (frame.mode === "form") { - // Form rendering isn't built yet (a follow-up phase); decline clearly - // rather than silently guessing at field values or hanging. - process.stderr.write( - style.yellow( - "This server is asking for form input, which mcpi doesn't support " + - "yet — declining.\n", - ) + ` ${frame.message}\n`, - ); - return declineResponse(frame); + const fields = parseFormSchema(frame.requestedSchema); + if (!fields) { + // Schema outside the spec's restricted primitive-field shape — + // shouldn't happen from a well-behaved server; decline clearly rather + // than silently guessing at field values. + process.stderr.write( + style.yellow( + "This server's form request uses a schema mcpi doesn't support " + + "— declining.\n", + ) + ` ${frame.message}\n`, + ); + return declineResponse(frame); + } + + if (!opts.interactive) { + process.stderr.write( + style.yellow( + "This server is asking for form input, which requires an " + + "interactive terminal — declining.\n", + ) + ` ${frame.message}\n`, + ); + return declineResponse(frame); + } + + const rl = createInterface({ + input: process.stdin, + output: process.stderr, + }); + try { + const outcome = await promptForm(rl, frame.message, fields, style); + if (outcome.action === "accept") { + return { + id: frame.id, + kind: "elicitation-response", + elicitationId: frame.elicitationId, + action: "accept", + content: outcome.content, + }; + } + if (outcome.action === "decline") return declineResponse(frame); + return cancelResponse(frame); + } catch { + return cancelResponse(frame); + } finally { + rl.close(); + } } if (!opts.interactive) { diff --git a/clients/mcpi/src/session/form-prompt.ts b/clients/mcpi/src/session/form-prompt.ts new file mode 100644 index 0000000000..789c62405e --- /dev/null +++ b/clients/mcpi/src/session/form-prompt.ts @@ -0,0 +1,216 @@ +/** + * Interactive terminal renderer for a form-mode elicitation + * (dual-era support, phase 3). Prompts once per field (type-appropriate: + * text, numeric, y/n, numbered single-select, numbered multi-select), + * pre-fills defaults, does light client-side validation (required/length/ + * range), then shows a review step before submitting so the user can + * re-edit any field or cancel outright. + */ +import type { Interface as ReadlineInterface } from "node:readline/promises"; +import type { Style } from "@inspector/cli/style.js"; +import type { FormField } from "./form-schema.js"; + +export type FormOutcome = + | { action: "accept"; content: Record } + | { action: "decline" } + | { action: "cancel" }; + +function formatDefault(field: FormField): string | undefined { + if (field.default === undefined) return undefined; + if (field.kind === "multiselect") { + return (field.default as string[]).join(", "); + } + return String(field.default); +} + +function describeField(field: FormField, style: Style): string { + const req = field.required ? style.yellow(" (required)") : ""; + const desc = field.description ? ` — ${field.description}` : ""; + const def = formatDefault(field); + const defHint = def !== undefined ? style.dim(` [default: ${def}]`) : ""; + return `${style.bold(field.title)}${req}${desc}${defHint}`; +} + +/** Prompts for one field's value; loops until a valid answer or a default/blank-when-optional. */ +async function promptField( + rl: ReadlineInterface, + field: FormField, + style: Style, +): Promise { + for (;;) { + if (field.kind === "boolean") { + const def = field.default; + const hint = def === undefined ? "y/n" : def ? "Y/n" : "y/N"; + const raw = ( + await rl.question(`${describeField(field, style)}\n [${hint}]: `) + ) + .trim() + .toLowerCase(); + if (raw === "" && def !== undefined) return def; + if (raw === "y" || raw === "yes") return true; + if (raw === "n" || raw === "no") return false; + if (raw === "" && !field.required) return undefined; + process.stderr.write(style.red(" Please answer y or n.\n")); + continue; + } + + if (field.kind === "enum" || field.kind === "multiselect") { + const lines = field.choices.map( + (choice, i) => ` ${i + 1}. ${choice.label}`, + ); + const multi = field.kind === "multiselect"; + const prompt = multi + ? "Enter one or more numbers separated by commas" + : "Enter a number"; + const raw = ( + await rl.question( + `${describeField(field, style)}\n${lines.join("\n")}\n ${prompt}: `, + ) + ).trim(); + if (raw === "") { + if (field.default !== undefined) return field.default; + if (!field.required) return undefined; + process.stderr.write(style.red(" This field is required.\n")); + continue; + } + const indices = raw.split(",").map((s) => Number.parseInt(s.trim(), 10)); + if ( + indices.some( + (n) => !Number.isInteger(n) || n < 1 || n > field.choices.length, + ) + ) { + process.stderr.write( + style.red( + ` Enter a number between 1 and ${field.choices.length}.\n`, + ), + ); + continue; + } + const values = indices.map((n) => field.choices[n - 1]!.value); + if (multi) { + const m = field as Extract; + if (m.minItems !== undefined && values.length < m.minItems) { + process.stderr.write(style.red(` Select at least ${m.minItems}.\n`)); + continue; + } + if (m.maxItems !== undefined && values.length > m.maxItems) { + process.stderr.write(style.red(` Select at most ${m.maxItems}.\n`)); + continue; + } + return values; + } + return values[0]; + } + + if (field.kind === "number") { + const def = field.default; + const raw = ( + await rl.question( + `${describeField(field, style)}\n ${def !== undefined ? `[${def}]` : ""}: `, + ) + ).trim(); + if (raw === "") { + if (def !== undefined) return def; + if (!field.required) return undefined; + process.stderr.write(style.red(" This field is required.\n")); + continue; + } + const n = Number(raw); + if ( + Number.isNaN(n) || + (field.integer && !Number.isInteger(n)) || + (field.minimum !== undefined && n < field.minimum) || + (field.maximum !== undefined && n > field.maximum) + ) { + const range = + field.minimum !== undefined || field.maximum !== undefined + ? ` (${field.minimum ?? "-∞"}..${field.maximum ?? "∞"})` + : ""; + process.stderr.write( + style.red( + ` Enter a valid ${field.integer ? "integer" : "number"}${range}.\n`, + ), + ); + continue; + } + return n; + } + + // string + const def = field.default; + const raw = await rl.question( + `${describeField(field, style)}\n ${def !== undefined ? `[${def}]` : ""}: `, + ); + const value = raw === "" && def !== undefined ? def : raw; + if (value === "" && field.required) { + process.stderr.write(style.red(" This field is required.\n")); + continue; + } + if (value === "" && !field.required) return undefined; + if (field.minLength !== undefined && value.length < field.minLength) { + process.stderr.write( + style.red(` Must be at least ${field.minLength} characters.\n`), + ); + continue; + } + if (field.maxLength !== undefined && value.length > field.maxLength) { + process.stderr.write( + style.red(` Must be at most ${field.maxLength} characters.\n`), + ); + continue; + } + return value; + } +} + +/** + * Collect one value per field, then loop on a review step (submit / edit a + * field by name / cancel) until the user submits or cancels. + */ +export async function promptForm( + rl: ReadlineInterface, + message: string, + fields: FormField[], + style: Style, +): Promise { + process.stderr.write(`\n${style.bold("Input requested: ")}${message}\n\n`); + + const values = new Map(); + for (const field of fields) { + values.set(field.name, await promptField(rl, field, style)); + } + + for (;;) { + process.stderr.write(`\n${style.bold("Review your answers:")}\n`); + for (const field of fields) { + const v = values.get(field.name); + process.stderr.write( + ` ${field.title}: ${v === undefined ? style.dim("(none)") : String(v)}\n`, + ); + } + const answer = ( + await rl.question( + "\nPress Enter to submit, type a field name to edit it, or 'c' to cancel: ", + ) + ).trim(); + if (answer === "") { + const content: Record = {}; + for (const field of fields) { + const v = values.get(field.name); + if (v !== undefined) content[field.name] = v; + } + return { action: "accept", content }; + } + if (answer.toLowerCase() === "c") { + return { action: "cancel" }; + } + const field = fields.find((f) => f.name === answer); + if (!field) { + process.stderr.write( + style.red(` Unknown field "${answer}". Try again.\n`), + ); + continue; + } + values.set(field.name, await promptField(rl, field, style)); + } +} diff --git a/clients/mcpi/src/session/form-schema.ts b/clients/mcpi/src/session/form-schema.ts new file mode 100644 index 0000000000..9bf47f34ba --- /dev/null +++ b/clients/mcpi/src/session/form-schema.ts @@ -0,0 +1,176 @@ +/** + * Parses a form-mode elicitation `requestedSchema` into a flat list of + * fields mcpi can prompt for. Per the MRTR elicitation spec (2026-07-28), + * form-mode schemas are restricted to a flat object whose properties are + * primitive types only — string, number/integer, boolean, single-select + * enum (`enum` or titled `oneOf`), or multi-select enum (`array` of one of + * those) — so this never needs to handle nesting, arrays of objects, or + * other general JSON Schema features. + * + * Returns `null` if the schema doesn't match that shape (defensive: a + * well-behaved server never sends anything else, but this is untrusted + * wire input from an arbitrary MCP server). + */ + +export type Choice = { value: string; label: string }; + +type FieldExtra = + | { + kind: "string"; + minLength?: number; + maxLength?: number; + format?: string; + default?: string; + } + | { + kind: "number"; + integer: boolean; + minimum?: number; + maximum?: number; + default?: number; + } + | { kind: "boolean"; default?: boolean } + | { kind: "enum"; choices: Choice[]; default?: string } + | { + kind: "multiselect"; + choices: Choice[]; + minItems?: number; + maxItems?: number; + default?: string[]; + }; + +export type FormField = { + name: string; + required: boolean; + title: string; + description?: string; +} & FieldExtra; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function parseChoicesFromEnum(value: unknown): Choice[] | undefined { + if (!Array.isArray(value) || value.some((v) => typeof v !== "string")) { + return undefined; + } + return (value as string[]).map((v) => ({ value: v, label: v })); +} + +function parseChoicesFromOneOf(value: unknown): Choice[] | undefined { + if (!Array.isArray(value)) return undefined; + const choices: Choice[] = []; + for (const entry of value) { + if (!isRecord(entry) || typeof entry.const !== "string") return undefined; + choices.push({ + value: entry.const, + label: typeof entry.title === "string" ? entry.title : entry.const, + }); + } + return choices; +} + +function parseField(prop: unknown): FieldExtra | null { + if (!isRecord(prop)) return null; + const type = prop.type; + + if (type === "boolean") { + return { + kind: "boolean", + default: typeof prop.default === "boolean" ? prop.default : undefined, + }; + } + + if (type === "number" || type === "integer") { + return { + kind: "number", + integer: type === "integer", + minimum: typeof prop.minimum === "number" ? prop.minimum : undefined, + maximum: typeof prop.maximum === "number" ? prop.maximum : undefined, + default: typeof prop.default === "number" ? prop.default : undefined, + }; + } + + if (type === "string") { + const enumChoices = parseChoicesFromEnum(prop.enum); + if (enumChoices) { + return { + kind: "enum", + choices: enumChoices, + default: typeof prop.default === "string" ? prop.default : undefined, + }; + } + if (prop.oneOf !== undefined) { + const oneOfChoices = parseChoicesFromOneOf(prop.oneOf); + if (!oneOfChoices) return null; + return { + kind: "enum", + choices: oneOfChoices, + default: typeof prop.default === "string" ? prop.default : undefined, + }; + } + return { + kind: "string", + minLength: + typeof prop.minLength === "number" ? prop.minLength : undefined, + maxLength: + typeof prop.maxLength === "number" ? prop.maxLength : undefined, + format: typeof prop.format === "string" ? prop.format : undefined, + default: typeof prop.default === "string" ? prop.default : undefined, + }; + } + + if (type === "array") { + const items = prop.items; + if (!isRecord(items)) return null; + const choices = + parseChoicesFromEnum(items.enum) ?? parseChoicesFromOneOf(items.anyOf); + if (!choices) return null; + const defaultValue = + Array.isArray(prop.default) && + prop.default.every((v) => typeof v === "string") + ? (prop.default as string[]) + : undefined; + return { + kind: "multiselect", + choices, + minItems: typeof prop.minItems === "number" ? prop.minItems : undefined, + maxItems: typeof prop.maxItems === "number" ? prop.maxItems : undefined, + default: defaultValue, + }; + } + + return null; +} + +/** Parse a `requestedSchema` into an ordered list of {@link FormField}s. */ +export function parseFormSchema( + schema: Record | undefined, +): FormField[] | null { + if (!isRecord(schema)) return null; + const properties = schema.properties; + if (!isRecord(properties)) return null; + const required = Array.isArray(schema.required) + ? (schema.required.filter((v) => typeof v === "string") as string[]) + : []; + + const fields: FormField[] = []; + for (const [name, prop] of Object.entries(properties)) { + const parsed = parseField(prop); + if (!parsed) return null; + const title = + isRecord(prop) && typeof prop.title === "string" ? prop.title : name; + const description = + isRecord(prop) && typeof prop.description === "string" + ? prop.description + : undefined; + fields.push({ + name, + required: required.includes(name), + title, + description, + ...parsed, + } as FormField); + } + return fields; +} From 98a41510bc715436ef6ff2dc33c0ead01b68d1da Mon Sep 17 00:00:00 2001 From: Bob Dickinson Date: Mon, 14 Sep 2026 16:49:34 -0700 Subject: [PATCH 17/23] mcpi: add --elicit capability override (#1783) Adds a per-connection override for the elicitation capability mcpi advertises to a server, mirroring the existing --era mechanism: - InspectorServerSettings.elicitCapability ("off"|"url"|"form"|"both", default "both") persists on disk as elicitCapability, omitted when it equals the default, and round-trips through serverList.ts the same way protocolEra does. - mcpi connect gains --elicit , validated the same way as --era, with a withElicitOverride() helper mirroring withEraOverride() (incl. synthesizing bare-defaults settings for ad-hoc targets). - createSessionClient() now derives the InspectorClient elicit option from serverSettings.elicitCapability via elicitCapabilityToClientOption() instead of the old Phase-1 hardcoded { url: true, form: true }. This lets a caller that cannot handle an interactive elicitation prompt (a script, an agent) opt out entirely so the server sees no elicitation capability and can fall back to its own alternative, instead of every elicitation request being auto-declined. Also updates clients/mcpi/README.md with an "Elicitation support" section (previously undocumented, despite already-shipped URL/form prompt rendering) and the --elicit flag, and refreshes the stale "Sampling / elicitation CLI: Still TUI/web" to-do row in specification/v2_cli_v2.md. Manually verified end-to-end against the modern-mrtr-http test server: --elicit off makes the server itself reject the mid-round input request ("capabilities do not declare the required capability"); --elicit both (default) succeeds and reaches the interactive/auto-decline prompt path as before. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- clients/mcpi/README.md | 37 +++++++++++ .../mcpi/__tests__/daemon-sessions.test.ts | 20 ++++++ clients/mcpi/__tests__/mcp-coverage.test.ts | 10 +++ clients/mcpi/src/daemon/sessions.ts | 38 ++++++++++-- clients/mcpi/src/session/mcp.ts | 61 +++++++++++++++++-- core/mcp/serverList.ts | 44 +++++++++++++ core/mcp/types.ts | 32 ++++++++++ specification/v2_cli_v2.md | 2 +- 8 files changed, 231 insertions(+), 13 deletions(-) diff --git a/clients/mcpi/README.md b/clients/mcpi/README.md index 72cf5171ec..c39e405d20 100644 --- a/clients/mcpi/README.md +++ b/clients/mcpi/README.md @@ -123,6 +123,43 @@ A paused modern (SEP-2663) task — one whose `tasks/get` shows mcpi tasks/update --input-responses '{"":{"approved":true}}' ``` +## Elicitation support + +mcpi can prompt interactively for both elicitation delivery mechanisms — +legacy server→client `elicitation/create` requests and modern non-task MRTR +(multi-round tool response) rounds — and both modes a server may ask for: + +- **URL mode**: mcpi prints the URL and waits for you to confirm you've + finished out-of-band (there's no "decline", only accept-that-you-finished + or cancel — the actual completion can't be observed locally). +- **Form mode**: mcpi renders one prompt per field from the schema, with a + review step (edit any field again, or submit) before answering. + +Non-interactive callers (`--format json`, no TTY, or a script) get an +automatic decline instead of hanging on a prompt. + +By default mcpi advertises **both** modes to the server (`elicit: {url, +form}`), matching pre-#1783 behavior. Override this per connection with +`--elicit ` on `connect`: + +- `off` — advertise no elicitation capability at all. Useful when whatever is + driving mcpi (a script, an agent) can't handle an interactive prompt itself + — omitting the capability lets a well-behaved server fall back to its own + alternative (e.g. proceeding with defaults) instead of the request being + auto-declined. +- `url` — URL mode only. +- `form` — form mode only. +- `both` — the default; both modes. + +Like `--era`, this overrides whatever a catalog/config entry's +`elicitCapability` says, and is the only way to set it for an ad-hoc target +(no config entry to read one from): + +```bash +mcpi connect my-server --config path/to/mcp.json --elicit off +mcpi connect https://example.com/mcp --elicit url +``` + ## Relation to one-shot CLI | | One-shot | Session (`mcpi`) | diff --git a/clients/mcpi/__tests__/daemon-sessions.test.ts b/clients/mcpi/__tests__/daemon-sessions.test.ts index b91d25b648..933492b50a 100644 --- a/clients/mcpi/__tests__/daemon-sessions.test.ts +++ b/clients/mcpi/__tests__/daemon-sessions.test.ts @@ -8,6 +8,7 @@ import { callDaemon } from "../src/daemon/client.js"; import { parseRequestLine, encodeResponse } from "../src/daemon/framing.js"; import { DEFAULT_IDLE_MS, + elicitCapabilityToClientOption, isSessionAuthRequiredError, SessionRegistry, } from "../src/daemon/sessions.js"; @@ -30,6 +31,25 @@ describe("daemon framing", () => { }); }); +describe("elicitCapabilityToClientOption", () => { + it("maps each elicitCapability mode to the InspectorClient elicit shape", () => { + expect(elicitCapabilityToClientOption("off")).toBe(false); + expect(elicitCapabilityToClientOption("url")).toEqual({ url: true }); + expect(elicitCapabilityToClientOption("form")).toEqual({ form: true }); + expect(elicitCapabilityToClientOption("both")).toEqual({ + url: true, + form: true, + }); + }); + + it("defaults to both (url+form) when unset, matching the pre-#1783 hardcoded default", () => { + expect(elicitCapabilityToClientOption(undefined)).toEqual({ + url: true, + form: true, + }); + }); +}); + describe("isSessionAuthRequiredError", () => { it("recognizes unauthorized, recovery, and SDK token-exchange failures", () => { expect(isSessionAuthRequiredError(new Error("nope"))).toBe(false); diff --git a/clients/mcpi/__tests__/mcp-coverage.test.ts b/clients/mcpi/__tests__/mcp-coverage.test.ts index f72b404426..ae59ca0533 100644 --- a/clients/mcpi/__tests__/mcp-coverage.test.ts +++ b/clients/mcpi/__tests__/mcp-coverage.test.ts @@ -296,6 +296,8 @@ describe("mcp.ts coverage", () => { "15000", "--era", "auto", + "--elicit", + "url", "--format", "json", command, @@ -313,6 +315,14 @@ describe("mcp.ts coverage", () => { expectCliFailure(badEra); expect(badEra.stderr).toMatch(/Invalid --era/); + // Invalid --elicit is rejected before any connection is attempted. + const badElicit = await runMcp( + ["connect", "--elicit", "bogus", "--format", "json", command, ...args], + { env: e, timeout: 20000 }, + ); + expectCliFailure(badElicit); + expect(badElicit.stderr).toMatch(/Invalid --elicit/); + await runMcp(["disconnect", "--session", "opts", "--format", "json"], { env: e, }); diff --git a/clients/mcpi/src/daemon/sessions.ts b/clients/mcpi/src/daemon/sessions.ts index 64aeaf30c0..fdda8ca3a9 100644 --- a/clients/mcpi/src/daemon/sessions.ts +++ b/clients/mcpi/src/daemon/sessions.ts @@ -1,7 +1,10 @@ import { InspectorClient } from "@inspector/core/mcp/index.js"; import type { InspectorClientEnvironment } from "@inspector/core/mcp/types.js"; import { + DEFAULT_ELICIT_CAPABILITY, eraToVersionNegotiation, + type ElicitCapabilityMode, + type InspectorClientOptions, type InspectorServerSettings, type MCPServerConfig, } from "@inspector/core/mcp/types.js"; @@ -320,6 +323,29 @@ export function isSessionAuthRequiredError(error: unknown): boolean { ); } +/** + * Maps a persisted/overridden `elicitCapability` mode onto the `elicit` shape + * `InspectorClient` expects. Absence reads back as {@link + * DEFAULT_ELICIT_CAPABILITY} (`"both"`), matching the pre-#1783 hardcoded + * default so existing sessions keep behaving the same until a caller opts + * into something narrower via `--elicit` or a catalog entry's + * `elicitCapability` field. + */ +export function elicitCapabilityToClientOption( + mode: ElicitCapabilityMode | undefined, +): InspectorClientOptions["elicit"] { + switch (mode ?? DEFAULT_ELICIT_CAPABILITY) { + case "off": + return false; + case "url": + return { url: true }; + case "form": + return { form: true }; + case "both": + return { url: true, form: true }; + } +} + async function createSessionClient( serverConfig: MCPServerConfig, serverSettings: InspectorServerSettings | undefined, @@ -361,12 +387,12 @@ async function createSessionClient( initialLoggingLevel: "debug", progress: false, sample: false, - // Phase 1 of dual-era elicitation support: advertise URL-mode only. Form - // mode isn't rendered yet (that's a follow-up phase), so it stays - // unadvertised here — a form-mode elicitation arriving anyway (a server - // ignoring our capabilities) is defensively auto-declined by the daemon's - // elicitation prompt. - elicit: { url: true, form: true }, + // Elicitation capability advertised to the server: derived from + // `serverSettings.elicitCapability` (settable via a catalog entry or the + // `--elicit` connect flag), defaulting to url+form when unset. A server + // that ignores our (possibly empty) capabilities and elicits anyway is + // defensively auto-declined by the daemon's elicitation prompt. + elicit: elicitCapabilityToClientOption(serverSettings?.elicitCapability), serverSettings, ...(serverSettings?.protocolEra && { versionNegotiation: eraToVersionNegotiation(serverSettings.protocolEra), diff --git a/clients/mcpi/src/session/mcp.ts b/clients/mcpi/src/session/mcp.ts index 129d6b4687..d3ef6a173c 100644 --- a/clients/mcpi/src/session/mcp.ts +++ b/clients/mcpi/src/session/mcp.ts @@ -1,6 +1,7 @@ import { Command, type Command as CommandType } from "commander"; import type { JsonValue } from "@inspector/core/mcp/index.js"; import type { + ElicitCapabilityMode, InspectorServerSettings, ServerProtocolEra, } from "@inspector/core/mcp/types.js"; @@ -281,6 +282,23 @@ function registerConnect(program: CommandType): void { "--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", ) + .option( + "--elicit ", + "Elicitation capability to advertise: off, url, form, or both (default). " + + "Overrides the catalog/config entry's elicitCapability; the only way to " + + "set it for an ad-hoc target, which has no config entry of its own. Use " + + "off when the caller of mcpi can't handle an elicitation request, so the " + + "server sees no elicitation capability and can fall back on its own.", + (value: string) => { + const valid: ElicitCapabilityMode[] = ["off", "url", "form", "both"]; + if (!valid.includes(value as ElicitCapabilityMode)) { + throw new Error( + `Invalid --elicit: ${value}. Use off, url, form, or both.`, + ); + } + return value as ElicitCapabilityMode; + }, + ) .action(async (target: string[], cmdOpts) => { const opts = program.opts(); const { name: positionalSession, rest } = splitSessionTarget(target); @@ -332,13 +350,16 @@ function registerConnect(program: CommandType): void { const entries = await loadServerEntries(serverOptions); const selected = selectServerEntry(entries, selectName); const serverConfig = selected.config; - const serverSettings = withEraOverride( - withConnectTimeout( - selected.settings, - (cmdOpts.connectTimeout as number | undefined) ?? - (adHoc ? DEFAULT_CONNECT_TIMEOUT_MS : undefined), + const serverSettings = withElicitOverride( + withEraOverride( + withConnectTimeout( + selected.settings, + (cmdOpts.connectTimeout as number | undefined) ?? + (adHoc ? DEFAULT_CONNECT_TIMEOUT_MS : undefined), + ), + cmdOpts.era as ServerProtocolEra | undefined, ), - cmdOpts.era as ServerProtocolEra | undefined, + cmdOpts.elicit as ElicitCapabilityMode | undefined, ); const { detail } = summarizeServerConfig(serverConfig); const name = stripAt(sessionName)!; @@ -898,6 +919,34 @@ function withEraOverride( }; } +/** + * Overlay `--elicit` onto the settings lifted from the file/ad-hoc target. + * Mirrors `withEraOverride`: only `elicitCapability` is overridden, and a + * bare-defaults settings object is synthesized when the target had none (the + * common ad-hoc case, which otherwise has no way to request anything but the + * default `both`). + */ +function withElicitOverride( + settings: InspectorServerSettings | undefined, + elicit: ElicitCapabilityMode | undefined, +): InspectorServerSettings | undefined { + if (elicit === undefined) return settings; + if (settings) return { ...settings, elicitCapability: elicit }; + return { + headers: [], + metadata: {}, + env: [], + connectionTimeout: DEFAULT_CONNECT_TIMEOUT_MS, + requestTimeout: 0, + taskTtl: DEFAULT_TASK_TTL_MS, + maxFetchRequests: DEFAULT_MAX_FETCH_REQUESTS, + autoRefreshOnListChanged: false, + paginatedLists: false, + roots: [], + elicitCapability: elicit, + }; +} + function parseKeyValue( value: string, previous: Record = {}, diff --git a/core/mcp/serverList.ts b/core/mcp/serverList.ts index 04728a5896..270b8ca249 100644 --- a/core/mcp/serverList.ts +++ b/core/mcp/serverList.ts @@ -7,6 +7,7 @@ import { DEFAULT_CONNECTION_TIMEOUT_MS, + DEFAULT_ELICIT_CAPABILITY, DEFAULT_MAX_FETCH_REQUESTS, DEFAULT_MODERN_LOG_LEVEL, DEFAULT_PROTOCOL_ERA, @@ -15,6 +16,7 @@ import { } from "./types.js"; import type { Root } from "@modelcontextprotocol/client"; import type { + ElicitCapabilityMode, InspectorServerSettings, RequestMetadata, MCPConfig, @@ -47,6 +49,28 @@ const VALID_PROTOCOL_ERAS: ReadonlySet = new Set([ "modern", ]); +const VALID_ELICIT_CAPABILITIES: ReadonlySet = new Set([ + "off", + "url", + "form", + "both", +]); + +/** + * Runtime guard for the `elicitCapability` literal, mirroring + * {@link isProtocolEra}: a hand-edited `mcp.json` read directly by the CLI/TUI + * can carry any string, and an unknown value should read back as the default + * rather than propagate to `createSessionClient`. + */ +export function isElicitCapability( + value: unknown, +): value is ElicitCapabilityMode { + return ( + typeof value === "string" && + VALID_ELICIT_CAPABILITIES.has(value as ElicitCapabilityMode) + ); +} + /** * Runtime guard for the `protocolEra` literal. `StoredMCPServer` types the * field as `ServerProtocolEra`, but a hand-edited `mcp.json` read directly by @@ -152,6 +176,7 @@ type StoredInspectorFields = Pick< | "headers" | "metadata" | "protocolEra" + | "elicitCapability" | "modernLogLevel" | "connectionTimeout" | "requestTimeout" @@ -524,6 +549,7 @@ export function storedFieldsToInspectorSettings( stored.oauth !== undefined || stored.roots !== undefined || stored.protocolEra !== undefined || + stored.elicitCapability !== undefined || stored.modernLogLevel !== undefined || stored.env !== undefined || stored.cwd !== undefined; @@ -564,6 +590,12 @@ export function storedFieldsToInspectorSettings( if (isProtocolEra(stored.protocolEra)) { settings.protocolEra = stored.protocolEra; } + // Like `protocolEra`: absent reads back as the default elicitation + // capability (`"both"`), and an unknown literal from a hand-edited file is + // dropped rather than surfaced. + if (isElicitCapability(stored.elicitCapability)) { + settings.elicitCapability = stored.elicitCapability; + } // Like `protocolEra`: absent reads back as the default modern log level (the // form defaults via `?? DEFAULT_MODERN_LOG_LEVEL`), and an unknown literal from // a hand-edited file is dropped rather than surfaced. @@ -718,6 +750,17 @@ export function inspectorSettingsToStoredFields( out.protocolEra = settings.protocolEra; } + // Persist only when it differs from the default elicitation capability; + // absent reads back as DEFAULT_ELICIT_CAPABILITY, so writing the default + // would inject the field into hand-edited files that never had it and break + // byte-stable round-trips. + if ( + settings.elicitCapability !== undefined && + settings.elicitCapability !== DEFAULT_ELICIT_CAPABILITY + ) { + out.elicitCapability = settings.elicitCapability; + } + // Persist only when it differs from the default modern log level; absent reads // back as DEFAULT_MODERN_LOG_LEVEL, so writing the default would inject the // field into files that never set it and break byte-stable round-trips. @@ -807,6 +850,7 @@ const INSPECTOR_FIELD_KEY_MAP = { headers: true, metadata: true, protocolEra: true, + elicitCapability: true, modernLogLevel: true, connectionTimeout: true, requestTimeout: true, diff --git a/core/mcp/types.ts b/core/mcp/types.ts index 87dc7dbf9c..45e1c25fef 100644 --- a/core/mcp/types.ts +++ b/core/mcp/types.ts @@ -125,6 +125,13 @@ export type StoredMCPServer = MCPServerConfig & { * (`"legacy"`). (#1626) */ protocolEra?: ServerProtocolEra; + /** + * Elicitation capability this client advertises to this server + * (`"off" | "url" | "form" | "both"`). Inspector-specific (no analog in the + * broader mcp.json ecosystem). Omitted on disk when it equals the default + * (`"both"`). Currently consumed by mcpi only. (#1783) + */ + elicitCapability?: ElicitCapabilityMode; /** * Modern-era per-request log level stamped by default (`"off"` or one of the * eight logging levels). Inspector-specific. Omitted on disk when it equals @@ -687,6 +694,15 @@ export type ServerProtocolEra = "legacy" | "auto" | "modern"; /** The default per-server protocol era when none is configured. */ export const DEFAULT_PROTOCOL_ERA: ServerProtocolEra = "legacy"; +/** + * Elicitation capability mode a client advertises to a server for one + * connection — see {@link InspectorServerSettings.elicitCapability}. + */ +export type ElicitCapabilityMode = "off" | "url" | "form" | "both"; + +/** The default elicitation capability mode when none is configured. */ +export const DEFAULT_ELICIT_CAPABILITY: ElicitCapabilityMode = "both"; + /** * Per-server modern (2026-07-28) per-request log level (#1629). `logging/setLevel` * is gone on the modern era; instead the client opts into logs by stamping @@ -915,6 +931,22 @@ export interface InspectorServerSettings { * omitted when it equals the default, keeping the file diff minimal. */ protocolEra?: ServerProtocolEra; + /** + * Elicitation capability this client advertises to the server for this + * connection: `"off"` (no `capabilities.elicitation` at all — the server + * sees a client that can't do elicitation and can fall back to whatever + * it does when the capability is absent, e.g. proceeding with defaults or + * failing its own way, rather than getting a guaranteed decline/cancel), + * `"url"` (URL-mode only), `"form"` (form-mode only), or `"both"`. Optional + * so a bare settings node reads back without one; absence means {@link + * DEFAULT_ELICIT_CAPABILITY} (`"both"`). Persisted on disk as + * `elicitCapability` and omitted when it equals the default. Currently + * consumed by mcpi only (#1783) — a connect-time, sticky-per-session + * choice rather than a per-call one, since a daemon-managed session can be + * reused by several later callers (interactive and scripted) over its + * lifetime. + */ + elicitCapability?: ElicitCapabilityMode; /** * Modern-era per-request log level stamped by default on this server's * connections (#1629). One of the eight logging levels, or `"off"` to not opt diff --git a/specification/v2_cli_v2.md b/specification/v2_cli_v2.md index 68bac9211d..b901707839 100644 --- a/specification/v2_cli_v2.md +++ b/specification/v2_cli_v2.md @@ -179,7 +179,7 @@ Both are wired into root `validate` / `coverage`. | **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. | +| **Sampling CLI** | Still TUI/web. mcpi handles server-driven *elicitation* (URL + form modes, `--elicit` capability override) since #1783; sampling remains unimplemented. | | **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. | From 29193232b3fbb2e898d36d5941d59b26537eb132 Mon Sep 17 00:00:00 2001 From: Bob Dickinson Date: Mon, 14 Sep 2026 20:29:30 -0700 Subject: [PATCH 18/23] Bundle mcpi into the published @modelcontextprotocol/inspector package Add mcpi's build output to the root package's `bin`/`files` so that `npm install -g @modelcontextprotocol/inspector` also installs the `mcpi` binary alongside `mcp-inspector`. mcpi's tsup build already bundles all monorepo-internal code, and root already declares every third-party dependency mcpi needs, so no new dependencies or CI risk are introduced. - package.json: add "mcpi" bin entry and "clients/mcpi/build" to files - README.md / AGENTS.md: update project-layout notes to reflect that mcpi is now bundled into the published package - clients/mcpi/README.md: add an "Install" section documenting `npm install -g @modelcontextprotocol/inspector` as the real-world install path, and rename the old install section to "Build / run from this repo (development)" to distinguish it from end-user install Verified with npm pack + an isolated global install from the packed tarball: both mcp-inspector and mcpi bins resolve and run correctly with no missing dependencies. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- AGENTS.md | 4 ++-- README.md | 2 +- clients/mcpi/README.md | 13 +++++++++++-- package.json | 4 +++- 4 files changed, 17 insertions(+), 6 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 9fe0ca8314..e6a938946c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -42,8 +42,8 @@ inspector/ │ │ └── static/ sandbox_proxy.html — served for the MCP Apps tab │ ├── cli/ Scriptable CLI (tsup bundle, @inspector/core alias) │ ├── mcpi/ Experimental session CLI (`mcpi` bin — connect once, many -│ │ commands; implicit Unix-socket session daemon). Not part -│ │ of the published package yet — see clients/mcpi/README.md +│ │ commands; implicit Unix-socket session daemon). Bundled +│ │ into the published package — see clients/mcpi/README.md │ ├── tui/ Ink + React terminal UI (tsup bundle) │ └── launcher/ The `mcp-inspector` bin; dispatches to web/cli/tui in-process ├── core/ Shared code, consumed via the `@inspector/core` alias (no package.json) diff --git a/README.md b/README.md index a08955e6c7..0fb8bcd0bf 100644 --- a/README.md +++ b/README.md @@ -49,7 +49,7 @@ inspector/ ├── clients/ │ ├── web/ Web client (Vite + React + Mantine). src/ = browser app; server/ = Node backend │ ├── cli/ CLI client (tsup bundle, @inspector/core alias) -│ ├── mcpi/ Experimental session CLI (`mcpi` bin) — not part of the +│ ├── mcpi/ Experimental session CLI (`mcpi` bin) — bundled into the │ │ published package; see clients/mcpi/README.md │ ├── tui/ TUI client (Ink + React, tsup bundle) │ └── launcher/ Shared launcher — provides the `mcp-inspector` bin, dispatches to web/cli/tui diff --git a/clients/mcpi/README.md b/clients/mcpi/README.md index c39e405d20..5a15038072 100644 --- a/clients/mcpi/README.md +++ b/clients/mcpi/README.md @@ -1,10 +1,19 @@ # 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). +**Experimental** separate client, bundled into the published `@modelcontextprotocol/inspector` npm package alongside `mcp-inspector`. 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) +## Install + +```bash +npm install -g @modelcontextprotocol/inspector +mcpi --help +``` + +This installs both bins from the same package: `mcp-inspector` (web/one-shot CLI/TUI launcher) and `mcpi` (this session CLI). `mcpi`'s own `clients/mcpi` package is `"private": true` and is never published on its own — it ships only as a bundled build inside `@modelcontextprotocol/inspector`. + +## Build / run from this repo (development) Build, then put `mcpi` on your PATH with `npm link` (points at this package’s `build/mcp-bin.js`): diff --git a/package.json b/package.json index 7110a7f5a1..b40e198a63 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,8 @@ "author": "The MCP Maintainers and Community", "type": "module", "bin": { - "mcp-inspector": "./clients/launcher/build/index.js" + "mcp-inspector": "./clients/launcher/build/index.js", + "mcpi": "./clients/mcpi/build/mcp-bin.js" }, "files": [ "clients/launcher/build", @@ -26,6 +27,7 @@ "clients/web/dist", "clients/web/static", "clients/cli/build", + "clients/mcpi/build", "clients/tui/build", "scripts/install-clients.mjs" ], From 9ecd264792c1f22eb71b4d43253fc6eeea1700d6 Mon Sep 17 00:00:00 2001 From: Bob Dickinson Date: Mon, 14 Sep 2026 21:41:37 -0700 Subject: [PATCH 19/23] Add mcpi agent-help command and skills/mcpi/SKILL.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a repo-root skills/mcpi/SKILL.md — a concise, usage-only guide to mcpi for coding agents/LLMs, installable via `npx skills add modelcontextprotocol/inspector --skill mcpi`. Ship it in the published npm package (added to root package.json's `files`) so it's also reachable from a plain `npm install -g @modelcontextprotocol/inspector` with no git/GitHub access needed. - clients/mcpi/src/session/mcp.ts: add `mcpi agent-help` (prints the SKILL.md content) and `mcpi agent-help --path` (prints its resolved file path); add a short "Agent skill for mcpi" pointer between the root command's description and its Options list. - package.json: add "skills/mcpi" to files. - skills/mcpi/SKILL.md: new — connect/run/target-session/disconnect usage, --format, catalog vs. ad-hoc targets, auth, elicitation, and session persistence across invocations. Deliberately excludes install instructions and daemon implementation details — assumes mcpi is already installed and documents only what's needed to use it. Verified: mcpi `npm run check` + `npm test` (184/184), and an isolated global install from a packed tarball resolving `mcpi agent-help` / `mcpi agent-help --path` correctly with no monorepo context present. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- clients/mcpi/src/session/mcp.ts | 48 +++++++++++++++++++++++++++++- package.json | 1 + skills/mcpi/SKILL.md | 52 +++++++++++++++++++++++++++++++++ 3 files changed, 100 insertions(+), 1 deletion(-) create mode 100644 skills/mcpi/SKILL.md diff --git a/clients/mcpi/src/session/mcp.ts b/clients/mcpi/src/session/mcp.ts index d3ef6a173c..2552509119 100644 --- a/clients/mcpi/src/session/mcp.ts +++ b/clients/mcpi/src/session/mcp.ts @@ -1,4 +1,7 @@ import { Command, type Command as CommandType } from "commander"; +import { existsSync, readFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; import type { JsonValue } from "@inspector/core/mcp/index.js"; import type { ElicitCapabilityMode, @@ -116,7 +119,8 @@ export async function runMcp(argv?: string[]): Promise { program .name("mcpi") .description( - "MCP Inspector session CLI — connect once, run many commands against a named session.", + "MCP Inspector session CLI — connect once, run many commands against a named session.\n\n" + + "Agent skill for mcpi: install with `npx skills add modelcontextprotocol/inspector --skill mcpi`, or see `agent-help` below.", ) .helpOption("-h, --help", "Display help for command") .helpCommand("help [command]", "Display help for command") @@ -209,6 +213,7 @@ export async function runMcp(argv?: string[]): Promise { // Keep infra commands last in --help (just before Commander's built-in help). registerDaemonCommands(program); registerPrivateCommand(program); + registerAgentHelpCommand(program); try { await program.parseAsync(rewritten); @@ -655,6 +660,47 @@ function registerPrivateCommand(program: CommandType): void { }); } +/** + * Locates the repo-root `skills/mcpi/SKILL.md` relative to this module. + * Tries both the built (bundled single-file, `clients/mcpi/build/`) and + * source (`clients/mcpi/src/session/`) layouts, since the two sit at + * different depths from the repo root. + */ +function resolveAgentSkillPath(): string | undefined { + const here = path.dirname(fileURLToPath(import.meta.url)); + const candidates = [ + path.resolve(here, "../../../skills/mcpi/SKILL.md"), + path.resolve(here, "../../../../skills/mcpi/SKILL.md"), + ]; + return candidates.find((candidate) => existsSync(candidate)); +} + +function registerAgentHelpCommand(program: CommandType): void { + program + .command("agent-help") + .description( + "Print mcpi's SKILL.md content — a concise, agent-oriented guide for " + + "coding agents/LLMs (also the file `npx skills` installs). Use " + + "--path to print its file location instead of its contents.", + ) + .option("--path", "Print the resolved file path instead of its contents") + .action(async (o: { path?: boolean }) => { + const skillPath = resolveAgentSkillPath(); + if (!skillPath) { + throw new CliExitCodeError( + EXIT_CODES.USAGE, + "Could not locate skills/mcpi/SKILL.md relative to this install.", + { code: "agent_help_not_found" }, + ); + } + if (o.path === true) { + await awaitableLog(skillPath + "\n"); + return; + } + await awaitableLog(readFileSync(skillPath, "utf8")); + }); +} + function registerRpcCommands(program: CommandType): void { for (const method of SESSION_RPC_METHODS) { const cmd = program diff --git a/package.json b/package.json index b40e198a63..b480bb4bbd 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "clients/web/static", "clients/cli/build", "clients/mcpi/build", + "skills/mcpi", "clients/tui/build", "scripts/install-clients.mjs" ], diff --git a/skills/mcpi/SKILL.md b/skills/mcpi/SKILL.md new file mode 100644 index 0000000000..79b34d9988 --- /dev/null +++ b/skills/mcpi/SKILL.md @@ -0,0 +1,52 @@ +--- +name: mcpi +description: Use the mcpi CLI to connect to Model Context Protocol (MCP) servers and run tools, read resources, list prompts, and more from the command line or from an agent's shell. Use this skill whenever a task requires inspecting, testing, or scripting against an MCP server (stdio or HTTP) rather than writing custom client code. +--- + +# mcpi — MCP Inspector session CLI + +Connect to an MCP server once, then run many commands against that named +session. + +```bash +mcpi connect ./path/to/server.json # config-file entry +mcpi connect https://example.com/mcp # ad-hoc HTTP/SSE target +mcpi connect node server.js # ad-hoc stdio target + +mcpi tools/list +mcpi tools/call arg:=value +mcpi resources/list +mcpi resources/read +mcpi prompts/list + +mcpi @my-session tools/list # target a specific session +mcpi --session my-session tools/list + +mcpi disconnect +``` + +Run `mcpi help` or `mcpi --help` for the full, authoritative list of +commands and flags. + +## Conventions + +- `--format json` outputs JSON; the default, `--format text`, is + human-readable. +- `mcpi sessions/list` shows open sessions; `@name` (prefix on any command) + or `--session ` selects one explicitly when the most-recently-used + session isn't the right one. +- A connected session persists across separate `mcpi` invocations — no need + to reconnect before each command. `mcpi disconnect` ends one session; + `mcpi daemon stop` resets everything. +- `mcpi connect --config path/to/mcp.json` connects a + pre-declared catalog entry (may include auth, headers, protocol-era + overrides); `mcpi connect ` connects an ad-hoc target with + defaults. +- Auth is handled automatically at connect time and stored for reuse (`mcpi + auth/list` / `mcpi auth/clear`); nothing extra is needed for authenticated + HTTP servers beyond `connect` and completing the browser flow if prompted. +- If a server asks a question mid-call (elicitation), mcpi prompts + interactively by default; running non-interactively (no TTY, scripted, or + `--format json`) auto-declines instead of hanging. Pass `--elicit off` on + `connect` if you want a well-behaved server to fall back to its own + defaults instead. From 0096e2c78c8470812895dae726ca79de10bbcdbb Mon Sep 17 00:00:00 2001 From: Bob Dickinson Date: Mon, 14 Sep 2026 22:22:20 -0700 Subject: [PATCH 20/23] mcpi: allow interactive OAuth without a TTY, add clean cancellation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit mcpi runs as a front-end for whatever invokes it — a human at a terminal, or an agent without a real TTY on stdin/stderr — but it inherited the one-shot CLI's TTY refuse-gate, which exists to protect unattended CI from hanging up to 15 minutes. That assumption doesn't hold for mcpi: an agent without a TTY is still expected to relay the printed URL to an attended human, not run unattended. mcpi now always admits interactive OAuth (`isTTY: true`), unless `--stored-auth-only` opts out entirely. Since the printed line can no longer assume a human is reading the terminal directly, mcpi wires a custom `promptMessage` into `createCliOAuthNavigation` (new option, additive, default preserved for the one-shot CLI): real TTY still gets "Please navigate to: "; no TTY gets "The user needs to navigate to this link to authenticate: " so an agent knows it must relay the link rather than treat it as its own. Live-testing this surfaced a real, pre-existing bug: mcpi never wired a shared `autoOpenControl` between the navigation and the OAuth call, so the printed line's `armed` gate was always false and nothing was ever printed, in either mode. Fixed by wiring the same disarmed-until-armed control the one-shot CLI uses. Also live-tested: interactive OAuth taking longer than the daemon's 60s idle timeout (very plausible for a real human login — SSO, MFA) left the pre-auth daemon having self-exited by the time mcpi retried the connect, producing a confusing `daemon_unreachable` error right after a successful login. Fixed by re-calling `ensureDaemon()` before the retry instead of reusing the pre-OAuth socket path; `ensureDaemon()` is already idempotent (pings first, only respawns if unreachable), so this is a no-op when the daemon is still alive. Finally, add a SIGINT/SIGTERM handler around the OAuth callback wait (`core/auth/node/runner-interactive-oauth.ts`, shared with the one-shot CLI and mirrors the existing streaming-command pattern in mcpi's dispatch.ts): Ctrl-C or a kill now rejects cleanly with "OAuth authorization cancelled (SIGINT/SIGTERM)." instead of an abrupt process death, classified as the existing `auth_required` exit code. All changes verified live end-to-end against a local OAuth test server (URL print, consent, loopback callback, token exchange, tools/call), not just via unit tests. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- clients/cli/src/cli-oauth-navigation.ts | 14 ++++- clients/mcpi/__tests__/authorize.test.ts | 38 ++++++++++++ .../mcpi/__tests__/mcp-auth-coverage.test.ts | 44 +++++++++++++ clients/mcpi/src/session/authorize.ts | 33 +++++++++- clients/mcpi/src/session/mcp.ts | 9 ++- .../auth/runner-interactive-oauth.test.ts | 62 +++++++++++++++++++ core/auth/node/runner-interactive-oauth.ts | 15 +++++ 7 files changed, 211 insertions(+), 4 deletions(-) diff --git a/clients/cli/src/cli-oauth-navigation.ts b/clients/cli/src/cli-oauth-navigation.ts index 1f5d1111c0..c0ad05fda0 100644 --- a/clients/cli/src/cli-oauth-navigation.ts +++ b/clients/cli/src/cli-oauth-navigation.ts @@ -50,6 +50,15 @@ export type CliOAuthNavigationOptions = { * (`MCP_AUTO_OPEN_ENABLED=true`). */ forceAutoOpen?: boolean; + /** + * Build the printed prompt line for a given authorize URL. Receives the + * (possibly OSC-8-linked) display string and whether stderr is a TTY. + * Defaults to the CLI's own "Please navigate to: " framing. Override + * when a different caller needs different wording — e.g. mcpi, addressed to + * whatever is running it (which may be an agent that must relay the link to + * a human) rather than to a human reading the terminal directly. + */ + promptMessage?: (hrefDisplay: string, tty: boolean) => string; }; /** @@ -108,7 +117,10 @@ export function createCliOAuthNavigation( ); const write = options.write ?? ((line: string) => process.stderr.write(line)); - write(`Please navigate to: ${style.link(href)}\n`); + const promptMessage = + options.promptMessage ?? + ((hrefDisplay: string) => `Please navigate to: ${hrefDisplay}`); + write(`${promptMessage(style.link(href), tty)}\n`); const envAllows = options.autoOpenEnabled !== undefined diff --git a/clients/mcpi/__tests__/authorize.test.ts b/clients/mcpi/__tests__/authorize.test.ts index a96f5b3a0e..d3708d21da 100644 --- a/clients/mcpi/__tests__/authorize.test.ts +++ b/clients/mcpi/__tests__/authorize.test.ts @@ -3,11 +3,19 @@ import type { MCPServerConfig } from "@inspector/core/mcp/types.js"; const connectSpy = vi.fn(); const disconnectSpy = vi.fn().mockResolvedValue(undefined); +const navigationSpy = vi.fn(); vi.mock("@inspector/cli/cliOAuth.js", () => ({ connectInspectorWithOAuth: (...args: unknown[]) => connectSpy(...args), })); +vi.mock("@inspector/cli/cli-oauth-navigation.js", () => ({ + createCliOAuthNavigation: (...args: unknown[]) => { + navigationSpy(...args); + return { navigate: vi.fn() }; + }, +})); + vi.mock("@inspector/core/mcp/index.js", () => ({ InspectorClient: class { connect = vi.fn(); @@ -29,6 +37,7 @@ describe("authorizeInFrontend", () => { afterEach(() => { connectSpy.mockReset(); disconnectSpy.mockClear(); + navigationSpy.mockClear(); }); it("no-ops for non-OAuth-capable (stdio) configs", async () => { @@ -63,4 +72,33 @@ describe("authorizeInFrontend", () => { ), ).resolves.toBeUndefined(); }); + + it("always admits interactive OAuth (isTTY: true), regardless of the real TTY state", async () => { + connectSpy.mockResolvedValue(undefined); + const { authorizeInFrontend } = await import("../src/session/authorize.js"); + await authorizeInFrontend( + { type: "streamable-http", url: "https://example.com/mcp" }, + undefined, + ); + const options = connectSpy.mock.calls[0]?.[5] as { isTTY?: boolean }; + expect(options.isTTY).toBe(true); + }); + + it("addresses the printed authorization line to whoever must relay it — a human directly, or an agent on behalf of one", async () => { + connectSpy.mockResolvedValue(undefined); + const { authorizeInFrontend } = await import("../src/session/authorize.js"); + await authorizeInFrontend( + { type: "streamable-http", url: "https://example.com/mcp" }, + undefined, + ); + const navOptions = navigationSpy.mock.calls[0]?.[0] as { + promptMessage: (hrefDisplay: string, tty: boolean) => string; + }; + expect(navOptions.promptMessage("https://example.com/auth", true)).toBe( + "Please navigate to: https://example.com/auth", + ); + expect(navOptions.promptMessage("https://example.com/auth", false)).toBe( + "The user needs to navigate to this link to authenticate: https://example.com/auth", + ); + }); }); diff --git a/clients/mcpi/__tests__/mcp-auth-coverage.test.ts b/clients/mcpi/__tests__/mcp-auth-coverage.test.ts index c7acafcfd7..cf49e44219 100644 --- a/clients/mcpi/__tests__/mcp-auth-coverage.test.ts +++ b/clients/mcpi/__tests__/mcp-auth-coverage.test.ts @@ -45,6 +45,7 @@ describe("mcp.ts auth / daemon error paths", () => { return true; }) as typeof process.stderr.write; + ensureDaemon.mockReset(); ensureDaemon.mockResolvedValue({ socketPath: "/tmp/mcp-auth-cov.sock" }); callDaemon.mockReset(); authorizeInFrontend.mockReset(); @@ -92,6 +93,49 @@ describe("mcp.ts auth / daemon error paths", () => { expect(JSON.parse(stdout.trim()).name).toBe("test-stdio"); }); + it("re-ensures the daemon after authorizeInFrontend, in case interactive OAuth outlasted its idle timeout", 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); + // Simulate the pre-auth daemon having idled out while OAuth ran: the + // retry's ensureDaemon() call returns a different (freshly respawned) + // socket than the one used for the first attempt. + ensureDaemon + .mockResolvedValueOnce({ socketPath: "/tmp/mcp-auth-cov-stale.sock" }) + .mockResolvedValueOnce({ socketPath: "/tmp/mcp-auth-cov-fresh.sock" }); + + const { runMcp } = await import("../src/session/mcp.js"); + await runMcp([ + "node", + "mcpi", + "connect", + "test-stdio", + "--config", + configPath, + "--format", + "json", + ]); + + expect(ensureDaemon).toHaveBeenCalledTimes(2); + expect(callDaemon).toHaveBeenCalledTimes(2); + expect(callDaemon.mock.calls[0][2]).toMatchObject({ + socketPath: "/tmp/mcp-auth-cov-stale.sock", + }); + expect(callDaemon.mock.calls[1][2]).toMatchObject({ + socketPath: "/tmp/mcp-auth-cov-fresh.sock", + }); + }); + it("rejects --relogin with --stored-auth-only", async () => { configPath = createSampleTestConfig(); const { runMcp } = await import("../src/session/mcp.js"); diff --git a/clients/mcpi/src/session/authorize.ts b/clients/mcpi/src/session/authorize.ts index 774d7a31c1..db31cff44c 100644 --- a/clients/mcpi/src/session/authorize.ts +++ b/clients/mcpi/src/session/authorize.ts @@ -44,9 +44,26 @@ export async function authorizeInFrontend( ); redirectUrlProvider.redirectUrl = formatRunnerOAuthRedirectUrl(callbackUrlConfig); + // Disarmed until connectInspectorWithOAuth's own interactive-OAuth window + // runs — mirrors the one-shot CLI's autoOpenControl (clients/cli/src/cli.ts): + // SDK `auth()` during plain connect() must not print/open before that + // window (or --stored-auth-only) gates it. + const autoOpenControl = { armed: false }; environment.oauth = { storage: new NodeOAuthStorage(), - navigation: createCliOAuthNavigation(), + // mcpi always attempts interactive OAuth (see the isTTY override below) — + // whoever is running it (human or agent) may not have a real TTY on + // stdin/stderr. Reword the printed line so an agent knows it must relay + // the link to a human rather than treating "Please navigate to" as + // addressed to itself. + navigation: createCliOAuthNavigation({ + autoOpenControl, + disableAutoOpen: options?.storedAuthOnly, + promptMessage: (hrefDisplay, tty) => + tty + ? `Please navigate to: ${hrefDisplay}` + : `The user needs to navigate to this link to authenticate: ${hrefDisplay}`, + }), redirectUrlProvider, }; @@ -81,7 +98,19 @@ export async function authorizeInFrontend( redirectUrlProvider, callbackUrlConfig, serverSettings, - { storedAuthOnly: options?.storedAuthOnly }, + { + storedAuthOnly: options?.storedAuthOnly, + // mcpi runs as a front-end for whatever invoked it (human terminal or + // agent subprocess) — always admit interactive OAuth rather than + // refusing when stdin/stderr aren't a real TTY. The CI-hang concern + // behind that gate (see clients/cli/README.md OAuth section) doesn't + // apply here: an agent without a TTY is still expected to relay the + // printed URL to an attended human, not run unattended. --stored-auth-only + // (checked above assertInteractiveOAuthAllowed, so unaffected by this) + // remains the way to opt out of interactive OAuth entirely. + isTTY: true, + autoOpenControl, + }, ); } finally { try { diff --git a/clients/mcpi/src/session/mcp.ts b/clients/mcpi/src/session/mcp.ts index 2552509119..23b08784cc 100644 --- a/clients/mcpi/src/session/mcp.ts +++ b/clients/mcpi/src/session/mcp.ts @@ -399,8 +399,15 @@ function registerConnect(program: CommandType): void { await authorizeInFrontend(serverConfig, serverSettings, { storedAuthOnly: false, }); + // Interactive OAuth can run well past the daemon's idle timeout + // (60s, armed while it holds zero sessions) — a slow human login + // (SSO, MFA) can leave the daemon we ensured above already exited. + // Re-ensure so the retry lands on a live daemon instead of a stale + // socket; ensureDaemon() is a no-op when the existing one still + // answers pings. + const { socketPath: freshSocketPath } = await ensureDaemon(); result = await callDaemon("connect", connectParams, { - socketPath, + socketPath: freshSocketPath, }); } await writeSessionOutput(outOpts(opts), { diff --git a/clients/web/src/test/core/auth/runner-interactive-oauth.test.ts b/clients/web/src/test/core/auth/runner-interactive-oauth.test.ts index b141c1cba8..1c53e4dc4f 100644 --- a/clients/web/src/test/core/auth/runner-interactive-oauth.test.ts +++ b/clients/web/src/test/core/auth/runner-interactive-oauth.test.ts @@ -522,4 +522,66 @@ describe("runRunnerInteractiveOAuth", () => { ).rejects.toThrow("bind failed"); expect(mockServer.stop).toHaveBeenCalled(); }); + + it("rejects cleanly on SIGINT while waiting on the callback, instead of hanging or killing the process", async () => { + const redirectUrlProvider = { redirectUrl: "" }; + const mockServer = createMockCallbackServer(handlers); + const client = mockClient({ + authenticate: vi.fn(async () => new URL("https://as.example/authorize")), + }); + + const promise = runRunnerInteractiveOAuth({ + client, + redirectUrlProvider, + callbackListen: { + hostname: "127.0.0.1", + port: 6276, + pathname: "/oauth/callback", + }, + createCallbackServer: () => mockServer, + }); + + // Give beginInteractiveAuthorization/authenticate a tick to register the + // listener before the signal fires. + await Promise.resolve(); + await Promise.resolve(); + process.emit("SIGINT", "SIGINT"); + + await expect(promise).rejects.toThrow( + "OAuth authorization cancelled (SIGINT).", + ); + expect(mockServer.stop).toHaveBeenCalled(); + // The handler must be removed once the wait settles, so a later SIGINT + // elsewhere in the process isn't accidentally swallowed by a stale + // listener from this call. + expect(process.listenerCount("SIGINT")).toBe(0); + }); + + it("rejects cleanly on SIGTERM the same way", async () => { + const redirectUrlProvider = { redirectUrl: "" }; + const mockServer = createMockCallbackServer(handlers); + const client = mockClient({ + authenticate: vi.fn(async () => new URL("https://as.example/authorize")), + }); + + const promise = runRunnerInteractiveOAuth({ + client, + redirectUrlProvider, + callbackListen: { + hostname: "127.0.0.1", + port: 6276, + pathname: "/oauth/callback", + }, + createCallbackServer: () => mockServer, + }); + + await Promise.resolve(); + await Promise.resolve(); + process.emit("SIGTERM", "SIGTERM"); + + await expect(promise).rejects.toThrow( + "OAuth authorization cancelled (SIGTERM).", + ); + expect(process.listenerCount("SIGTERM")).toBe(0); + }); }); diff --git a/core/auth/node/runner-interactive-oauth.ts b/core/auth/node/runner-interactive-oauth.ts index ce0dd199ee..4adfbbbdb0 100644 --- a/core/auth/node/runner-interactive-oauth.ts +++ b/core/auth/node/runner-interactive-oauth.ts @@ -78,6 +78,19 @@ export async function runRunnerInteractiveOAuth( flowReject = reject; }); + // Ctrl-C / a caller killing the process while waiting on the loopback + // callback would otherwise either hang until the timeout below or (for + // SIGINT specifically, absent any handler) hit Node's default abrupt exit + // with no cleanup. Reject cleanly instead so the server is stopped and the + // caller gets a normal, classifiable error ("OAuth" in the message maps to + // AUTH_REQUIRED — see clients/cli/src/error-handler.ts) rather than a raw + // process death. + const onSignal = (signal: NodeJS.Signals) => { + flowReject(new Error(`OAuth authorization cancelled (${signal}).`)); + }; + process.on("SIGINT", onSignal); + process.on("SIGTERM", onSignal); + let timeoutId: ReturnType | undefined; try { @@ -154,6 +167,8 @@ export async function runRunnerInteractiveOAuth( return { kind: "success" }; } finally { + process.off("SIGINT", onSignal); + process.off("SIGTERM", onSignal); if (timeoutId !== undefined) { clearTimeout(timeoutId); } From 12353bea2eb53bd3cbbba39c266ebae7bf2737b6 Mon Sep 17 00:00:00 2001 From: Bob Dickinson Date: Mon, 14 Sep 2026 22:42:17 -0700 Subject: [PATCH 21/23] mcpi: close per-file coverage gaps in mcp.ts (CI fix) CI's coverage gate (per-file, >=90% on all four dimensions) was failing for src/session/mcp.ts (88% functions, 88.1% branches). This predates the OAuth work in the previous commit -- it was already failing on the prior "Add mcpi agent-help command" commit too, since that added the agent-help command (and its resolveAgentSkillPath helper) with zero test coverage. It went unnoticed locally because validate:mcpi runs vitest run without --coverage, and only coverage:mcpi (which CI's coverage job runs separately) enforces the per-file threshold. Add the missing coverage: - New __tests__/agent-help.test.ts: exercises "mcpi agent-help" (prints SKILL.md content) and "mcpi agent-help --path" (prints resolved path). - __tests__/mcp-coverage.test.ts: exercise the previously-unregistered servers/show, skills/list (with/without --verify), and skills/get (positional and --uri forms) RPC command actions over a live connected session. Verified locally: "cd clients/mcpi && npx vitest run --coverage" now exits 0 with mcp.ts at 96.66/92.43/100/96.92 (stmts/branch/funcs/lines), all above the 90% gate. Also re-checked coverage:cli and coverage:web (cover the other files touched by the previous commit) -- both clean. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- clients/mcpi/__tests__/agent-help.test.ts | 20 +++++++++++ clients/mcpi/__tests__/mcp-coverage.test.ts | 39 +++++++++++++++++++++ 2 files changed, 59 insertions(+) create mode 100644 clients/mcpi/__tests__/agent-help.test.ts diff --git a/clients/mcpi/__tests__/agent-help.test.ts b/clients/mcpi/__tests__/agent-help.test.ts new file mode 100644 index 0000000000..89f5fb5978 --- /dev/null +++ b/clients/mcpi/__tests__/agent-help.test.ts @@ -0,0 +1,20 @@ +import { describe, it, expect } from "vitest"; +import { existsSync } from "node:fs"; +import { runMcp } from "./helpers/mcp-runner.js"; + +describe("mcpi agent-help", () => { + it("prints skills/mcpi/SKILL.md content, including its frontmatter", async () => { + const result = await runMcp(["agent-help"]); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("name: mcpi"); + expect(result.stdout).toContain("mcpi connect"); + }); + + it("--path prints the resolved SKILL.md file path", async () => { + const result = await runMcp(["agent-help", "--path"]); + expect(result.exitCode).toBe(0); + const printedPath = result.stdout.trim(); + expect(printedPath.endsWith("skills/mcpi/SKILL.md")).toBe(true); + expect(existsSync(printedPath)).toBe(true); + }); +}); diff --git a/clients/mcpi/__tests__/mcp-coverage.test.ts b/clients/mcpi/__tests__/mcp-coverage.test.ts index ae59ca0533..63735452a1 100644 --- a/clients/mcpi/__tests__/mcp-coverage.test.ts +++ b/clients/mcpi/__tests__/mcp-coverage.test.ts @@ -267,6 +267,45 @@ describe("mcp.ts coverage", () => { expectCliSuccess(rootsList); expect(JSON.parse(rootsList.stdout)).toHaveProperty("roots"); + const show = await runMcp( + [ + "servers/show", + "test-stdio", + "--config", + configPath, + "--format", + "json", + ], + { env: e, timeout: 20000 }, + ); + expectCliSuccess(show); + + // Skills support is optional; the default test server may not advertise + // it. Either way, the RPC action itself should run (not a usage error). + const skillsList = await runMcp(["skills/list", "--format", "json"], { + env: e, + timeout: 20000, + }); + expect([0, 1]).toContain(skillsList.exitCode); + + const skillsListVerify = await runMcp( + ["skills/list", "--verify", "--format", "json"], + { env: e, timeout: 20000 }, + ); + expect([0, 1]).toContain(skillsListVerify.exitCode); + + const skillsGet = await runMcp( + ["skills/get", "test://skill", "--verify", "--format", "json"], + { env: e, timeout: 20000 }, + ); + expect([0, 1]).toContain(skillsGet.exitCode); + + const skillsGetFlagUri = await runMcp( + ["skills/get", "--uri", "test://skill", "--format", "json"], + { env: e, timeout: 20000 }, + ); + expect([0, 1]).toContain(skillsGetFlagUri.exitCode); + await runMcp( ["disconnect", "--session", "test-stdio", "--format", "json"], { From 7cf453842db00319e0c083004ec72ba77a0f182a Mon Sep 17 00:00:00 2001 From: Bob Dickinson Date: Mon, 14 Sep 2026 22:56:25 -0700 Subject: [PATCH 22/23] mcpi: allow non-TTY elicitation prompting (agent-attended) Elicitation (both URL mode and form mode) was gated on a real TTY the same way OAuth used to be: `interactive` required `stdin.isTTY && stdout.isTTY`, so a piped/non-TTY stdin (an agent relaying prompts to a human, or answering on their behalf) always got an immediate decline/cancel instead of a chance to answer. That gate wasn't technically necessary. promptForm()/promptField() and the URL-mode confirm all just use readline's question() over process.stdin/stderr -- plain line-based I/O, no raw-mode or TTY features involved. An agent piping answers back on stdin after reading the prompts on stderr works exactly like a human typing at a terminal. Changes: - dispatch.ts: `interactive` is now `format === "text"` (drop the isTTY checks). Only `--format json` still auto-declines/cancels, since its stdout is a single machine-readable payload with no room to interleave a prompt. - elicitation-prompt.ts: reworded the two "requires an interactive terminal" messages to reflect the real reason (`--format json`), and raced the URL-mode confirm's rl.question() against a new watchForClose() signal (see below). - form-prompt.ts: added watchForClose(rl)/ask() -- every rl.question() in promptField() and the review step now races against the readline interface's "close" event. Previously, if stdin closed before an answer arrived (e.g. `mcpi ... --- .../mcpi/__tests__/elicitation-prompt.test.ts | 39 ++++++++++++--- clients/mcpi/__tests__/form-prompt.test.ts | 24 ++++++++- clients/mcpi/src/session/dispatch.ts | 13 +++-- .../mcpi/src/session/elicitation-prompt.ts | 30 ++++++++---- clients/mcpi/src/session/form-prompt.ts | 49 ++++++++++++++++--- 5 files changed, 127 insertions(+), 28 deletions(-) diff --git a/clients/mcpi/__tests__/elicitation-prompt.test.ts b/clients/mcpi/__tests__/elicitation-prompt.test.ts index 17f0f164ad..0977592450 100644 --- a/clients/mcpi/__tests__/elicitation-prompt.test.ts +++ b/clients/mcpi/__tests__/elicitation-prompt.test.ts @@ -6,13 +6,21 @@ const question = vi.fn(); const close = vi.fn(); const promptFormMock = vi.fn(); +const once = vi.fn(); + vi.mock("node:readline/promises", () => ({ - createInterface: () => ({ question, close }), + createInterface: () => ({ question, close, once }), })); -vi.mock("../src/session/form-prompt.js", () => ({ - promptForm: (...args: unknown[]) => promptFormMock(...args), -})); +vi.mock("../src/session/form-prompt.js", async () => { + const actual = await vi.importActual< + typeof import("../src/session/form-prompt.js") + >("../src/session/form-prompt.js"); + return { + promptForm: (...args: unknown[]) => promptFormMock(...args), + watchForClose: actual.watchForClose, + }; +}); /** * Covers `promptElicitation`'s terminal UI: form mode always declines @@ -37,6 +45,7 @@ describe("promptElicitation", () => { }) as typeof process.stderr.write; question.mockReset(); close.mockReset(); + once.mockReset(); promptFormMock.mockReset(); }); @@ -117,10 +126,10 @@ describe("promptElicitation", () => { action: "decline", }); expect(question).not.toHaveBeenCalled(); - expect(stderr).toContain("requires an interactive terminal"); + expect(stderr).toContain("--format json"); }); - it("cancels non-interactively (e.g. --format json or non-TTY) without prompting", async () => { + it("cancels when the caller isn't interactive (e.g. --format json) without prompting", async () => { const { promptElicitation } = await import("../src/session/elicitation-prompt.js"); const frame = urlFrame(); @@ -135,7 +144,7 @@ describe("promptElicitation", () => { action: "cancel", }); expect(question).not.toHaveBeenCalled(); - expect(stderr).toContain("requires an interactive terminal"); + expect(stderr).toContain("--format json"); }); it("cancels non-interactively without a url line when the frame has none", async () => { @@ -186,6 +195,22 @@ describe("promptElicitation", () => { expect(close).toHaveBeenCalled(); }); + it("cancels URL mode if stdin closes before the user answers", async () => { + // Simulates a non-TTY stdin (e.g. an agent-driven pipe) hitting EOF + // before an answer arrives: question() hangs, but the "close" listener + // registered via watchForClose() fires and wins the race. + question.mockImplementation(() => new Promise(() => {})); + once.mockImplementation((event: string, cb: () => void) => { + if (event === "close") cb(); + }); + const { promptElicitation } = + await import("../src/session/elicitation-prompt.js"); + const frame = urlFrame(); + const answer = await promptElicitation(frame, { interactive: true, style }); + expect(answer.action).toBe("cancel"); + expect(close).toHaveBeenCalled(); + }); + it("accepts an interactive form submission and returns its content", async () => { promptFormMock.mockResolvedValue({ action: "accept", diff --git a/clients/mcpi/__tests__/form-prompt.test.ts b/clients/mcpi/__tests__/form-prompt.test.ts index c5f318f405..9b77a75f4d 100644 --- a/clients/mcpi/__tests__/form-prompt.test.ts +++ b/clients/mcpi/__tests__/form-prompt.test.ts @@ -32,6 +32,7 @@ describe("promptForm", () => { function fakeRl(answers: string[]) { let i = 0; + const closeHandlers: Array<() => void> = []; return { question: vi.fn(async () => { const answer = answers[i]; @@ -41,7 +42,16 @@ describe("promptForm", () => { } return answer; }), - } as unknown as Parameters[0]; + once: vi.fn((event: string, cb: () => void) => { + if (event === "close") closeHandlers.push(cb); + }), + // Test-only hook: simulates the underlying stdin closing (e.g. a + // redirected/piped input hitting EOF) so we can exercise the + // watchForClose() race without a real stream. + __triggerClose: () => closeHandlers.forEach((cb) => cb()), + } as unknown as Parameters[0] & { + __triggerClose: () => void; + }; } const stringField: FormField = { @@ -374,4 +384,16 @@ describe("promptForm", () => { await promptForm(rl, "msg", [field], style); expect(stderr).toContain("(none)"); }); + + it("rejects instead of hanging when stdin closes before an answer arrives", async () => { + const rl = fakeRl([]); + (rl.question as ReturnType).mockImplementation( + () => new Promise(() => {}), // never resolves on its own + ); + const outcome = promptForm(rl, "msg", [stringField], style); + (rl as unknown as { __triggerClose: () => void }).__triggerClose(); + await expect(outcome).rejects.toThrow( + "stdin closed before an answer was given", + ); + }); }); diff --git a/clients/mcpi/src/session/dispatch.ts b/clients/mcpi/src/session/dispatch.ts index 7de50f791c..cdb8d3a951 100644 --- a/clients/mcpi/src/session/dispatch.ts +++ b/clients/mcpi/src/session/dispatch.ts @@ -77,10 +77,15 @@ export async function dispatchSessionRpc( onElicitation: (frame) => promptElicitation(frame, { style, - interactive: - format === "text" && - process.stdin.isTTY === true && - process.stdout.isTTY === true, + // Prompting only needs a readable stdin and a text-based reply + // channel, not an actual TTY — an agent relaying prompts to a human + // (or answering directly) over a plain pipe works the same way a + // human at a terminal does. `--format json` is still excluded since + // stdout is a single machine-readable payload there, not a place to + // interleave prompts. A stdin that's already closed (e.g. ` { + return new Promise((_, reject) => { + rl.once("close", () => + reject(new Error("stdin closed before an answer was given")), + ); + }); +} + +/** `rl.question()`, but rejects instead of hanging if stdin closes first. */ +function ask( + rl: ReadlineInterface, + closed: Promise, + prompt: string, +): Promise { + return Promise.race([rl.question(prompt), closed]); +} + function formatDefault(field: FormField): string | undefined { if (field.default === undefined) return undefined; if (field.kind === "multiselect") { @@ -34,6 +59,7 @@ function describeField(field: FormField, style: Style): string { /** Prompts for one field's value; loops until a valid answer or a default/blank-when-optional. */ async function promptField( rl: ReadlineInterface, + closed: Promise, field: FormField, style: Style, ): Promise { @@ -42,7 +68,7 @@ async function promptField( const def = field.default; const hint = def === undefined ? "y/n" : def ? "Y/n" : "y/N"; const raw = ( - await rl.question(`${describeField(field, style)}\n [${hint}]: `) + await ask(rl, closed, `${describeField(field, style)}\n [${hint}]: `) ) .trim() .toLowerCase(); @@ -63,7 +89,9 @@ async function promptField( ? "Enter one or more numbers separated by commas" : "Enter a number"; const raw = ( - await rl.question( + await ask( + rl, + closed, `${describeField(field, style)}\n${lines.join("\n")}\n ${prompt}: `, ) ).trim(); @@ -105,7 +133,9 @@ async function promptField( if (field.kind === "number") { const def = field.default; const raw = ( - await rl.question( + await ask( + rl, + closed, `${describeField(field, style)}\n ${def !== undefined ? `[${def}]` : ""}: `, ) ).trim(); @@ -138,7 +168,9 @@ async function promptField( // string const def = field.default; - const raw = await rl.question( + const raw = await ask( + rl, + closed, `${describeField(field, style)}\n ${def !== undefined ? `[${def}]` : ""}: `, ); const value = raw === "" && def !== undefined ? def : raw; @@ -174,10 +206,11 @@ export async function promptForm( style: Style, ): Promise { process.stderr.write(`\n${style.bold("Input requested: ")}${message}\n\n`); + const closed = watchForClose(rl); const values = new Map(); for (const field of fields) { - values.set(field.name, await promptField(rl, field, style)); + values.set(field.name, await promptField(rl, closed, field, style)); } for (;;) { @@ -189,7 +222,9 @@ export async function promptForm( ); } const answer = ( - await rl.question( + await ask( + rl, + closed, "\nPress Enter to submit, type a field name to edit it, or 'c' to cancel: ", ) ).trim(); @@ -211,6 +246,6 @@ export async function promptForm( ); continue; } - values.set(field.name, await promptField(rl, field, style)); + values.set(field.name, await promptField(rl, closed, field, style)); } } From 7fd4af59a794316c50f1643a438dad8cc0d59264 Mon Sep 17 00:00:00 2001 From: Bob Dickinson Date: Mon, 14 Sep 2026 23:16:54 -0700 Subject: [PATCH 23/23] mcpi: clean SIGINT/SIGTERM cancellation for the general rpc path Previously SIGINT/SIGTERM handling only existed for the two STREAM_METHODS (logging/tail, resources/subscribe). Every other session RPC -- tools/call, connect, an elicitation wait, etc -- had no signal handler at all, so Ctrl-C during one of those just killed the process abruptly instead of cancelling cleanly (the same class of problem the OAuth SIGINT fix addressed earlier this session, just for a different code path). - daemon/client.ts: callDaemon() now accepts a `signal?: AbortSignal` option (mirroring streamDaemon's existing one). On abort it fails the in-flight call with a clear `'' cancelled.` error (`code: "cancelled"`, exit code USAGE) instead of leaving the socket to time out or the process to die uncleanly. - session/dispatch.ts: wires an AbortController + SIGINT/SIGTERM handlers around the general `callDaemon("rpc", ...)` call the same way STREAM_METHODS already does, listeners removed in `finally`. Also fixed a stale doc comment: daemon/protocol.ts's `SessionNameParams.requireExplicit` said the front-end sets it from `!process.stdout.isTTY`, but dispatch.ts actually keys off `stdin` (so piping output, e.g. `mcpi tools/list | jq`, still uses MRU when a human is at the keyboard) -- the comment just hadn't been updated to match. New tests: - __tests__/dispatch.test.ts: SIGINT/SIGTERM now aborts the general rpc call too (not just streams), and listeners are removed once the call settles (no leak across calls). - __tests__/elicitation-client.test.ts: a real socket that never responds is aborted mid-call via signal.abort(), rejecting promptly with the cancellation error rather than hanging until timeoutMs. Verified: validate:mcpi (194 tests, format/lint/typecheck clean) and coverage:mcpi (per-file gate, all green). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- clients/mcpi/__tests__/dispatch.test.ts | 29 +++++++++++++ .../mcpi/__tests__/elicitation-client.test.ts | 17 ++++++++ clients/mcpi/src/daemon/client.ts | 17 ++++++++ clients/mcpi/src/daemon/protocol.ts | 5 ++- clients/mcpi/src/session/dispatch.ts | 43 ++++++++++++------- 5 files changed, 94 insertions(+), 17 deletions(-) diff --git a/clients/mcpi/__tests__/dispatch.test.ts b/clients/mcpi/__tests__/dispatch.test.ts index 4df91816a2..a037270757 100644 --- a/clients/mcpi/__tests__/dispatch.test.ts +++ b/clients/mcpi/__tests__/dispatch.test.ts @@ -107,6 +107,35 @@ describe("dispatchSessionRpc", () => { expect(streamDaemon).toHaveBeenCalled(); }); + it("wires SIGINT/SIGTERM abort for the general rpc path (not just streams)", async () => { + callDaemon.mockImplementation( + async (_op: string, _params: unknown, opts: { signal?: AbortSignal }) => { + process.emit("SIGTERM"); + expect(opts.signal?.aborted).toBe(true); + return { kind: "result", result: {} }; + }, + ); + const { dispatchSessionRpc } = await import("../src/session/dispatch.js"); + await dispatchSessionRpc( + "tools/call", + {}, + { format: "json", requireExplicit: false }, + ); + expect(callDaemon).toHaveBeenCalled(); + }); + + it("removes the SIGINT/SIGTERM listeners after the rpc call settles", async () => { + callDaemon.mockResolvedValue({ kind: "result", result: {} }); + const before = process.listenerCount("SIGINT"); + const { dispatchSessionRpc } = await import("../src/session/dispatch.js"); + await dispatchSessionRpc( + "tools/call", + {}, + { format: "json", requireExplicit: false }, + ); + expect(process.listenerCount("SIGINT")).toBe(before); + }); + it("wires onElicitation as interactive when text format + TTY stdin/stdout", async () => { callDaemon.mockResolvedValue({ kind: "result", result: {} }); const stdinDesc = Object.getOwnPropertyDescriptor(process.stdin, "isTTY"); diff --git a/clients/mcpi/__tests__/elicitation-client.test.ts b/clients/mcpi/__tests__/elicitation-client.test.ts index c39bbd3424..37bcb2ad95 100644 --- a/clients/mcpi/__tests__/elicitation-client.test.ts +++ b/clients/mcpi/__tests__/elicitation-client.test.ts @@ -251,6 +251,23 @@ describe("callDaemon elicitation duplex", () => { ).rejects.toThrow("prompt blew up"); }); + it("fails with a clear cancellation error when the abort signal fires mid-call", async () => { + const sock = freshSock(); + await listen(sock, () => { + // Never respond — the call should hang until aborted, not until + // timeoutMs, proving the signal (not the timeout) ended it. + }); + + const ac = new AbortController(); + const promise = callDaemon( + "rpc", + { method: "tools/call" }, + { socketPath: sock, timeoutMs: 60_000, signal: ac.signal }, + ); + ac.abort(); + await expect(promise).rejects.toThrow("cancelled"); + }); + it("silently swallows a post-settle socket error (e.g. late ECONNRESET)", async () => { const sock = freshSock(); let serverSocket: net.Socket | undefined; diff --git a/clients/mcpi/src/daemon/client.ts b/clients/mcpi/src/daemon/client.ts index a49ed82f48..c83f6e804f 100644 --- a/clients/mcpi/src/daemon/client.ts +++ b/clients/mcpi/src/daemon/client.ts @@ -29,6 +29,12 @@ export type DaemonClientOptions = { onElicitation?: ( frame: ElicitationRequestFrame, ) => Promise; + /** + * Abort the in-flight request (e.g. on SIGINT/SIGTERM), failing it with a + * clear cancellation error instead of leaving the caller to kill the + * process abruptly mid-call (mid-`tools/call`, mid-elicitation-wait, etc). + */ + signal?: AbortSignal; }; /** @@ -60,11 +66,20 @@ export async function callDaemon( if (settled) return; settled = true; if (timer !== undefined) clearTimeout(timer); + options.signal?.removeEventListener("abort", onAbort); socket.removeAllListeners(); socket.on("error", () => {}); fn(); } + function onAbort() { + fail( + new CliExitCodeError(EXIT_CODES.USAGE, `'${op}' cancelled.`, { + code: "cancelled", + }), + ); + } + function fail(error: unknown) { settle(() => { socket.destroy(); @@ -176,6 +191,8 @@ export async function callDaemon( ); }, timeoutMs); + options.signal?.addEventListener("abort", onAbort, { once: true }); + socket.once("connect", () => { socket.write(encodeRequest(request)); }); diff --git a/clients/mcpi/src/daemon/protocol.ts b/clients/mcpi/src/daemon/protocol.ts index 8ae7dd0743..5291114969 100644 --- a/clients/mcpi/src/daemon/protocol.ts +++ b/clients/mcpi/src/daemon/protocol.ts @@ -39,7 +39,10 @@ export type SessionNameParams = { 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. + * Front-end sets this from `!process.stdin.isTTY` (not stdout — keying off + * stdin lets piping output, e.g. `mcpi tools/list | jq`, still use MRU when + * a human is at the keyboard) unless opted out via + * `MCP_ALLOW_DEFAULT_SESSION=1`. */ requireExplicit?: boolean; }; diff --git a/clients/mcpi/src/session/dispatch.ts b/clients/mcpi/src/session/dispatch.ts index cdb8d3a951..c7b912050e 100644 --- a/clients/mcpi/src/session/dispatch.ts +++ b/clients/mcpi/src/session/dispatch.ts @@ -72,22 +72,33 @@ export async function dispatchSessionRpc( return; } - const outcome = await callDaemon("rpc", params, { - socketPath, - onElicitation: (frame) => - promptElicitation(frame, { - style, - // Prompting only needs a readable stdin and a text-based reply - // channel, not an actual TTY — an agent relaying prompts to a human - // (or answering directly) over a plain pipe works the same way a - // human at a terminal does. `--format json` is still excluded since - // stdout is a single machine-readable payload there, not a place to - // interleave prompts. A stdin that's already closed (e.g. ` ac.abort(); + process.on("SIGINT", onSignal); + process.on("SIGTERM", onSignal); + let outcome: RpcResult; + try { + outcome = await callDaemon("rpc", params, { + socketPath, + signal: ac.signal, + onElicitation: (frame) => + promptElicitation(frame, { + style, + // Prompting only needs a readable stdin and a text-based reply + // channel, not an actual TTY — an agent relaying prompts to a human + // (or answering directly) over a plain pipe works the same way a + // human at a terminal does. `--format json` is still excluded since + // stdout is a single machine-readable payload there, not a place to + // interleave prompts. A stdin that's already closed (e.g. `