diff --git a/.claude/skills/pr-flow/SKILL.md b/.claude/skills/pr-flow/SKILL.md index fb17c0ee76..528d9a4b97 100644 --- a/.claude/skills/pr-flow/SKILL.md +++ b/.claude/skills/pr-flow/SKILL.md @@ -83,13 +83,41 @@ branch cut from `v2/main` arrives at `v1/main` carrying the whole v2 tree (Copilot). And never cut from a milestone-merge branch — it carries release-only commits that will show up in your PR's diff. -Working in a git worktree is fine and often preferable. ⚠️ **A worktree needs a -real `npm install`, not a symlinked `node_modules`** — a symlink passes -lint/test/coverage and then fails all Storybook story files on Vite's -`fs.allow`. +**Always cut it into a new git worktree, never by switching branches in the +primary checkout** (#2661). This repo is routinely worked by several agent +sessions on one machine at once, and they share the primary checkout: a +`git switch` there moves the branch out from under every other session using +it, and carries one task's uncommitted edits, build output and `node_modules` +state into another's. A worktree per issue gives each task its own tree and +leaves the primary checkout alone. + +Run it from the primary checkout. The worktree is a **sibling** directory named +for the checkout plus the issue number (`mcp-inspector-v2` → +`../mcp-inspector-v2-2661`), never a directory inside the repo, where every +lint, format and typecheck scope would see a second copy of the source: + +```sh +git fetch origin +git worktree add --no-track "../$(basename "$PWD")-" \ + -b v2/fix/- origin/v2/main # origin/v1/main for v1 +cd "../$(basename "$PWD")-" +npm install +``` + +- `--no-track` keeps the new branch from tracking `origin/v2/main`, so + `git status` does not report your work as "ahead of v2/main" and the first + push is an explicit `git push -u origin ` to a branch of its own name. +- ⚠️ **The `npm install` is part of the step, not optional.** A worktree starts + with no `node_modules`, and it needs a **real** install — a symlinked + `node_modules` passes lint/test/coverage and then fails all Storybook story + files on Vite's `fs.allow`. Background it if you have edits to make first; + the gate needs it finished. +- Every later step — edits, the gate, commits, the push — runs **inside the + worktree**. For order-dependent PRs on one issue, **stack** them: each branch is based on the -previous one, not all cut from `v2/main`. +previous one, not all cut from `v2/main` — so the child's worktree is cut from +the parent branch (`git worktree add … -b `). ## 3. Sign off every commit @@ -453,3 +481,16 @@ nothing else. `Done` is only for work that **shipped** — a merged PR, or a parent whose last sub-issue closed. Anything else (duplicate, won't fix, not planned, obsolete) means nothing shipped, so **delete the card** instead; see `/board-ops`. + +**Then remove the worktree and its local branch**, from the primary checkout, +once the PR shows as merged (or closed, for work abandoned). A stale worktree +keeps its branch checked out — git refuses to check that branch out anywhere +else — and holds a full `node_modules` on disk per issue: + +```sh +git worktree remove "../$(basename "$PWD")-" +git branch -D v2/fix/- +``` + +`git worktree remove` refuses a worktree with uncommitted or untracked changes; +read what is there before reaching for `--force`. diff --git a/.claude/skills/pre-push-gate/SKILL.md b/.claude/skills/pre-push-gate/SKILL.md index cb31fc1cde..c43fbdde9a 100644 --- a/.claude/skills/pre-push-gate/SKILL.md +++ b/.claude/skills/pre-push-gate/SKILL.md @@ -98,6 +98,21 @@ stale install otherwise passes every check and fails later as a behavioral test reporting the *old* dependency's behavior as a product bug (#2494). Don't "fix" that test. +### `verify:install-scripts` + +A dependency with an install script is neither allowed nor denied in the +`allowScripts` of the `package.json` beside that lockfile (#2712) — usually a +dependency you just added or bumped. npm 11.x still **runs** such a script, so +this is a review decision, not a typo: add `"": false` to block it, or +`"@": true` if the package genuinely needs it. Each install +reads only its own manifest, so a package held by several installs needs an +entry in each. A **non-registry** dependency (git, file, remote tarball) is +reported even with no flagged script, since npm may run its `prepare`; clear it +with a key equal to its exact `resolved` spec from the lockfile. A **bundled** +one can't be cleared by any key — the fix is the dependency that bundles it. +Don't reach for `ignore-scripts` — it also disables the root +`postinstall` that installs every client. + ### `local:dco` A commit in `origin/v2/main..HEAD` (minus anything already on `origin/main`) @@ -178,6 +193,25 @@ lowering the gate. See `/testing` for the acceptable reasons. Also check the file is inside a gated directory — the web coverage `include` is a whitelist, so a module placed outside it falls out of the gate silently. +### `verify:coverage-scope` + +The last step of `coverage` (#2709). A client's `coverage-summary.json` is +missing files its own coverage `include`/`exclude` declares, so those files +were never judged by the per-file gate. The failure names the client and each +missing file. Two causes: + +- **The run's glob matching changed** — typically a Vitest bump. The fix is + the client's `include` patterns (an absolute `path.join(dirname, …)` pattern + means the same thing under every matcher), never an entry that excuses the + files. +- **A file is in scope but the run legitimately drops it.** State it in + `KNOWN_OMISSIONS` in `scripts/verify-coverage-scope.mjs`, with the reason — + or, better, add it to the client's coverage `exclude`, which says the same + thing where the gate reads it. + +"no `coverage-summary.json`" means the client's coverage did not run (or ran +without the `json-summary` reporter). Run `npm run coverage`. + ### `smoke:web*` ⚠️ **An orphaned prod web server from a previous run fakes a rejection.** It @@ -241,6 +275,14 @@ One stage has no GitHub CI counterpart, deliberately: Firefox. Trialled as a CI job and removed (#2086): across a dozen runs it never disagreed with Chromium, and `playwright install --with-deps` carries a real flake surface. Kept in front of a human about to push instead. + ⚠️ **On macOS 27 it runs inside the Linux Playwright container** (#2625): + Playwright's Firefox cannot launch there. It needs a running Docker daemon; + without one the stage prints a `SKIPPING … this is NOT a pass` warning and + exits 0 so `local:storybook` still runs — start Docker and re-run + `npm run smoke:web:firefox` before pushing. A container failure is a real + Firefox failure, not an infrastructure one, unless the log shows the image + pull or `npm install` failing. `INSPECTOR_FIREFOX_SMOKE=native|container` + overrides the routing (`scripts/lib/firefox-container.mjs`). A guard (`scripts/lib/workflow-gate.mjs`, run by `npm run test:scripts`) fails the suite if a workflow invokes a `local:*` script, a non-Chromium engine pass, diff --git a/.claude/skills/release/SKILL.md b/.claude/skills/release/SKILL.md index d076c308e1..844f6baae1 100644 --- a/.claude/skills/release/SKILL.md +++ b/.claude/skills/release/SKILL.md @@ -161,9 +161,13 @@ the `pack:verify OK` line) for the ledger below. **Every contribution closed in the milestone gets driven, not read.** The bar is observed behavior from the running app — a rendered panel, a status attribute, a -server's own stderr — against a real test server, through whichever clients the -change touches (web, CLI, TUI). "Its tests pass" is not evidence for this step; -the gate already said that. For a change with no observable surface, the +server's own stderr — against a real test server, through whichever surfaces the +change touches: the web client, the one-shot CLI, the TUI, `mcpdo` (the +connection CLI, shipped in the same package since 2.10.0) and the Docker image. +**A change to `core/cli/` is driven through both CLIs** — the one-shot CLI and +`mcpdo` share its method handlers, exit codes, OAuth flow and output styling +(#2461), so passing through one says nothing about the other. "Its tests pass" +is not evidence for this step; the gate already said that. For a change with no observable surface, the evidence is the thing that holds it — a probe that makes the guard fire, a counted before/after, a resolved binary path. @@ -187,6 +191,14 @@ and link it from PR 2. Shape it like the - **One section per theme**, each a table of *Issue · What was driven · Observed · Status*. One row per closed issue, issue-linked, with the actual output in the Observed cell. + - **An `mcpdo` row runs against a real connection** — `mcpdo connect …`, the + command under test, then `mcpdo disconnect …` — and its *What was driven* + cell shows all three. ⚠️ The connection daemon (`mcpdod`) outlives a + rebuild and keeps serving the code it started with, so a daemon left over + from before the production build smokes the wrong bundle and the row reads + as a pass. Run `pkill mcpdod` before the first `mcpdo` row (or rebuild with + `npm run build:dev` in `clients/mcpdo`, which stops it); see + `clients/mcpdo/README.md`. - **Notes / findings** — anything that is a caveat rather than a pass, called out rather than folded into a row. diff --git a/.claude/skills/test-servers/SKILL.md b/.claude/skills/test-servers/SKILL.md index 97ebfd68c2..fbde45e502 100644 --- a/.claude/skills/test-servers/SKILL.md +++ b/.claude/skills/test-servers/SKILL.md @@ -207,7 +207,7 @@ exist whichever one you are in: cd clients/web && npm run test-servers:build # tsc -p test-servers → test-servers/build/ ``` -Scripts reach this through `scripts/ensure-test-servers.mjs`, which builds +Scripts reach this through `scripts/lib/ensure-test-servers.mjs`, which builds **unconditionally** (once per process per repo root). ⚠️ **Unconditional emit is not a clean.** A **deleted** source file leaves its diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 52a0c8ec56..a05fbcf93e 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -217,6 +217,10 @@ jobs: # This also covers the web integration project: web's `test:coverage` # runs `--project=unit --project=integration --coverage`, so there is no # separate integration step anywhere in this workflow. + # + # `coverage` ends with `verify:coverage-scope` (#2709), which fails if + # any client's report measured fewer files than its coverage `include` + # declares — the gate otherwise passes on whatever subset it was handed. run: npm run coverage # Publish the single `@modelcontextprotocol/inspector` package to npm on a diff --git a/AGENTS.md b/AGENTS.md index 76db4c1123..aad77a1906 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -390,12 +390,13 @@ skills; the rules are here. - **Every v2 board item has a Priority.** Priority is a **board field**, not a label, so an unboarded issue has nowhere to store it. Derive it with the rubric in the `issue-triage` skill rather than asserting it. Board #11 has no Priority field; a v1 issue gets a Status and nothing else. - **`Incoming` ⇔ no milestone; everything past it ⇔ milestoned — on board #28.** Board #11 is exempt for the reason above: a v1 issue has no bucket to take, so its Status is set on its own and the audit's milestone checks do not apply to it. A `[GHSA-` **advisory draft** on #28 is exempt too, for a different reason: a draft card cannot carry a milestone, so its approval act is **accepting the advisory**, which moves it `Incoming` → `Todo`; its milestone arrives with the public issue after publication. The rest of the invariant is unchanged: assigning the milestone _is_ the approval act, so the two always go together. `Todo` asserts a maintainer signed off, so never park an unreviewed issue there — that erases the distinction and quietly promotes unreviewed work into the queue. An issue created through the documented flow skips `Incoming` entirely, because filing it _was_ the approval. - **`Done` means the work shipped.** Exactly two things earn a card a place in Done: its **PR merged**, or it is a **parent whose last sub-issue closed**. Anything else — duplicate, won't fix, not planned, obsolete, superseded — means nothing shipped, so the card is **deleted**. Done is read as the record of what a milestone actually delivered; a duplicate sitting there makes that record wrong in a way nobody can detect later. Deleting a card touches the board only — the issue keeps its labels and comments and stays searchable forever. -- **When work begins**, assign the issue to yourself, create a feature branch and set Status to **In Progress**. **Branch names start with the target version segment** — `v2/fix/2071-oauth-resource-metadata`, `v1/fix/proxy-ssrf-pin` — matching the base branches themselves. +- **When work begins**, assign the issue to yourself, set Status to **In Progress**, and create the feature branch **in a new git worktree** — never by switching branches in the primary checkout, which concurrent agent sessions share (#2661); remove the worktree once the PR merges. The recipe is the `pr-flow` skill, step 2. **Branch names start with the target version segment** — `v2/fix/2071-oauth-resource-metadata`, `v1/fix/proxy-ssrf-pin` — matching the base branches themselves. - **When work is complete**, run `npm run format` then `npm run local:gate`, **sign off every commit** (`git commit -s` — the repo-owned `DCO` check, `.github/workflows/dco.yml`, fails a PR on any unsigned commit with no partial credit; it gates merges into `v2/main` as a **required** status check, set by the `v2/main - DCO` repository ruleset (#2621) and pinned to GitHub Actions so only the workflow can satisfy it; a PR showing the check as missing or "expected" is an outage, not a pass), open a PR against the matching base branch with **`Closes #` as the body's first line**, and set Status to **In Review**. - **After opening a PR, run a Copilot review loop to exhaustion — unprompted.** Request a review, wait for the round to post _or_ for Copilot's session to end without one, answer every comment, and request again whenever a fix was pushed. Stop on the **first** clean round (no confirming round "just to be sure"), a round holding only out-of-scope findings, or two rounds in a row where Copilot's session ends without posting. **Weigh each finding against the issue the PR closes and decline scope expansion** — pre-existing behavior, new capabilities, and hardening the issue did not ask for — because that is what turns a review cycle into overbuilding. The recipe is the `pr-flow` skill, step 7. - **Attach screenshots as proof of functionality** for any web-UI or TUI change. Put them in a **`pr-screenshots/`** folder off the repo root — it is **gitignored**, so the images are staged for upload and never committed — and name them for what they show. - ⚠️ Closing keywords only auto-link and auto-close for PRs targeting the **default branch** (`main`). A v2 PR targets `v2/main`, so `Closes #N` there is only a cross-reference and the card shows no linked PR. **Link it explicitly** with the `addCloseIssueReferences` GraphQL mutation right after opening the PR (recipe in `pr-flow`, step 6). **On merge, manually close the issue and move the card to Done.** Keep the line anyway, so the issues close if/when `v2/main` reaches `main`. - **If new tasks are discovered during development, create issues** and add them to the board. +- **Pace bulk GitHub writes: 1 s between requests, never in parallel, under 80 a minute and 500 an hour.** The secondary limit refuses while `gh api rate_limit` still shows quota, and most `gh` writes are GraphQL. After a refusal, wait out `retry-after`, or a minute without one; a refused create can still land, so look before retrying. ### Responding to Code Reviews @@ -421,7 +422,7 @@ diagnose a failing gate — is the `testing` skill. These are the rules. - **Root tooling**: a `scripts/*.mjs` helper with pure logic gets a sibling `*.test.mjs`. Keep that exact filename — `node --test` silently _skips_ a file its glob misses and still exits 0. - **Render Ink components through the TUI's own `render`** (`clients/tui/__tests__/helpers/renderTui.tsx`), never `ink-testing-library`'s directly. It is the same function with every frame ANSI-stripped, which is what keeps an assertion on styled text from depending on the ambient environment: Ink writes styling _inside_ the styled run, so `Info` reaches the frame buffer with escapes between `I` and `nfo` and `toContain("Info")` fails. It only bites where chalk emits color — a developer whose shell exports `FORCE_COLOR` — so CI is green on a suite that is broken for them (#2207). A test that genuinely needs the raw bytes reads `stdout.lastFrame()` off the returned instance. - **Render React components through `renderWithMantine`** (`src/test/renderWithMantine.tsx`); do not hand-roll a bare `MantineProvider`, which skips the project theme and the helper's options and drifts from every other test. Pass the `colorScheme` option to exercise a forced scheme rather than hand-rolling `defaultColorScheme`. Use `renderWithMantineTransitions` **only** when a test must assert mid-flight transition state, and read the long comment on the helper before changing anything about it. -- **The web coverage `include` is a whitelist.** It names `components`/`hooks`/`theme`/`lib`/`utils`/`server` plus the browser-consumed `core/*` runtime, so a module placed **outside** those directories falls out of the gate entirely, silently. Place new modules inside a gated directory. The documented exceptions — `src/App.tsx` and the `src/main.tsx` / `src/index.ts` bootstraps — are called out in a comment on the `include` array itself. +- **The web coverage `include` is a whitelist.** It names `components`/`hooks`/`theme`/`lib`/`utils`/`server` plus the browser-consumed `core/*` runtime, so a module placed **outside** those directories falls out of the gate entirely, silently. Place new modules inside a gated directory. The converse — the run measuring **fewer** files than the `include` declares, as web's did under vitest 5 (418 → 11, still green) — is not silent: `npm run verify:coverage-scope`, the last step of `coverage`, fails naming each missing file (#2709). Fix that by fixing the globs, never by excusing the files. The documented exceptions — `src/App.tsx` and the `src/main.tsx` / `src/index.ts` bootstraps — are called out in a comment on the `include` array itself. ### Test-gate timeouts are chosen values, stated once diff --git a/clients/cli/README.md b/clients/cli/README.md index 16803fa023..db48f91e21 100644 --- a/clients/cli/README.md +++ b/clients/cli/README.md @@ -62,12 +62,14 @@ npx @modelcontextprotocol/inspector --cli node build/index.js --method prompts/l You can also connect to remote MCP servers using HTTP or SSE transports. -**Connect to a remote MCP server (default is SSE)** +**Connect to a remote MCP server (transport inferred from a `/mcp` or `/sse` path)** ```bash -npx @modelcontextprotocol/inspector --cli https://my-mcp-server.example.com +npx @modelcontextprotocol/inspector --cli https://my-mcp-server.example.com/mcp --method tools/list ``` +There is no default transport: a URL whose path ends in neither `/mcp` nor `/sse` needs an explicit `--transport http` or `--transport sse`, as below. A target is either a positional command/URL or `--server-url`, never both, and `--transport stdio` cannot be used with a URL — either combination is refused rather than resolved by precedence. + **Connect with Streamable HTTP transport** ```bash @@ -279,7 +281,7 @@ mcp-inspector --cli --method tools/call --tool-name summarize --output mcp-inspector --cli --method tools/call --tool-name render_chart --output chart.png --output-format raw ``` -The flag is `--output-format` rather than a new `--format` value because `--format` already shapes **stdout**, and the two compose: in text mode stdout stays empty and a one-line `Wrote N bytes (json) to ` confirmation goes to stderr; under `--format json` stdout still carries exactly one envelope, with `output` in place of `result` — `{"output":{"path":"echo.json","format":"json","bytes":123}}`, plus `appInfo` / `schemaFindings` when they would otherwise appear. +The flag is `--output-format` rather than a new `--format` value because `--format` already shapes **stdout**, and the two compose: in text mode stdout stays empty and a one-line `Wrote N bytes (json) to ` confirmation goes to stderr (suppressed by `-q`, like any other status line); under `--format json` stdout still carries exactly one envelope, with `output` in place of `result` — `{"output":{"path":"echo.json","format":"json","bytes":123}}`, plus `appInfo` / `schemaFindings` when they would otherwise appear. Exit codes are unchanged: a `tools/call` that returns `isError:true` is still written and still exits `5`. A file that cannot be written (missing directory, no permission) exits `1` with envelope code `output_write_failed`. @@ -430,7 +432,7 @@ For the common case where OAuth was already completed in the **web inspector on **State-file resolution** follows `MCP_INSPECTOR_OAUTH_STATE_PATH` → `/oauth.json` → `~/.mcp-inspector/storage/oauth.json` — the same precedence the rest of the Inspector uses, so the CLI and web backend agree on the file. Server keys are canonicalised with `new URL().href` (the scheme the web store writes), so a trailing-slash or case mismatch between the URL a human opened and the one the agent passed still resolves. -**Token refresh (#1665).** When the stored server state carries a `refresh_token` (plus the `clientInformation` the web inspector persists after a completed flow), `--use-stored-auth` runs the SDK's OAuth `refresh_token` grant to mint a fresh access token before connecting, then writes the rotated tokens back to the state file (owner-only `0o600`, via the shared store writer) so web and CLI stay consistent. The auth-server metadata is reused from the stored state, or discovered from `--server-url` when absent. This covers both an absent and an expired stored access token — the persisted blob carries no expiry, so the refresh token is treated as the durable credential. If the refresh fails (revoked token, transient auth-server error) **and** a stored access token is also present, the CLI falls back to injecting that token rather than hard-failing; only when there is nothing to fall back on does it exit `3` (`auth_required`, envelope `refresh_failed`). Without a stored `refresh_token` the access token is injected as-is, and a stale one surfaces as an HTTP `401` → exit `3`. +**Token refresh (#1665).** When the stored server state carries a `refresh_token` (plus the `clientInformation` the web inspector persists after a completed flow), `--use-stored-auth` runs the SDK's OAuth `refresh_token` grant to mint a fresh access token before connecting, then writes the rotated tokens back to the state file (owner-only `0o600`, via the shared store writer) so web and CLI stay consistent. The write-back updates only the refreshed issuer slot's tokens, against the entry as it is on disk at write time, so a same-server change made while the grant was in flight (say, a web OAuth flow against another authorization server) is kept rather than overwritten, and a slot whose tokens were cleared or replaced meanwhile is left as it is (#2574). The auth-server metadata is reused from the stored state, or discovered from `--server-url` when absent. This covers both an absent and an expired stored access token — the persisted blob carries no expiry, so the refresh token is treated as the durable credential. If the refresh fails (revoked token, transient auth-server error) **and** a stored access token is also present, the CLI falls back to injecting that token rather than hard-failing; only when there is nothing to fall back on does it exit `3` (`auth_required`, envelope `refresh_failed`). Without a stored `refresh_token` the access token is injected as-is, and a stale one surfaces as an HTTP `401` → exit `3`. Because there is no stored expiry, a `refresh_token` is refreshed on every `--use-stored-auth` run. Two consequences with rotating refresh tokens: two concurrent invocations against the same state file race the single-use token (one wins), and a crash between a successful grant and the write-back leaves the rotated token unsaved. Both are narrow; re-authorize in the web inspector to recover. diff --git a/clients/cli/__tests__/arg-order.test.ts b/clients/cli/__tests__/arg-order.test.ts new file mode 100644 index 0000000000..313c00e1ad --- /dev/null +++ b/clients/cli/__tests__/arg-order.test.ts @@ -0,0 +1,121 @@ +import { describe, it, expect } from "vitest"; +import { + CRASHABLE_FLAG, + getTestMcpServerCommand, +} from "@modelcontextprotocol/inspector-test-server"; +import { runCli } from "./helpers/cli-runner.js"; +import { expectCliSuccess, expectJsonStructure } from "./helpers/assertions.js"; + +/** + * Where an ad-hoc stdio target may sit relative to the Inspector's own + * options, and how arguments reach the server (#2631). The target is whatever + * Commander does not consume as an Inspector option, so option order around it + * does not matter and a flag the Inspector does not define is forwarded. + * + * The crashable test server is the probe for "did the argument reach the + * server": started with `--crashable` it advertises a `crash_server` tool the + * default configuration does not. + */ +describe("CLI argument order (#2631)", () => { + const { command, args } = getTestMcpServerCommand(); + const target = [command, ...args]; + + async function toolNames(argv: string[]): Promise { + const result = await runCli(argv); + expectCliSuccess(result); + const json = expectJsonStructure(result, ["tools"]); + return (json.tools as { name: string }[]).map((tool) => tool.name); + } + + it.each([ + ["-q"], + ["--quiet"], + ["--strict"], + ["--advertise-apps"], + ["--stored-auth-only"], + ["--format", "json"], + ])("keeps the target when %s precedes it", async (...flag) => { + const result = await runCli([...flag, ...target, "--method", "tools/list"]); + expectCliSuccess(result); + expect(result.stdout).toContain('"echo"'); + }); + + it("keeps the target when --method precedes it", async () => { + expect(await toolNames(["--method", "tools/list", ...target])).toContain( + "echo", + ); + }); + + it("forwards an unknown flag after the target to the server", async () => { + const names = await toolNames([ + ...target, + CRASHABLE_FLAG, + "--method", + "tools/list", + ]); + expect(names).toContain("crash_server"); + }); + + it("does not forward a known option placed between target tokens", async () => { + const names = await toolNames([command, "--method", "tools/list", ...args]); + expect(names).toContain("echo"); + expect(names).not.toContain("crash_server"); + }); + + it("appends everything after `--` to the target when an option leads", async () => { + const names = await toolNames([ + "-q", + ...target, + "--method", + "tools/list", + "--", + CRASHABLE_FLAG, + ]); + expect(names).toContain("crash_server"); + }); + + it("takes the whole target from after a leading `--`", async () => { + const names = await toolNames([ + "--method", + "tools/list", + "--", + ...target, + CRASHABLE_FLAG, + ]); + expect(names).toContain("crash_server"); + }); + + it("keeps the documented ` -- ` form", async () => { + // Before `--` the target is taken verbatim, flags included; after it come + // the Inspector's options, and an unknown flag there still reaches the + // server. + const names = await toolNames([ + ...target, + "--", + "--method", + "tools/list", + CRASHABLE_FLAG, + ]); + expect(names).toContain("crash_server"); + + const verbatim = await toolNames([ + ...target, + CRASHABLE_FLAG, + "--", + "--method", + "tools/list", + ]); + expect(verbatim).toContain("crash_server"); + }); + + it("drops unknown flags ahead of the target instead of running them", async () => { + const names = await toolNames([ + "--not-an-inspector-flag", + ...target, + "--method", + "tools/list", + ]); + expect(names).toContain("echo"); + expect(names).not.toContain("crash_server"); + }); +}); diff --git a/clients/cli/__tests__/completion.test.ts b/clients/cli/__tests__/completion.test.ts index 4c7043428d..304560c33a 100644 --- a/clients/cli/__tests__/completion.test.ts +++ b/clients/cli/__tests__/completion.test.ts @@ -4,7 +4,7 @@ * shell. The shell-level tests spawn bash / zsh / fish when installed and are * skipped otherwise; the structural tests below always run. */ -import { describe, it, expect, afterAll } from "vitest"; +import { describe, it, expect, afterAll, afterEach, vi } from "vitest"; import { spawnSync } from "node:child_process"; import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; @@ -409,3 +409,66 @@ describe.skipIf(!hasShell("fish"))("fish script", () => { ).toEqual(["--method=tools/call", "--method=tools/list"]); }); }); + +/** + * #2646: the script write treats EPIPE (a reader that closed early) as a + * normal end. Node calls the write callback with the error and then emits + * `'error'`, so the fake does both, in that order. + */ +describe("emitCompletionIfRequested stdout errors", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + function failWritesWith(err: Error): void { + vi.spyOn(process.stdout, "write").mockImplementation((( + _chunk: unknown, + cb?: (err?: Error | null) => void, + ) => { + cb?.(err); + process.nextTick(() => process.stdout.emit("error", err)); + return false; + }) as typeof process.stdout.write); + } + + function completionProgram(): Command { + const program = new Command(); + registerCompletionOption(program); + program.parse(["--completion", "bash"], { from: "user" }); + return program; + } + + async function flushErrorEmit(): Promise { + await new Promise((resolve) => process.nextTick(resolve)); + } + + it("resolves on EPIPE and leaves no listener behind", async () => { + const before = process.stdout.listenerCount("error"); + failWritesWith(Object.assign(new Error("write EPIPE"), { code: "EPIPE" })); + expect(await emitCompletionIfRequested(completionProgram())).toBe(true); + await flushErrorEmit(); + expect(process.stdout.listenerCount("error")).toBe(before); + }); + + it("rejects any other stdout error", async () => { + const err = Object.assign(new Error("write EIO"), { code: "EIO" }); + failWritesWith(err); + await expect(emitCompletionIfRequested(completionProgram())).rejects.toBe( + err, + ); + await flushErrorEmit(); + }); + + it("detaches its listener after a successful write", async () => { + const before = process.stdout.listenerCount("error"); + vi.spyOn(process.stdout, "write").mockImplementation((( + _chunk: unknown, + cb?: (err?: Error | null) => void, + ) => { + cb?.(); + return true; + }) as typeof process.stdout.write); + expect(await emitCompletionIfRequested(completionProgram())).toBe(true); + expect(process.stdout.listenerCount("error")).toBe(before); + }); +}); diff --git a/clients/cli/__tests__/e2e.test.ts b/clients/cli/__tests__/e2e.test.ts index e8e485f607..d2309bc750 100644 --- a/clients/cli/__tests__/e2e.test.ts +++ b/clients/cli/__tests__/e2e.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect, beforeEach, afterEach } from "vitest"; import { spawn } from "node:child_process"; import { resolve, dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; -import { mkdtempSync, rmSync } from "node:fs"; +import { mkdtempSync, readFileSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { getTestMcpServerCommand } from "@modelcontextprotocol/inspector-test-server"; @@ -77,6 +77,35 @@ function spawnCli( }); } +/** + * Spawn the binary with its stdout read end closed before the child can write, + * so its first stdout write fails with EPIPE — the `| head` / bash 3.2 + * `source <(…)` early close (#2646), without a shell or a timing race: node + * boots for far longer than the close takes. + */ +function spawnCliWithClosedStdout(args: string[]): Promise { + return new Promise((resolvePromise, reject) => { + const child = spawn("node", [BIN, ...args], { + stdio: ["ignore", "pipe", "pipe"], + }); + child.stdout.destroy(); + let stderr = ""; + const timer = setTimeout(() => { + child.kill("SIGTERM"); + reject(new Error("E2E CLI timed out")); + }, CHILD_TIMEOUT_MS); + child.stderr.on("data", (d) => (stderr += d.toString())); + child.on("error", (err) => { + clearTimeout(timer); + reject(err); + }); + child.on("close", (exitCode) => { + clearTimeout(timer); + resolvePromise({ exitCode, stdout: "", stderr }); + }); + }); +} + describe("CLI binary (out-of-process E2E)", () => { const { command, args } = getTestMcpServerCommand(); @@ -226,4 +255,34 @@ describe("CLI binary (out-of-process E2E)", () => { }, E2E_SPAWN_MS, ); + + // #2632: `--version` used to fall through to the missing-method error. Out of + // process because commander exits 0 itself, which would end the test worker. + const { version } = JSON.parse( + readFileSync(resolve(here, "../../../package.json"), "utf8"), + ) as { version: string }; + + it.each(["--version", "-V"])( + "prints the root version and exits 0 for %s", + async (flag) => { + const result = await spawnCli([flag]); + + expect(result.exitCode).toBe(0); + expect(result.stdout.trim()).toBe(version); + }, + E2E_SPAWN_MS, + ); + + // #2646: a reader that closes before the completion script is written used + // to crash the CLI with an unhandled `'error'` event (`write EPIPE`). + it( + "exits quietly when --completion's reader closes early", + async () => { + const result = await spawnCliWithClosedStdout(["--completion", "bash"]); + + expect(result.stderr).toBe(""); + expect(result.exitCode).toBe(0); + }, + E2E_SPAWN_MS, + ); }); diff --git a/clients/cli/__tests__/emit-result.test.ts b/clients/cli/__tests__/emit-result.test.ts index 1776e385ce..f52d1731ca 100644 --- a/clients/cli/__tests__/emit-result.test.ts +++ b/clients/cli/__tests__/emit-result.test.ts @@ -1,4 +1,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { emitResult, collectAppInfo } from "../src/cli.js"; import { CliExitCodeError } from "@inspector/core/cli/error-handler.js"; import type { InspectorClient } from "@inspector/core/mcp/index.js"; @@ -80,6 +83,58 @@ describe("emitResult", () => { }); }); +/** + * `--output` in text mode confirms the write with a status line on stderr, + * which `-q` must suppress like every other status line (#2614). + */ +describe("emitResult --output status line", () => { + let dir: string; + let stderr: string; + let originalWrite: typeof process.stderr.write; + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), "inspector-cli-emit-")); + stderr = ""; + originalWrite = process.stderr.write; + process.stderr.write = ((chunk: unknown, ...rest: unknown[]): boolean => { + 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; + rmSync(dir, { recursive: true, force: true }); + }); + + it("prints the Wrote line to stderr without -q", async () => { + const path = join(dir, "out.json"); + await emitResult({ ok: true }, undefined, { + method: "tools/call", + format: "text", + output: path, + }); + expect(stderr).toMatch(/^Wrote \d+ bytes \(json\) to .*out\.json\n$/); + expect(JSON.parse(readFileSync(path, "utf8"))).toEqual({ ok: true }); + }); + + it("suppresses the Wrote line under -q but still writes the file", async () => { + const path = join(dir, "out.json"); + await emitResult({ ok: true }, undefined, { + method: "tools/call", + format: "text", + output: path, + quiet: true, + }); + expect(stderr).toBe(""); + expect(JSON.parse(readFileSync(path, "utf8"))).toEqual({ ok: true }); + }); +}); + describe("collectAppInfo", () => { it("returns base info for a non-App tool without reading a resource", async () => { const client = { diff --git a/clients/cli/__tests__/error-handler.test.ts b/clients/cli/__tests__/error-handler.test.ts index b08a9d28d4..a587ac14bc 100644 --- a/clients/cli/__tests__/error-handler.test.ts +++ b/clients/cli/__tests__/error-handler.test.ts @@ -206,7 +206,8 @@ describe("classifyError", () => { // rest of the network-layer failures. const err = new Error( "Connection timed out after 30000 ms. To accommodate a slower server, " + - "you may increase the timeout value in Server Settings.", + "pass a larger --connect-timeout , or set connectionTimeout on " + + "the server's catalog or config entry.", ); const { exitCode, envelope } = classifyError(err); expect(exitCode).toBe(EXIT_CODES.UNREACHABLE); diff --git a/clients/cli/__tests__/programmatic-ergonomics.test.ts b/clients/cli/__tests__/programmatic-ergonomics.test.ts index 83da27d129..63b69127b6 100644 --- a/clients/cli/__tests__/programmatic-ergonomics.test.ts +++ b/clients/cli/__tests__/programmatic-ergonomics.test.ts @@ -1,4 +1,5 @@ import { describe, it, expect } from "vitest"; +import * as net from "node:net"; import { createEchoTool, createTestServerHttp, @@ -7,6 +8,7 @@ import { } from "@modelcontextprotocol/inspector-test-server"; import { runCli } from "./helpers/cli-runner.js"; import { DEFAULT_CONNECT_TIMEOUT_MS, withConnectTimeout } from "../src/cli.js"; +import { CLI_CONNECTION_TIMEOUT_REMEDY } from "@inspector/core/cli/handlers/connect-timeout.js"; import { MODERN_PROTOCOL_VERSION, type InspectorServerSettings, @@ -214,6 +216,40 @@ describe("CLI --relogin flag conflicts", () => { }); }); +describe("connect-timeout remedy (#2615)", () => { + it("names --connect-timeout and mcp.json, not the web's Server Settings", async () => { + // A listener that accepts the TCP connection and never answers, so the + // handshake hangs until the connect-time timeout fires. + const sockets = new Set(); + const server = net.createServer((socket) => { + sockets.add(socket); + }); + await new Promise((resolve) => + server.listen(0, "127.0.0.1", resolve), + ); + const { port } = server.address() as net.AddressInfo; + try { + const result = await runCli([ + "--server-url", + `http://127.0.0.1:${port}/mcp`, + "--transport", + "http", + "--connect-timeout", + "200", + "--method", + "tools/list", + ]); + expectCliFailure(result); + expect(result.stderr).toContain("Connection timed out after 200 ms."); + expect(result.stderr).toContain(CLI_CONNECTION_TIMEOUT_REMEDY); + expect(result.stderr).not.toContain("Server Settings"); + } finally { + for (const socket of sockets) socket.destroy(); + await new Promise((resolve) => server.close(() => resolve())); + } + }); +}); + describe("CLI --stored-auth-only wiring", () => { it("accepts the flag and fails connect without interactive OAuth prompts", async () => { // Unreachable host: proves the flag is accepted through parse → callMethod diff --git a/clients/cli/__tests__/skills-verify-cli.test.ts b/clients/cli/__tests__/skills-verify-cli.test.ts index 704154294e..a3adc63a54 100644 --- a/clients/cli/__tests__/skills-verify-cli.test.ts +++ b/clients/cli/__tests__/skills-verify-cli.test.ts @@ -347,6 +347,10 @@ describe("the catalog-budget escape hatch (#2428)", () => { ); if (!skipped) throw new Error(`no skipped skill in: ${listed.stdout}`); expect(skipped.files).toHaveLength(0); + // The CLI's own way to raise the budget, not another client's (#2633). + expect(skipped.incomplete).toContain( + "--skill-catalog-max-skills / --skill-catalog-max-bytes", + ); const suggested = suggestedArgs(skipped); const got = await runCliCaptured([...target, ...suggested]); diff --git a/clients/cli/__tests__/stored-auth.test.ts b/clients/cli/__tests__/stored-auth.test.ts index 1755cf5b70..3147baf217 100644 --- a/clients/cli/__tests__/stored-auth.test.ts +++ b/clients/cli/__tests__/stored-auth.test.ts @@ -22,7 +22,11 @@ import { type StoredServers, } from "../src/cli.js"; import { SecretFileLockHeldError } from "@inspector/core/auth/node/secret-store.js"; -import { readOAuthStore } from "@inspector/core/auth/node/oauth-persist-file.js"; +import { + readOAuthStore, + writeOAuthSections, +} from "@inspector/core/auth/node/oauth-persist-file.js"; +import type { ServerOAuthState } from "@inspector/core/auth/store.js"; import { oauthSecretServerId } from "@inspector/core/auth/node/oauth-secrets.js"; import { defaultSecretStore } from "@inspector/core/auth/node/secret-store-selection.js"; import { @@ -1325,6 +1329,187 @@ describe("issuer-keyed stored auth (#2517)", () => { } }); + // #2574: the write-back lands after a network round trip, so a same-server + // write made meanwhile — here a web OAuth flow against a second AS — must + // survive it. Only the refreshed slot's tokens may change. + describe("concurrent same-server writes during the grant (#2574)", () => { + const SLOT = { + tokens: { refresh_token: "active-refresh", token_type: "Bearer" }, + clientInformation: { client_id: "active-client" }, + }; + const ROTATED = { + access_token: "rotated-access", + token_type: "Bearer", + refresh_token: "rotated-refresh", + }; + const fixture = () => + writeOAuthFixture({ + [SERVER]: { + activeIssuer: ISSUER, + byIssuer: { [ISSUER]: SLOT }, + serverMetadata: { + issuer: ISSUER, + token_endpoint: `${ISSUER}/token`, + }, + }, + }); + + /** A refresh grant that lets `during` rewrite the server mid-flight. */ + const refreshWhile = ( + path: string, + during: (entry: ServerOAuthState) => ServerOAuthState | undefined, + ) => + vi.fn(async () => { + const current = (await readOAuthStore(path))!.servers[SERVER]!; + const next = during(current); + await writeOAuthSections( + path, + { + servers: next === undefined ? {} : { [SERVER]: next }, + idpSessions: {}, + }, + { servers: [SERVER] }, + ); + return ROTATED; + }); + + it("keeps an issuer slot and activeIssuer change made during the refresh", async () => { + const path = fixture(); + try { + const refresh = refreshWhile(path, (entry) => ({ + ...entry, + activeIssuer: OTHER, + byIssuer: { + ...entry.byIssuer, + [OTHER]: { + tokens: { access_token: "web-access", token_type: "Bearer" }, + clientInformation: { client_id: "web-client" }, + }, + }, + })); + await expect( + refreshStoredAuthToken(SERVER, path, { refresh }), + ).resolves.toBe("rotated-access"); + + const persisted = (await readOAuthStore(path))?.servers[SERVER]; + // The web flow's write survives… + expect(persisted?.activeIssuer).toBe(OTHER); + expect(persisted?.byIssuer?.[OTHER]?.tokens?.access_token).toBe( + "web-access", + ); + // …and the rotation still lands in the slot it was read from. + expect(persisted?.byIssuer?.[ISSUER]).toEqual({ + ...SLOT, + tokens: ROTATED, + }); + } finally { + rmSync(path, { force: true }); + } + }); + + it("does not resurrect a server cleared during the refresh", async () => { + const path = fixture(); + try { + const refresh = refreshWhile(path, () => undefined); + await expect( + refreshStoredAuthToken(SERVER, path, { refresh }), + ).resolves.toBe("rotated-access"); + expect((await readOAuthStore(path))?.servers[SERVER]).toBeUndefined(); + } finally { + rmSync(path, { force: true }); + } + }); + + it("keeps a slot whose tokens were cleared during the refresh cleared", async () => { + const path = fixture(); + try { + const refresh = refreshWhile(path, (entry) => ({ + ...entry, + byIssuer: { + [ISSUER]: { clientInformation: SLOT.clientInformation }, + }, + })); + await refreshStoredAuthToken(SERVER, path, { refresh }); + const persisted = (await readOAuthStore(path))?.servers[SERVER]; + expect(persisted?.byIssuer?.[ISSUER]?.tokens).toBeUndefined(); + expect(persisted?.byIssuer?.[ISSUER]?.clientInformation).toEqual( + SLOT.clientInformation, + ); + } finally { + rmSync(path, { force: true }); + } + }); + + it("keeps a same-issuer re-authorization made during the refresh", async () => { + const path = fixture(); + const reauthed = { + access_token: "web-access", + token_type: "Bearer", + refresh_token: "web-refresh", + }; + try { + const refresh = refreshWhile(path, (entry) => ({ + ...entry, + byIssuer: { [ISSUER]: { ...SLOT, tokens: reauthed } }, + })); + await expect( + refreshStoredAuthToken(SERVER, path, { refresh }), + ).resolves.toBe("rotated-access"); + const persisted = (await readOAuthStore(path))?.servers[SERVER]; + expect(persisted?.byIssuer?.[ISSUER]?.tokens).toEqual(reauthed); + } finally { + rmSync(path, { force: true }); + } + }); + + it("keeps a legacy entry's tokens replaced during the refresh", async () => { + const path = writeOAuthFixture({ + [SERVER]: { + tokens: { refresh_token: "legacy-refresh", token_type: "Bearer" }, + clientInformation: { client_id: "legacy-client" }, + serverMetadata: { + issuer: ISSUER, + token_endpoint: `${ISSUER}/token`, + }, + }, + }); + const replaced = { + access_token: "web-access", + token_type: "Bearer", + refresh_token: "web-refresh", + }; + try { + const refresh = refreshWhile(path, (entry) => ({ + ...entry, + tokens: replaced, + })); + await refreshStoredAuthToken(SERVER, path, { refresh }); + expect((await readOAuthStore(path))?.servers[SERVER]?.tokens).toEqual( + replaced, + ); + } finally { + rmSync(path, { force: true }); + } + }); + + it("does not resurrect an issuer slot removed during the refresh", async () => { + const path = fixture(); + try { + const refresh = refreshWhile(path, (entry) => ({ + ...entry, + activeIssuer: undefined, + byIssuer: {}, + })); + await refreshStoredAuthToken(SERVER, path, { refresh }); + const persisted = (await readOAuthStore(path))?.servers[SERVER]; + expect(persisted?.byIssuer?.[ISSUER]).toBeUndefined(); + expect(persisted?.tokens).toBeUndefined(); + } finally { + rmSync(path, { force: true }); + } + }); + }); + it("reports no_client_information when the active slot has a refresh token but no client", async () => { const path = writeOAuthFixture({ [SERVER]: { diff --git a/clients/cli/__tests__/tools.test.ts b/clients/cli/__tests__/tools.test.ts index 8ca50f474e..ee9587bc28 100644 --- a/clients/cli/__tests__/tools.test.ts +++ b/clients/cli/__tests__/tools.test.ts @@ -5,7 +5,12 @@ import { expectCliFailure, expectValidJson, } from "./helpers/assertions.js"; -import { getTestMcpServerCommand } from "@modelcontextprotocol/inspector-test-server"; +import { + getTestMcpServerCommand, + createTestServerHttp, + createTestServerInfo, + createSimpleTaskTool, +} from "@modelcontextprotocol/inspector-test-server"; describe("Tool Tests", () => { describe("Tool Discovery", () => { @@ -530,4 +535,40 @@ describe("Tool Tests", () => { expect(resultData.result).toBe(30); }); }); + + describe("Task-required tools", () => { + // A legacy tool declaring `taskSupport: "required"` refuses a plain + // callTool(); the CLI must take the task path for it without being told, + // and print the task's result (#2649). + it("calls a taskSupport: required tool and prints its result", async () => { + const server = createTestServerHttp({ + serverInfo: createTestServerInfo("tasks-legacy", "1.0.0"), + tools: [createSimpleTaskTool("simple_task", 50)], + tasks: { list: true, cancel: true }, + logging: true, + }); + + try { + await server.start(); + const result = await runCli([ + server.url, + "--cli", + "--transport", + "http", + "--method", + "tools/call", + "--tool-name", + "simple_task", + ]); + + expectCliSuccess(result); + const json = expectValidJson(result); + expect(json.isError).not.toBe(true); + expect(Array.isArray(json.content)).toBe(true); + expect(json.content.length).toBeGreaterThan(0); + } finally { + await server.stop(); + } + }); + }); }); diff --git a/clients/cli/package-lock.json b/clients/cli/package-lock.json index 9d514f2812..8016d0d25c 100644 --- a/clients/cli/package-lock.json +++ b/clients/cli/package-lock.json @@ -907,13 +907,13 @@ "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==", + "version": "24.19.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.19.2.tgz", + "integrity": "sha512-93d49wCWJkCyN87MxLpNN6KXPCwFw7bVUg79mGUV1OS40JGWwVCr8n61DXCSqz7CV6/VfPkb7xec7uP2M+QHWQ==", "dev": true, "license": "MIT", "dependencies": { - "undici-types": "~7.18.0" + "undici-types": ">=7.24.0 <7.24.7" } }, "node_modules/@types/qs": { @@ -1556,9 +1556,9 @@ "license": "MIT" }, "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==", + "version": "7.24.6", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", + "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", "dev": true, "license": "MIT" } diff --git a/clients/cli/package.json b/clients/cli/package.json index 79ef02d32b..5f48bd335f 100644 --- a/clients/cli/package.json +++ b/clients/cli/package.json @@ -32,6 +32,10 @@ "format": "prettier --write src __tests__ \"*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}\"", "format:check": "prettier --check src __tests__ \"*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}\"" }, + "allowScripts": { + "esbuild": false, + "fsevents": false + }, "devDependencies": { "@types/express": "^5.0.6", "tsup": "^8.5.0" diff --git a/clients/cli/src/cli.ts b/clients/cli/src/cli.ts index 34653b7f1e..c9fa21593d 100644 --- a/clients/cli/src/cli.ts +++ b/clients/cli/src/cli.ts @@ -1,4 +1,4 @@ -import { Command } from "commander"; +import { Command, Option } from "commander"; import { CATALOG_METHODS, emitCompletionIfRequested, @@ -15,6 +15,7 @@ import type { } from "@inspector/core/mcp/types.js"; import { eraToVersionNegotiation } from "@inspector/core/mcp/types.js"; import { + CLI_CONNECTION_TIMEOUT_REMEDY, DEFAULT_CONNECT_TIMEOUT_MS, withConnectTimeout, } from "@inspector/core/cli/handlers/connect-timeout.js"; @@ -70,7 +71,7 @@ import type { ServerOAuthState } from "@inspector/core/auth/store.js"; import { getOwnEntry } from "@inspector/core/storage/own-entry.js"; import { readOAuthStore, - writeOAuthSections, + updateOAuthServerEntry, } from "@inspector/core/auth/node/oauth-persist-file.js"; import { discoverAuthorizationServerMetadataFromCandidates, @@ -209,6 +210,9 @@ async function callMethod( // which nothing here subscribes to, so the lines are discarded without the // child ever blocking on a full pipe (#2435). pipeStderr: args.quiet === true, + // Name the CLI's own knobs on a connect timeout, not the web's Server + // Settings (#2615). + connectionTimeoutRemedy: CLI_CONNECTION_TIMEOUT_REMEDY, progress: false, sample: false, elicit: false, @@ -460,8 +464,7 @@ export async function refreshStoredAuthToken( fetchFn: options?.fetchFn ?? storedAuthFetch, })); - const snapshot = await readOAuthSnapshot(statePath); - const servers = snapshot.servers as StoredServers; + const servers = await readOAuthServers(statePath); const found = findStoredServerState(servers, serverUrl); const credentials: StoredCredentials = found ? resolveStoredCredentials(found.state) @@ -523,30 +526,36 @@ export async function refreshStoredAuthToken( ); } - // Persist the rotated tokens back under the same key via the shared - // sectioned write: lock → fresh read → overlay just this server's entry → - // atomic write. This generalizes the read-modify-write this function used - // to hand-roll — the merge now happens against the file as it is at write - // time (not the snapshot read before the network round-trip), under the - // same cross-process lock every other writer uses, and keeps the file's - // owner-only `0o600` mode + `mkdir -p` via the shared store IO. + // Persist the rotated tokens back under the same key, against the entry as + // it is on disk NOW — not the snapshot read before the network round trip. + // The update runs under the shared state-file lock on a fresh, store-joined + // read, so a same-server write that landed during the refresh (a web OAuth + // flow adding an issuer slot, switching `activeIssuer`, saving new tokens) + // survives: only the refreshed slot's `tokens` change (#2574). // // The rotated tokens go back to the slot they were read from: the active // issuer's `byIssuer` entry (#2517), or the legacy top-level fields for a - // pre-issuer entry — never a slot the old tokens did not come from. + // pre-issuer entry — never a slot the old tokens did not come from. And only + // while that slot still holds the refresh token this grant spent: a slot + // whose tokens were cleared or replaced meanwhile (a sign-out, a same-issuer + // re-authorization) keeps its fresh state, as does a removed slot or entry. + // The fresh token is still returned for this run either way. const { issuer } = credentials; - servers[found.key] = - issuer !== undefined - ? { - ...found.state, - byIssuer: { - ...found.state.byIssuer, - // A computed key defines an own property, so `__proto__` is safe. - [issuer]: { ...getOwnEntry(found.state.byIssuer, issuer), tokens }, - }, - } - : { ...found.state, tokens }; - await writeOAuthSections(statePath, snapshot, { servers: [found.key] }); + const unchanged = (current: OAuthTokens | undefined): boolean => + current?.refresh_token === refreshToken; + await updateOAuthServerEntry(statePath, found.key, (fresh) => { + if (fresh === undefined) return undefined; + if (issuer === undefined) { + return unchanged(fresh.tokens) ? { ...fresh, tokens } : fresh; + } + const slot = getOwnEntry(fresh.byIssuer, issuer); + if (slot === undefined || !unchanged(slot.tokens)) return fresh; + return { + ...fresh, + // A computed key defines an own property, so `__proto__` is safe. + byIssuer: { ...fresh.byIssuer, [issuer]: { ...slot, tokens } }, + }; + }); return tokens.access_token; } @@ -785,6 +794,11 @@ type ParseResult = // output and need no server connection; runCli returns immediately. | { shortCircuit: true }; +/** Commander's own test for "looks like an option": a dash plus at least one character. */ +function isOptionToken(arg: string): boolean { + return arg.length > 1 && arg.startsWith("-"); +} + async function parseArgs(argv?: string[]): Promise { const program = new Command(); // On a parse/usage ERROR (exitCode !== 0), throw the CommanderError instead @@ -822,21 +836,39 @@ async function parseArgs(argv?: string[]): Promise { throw err; } }); + // The ad-hoc target is whatever Commander does not consume as one of the + // Inspector's own options (#2631): its positionals plus any unknown flag, in + // their original order. So Inspector options may sit before, between or + // after the target's tokens, and a flag the Inspector does not define + // (`--crashable`) reaches the server. Splitting the target off as the leading + // run of non-dash tokens before parsing — the previous approach — lost the + // whole target as soon as any flag preceded it. + // + // A bare `--` has two readings, chosen by what comes first: + // - ` -- ` (the line starts with a target token): the + // documented CLI form. Everything before `--` is the target verbatim, so a + // server flag that collides with an Inspector option (`--config`) can be + // passed there; what follows is parsed as Inspector options. + // - ` -- ` (the line starts with an option, or with `--`): + // conventional end-of-options. Everything after `--` is appended to the + // target verbatim. The old split read this shape as a target starting with + // a dash, which could never run, so no working invocation changes meaning. const rawArgs = argv ?? process.argv; const scriptArgs = rawArgs.slice(2); const dashDashIndex = scriptArgs.indexOf("--"); - let targetArgs: string[] = []; - let optionArgs: string[]; + let leadingTarget: string[] = []; + let trailingTarget: string[] = []; + let optionArgs = scriptArgs; if (dashDashIndex >= 0) { - targetArgs = scriptArgs.slice(0, dashDashIndex); - optionArgs = scriptArgs.slice(dashDashIndex + 1); - } else { - let i = 0; - while (i < scriptArgs.length && !scriptArgs[i]!.startsWith("-")) { - targetArgs.push(scriptArgs[i]!); - i++; + const before = scriptArgs.slice(0, dashDashIndex); + const after = scriptArgs.slice(dashDashIndex + 1); + if (before.length > 0 && !isOptionToken(before[0]!)) { + leadingTarget = before; + optionArgs = after; + } else { + optionArgs = before; + trailingTarget = after; } - optionArgs = scriptArgs.slice(i); } const preArgs: string[] = [ rawArgs[0] ?? "node", @@ -846,6 +878,13 @@ async function parseArgs(argv?: string[]): Promise { program .name("inspector-cli") + // `-V, --version` prints the root package version and exits 0 through the + // exitOverride's non-throwing arm above, like `--help` (#2632). + .version( + readInspectorVersion(import.meta.url), + "-V, --version", + "Print the Inspector version", + ) .allowUnknownOption() .argument( "[target...]", @@ -1067,13 +1106,30 @@ async function parseArgs(argv?: string[]): Promise { .option( "--print-handoff", "Print a JSON handoff block (deepLink, portForwardCmd, oauthStatePath, apiToken) for --server-url and exit. No server connection is made.", - ); + ) + // The launcher's mode flag, accepted and ignored wherever it appears. It + // must be a known option now that unknown flags are forwarded to the + // target (#2631): forwarded, a stray `--cli` would reach a stdio server as + // an argument and make a URL target fail ("Arguments cannot be passed"). + .addOption(new Option("--cli").hideHelp()); registerCompletionOption(program); program.parse(preArgs); // `--completion ` (#2434): print the script and exit, no connect. if (await emitCompletionIfRequested(program)) return { shortCircuit: true }; + // A target cannot begin with a dash, so unknown flags ahead of its first + // token are not server arguments. They are dropped, as every unknown flag + // was before #2631 — callers pass e.g. a stray `--cli` — rather than turned + // into the command, which would make a catalog run a failed ad-hoc launch. + const parsedTarget = [...program.args]; + if (leadingTarget.length === 0) { + while (parsedTarget.length > 0 && isOptionToken(parsedTarget[0]!)) { + parsedTarget.shift(); + } + } + const targetArgs = [...leadingTarget, ...parsedTarget, ...trailingTarget]; + // `--quiet` mutes `console.warn` for the rest of the run; `runCli` restores // it (#2435). Core reports advisories that way from many places the CLI // reaches (the secret-store notice, `roots` / OAuth-endpoint settings it diff --git a/clients/cli/src/completion.ts b/clients/cli/src/completion.ts index 90cfdf4b7f..0f352d306a 100644 --- a/clients/cli/src/completion.ts +++ b/clients/cli/src/completion.ts @@ -24,9 +24,9 @@ import type { Command, Option } from "commander"; import { LoggingLevelSchema } from "@modelcontextprotocol/core"; import { ONE_SHOT_METHODS } from "@inspector/core/cli/handlers/method-types.js"; -import { awaitableLog } from "@inspector/core/cli/utils/awaitable-log.js"; import { OUTPUT_FILE_FORMATS } from "@inspector/core/cli/handlers/output-file.js"; import { CATALOG_WRITE_METHODS } from "./handlers/servers-write.js"; +import { isBrokenPipe } from "./handlers/consume-outcome.js"; export const COMPLETION_SHELLS = ["bash", "zsh", "fish"] as const; export type CompletionShell = (typeof COMPLETION_SHELLS)[number]; @@ -351,6 +351,34 @@ export function registerCompletionOption(program: Command): void { ); } +/** + * Write the script to stdout, treating a reader that closes early as a normal + * end rather than a crash (#2646). `| head` closes the pipe before the whole + * script is written, and so does bash 3.2's `source <(…)`, whose process + * substitution does not keep it open; either way the write fails with EPIPE, + * which without an `'error'` listener is an unhandled event and a stack trace. + * Any other stdout error still rejects into the CLI's error path. + * + * The listener stays attached after a failed write: Node calls the write + * callback first and emits `'error'` afterwards, so detaching in the callback + * would leave that later emit unhandled. It is a `once`, so the emit removes + * it, and settling twice is a no-op. + */ +function writeCompletion(script: string): Promise { + return new Promise((resolve, reject) => { + const settle = (err: unknown) => { + if (isBrokenPipe(err)) resolve(); + else reject(err); + }; + process.stdout.once("error", settle); + process.stdout.write(script, (err) => { + if (err) return settle(err); + process.stdout.off("error", settle); + resolve(); + }); + }); +} + /** * After `program.parse`: when `--completion` was given, write the script for * that shell and return true so the caller can short-circuit. @@ -360,6 +388,8 @@ export async function emitCompletionIfRequested( ): Promise { const shell = program.opts<{ completion?: CompletionShell }>().completion; if (!shell) return false; - await awaitableLog(renderCompletion(shell, collectCompletionFlags(program))); + await writeCompletion( + renderCompletion(shell, collectCompletionFlags(program)), + ); return true; } diff --git a/clients/cli/src/handlers/consume-outcome.ts b/clients/cli/src/handlers/consume-outcome.ts index 45583ae002..c49c1dba93 100644 --- a/clients/cli/src/handlers/consume-outcome.ts +++ b/clients/cli/src/handlers/consume-outcome.ts @@ -16,7 +16,7 @@ import type { * True for the error a write to a closed pipe raises — the reader went away * (`| head`, `| grep -m1`, quitting `less`), which ends the stream normally. */ -function isBrokenPipe(err: unknown): boolean { +export function isBrokenPipe(err: unknown): boolean { return ( err instanceof Error && (err as NodeJS.ErrnoException).code === "EPIPE" ); diff --git a/clients/cli/src/handlers/emit-result.ts b/clients/cli/src/handlers/emit-result.ts index e8488ad715..4339d65211 100644 --- a/clients/cli/src/handlers/emit-result.ts +++ b/clients/cli/src/handlers/emit-result.ts @@ -52,7 +52,7 @@ export async function emitResult( // `curl -o`. Under `--format json` stdout still carries exactly one envelope — // `{ output }` in place of `{ result }` — so a caller parsing it learns where // the result went; in text mode the confirmation goes to stderr, leaving - // stdout empty. + // stdout empty. That confirmation is a status line, so `-q` drops it (#2614). const written = args.output ? await writeResultFile( result, @@ -70,9 +70,11 @@ export async function emitResult( if (args.strict && lint && lint.length > 0) envelope.schemaFindings = lint; await awaitableLog(JSON.stringify(envelope) + "\n"); } else if (written) { - await awaitableError( - `Wrote ${written.bytes} bytes (${written.format}) to ${written.path}\n`, - ); + if (!args.quiet) { + await awaitableError( + `Wrote ${written.bytes} bytes (${written.format}) to ${written.path}\n`, + ); + } } else { await awaitableLog(JSON.stringify(result, null, 2) + "\n"); } diff --git a/clients/launcher/README.md b/clients/launcher/README.md index af91f9a6ea..280f5e75f7 100644 --- a/clients/launcher/README.md +++ b/clients/launcher/README.md @@ -8,6 +8,7 @@ The root `package.json` also publishes it as an `inspector` bin. That alias is w - Parse mode from a leading prefix of `--web` (default), `--cli`, or `--tui` immediately after the script name. - Forward all following arguments unchanged (including tokens that look like mode flags). +- `--version` / `-V` is forwarded like any other flag; each client prints the root package version and exits `0` without starting anything, so `mcp-inspector --version`, `--cli --version` and `--tui --version` all print the same version (#2632). `smoke:launcher` checks every mode. - Dynamically import that app’s runner from `clients/{web,cli,tui}/build/index.js` (relative to the launcher build output) and call it **in-process** (no `spawn()`). All configuration parsing, config-file loading, and server setup are handled by the app runners and by **core**; the launcher does not interpret server or client configuration, from files or from env vars. Its one exception is the debug switch described below. diff --git a/clients/launcher/src/index.ts b/clients/launcher/src/index.ts index e42f4e53c8..39582551e1 100644 --- a/clients/launcher/src/index.ts +++ b/clients/launcher/src/index.ts @@ -34,7 +34,10 @@ program .description("MCP Inspector – run web UI, CLI, or TUI") .option("--web", "Run web UI (default)") .option("--cli", "Run CLI") - .option("--tui", "Run TUI"); + .option("--tui", "Run TUI") + // Listed for the launcher's own help only: the flag is forwarded to the + // selected client (web by default), whose commander prints the version. + .option("-V, --version", "Print the Inspector version"); let parsedArgv; try { diff --git a/clients/mcpdo/__tests__/auth-helper.test.ts b/clients/mcpdo/__tests__/auth-helper.test.ts index e4368c5d0b..1dc78f0ba6 100644 --- a/clients/mcpdo/__tests__/auth-helper.test.ts +++ b/clients/mcpdo/__tests__/auth-helper.test.ts @@ -7,6 +7,14 @@ import type { CallbackNavigation } from "@inspector/core/auth/index.js"; const authorizeInFrontend = vi.fn(); +// Wrap renameSync in a pass-through vi.fn so the stale-lock steal tests can +// inject a concurrent stealer in the stat→rename window (ESM namespaces +// cannot be spied on directly). +vi.mock("node:fs", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, renameSync: vi.fn(actual.renameSync) }; +}); + vi.mock("../src/connection/authorize.js", () => ({ authorizeInFrontend: (...args: unknown[]) => authorizeInFrontend(...args), })); @@ -319,6 +327,67 @@ describe("auth-helper", () => { // place — either way this process never spawned a helper. }); + describe("a stealer that claims a lock another stealer just refreshed", () => { + const realRename = vi.mocked(fs.renameSync).getMockImplementation()!; + + afterEach(() => { + vi.mocked(fs.renameSync).mockImplementation(realRename); + }); + + function staleLock(): string { + const lockPath = `${pendingAuthMarkerPath(SERVER_URL)}.lock`; + fs.writeFileSync(lockPath, "1234\n", { mode: 0o600 }); + const old = new Date(Date.now() - 120_000); + fs.utimesSync(lockPath, old, old); + return lockPath; + } + + function reserveAndExpectBackOff() { + return expect( + obtainPendingAuthUrl( + { type: "streamable-http", url: SERVER_URL }, + undefined, + // Would fail loudly if a spawn were attempted. + { + helperArgv1: path.join(dir, "does-not-exist.mjs"), + waitMs: 60, + pollMs: 10, + }, + ), + ).rejects.toMatchObject({ envelope: { code: "auth_required" } }); + } + + it("restores the fresh lock instead of deleting it", async () => { + const lockPath = staleLock(); + // Stealer A wins first: it replaces the stale lock with a fresh one + // after this process saw it stale but before this process renames. + vi.mocked(fs.renameSync).mockImplementationOnce((from, to) => { + fs.rmSync(lockPath); + fs.writeFileSync(lockPath, "4242\n", { flag: "wx", mode: 0o600 }); + realRename(from, to); + }); + await reserveAndExpectBackOff(); + // A's lock is back in place, so a third connect cannot win create(). + expect(fs.readFileSync(lockPath, "utf8")).toBe("4242\n"); + expect(fs.existsSync(`${lockPath}.claim-${process.pid}`)).toBe(false); + }); + + it("backs off without restoring when a newer lock already took the path", async () => { + const lockPath = staleLock(); + vi.mocked(fs.renameSync).mockImplementationOnce((from, to) => { + fs.rmSync(lockPath); + fs.writeFileSync(lockPath, "4242\n", { flag: "wx", mode: 0o600 }); + realRename(from, to); + // A third reserver created a lock in the now-empty path. + fs.writeFileSync(lockPath, "5353\n", { flag: "wx", mode: 0o600 }); + }); + await reserveAndExpectBackOff(); + // The link refused to clobber the newer lock, and the claim is gone. + expect(fs.readFileSync(lockPath, "utf8")).toBe("5353\n"); + expect(fs.existsSync(`${lockPath}.claim-${process.pid}`)).toBe(false); + }); + }); + it("maps a helper spawn failure to auth_required", async () => { const originalExecPath = process.execPath; // A nonexistent interpreter makes spawn emit `error` instead of `exit`. diff --git a/clients/mcpdo/__tests__/authorize.test.ts b/clients/mcpdo/__tests__/authorize.test.ts index ea73822cb0..89b92d15cb 100644 --- a/clients/mcpdo/__tests__/authorize.test.ts +++ b/clients/mcpdo/__tests__/authorize.test.ts @@ -1,9 +1,12 @@ import { describe, it, expect, vi, afterEach } from "vitest"; import type { MCPServerConfig } from "@inspector/core/mcp/types.js"; +import { CLI_CONNECTION_TIMEOUT_REMEDY } from "@inspector/core/cli/handlers/connect-timeout.js"; const connectSpy = vi.fn(); const disconnectSpy = vi.fn().mockResolvedValue(undefined); const navigationSpy = vi.fn(); +const clientCtorSpy = vi.fn(); +const createProxyFetchSpy = vi.fn<() => typeof fetch | undefined>(); vi.mock("@inspector/core/cli/cliOAuth.js", () => ({ connectInspectorWithOAuth: (...args: unknown[]) => connectSpy(...args), @@ -18,11 +21,20 @@ vi.mock("@inspector/core/cli/cli-oauth-navigation.js", () => ({ vi.mock("@inspector/core/mcp/index.js", () => ({ InspectorClient: class { + constructor(...args: unknown[]) { + clientCtorSpy(...args); + } connect = vi.fn(); disconnect = disconnectSpy; }, })); +vi.mock("@inspector/core/mcp/node/index.js", async (importOriginal) => { + const actual = + await importOriginal(); + return { ...actual, createProxyFetch: () => createProxyFetchSpy() }; +}); + vi.mock("@inspector/core/client/runner.js", async (importOriginal) => { const actual = await importOriginal(); @@ -38,6 +50,41 @@ describe("authorizeInFrontend", () => { connectSpy.mockReset(); disconnectSpy.mockClear(); navigationSpy.mockClear(); + clientCtorSpy.mockClear(); + createProxyFetchSpy.mockReset(); + }); + + it("installs the proxy-aware fetch on the client environment (#2642)", async () => { + // `InspectorClient` always hands its transport a defined fetch, so the + // environment is the only place a proxy can go for OAuth discovery and + // token requests to reach it. + connectSpy.mockResolvedValue(undefined); + const proxyFetch: typeof fetch = vi.fn(); // a stand-in, never called + createProxyFetchSpy.mockReturnValue(proxyFetch); + const { authorizeInFrontend } = + await import("../src/connection/authorize.js"); + await authorizeInFrontend( + { type: "streamable-http", url: "https://example.com/mcp" }, + undefined, + ); + const options = clientCtorSpy.mock.calls[0]?.[1] as { + environment: { fetch?: typeof fetch }; + }; + expect(options.environment.fetch).toBe(proxyFetch); + }); + + it("names the CLI's timeout knobs, not the web's Server Settings (#2615)", async () => { + connectSpy.mockResolvedValue(undefined); + const { authorizeInFrontend } = + await import("../src/connection/authorize.js"); + await authorizeInFrontend( + { type: "streamable-http", url: "https://example.com/mcp" }, + undefined, + ); + const options = clientCtorSpy.mock.calls[0]?.[1] as { + connectionTimeoutRemedy?: string; + }; + expect(options.connectionTimeoutRemedy).toBe(CLI_CONNECTION_TIMEOUT_REMEDY); }); it("no-ops for non-OAuth-capable (stdio) configs", async () => { diff --git a/clients/mcpdo/__tests__/daemon-coverage.test.ts b/clients/mcpdo/__tests__/daemon-coverage.test.ts index 6517986567..9cadc8210d 100644 --- a/clients/mcpdo/__tests__/daemon-coverage.test.ts +++ b/clients/mcpdo/__tests__/daemon-coverage.test.ts @@ -1268,6 +1268,41 @@ describe("mcp connection coverage", () => { expectCliFailure(result); }); + it("names --connect-timeout, not Server Settings, when connect times out (#2615)", async () => { + // Accept the TCP connection and never answer, so the handshake hangs + // until the connect-time timeout fires. + const sockets = new Set(); + const listener = net.createServer((socket) => { + sockets.add(socket); + }); + await new Promise((resolve) => + listener.listen(0, "127.0.0.1", resolve), + ); + const { port } = listener.address() as net.AddressInfo; + try { + const result = await runMcp( + [ + "connect", + `http://127.0.0.1:${port}/mcp`, + "--connection", + "hang", + "--connect-timeout", + "200", + "--format", + "json", + ], + { env: env(), timeout: 10000 }, + ); + expectCliFailure(result); + expect(result.stderr).toContain("Connection timed out after 200 ms."); + expect(result.stderr).toContain("--connect-timeout "); + expect(result.stderr).not.toContain("Server Settings"); + } finally { + for (const socket of sockets) socket.destroy(); + await new Promise((resolve) => listener.close(() => resolve())); + } + }); + it("requires explicit connection in non-interactive mode without opt-in", async () => { configPath = createSampleTestConfig(); storageDir = fs.mkdtempSync(path.join(os.tmpdir(), "mcp-sess-ci-")); diff --git a/clients/mcpdo/__tests__/daemon-proxy.test.ts b/clients/mcpdo/__tests__/daemon-proxy.test.ts new file mode 100644 index 0000000000..b754df3f55 --- /dev/null +++ b/clients/mcpdo/__tests__/daemon-proxy.test.ts @@ -0,0 +1,125 @@ +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import { createServer, request, type Server } from "node:http"; +import { + createEchoTool, + createTestServerHttp, + createTestServerInfo, +} from "@modelcontextprotocol/inspector-test-server"; +import { ConnectionRegistry } from "../src/daemon/connections.js"; + +/** + * The daemon routes its connections through HTTPS_PROXY / HTTP_PROXY, honoring + * NO_PROXY (#2642). + * + * Asserted behaviorally, through a real forwarding proxy, as the one-shot CLI's + * proxy test is: `InspectorClient` always hands the transport a defined fetch, + * so the transport-level proxy fallback never fires and the only thing that + * puts a proxy under the daemon is `fetch: createProxyFetch()` on its + * environment. Delete that line and the first case here goes red. + * + * Proxy variables are set for the WHOLE file and the ambient ones restored + * after it. `createProxyFetch`'s `EnvHttpProxyAgent` is memoized process-wide + * and captures the proxy URL when it is first built (only NO_PROXY is re-read + * per request), so the proxy must be listening before any request is made and + * stay listening until the last one. + */ +const PROXY_VARS = [ + "HTTP_PROXY", + "http_proxy", + "HTTPS_PROXY", + "https_proxy", + "NO_PROXY", + "no_proxy", +] as const; + +describe("daemon outbound proxy (#2642)", () => { + const ambient: Record = {}; + const seen: string[] = []; + let proxy: Server; + + beforeAll(async () => { + for (const name of PROXY_VARS) { + ambient[name] = process.env[name]; + delete process.env[name]; + } + proxy = createServer((req, res) => { + seen.push(req.url ?? ""); + const target = new URL(req.url ?? ""); + const upstream = request( + { + host: target.hostname, + port: target.port, + path: target.pathname + target.search, + method: req.method, + headers: req.headers, + }, + (up) => { + res.writeHead(up.statusCode ?? 502, up.headers); + up.pipe(res); + }, + ); + upstream.on("error", () => { + res.writeHead(502); + res.end(); + }); + req.pipe(upstream); + }); + await new Promise((r) => proxy.listen(0, "127.0.0.1", () => r())); + const addr = proxy.address(); + const port = typeof addr === "object" && addr !== null ? addr.port : 0; + process.env.HTTP_PROXY = `http://127.0.0.1:${port}`; + }); + + afterAll(async () => { + for (const name of PROXY_VARS) { + const previous = ambient[name]; + if (previous === undefined) delete process.env[name]; + else process.env[name] = previous; + } + await new Promise((r) => proxy.close(() => r())); + }); + + /** Connects the daemon registry to a live server and lists its tools; returns the server URL. */ + async function listToolsThroughDaemon(): Promise { + const server = createTestServerHttp({ + serverInfo: createTestServerInfo(), + tools: [createEchoTool()], + }); + const registry = new ConnectionRegistry(0); + try { + await server.start(); + await registry.connect({ + name: "remote", + serverConfig: { type: "streamable-http", url: server.url }, + serverIdentity: server.url, + }); + const tools = await registry.resolve("remote", false).client.listTools(); + expect(tools.tools.map((t) => t.name)).toContain("echo"); + return server.url; + } finally { + await registry.disconnect(undefined, false).catch(() => undefined); + await server.stop(); + } + } + + it("routes the connection through HTTP_PROXY", async () => { + seen.length = 0; + const serverUrl = await listToolsThroughDaemon(); + // The point of the test: it did not go direct. + expect(seen.length).toBeGreaterThan(0); + expect(seen.every((u) => u.startsWith(serverUrl))).toBe(true); + }); + + it("connects direct to a NO_PROXY host", async () => { + seen.length = 0; + // The test server announces itself as `localhost`, so that is the host to + // exempt; NO_PROXY is re-read per request, so the memoized agent sees it. + process.env.NO_PROXY = "localhost"; + try { + await listToolsThroughDaemon(); + expect(seen).toEqual([]); + } finally { + delete process.env.NO_PROXY; + } + }); +}); diff --git a/clients/mcpdo/__tests__/ema.test.ts b/clients/mcpdo/__tests__/ema.test.ts index 6a68aa5641..081f3f7eda 100644 --- a/clients/mcpdo/__tests__/ema.test.ts +++ b/clients/mcpdo/__tests__/ema.test.ts @@ -11,6 +11,11 @@ import { const runRunnerInteractiveOAuth = vi.fn(); const startIdpOidcAuthorization = vi.fn(); const completeIdpOidcAuthorization = vi.fn(); +const createProxyFetch = vi.fn<() => typeof fetch | undefined>(); + +vi.mock("@inspector/core/mcp/node/proxyFetch.js", () => ({ + createProxyFetch: () => createProxyFetch(), +})); vi.mock("@inspector/core/auth/node/index.js", async (importOriginal) => { const actual = @@ -57,6 +62,7 @@ describe("mcpdo ema helpers", () => { runRunnerInteractiveOAuth.mockReset(); startIdpOidcAuthorization.mockReset(); completeIdpOidcAuthorization.mockReset(); + createProxyFetch.mockReset(); }); afterEach(() => { @@ -258,6 +264,45 @@ describe("mcpdo ema helpers", () => { ); }); + it("emaLogin routes IdP discovery and the code exchange through the proxy-aware fetch (#2642)", async () => { + writeClientConfig(emaClientConfig()); + const proxyFetch: typeof fetch = vi.fn(); // a stand-in, never called + createProxyFetch.mockReturnValue(proxyFetch); + startIdpOidcAuthorization.mockResolvedValue({ + authorizationUrl: new URL("https://idp.example.com/authorize"), + }); + completeIdpOidcAuthorization.mockImplementation(async () => { + await seedIdpSession(); + return { idToken: fakeIdToken() }; + }); + runRunnerInteractiveOAuth.mockImplementation( + async (options: { + client: { + authenticate: () => Promise; + completeOAuthFlow: (code: string, iss?: string) => Promise; + }; + }) => { + await options.client.authenticate(); + await options.client.completeOAuthFlow("code-1", ISSUER); + return { kind: "success" }; + }, + ); + const originalWrite = process.stderr.write; + process.stderr.write = (() => true) as typeof process.stderr.write; + try { + const { emaLogin } = await import("../src/connection/ema.js"); + await emaLogin(); + } finally { + process.stderr.write = originalWrite; + } + expect(startIdpOidcAuthorization).toHaveBeenCalledWith( + expect.objectContaining({ fetchFn: proxyFetch }), + ); + expect(completeIdpOidcAuthorization).toHaveBeenCalledWith( + expect.objectContaining({ fetchFn: proxyFetch }), + ); + }); + it("emaLogin --relogin clears the existing connection and re-runs the flow", async () => { writeClientConfig(emaClientConfig()); await seedIdpSession(); diff --git a/clients/mcpdo/__tests__/skills-budget-hint.test.ts b/clients/mcpdo/__tests__/skills-budget-hint.test.ts new file mode 100644 index 0000000000..6ff17c7043 --- /dev/null +++ b/clients/mcpdo/__tests__/skills-budget-hint.test.ts @@ -0,0 +1,159 @@ +/** + * mcpdo names its OWN remedies for a skill `--verify` left unread under the + * catalog budget (#2633). + * + * The walk is shared core, and the message once hard-coded the web's Skills + * settings and the one-shot CLI's `--method skills/get --uri` — so an mcpdo + * user was sent to a screen and a flag mcpdo does not have. The daemon now + * passes mcpdo's hint into `runMethod`; this runs it end to end and then runs + * the command the message suggests back through mcpdo, since that text is the + * only route the user has to a verdict for the skipped skill. + */ +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 { + createTestServerHttp, + createTestServerInfo, +} from "@modelcontextprotocol/inspector-test-server"; +import type { SkillVerifyReport } from "@inspector/core/mcp/skillsVerification.js"; +import { runMcp, type McpResult } from "./helpers/mcp-runner.js"; +import { + createTestConfig, + deleteConfigFile, +} from "../../cli/__tests__/helpers/fixtures.js"; +import { expectCliSuccess } from "../../cli/__tests__/helpers/assertions.js"; +import { callDaemon } from "../src/daemon/client.js"; + +/** Budget for each mcpdo command, matching the sibling suites' `runMcp` calls. */ +const COMMAND_TIMEOUT_MS = 20_000; +/** + * The test runs three commands in sequence, so its own budget must exceed all + * three together — otherwise Vitest's 15s default cuts in before a slow + * command's own timer can name it (an inner budget must be the tighter one; + * see AGENTS.md "Test-gate timeouts"), plus headroom for the HTTP server. + */ +const TEST_TIMEOUT_MS = 3 * COMMAND_TIMEOUT_MS + 10_000; + +/** + * The reports of a `--verify` run under `--format json`, which mcpdo prints as + * one JSON array — or a failure naming what the run printed instead. + */ +function reportsOf(result: McpResult): SkillVerifyReport[] { + try { + return JSON.parse(result.stdout) as SkillVerifyReport[]; + } catch { + throw new Error( + `not a --verify report (exit ${result.exitCode}):\n${result.output}`, + ); + } +} + +describe("mcpdo skills catalog-budget hint (#2633)", () => { + let storageDir: string | undefined; + let configPath: string | undefined; + + afterEach(async () => { + if (storageDir) { + const socketPath = path.join(storageDir, "mcpdod.sock"); + if (fs.existsSync(socketPath)) { + try { + await callDaemon("daemon/stop", {}, { socketPath, timeoutMs: 2000 }); + } catch { + // already stopped + } + // `daemon/stop` answers before shutdown finishes, so wait for the + // socket to go before deleting the directory a live daemon still + // holds — the same wait `mcp-coverage.test.ts` makes. + 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; + } + }); + + it( + "names mcpdo's remedies, and the suggested command verifies the skipped skill", + async () => { + const server = createTestServerHttp({ + serverInfo: createTestServerInfo("skills-budget", "1.0.0"), + skills: true, + }); + try { + await server.start(); + configPath = createTestConfig({ + mcpServers: { + skills: { + type: "streamable-http", + url: server.url, + skillCatalogMaxSkills: 1, + }, + }, + }); + storageDir = fs.mkdtempSync(path.join(os.tmpdir(), "mcpdo-budget-")); + const env = { + MCP_STORAGE_DIR: storageDir, + MCP_INSPECTOR_DAEMON_DIR: storageDir, + }; + + expectCliSuccess( + await runMcp( + ["connect", "skills", "--config", configPath, "--format", "json"], + { env, timeout: COMMAND_TIMEOUT_MS }, + ), + ); + + const listed = await runMcp( + ["@skills", "skills/list", "--verify", "--format", "json"], + { env, timeout: COMMAND_TIMEOUT_MS }, + ); + const skipped = reportsOf(listed).find((report) => + report.incomplete?.includes("catalog budget"), + ); + if (!skipped) throw new Error(`no skipped skill in: ${listed.output}`); + // mcpdo's own remedies — not the web's settings screen, nor the + // one-shot CLI's flags and `--method` syntax. + expect(skipped.incomplete).toContain( + "raise it with skillCatalogMaxSkills / skillCatalogMaxBytes in the connection's catalog entry", + ); + expect(skipped.incomplete).toContain( + "`mcpdo @ skills/get --verify`", + ); + expect(skipped.incomplete).not.toMatch( + /--method|--skill-catalog-max|Skills settings/, + ); + expect(skipped.incomplete).not.toContain(skipped.uri); + + // Run the suggested command back, placeholders filled the way a user + // would fill them, minus the `mcpdo` program name itself. + const command = /`([^`]+)`/.exec(skipped.incomplete ?? "")?.[1] ?? ""; + const argv = command + .split(/\s+/) + .slice(1) + .map((arg) => + arg === "@" ? "@skills" : arg === "" ? skipped.uri : arg, + ); + const got = await runMcp([...argv, "--format", "json"], { + env, + timeout: COMMAND_TIMEOUT_MS, + }); + const reports = reportsOf(got); + expect(reports).toHaveLength(1); + expect(reports[0].uri).toBe(skipped.uri); + expect(reports[0].incomplete ?? "").not.toMatch(/catalog budget/); + expect(reports[0].files.length).toBeGreaterThan(0); + } finally { + await server.stop(); + } + }, + TEST_TIMEOUT_MS, + ); +}); diff --git a/clients/mcpdo/package-lock.json b/clients/mcpdo/package-lock.json index 1d41f9f910..4b60396c0e 100644 --- a/clients/mcpdo/package-lock.json +++ b/clients/mcpdo/package-lock.json @@ -923,13 +923,13 @@ "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==", + "version": "24.19.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.19.2.tgz", + "integrity": "sha512-93d49wCWJkCyN87MxLpNN6KXPCwFw7bVUg79mGUV1OS40JGWwVCr8n61DXCSqz7CV6/VfPkb7xec7uP2M+QHWQ==", "dev": true, "license": "MIT", "dependencies": { - "undici-types": "~7.18.0" + "undici-types": ">=7.24.0 <7.24.7" } }, "node_modules/@types/qs": { @@ -1030,13 +1030,13 @@ } }, "node_modules/commander": { - "version": "13.1.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-13.1.0.tgz", - "integrity": "sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==", + "version": "15.0.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-15.0.0.tgz", + "integrity": "sha512-z67u4ZhzCL/Tydu1lJARtEZYWbWaN7oYLHbsuzocr6y4N6WZAagG3RQ4FW61V1/0+jImpj293XfrcYnd1qxtPg==", "dev": true, "license": "MIT", "engines": { - "node": ">=18" + "node": ">=22.12.0" } }, "node_modules/confbox": { @@ -1573,9 +1573,9 @@ "license": "MIT" }, "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==", + "version": "7.24.6", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", + "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", "dev": true, "license": "MIT" } diff --git a/clients/mcpdo/package.json b/clients/mcpdo/package.json index 49c5bd84bc..e38c7265fe 100644 --- a/clients/mcpdo/package.json +++ b/clients/mcpdo/package.json @@ -27,6 +27,10 @@ "format": "prettier --write src __tests__ scripts \"*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}\"", "format:check": "prettier --check src __tests__ scripts \"*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}\"" }, + "allowScripts": { + "esbuild": false, + "fsevents": false + }, "devDependencies": { "@types/express": "^5.0.6", "tsup": "^8.5.0" @@ -35,7 +39,7 @@ "@types/node": "^24.12.4", "esbuild": "^0.28.2", "sucrase": { - "commander": "^13.1.0" + "commander": "^15.0.0" } } } diff --git a/clients/mcpdo/src/connection/auth-helper.ts b/clients/mcpdo/src/connection/auth-helper.ts index 073779a867..cd67546d4e 100644 --- a/clients/mcpdo/src/connection/auth-helper.ts +++ b/clients/mcpdo/src/connection/auth-helper.ts @@ -241,9 +241,9 @@ export async function runAuthHelper(): Promise { * A leftover lock from a crashed reserver is stolen once it is older than * the URL wait window. The steal claims the specific stale file by an * atomic rename to a per-pid path — concurrent stealers cannot both win, - * and a winner that renamed a lock which turned out to be fresh backs off - * (POSIX has no compare-and-delete; the rename makes the claim itself - * exclusive, which is what prevents a double spawn). + * and a winner that renamed a lock which turned out to be fresh restores it + * and backs off (POSIX has no compare-and-delete; the rename makes the claim + * itself exclusive, which is what prevents a double spawn). * * @returns true if this process holds the reservation. */ @@ -261,13 +261,22 @@ function tryReserveAuthFlow(lockPath: string): boolean { // Claim the stale lock atomically: only one renamer succeeds. const claimPath = `${lockPath}.claim-${process.pid}`; fs.renameSync(lockPath, claimPath); - const claimedFresh = !isStale(fs.statSync(claimPath).mtimeMs); + if (!isStale(fs.statSync(claimPath).mtimeMs)) { + // The claimed file was recreated fresh between stat and rename: an + // active reserver (a stealer that won first) holds the flow. Put its + // lock back before backing off — deleting it would let a third + // connect win create() and spawn a competing helper (#2643). The + // link refuses (EEXIST) if a newer lock already took the path, and + // the outer catch then backs off all the same; either way only a + // lock confirmed stale is ever discarded. + try { + fs.linkSync(claimPath, lockPath); + } finally { + fs.rmSync(claimPath, { force: true }); + } + return false; + } fs.rmSync(claimPath, { force: true }); - // The claimed file was recreated fresh between stat and rename: an - // active reserver holds the flow — back off and wait for its marker. - // (Un-injectable microsecond race; the guard is what matters.) - /* v8 ignore next */ - if (claimedFresh) return false; create(); return true; } catch { diff --git a/clients/mcpdo/src/connection/authorize.ts b/clients/mcpdo/src/connection/authorize.ts index 6aff3f2a38..a999212b76 100644 --- a/clients/mcpdo/src/connection/authorize.ts +++ b/clients/mcpdo/src/connection/authorize.ts @@ -11,7 +11,10 @@ import { 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 { + createProxyFetch, + createTransportNode, +} from "@inspector/core/mcp/node/index.js"; import { eraToVersionNegotiation, type InspectorClientEnvironment, @@ -19,6 +22,7 @@ import { type MCPServerConfig, } from "@inspector/core/mcp/types.js"; import { readInspectorVersion } from "@inspector/core/node/version.js"; +import { CLI_CONNECTION_TIMEOUT_REMEDY } from "@inspector/core/cli/handlers/connect-timeout.js"; import { createCliOAuthNavigation } from "@inspector/core/cli/cli-oauth-navigation.js"; import { connectInspectorWithOAuth } from "@inspector/core/cli/cliOAuth.js"; import { @@ -55,6 +59,12 @@ export async function authorizeInFrontend( const environment: InspectorClientEnvironment = { transport: createTransportNode, + // HTTPS_PROXY / HTTP_PROXY / NO_PROXY, as in the one-shot CLI (#2642): + // `InspectorClient` always hands the transport a defined fetch, so the + // transport's own proxy fallback never fires — the proxy has to sit here, + // at the bottom of the stack, for OAuth discovery and token requests to + // reach it. Undefined when no proxy variable is set. + fetch: createProxyFetch(), }; const redirectUrlProvider = new MutableRedirectUrlProvider(); const callbackUrlConfig = parseRunnerOAuthCallbackUrl( @@ -100,6 +110,8 @@ export async function authorizeInFrontend( name: "inspector-cli", version: readInspectorVersion(import.meta.url), }, + // Same knobs as the daemon's connect: --connect-timeout or mcp.json (#2615). + connectionTimeoutRemedy: CLI_CONNECTION_TIMEOUT_REMEDY, initialLoggingLevel: "debug", progress: false, sample: false, diff --git a/clients/mcpdo/src/connection/ema.ts b/clients/mcpdo/src/connection/ema.ts index 89e11683d0..88e7a5353b 100644 --- a/clients/mcpdo/src/connection/ema.ts +++ b/clients/mcpdo/src/connection/ema.ts @@ -22,6 +22,7 @@ import { parseRunnerOAuthCallbackUrl, } from "@inspector/core/auth/node/runner-oauth-callback.js"; import { getClientConfigFilePath } from "@inspector/core/client/index.js"; +import { createProxyFetch } from "@inspector/core/mcp/node/proxyFetch.js"; import { loadRunnerClientConfig } from "@inspector/core/client/runner.js"; import type { EnterpriseManagedAuthIdpConfig } from "@inspector/core/client/types.js"; import { createCliOAuthNavigation } from "@inspector/core/cli/cli-oauth-navigation.js"; @@ -231,6 +232,10 @@ export async function runEmaIdpInteractiveFlow( const redirectUrlProvider = new MutableRedirectUrlProvider(); redirectUrlProvider.redirectUrl = formatRunnerOAuthRedirectUrl(callbackUrlConfig); + // IdP discovery and the code exchange run outside `InspectorClient`, so + // nothing else puts HTTPS_PROXY / HTTP_PROXY under them (#2642). Undefined + // when no proxy variable is set, which leaves the built-in fetch in place. + const fetchFn = createProxyFetch(); // Adapter over the server-bound runner-interactive-OAuth surface: EMA leg 1 // is server-less, so authenticate/completeOAuthFlow map straight onto the @@ -243,6 +248,7 @@ export async function runEmaIdpInteractiveFlow( idp: active, redirectUrl: redirectUrlProvider.redirectUrl, storage, + fetchFn, }); navigation.navigateToAuthorization(authorizationUrl); return authorizationUrl; @@ -256,6 +262,7 @@ export async function runEmaIdpInteractiveFlow( iss, redirectUrl: redirectUrlProvider.redirectUrl, storage, + fetchFn, }); }, /* v8 ignore next 2 -- only reached when options.authChallenge is set, which this flow never does */ diff --git a/clients/mcpdo/src/connection/mcp.ts b/clients/mcpdo/src/connection/mcp.ts index 131e374238..e4b200aa10 100644 --- a/clients/mcpdo/src/connection/mcp.ts +++ b/clients/mcpdo/src/connection/mcp.ts @@ -1334,7 +1334,14 @@ function registerRpcCommands(program: CommandType): void { parseKeyValue, {}, ) - .option("--task", "Task-augmented tool call (callToolStream)") + // Legacy-only choice: on a modern (SEP-2663) connection the server + // decides per request whether to return a task, and an ordinary call + // already follows one to completion, so the flag changes nothing on + // the wire there (#2674). + .option( + "--task", + "Request a task-augmented tool call (legacy servers; on modern the server decides)", + ) .option("--app-info", "Probe MCP App metadata only"); cmd.action( async ( diff --git a/clients/mcpdo/src/daemon/connections.ts b/clients/mcpdo/src/daemon/connections.ts index 0f175fb8fe..77809afc7e 100644 --- a/clients/mcpdo/src/daemon/connections.ts +++ b/clients/mcpdo/src/daemon/connections.ts @@ -9,7 +9,10 @@ import { type InspectorServerSettings, type MCPServerConfig, } from "@inspector/core/mcp/types.js"; -import { createTransportNode } from "@inspector/core/mcp/node/index.js"; +import { + createProxyFetch, + createTransportNode, +} from "@inspector/core/mcp/node/index.js"; import { buildOAuthConnectionState, ConsoleNavigation, @@ -32,6 +35,7 @@ import { loadRunnerClientConfig, } from "@inspector/core/client/runner.js"; import { readInspectorVersion } from "@inspector/core/node/version.js"; +import { CLI_CONNECTION_TIMEOUT_REMEDY } from "@inspector/core/cli/handlers/connect-timeout.js"; import { cleanRoots } from "@inspector/core/mcp/serverList.js"; import { AuthRecoveryRequiredError, @@ -816,6 +820,12 @@ async function createConnectionClient( ): Promise { const environment: InspectorClientEnvironment = { transport: createTransportNode, + // HTTPS_PROXY / HTTP_PROXY / NO_PROXY, as in the one-shot CLI (#2642). + // `InspectorClient` builds its fetch chain on `environment.fetch ?? fetch` + // and always hands the transport the result, so the transport's own + // `createProxyFetch()` fallback is never reached — leave this out and the + // daemon connects direct. Undefined when no proxy variable is set. + fetch: createProxyFetch(), }; const redirectUrlProvider = new MutableRedirectUrlProvider(); if (isOAuthCapableServerConfig(serverConfig)) { @@ -848,6 +858,9 @@ async function createConnectionClient( name: CONNECTION_CLIENT_NAME, version: readInspectorVersion(import.meta.url), }, + // `mcpdo connect` takes the same --connect-timeout as the one-shot CLI, so + // a timeout names that rather than the web's Server Settings (#2615). + connectionTimeoutRemedy: CLI_CONNECTION_TIMEOUT_REMEDY, initialLoggingLevel: "debug", progress: false, sample: false, diff --git a/clients/mcpdo/src/daemon/server.ts b/clients/mcpdo/src/daemon/server.ts index 7b45e83709..dfc4789c7d 100644 --- a/clients/mcpdo/src/daemon/server.ts +++ b/clients/mcpdo/src/daemon/server.ts @@ -5,7 +5,11 @@ import { CliExitCodeError, EXIT_CODES, } from "@inspector/core/cli/error-handler.js"; -import { runMethod } from "@inspector/core/cli/handlers/run-method.js"; +import { + runMethod, + type RunMethodOptions, +} from "@inspector/core/cli/handlers/run-method.js"; +import { MCPDO_SKILL_BUDGET_HINT } from "@inspector/core/cli/handlers/skills-verify.js"; import type { MethodArgs } from "@inspector/core/cli/handlers/method-types.js"; import { acceptDaemonConnection, @@ -51,6 +55,15 @@ import { ConnectionRegistry, } from "./connections.js"; +/** + * What every `runMethod` call from the daemon contributes on mcpdo's behalf: + * its own remedies for a skill `--verify` left unread under the catalog + * budget, rather than the one-shot CLI's flags and syntax (#2633). + */ +const MCPDO_RUN_OPTIONS: RunMethodOptions = { + skillBudgetHint: MCPDO_SKILL_BUDGET_HINT, +}; + /** * Default channel used when a caller doesn't wire a real one (in-process * `handle`/`handleOutcome` test call sites that predate elicitation support). @@ -661,7 +674,7 @@ export class DaemonServer { const clearAmbientSignal = client.setAmbientRequestSignal(signal); let outcome; try { - outcome = await runMethod(client, methodArgs); + outcome = await runMethod(client, methodArgs, MCPDO_RUN_OPTIONS); } finally { clearAmbientSignal(); signal?.removeEventListener("abort", onAbort); @@ -712,7 +725,10 @@ export class DaemonServer { const unwire = wireElicitationBridge(client, channel, requestId); const outcome: Promise = (async () => { try { - return toRpcResult(await runMethod(client, methodArgs), params.method); + return toRpcResult( + await runMethod(client, methodArgs, MCPDO_RUN_OPTIONS), + params.method, + ); } finally { unwire(); } @@ -847,7 +863,7 @@ export class DaemonServer { params.interactive, ); const methodArgs = stripConnectionFields(params); - const outcome = await runMethod(client, methodArgs); + const outcome = await runMethod(client, methodArgs, MCPDO_RUN_OPTIONS); if (outcome.kind !== "stream") { throw new CliExitCodeError( EXIT_CODES.USAGE, diff --git a/clients/tui/README.md b/clients/tui/README.md index 876ba5a773..67552a4e16 100644 --- a/clients/tui/README.md +++ b/clients/tui/README.md @@ -103,7 +103,7 @@ The TUI provides terminal-native tabs and panes for interacting with your MCP se Any details view (opened from Resources, Prompts, Tools, Protocol or Network) and the **Auth** tab's access token can be copied out of the TUI ([#2421](https://github.com/modelcontextprotocol/inspector/issues/2421)): - **Y** copies the value to your clipboard with an [OSC 52](https://invisible-island.net/xterm/ctlseqs/ctlseqs.html#h3-Operating-System-Commands) escape sequence. The sequence asks the _terminal emulator_ to set its clipboard, so it reaches the machine you are sitting at even when the TUI runs over SSH. A details view copies the entry's raw JSON; the Auth tab copies the full token, not the truncated prefix it displays. -- **W** saves the value to a private temp file (`mcp-inspector-copy-*/value.txt`, mode `0600`) and shows its path — the fallback when OSC 52 does not work. +- **W** saves the value to a private temp file (`mcp-inspector-copy-*/value.txt`, mode `0600`) and shows its path — the fallback when OSC 52 does not work. The file is deleted when the TUI exits, since it may hold a token; copy it somewhere else first if you want to keep it. OSC 52 is fire-and-forget: a terminal that does not support it (or has it disabled) fails silently, which is why the status line after **Y** points at **W**. Most modern terminals support it — iTerm2 (enable _Applications in terminal may access clipboard_), kitty, WezTerm, Alacritty, Windows Terminal, foot, and xterm with `allowWindowOps`. Inside **tmux**, set `set -g set-clipboard on` so tmux forwards the sequence. Some terminals cap the payload (often around 100 KB); a copy above that is flagged in the status line, and **W** has no such limit. @@ -211,19 +211,3 @@ declaring a `react` peer must be in `noExternal` unless it is listed as external by design, each exempt package must also be a root dependency (external means consumers install it), and the root `react` range must stay open to the major. Add a React-rendering dependency, and that test tells you to bundle it. - -### The `ink-form` label patch - -Bundling `ink-form` also makes it patchable, which one label needs: the hint -under an incomplete form reads "you have not **competed** yet". It is upstream's -string, hardcoded in `ink-form/lib/SubmitButton.js` with no prop to override, -and `ink-form` was last published in 2024 — so `tsup.config.ts` corrects it with -an esbuild `onLoad` hook as the file enters the bundle. It is reported upstream -as [lukasbach/ink-form#14](https://github.com/lukasbach/ink-form/issues/14); if -a release ever carries the fix, drop the patch. - -The hook **throws** when the string isn't found rather than passing the source -through. A silent no-op would let an `ink-form` upgrade retire the patch without -anyone noticing it had stopped applying — or leave a patch aimed at a string -that no longer exists. If the build fails there, check whether upstream fixed -the typo and delete the patch instead of re-targeting it. diff --git a/clients/tui/__tests__/App.test.tsx b/clients/tui/__tests__/App.test.tsx index 6d70a59252..07f2ad9dbf 100644 --- a/clients/tui/__tests__/App.test.tsx +++ b/clients/tui/__tests__/App.test.tsx @@ -2,6 +2,7 @@ import React from "react"; import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { render } from "./helpers/renderTui"; import { BodyLines } from "../src/components/BodyLines.js"; +import { readInspectorVersion } from "@inspector/core/node/version.js"; type RenderResult = ReturnType; @@ -135,8 +136,13 @@ const h = vi.hoisted(() => { // The options the mount effect built for this server. Captured so a test // can assert what was actually handed to the client — `defaultMetadata` // and the `environment.fetch` proxy wiring (#2067), both of which are - // invisible to a render-only assertion. - opts?: { defaultMetadata?: unknown; environment?: { fetch?: unknown } }; + // invisible to a render-only assertion — plus the connect-timeout remedy + // (#2615). + opts?: { + defaultMetadata?: unknown; + environment?: { fetch?: unknown }; + connectionTimeoutRemedy?: string; + }; }> = []; // What the mocked `createProxyFetch` returns. `undefined` is the real // behavior with no proxy env var set; a test points this at a sentinel to @@ -167,11 +173,19 @@ const h = vi.hoisted(() => { class FakeClient { cfg: { type?: string; url?: string } | undefined; opts: - | { defaultMetadata?: unknown; environment?: { fetch?: unknown } } + | { + defaultMetadata?: unknown; + environment?: { fetch?: unknown }; + connectionTimeoutRemedy?: string; + } | undefined; constructor( config?: { type?: string; url?: string }, - opts?: { defaultMetadata?: unknown; environment?: { fetch?: unknown } }, + opts?: { + defaultMetadata?: unknown; + environment?: { fetch?: unknown }; + connectionTimeoutRemedy?: string; + }, ) { this.cfg = config; this.opts = opts; @@ -390,7 +404,7 @@ vi.mock("../src/utils/openUrl.js", () => ({ openUrl: h.openUrl, })); -import App from "../src/App.js"; +import App, { TUI_CONNECTION_TIMEOUT_REMEDY } from "../src/App.js"; import type { InspectorClient } from "@inspector/core/mcp/index.js"; import type { TuiServer } from "../src/tui-servers.js"; import { @@ -819,6 +833,42 @@ describe("App (foundation)", () => { expect(frame).toContain("beta"); }); + // The header box draws a bottom border, which takes a row of its own; sized + // to one row, the border left the title row zero height and Ink clipped the + // right-justified version off screen (#2656). + describe("header", () => { + const version = readInspectorVersion(import.meta.url); + const originalColumns = process.stdout.columns; + afterEach(() => { + process.stdout.columns = originalColumns; + }); + + async function headerRows(columns: number) { + process.stdout.columns = columns; + const r = renderApp(stdioServer()); + await expectFrame(r, "MCP Servers"); + const [titleRow, borderRow] = (r.lastFrame() ?? "").split("\n"); + return { titleRow, borderRow }; + } + + it("shows the full title and the version where the width allows", async () => { + const { titleRow, borderRow } = await headerRows(100); + expect(titleRow).toContain( + "MCP Inspector TUI - Terminal User Interface for the Model Context Protocol Inspector", + ); + expect(titleRow.trimEnd().endsWith(`v${version}`)).toBe(true); + expect(borderRow).toMatch(/^─+$/); + }); + + it("truncates the description, not the version, on a narrow terminal", async () => { + const { titleRow, borderRow } = await headerRows(80); + expect(titleRow).toContain("MCP Inspector TUI - "); + expect(titleRow).toContain("…"); + expect(titleRow.trimEnd().endsWith(`v${version}`)).toBe(true); + expect(borderRow).toMatch(/^─+$/); + }); + }); + it("auto-selects the first server and shows its config", async () => { const r = renderApp(stdioServer()); await expectFrame(r, "Server Configuration"); @@ -1138,6 +1188,24 @@ describe("App (status, layout, modals)", () => { expect(h.disconnect).toHaveBeenCalled(); }); + it("clears a kept list filter on Esc instead of exiting (#2647)", async () => { + h.ctrl.status = "connected"; + h.ctrl.tools = [ + sampleTool, + { ...sampleTool, name: "dpt-tool", description: "D" }, + ]; + const r = await mount(oneStdio()); + await press(r, ["t", TAB, "/", "d", "p", "t", ENTER]); + await expectFrame(r, "Tools (1/2)"); + // The first Esc clears the kept filter; the app is still running and + // answering its accelerators. + await press(r, [ESC]); + await expectFrame(r, "Tools (2)"); + await press(r, ["d"]); + await waitUntil(() => h.disconnect.mock.calls.length > 0); + expect(h.disconnect).toHaveBeenCalled(); + }); + it("types '?' into a list filter instead of opening the help (#2430 + #2436)", async () => { h.ctrl.status = "connected"; h.ctrl.tools = [sampleTool]; @@ -1434,6 +1502,17 @@ describe("App (input handling, focus, effects)", () => { }); }); + it("names mcp.json's connectionTimeout, not Server Settings, as the timeout remedy (#2615)", async () => { + const r = await mount(httpWithSettings()); + await expectFrame(r, "MCP Servers"); + const web = h.clientInstances.find((c) => c.cfg?.url === "http://x"); + expect(web?.opts?.connectionTimeoutRemedy).toBe( + TUI_CONNECTION_TIMEOUT_REMEDY, + ); + expect(TUI_CONNECTION_TIMEOUT_REMEDY).toContain("connectionTimeout"); + expect(TUI_CONNECTION_TIMEOUT_REMEDY).not.toContain("Server Settings"); + }); + it("passes top-level oauth client credentials into an http client", async () => { const r = render( { expect(lastFrame()).toContain("Enter keep · Esc clear"); }); + it("tells a focused list that Esc clears a kept query (#2647)", () => { + const { lastFrame } = render( + , + ); + expect(lastFrame()).toContain("/echo"); + expect(lastFrame()).toContain("(/ edit · Esc clear)"); + }); + it("shows a kept query and how to change it", () => { const { lastFrame } = render( , diff --git a/clients/tui/__tests__/SkillsTab.test.tsx b/clients/tui/__tests__/SkillsTab.test.tsx index eb95a02e3a..1efe5faa1c 100644 --- a/clients/tui/__tests__/SkillsTab.test.tsx +++ b/clients/tui/__tests__/SkillsTab.test.tsx @@ -997,6 +997,11 @@ describe("SkillsTab verify all (#2590)", () => { expect(frame).toContain("Incomplete:"); expect(frame).toContain("catalog budget of 1 skills"); expect(frame).toContain("Verification INCOMPLETE"); + // The pane's own remedies — its launch flags and Enter — not the web's + // settings screen or the one-shot CLI's syntax (#2633). + expect(frame).toContain("--skill-catalog-max-skills"); + expect(frame).toContain("by selecting it and pressing Enter"); + expect(frame).not.toContain("--method"); }); it("covers the whole catalog even while a filter narrows the list", async () => { diff --git a/clients/tui/__tests__/ToolsTab.test.tsx b/clients/tui/__tests__/ToolsTab.test.tsx index b5a39cbc0e..15bbd36a05 100644 --- a/clients/tui/__tests__/ToolsTab.test.tsx +++ b/clients/tui/__tests__/ToolsTab.test.tsx @@ -326,7 +326,7 @@ describe("ToolsTab list filter (#2430)", () => { stdin.write(DOWN); await tick(); frame = lastFrame() ?? ""; - expect(frame).toContain("(/ to edit)"); + expect(frame).toContain("(/ edit · Esc clear)"); expect(frame).toContain("▶ delta"); // A query nothing matches. diff --git a/clients/tui/__tests__/clipboard.test.ts b/clients/tui/__tests__/clipboard.test.ts index 965070f72a..e0a664b519 100644 --- a/clients/tui/__tests__/clipboard.test.ts +++ b/clients/tui/__tests__/clipboard.test.ts @@ -1,11 +1,12 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { OSC52_LARGE_PAYLOAD_BYTES, buildOsc52Sequence, copyViaOsc52, + removeCopyFiles, toCopyText, writeCopyFile, } from "../src/utils/clipboard.js"; @@ -61,6 +62,7 @@ describe("copyViaOsc52", () => { describe("writeCopyFile", () => { const created: string[] = []; afterEach(() => { + vi.restoreAllMocks(); for (const dir of created.splice(0)) { fs.rmSync(dir, { recursive: true, force: true }); } @@ -83,4 +85,58 @@ describe("writeCopyFile", () => { created.push(path.dirname(file)); expect(file.startsWith(os.tmpdir())).toBe(true); }); + + it("removes every directory it created on removeCopyFiles (#2648)", () => { + const base = fs.mkdtempSync(path.join(os.tmpdir(), "clip-test-")); + created.push(base); + const first = writeCopyFile("token-a", base); + const second = writeCopyFile("token-b", base); + removeCopyFiles(); + expect(fs.existsSync(path.dirname(first))).toBe(false); + expect(fs.existsSync(path.dirname(second))).toBe(false); + // The base directory is not ours, so it survives. + expect(fs.existsSync(base)).toBe(true); + // A second sweep has nothing left to remove and does not throw. + expect(() => removeCopyFiles()).not.toThrow(); + }); + + it("registers the cleanup as a single process exit listener", () => { + const base = fs.mkdtempSync(path.join(os.tmpdir(), "clip-test-")); + created.push(base); + writeCopyFile("a", base); + writeCopyFile("b", base); + const listeners = process + .listeners("exit") + .filter((l) => l === removeCopyFiles); + expect(listeners).toHaveLength(1); + removeCopyFiles(); + }); + + it("still cleans up a directory whose file write failed", () => { + const base = fs.mkdtempSync(path.join(os.tmpdir(), "clip-test-")); + created.push(base); + vi.spyOn(fs, "writeFileSync").mockImplementationOnce(() => { + throw new Error("disk full"); + }); + expect(() => writeCopyFile("v", base)).toThrow("disk full"); + expect(fs.readdirSync(base)).toHaveLength(1); + removeCopyFiles(); + expect(fs.readdirSync(base)).toHaveLength(0); + }); + + it("keeps going when a directory cannot be removed", () => { + const base = fs.mkdtempSync(path.join(os.tmpdir(), "clip-test-")); + created.push(base); + const first = writeCopyFile("a", base); + const second = writeCopyFile("b", base); + const realRm = fs.rmSync; + vi.spyOn(fs, "rmSync").mockImplementationOnce(() => { + throw new Error("EBUSY"); + }); + expect(() => removeCopyFiles()).not.toThrow(); + // The first removal threw; the second still ran. + expect(fs.existsSync(path.dirname(first))).toBe(true); + expect(fs.existsSync(path.dirname(second))).toBe(false); + realRm(path.dirname(first), { recursive: true, force: true }); + }); }); diff --git a/clients/tui/__tests__/tsupConfig.test.ts b/clients/tui/__tests__/tsupConfig.test.ts index 8c61e9e933..aca7ecf75c 100644 --- a/clients/tui/__tests__/tsupConfig.test.ts +++ b/clients/tui/__tests__/tsupConfig.test.ts @@ -25,12 +25,7 @@ import { readFileSync } from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; import type { Options } from "tsup"; -import tsupConfig, { - INK_FORM_INCOMPLETE_HINT, - INK_FORM_SUBMIT_BUTTON, - fixInkFormIncompleteHint, - inkFormLabelPatch, -} from "../tsup.config.js"; +import tsupConfig from "../tsup.config.js"; const clientDir = path.resolve( path.dirname(fileURLToPath(import.meta.url)), @@ -94,15 +89,6 @@ function reactRenderingDependencies(): string[] { ); } -/** The real `ink-form` module the label patch targets. */ -const submitButtonPath = path.join( - clientDir, - "node_modules", - "ink-form", - "lib", - "SubmitButton.js", -); - const config = singleConfig(tsupConfig); const noExternal = stringEntries(config.noExternal); const external = stringEntries(config.external); @@ -176,84 +162,6 @@ describe("tui tsup config", () => { expect(noExternal).not.toContain("react"); }); - it("corrects ink-form's misspelled incomplete-form hint", () => { - // Read the real dependency, so an `ink-form` upgrade that fixes or rewords - // the label fails here — the patch must then be removed, not left silently - // matching nothing. - const patched = fixInkFormIncompleteHint( - readFileSync(submitButtonPath, "utf8"), - submitButtonPath, - ); - expect(patched).toContain(INK_FORM_INCOMPLETE_HINT.fixed); - expect(patched).not.toContain(INK_FORM_INCOMPLETE_HINT.typo); - }); - - it("routes the real module through the plugin, not just the helper", async () => { - // The helper is only reached if the plugin's onLoad filter matches. Drive - // the plugin as esbuild would — register, then invoke — so a filter that - // stops matching the real module's path fails here instead of no-opping - // through a green build (the exact silence this patch exists to prevent). - type OnLoadCallback = (args: { - path: string; - }) => Promise<{ contents: string }>; - const registered: { filter: RegExp; callback: OnLoadCallback }[] = []; - - const build = { - onLoad: (options: { filter: RegExp }, callback: OnLoadCallback) => - registered.push({ filter: options.filter, callback }), - }; - // esbuild's `PluginBuild` carries far more than this patch touches, and the - // stub above deliberately implements only the one hook it registers — so - // the double cast is bridging a real structural gap, not hiding a mismatch. - // A hook the patch called but the stub lacks fails as undefined here rather - // than passing silently. - // esbuild types `setup` as `void | Promise`; await it so the - // assertions below can't run before an async setup has registered. - await inkFormLabelPatch.setup( - build as unknown as Parameters[0], - ); - - expect(registered).toHaveLength(1); - const [{ filter, callback }] = registered; - expect(filter.test(submitButtonPath)).toBe(true); - - const { contents } = await callback({ path: submitButtonPath }); - expect(contents).toContain(INK_FORM_INCOMPLETE_HINT.fixed); - expect(contents).not.toContain(INK_FORM_INCOMPLETE_HINT.typo); - }); - - it("scopes the patch to ink-form's SubmitButton and nothing else", () => { - expect(INK_FORM_SUBMIT_BUTTON.test(submitButtonPath)).toBe(true); - // A Windows-style path must match too — the filter runs against whatever - // esbuild resolved, and its separator is the platform's. - expect( - INK_FORM_SUBMIT_BUTTON.test( - "C:\\repo\\node_modules\\ink-form\\lib\\SubmitButton.js", - ), - ).toBe(true); - for (const other of [ - "/repo/node_modules/ink-form/lib/Form.js", - "/repo/node_modules/ink-select-input/build/SubmitButton.js", - "/repo/src/SubmitButton.jsx", - ]) { - expect(INK_FORM_SUBMIT_BUTTON.test(other), other).toBe(false); - } - }); - - it("fails loudly rather than silently skipping a label it cannot find", () => { - expect(() => - fixInkFormIncompleteHint("no such label here", "SubmitButton.js"), - ).toThrow(/no longer contains the ink-form label/); - }); - - it("only rewrites the misspelling", () => { - // The two strings must differ by exactly the fix, or the patch is silently - // changing copy nobody reviewed. - expect(INK_FORM_INCOMPLETE_HINT.fixed).toBe( - INK_FORM_INCOMPLETE_HINT.typo.replace("competed", "completed"), - ); - }); - it("verifies the deps it exempts are still declared", () => { // If `ink` ever leaves the dependency list, the exemption above is stale // and would silently excuse a future package that took its name. diff --git a/clients/tui/__tests__/useCopyKeys.test.tsx b/clients/tui/__tests__/useCopyKeys.test.tsx index 997c2e9f80..8751b99ca1 100644 --- a/clients/tui/__tests__/useCopyKeys.test.tsx +++ b/clients/tui/__tests__/useCopyKeys.test.tsx @@ -88,7 +88,9 @@ describe("useCopyKeys", () => { // Compare with all whitespace removed, so a wrap anywhere in the line — // including mid-path — cannot fail the check. expect(squashWhitespace(lastFrame() ?? "")).toContain( - squashWhitespace(`[success] Saved thing to ${file}`), + squashWhitespace( + `[success] Saved thing to ${file} (deleted when the TUI exits)`, + ), ); }); diff --git a/clients/tui/__tests__/useListFilter.test.tsx b/clients/tui/__tests__/useListFilter.test.tsx index c3e89bc660..7430266602 100644 --- a/clients/tui/__tests__/useListFilter.test.tsx +++ b/clients/tui/__tests__/useListFilter.test.tsx @@ -35,13 +35,19 @@ const fields = (item: Item) => [item.name, item.title]; function Harness({ enabled = true, onEditingChange, + onEscapeClaimChange, onUnconsumed, }: { enabled?: boolean; onEditingChange?: (editing: boolean) => void; + onEscapeClaimChange?: (claimed: boolean) => void; onUnconsumed?: (input: string) => void; }) { - const filter = useListFilter(items, fields, { enabled, onEditingChange }); + const filter = useListFilter(items, fields, { + enabled, + onEditingChange, + onEscapeClaimChange, + }); useInput((input, key) => { if (!filter.handleInput(input, key)) onUnconsumed?.(input); }); @@ -106,6 +112,65 @@ describe("useListFilter", () => { expect(lastFrame()).toContain("items=alpha,beta,gamma"); }); + it("clears a kept filter on Esc, claiming Esc only while one is kept (#2647)", async () => { + const onEscapeClaimChange = vi.fn(); + const onUnconsumed = vi.fn(); + const { lastFrame, stdin } = render( + , + ); + await tick(); + // Editing is App's whole-keyboard mute, not the Esc claim. + await type(stdin, ["/", "b"]); + expect(onEscapeClaimChange).not.toHaveBeenCalled(); + + await type(stdin, [ENTER]); + expect(lastFrame()).toContain("editing=false"); + expect(lastFrame()).toContain("items=beta"); + expect(onEscapeClaimChange).toHaveBeenLastCalledWith(true); + + // The first Esc is the filter's: it clears the query and withdraws. + await type(stdin, [ESC]); + expect(lastFrame()).toContain("q=[]"); + expect(lastFrame()).toContain("items=alpha,beta,gamma"); + expect(onEscapeClaimChange).toHaveBeenLastCalledWith(false); + expect(onUnconsumed).not.toHaveBeenCalled(); + + // The second has nothing to clear, so it is left to the app. + await type(stdin, [ESC]); + expect(onUnconsumed).toHaveBeenCalledTimes(1); + }); + + it("withdraws the Esc claim while the list lacks the keyboard", async () => { + const onEscapeClaimChange = vi.fn(); + const onUnconsumed = vi.fn(); + const { lastFrame, stdin, rerender } = render( + , + ); + await tick(); + await type(stdin, ["/", "b", ENTER]); + expect(onEscapeClaimChange).toHaveBeenLastCalledWith(true); + + rerender( + , + ); + await tick(); + expect(onEscapeClaimChange).toHaveBeenLastCalledWith(false); + // The filter is kept, not cleared, and Esc is no longer its to take. + await type(stdin, [ESC]); + expect(lastFrame()).toContain("q=[b]"); + expect(onUnconsumed).toHaveBeenCalledTimes(1); + }); + it("leaves navigation and unrelated keys to the list", async () => { const onUnconsumed = vi.fn(); const { lastFrame, stdin } = render( diff --git a/clients/tui/package-lock.json b/clients/tui/package-lock.json index 33617e0a94..de0d6d82f5 100644 --- a/clients/tui/package-lock.json +++ b/clients/tui/package-lock.json @@ -9,7 +9,7 @@ "license": "SEE LICENSE IN ../../LICENSE", "dependencies": { "ink": "^6.0.0", - "ink-form": "^2.0.1", + "ink-form": "^2.0.2", "ink-scroll-view": "^0.3.6" }, "bin": { @@ -306,38 +306,30 @@ "node": ">=6.9.0" } }, - "node_modules/@emnapi/core": { - "version": "2.0.0-alpha.3", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-2.0.0-alpha.3.tgz", - "integrity": "sha512-AZypUeJ/yByuxyS7BlSNRDOMLMlROYtjYdIAuBmJssVz1UJDSeYxLrdizhXCFYhedC5bqd/ASy8EuNXbVVXp9g==", + "node_modules/@cacheable/memory": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@cacheable/memory/-/memory-2.2.0.tgz", + "integrity": "sha512-CTLKqLItRCEixEAewD3/j9DB3/o96gpTPD4eJ1v+DGOlxZRZncRQkGYqqnAGCscYd6RNeXfGeiuCphsPtqyIfQ==", "dev": true, "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/wasi-threads": "2.0.1", - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/runtime": { - "version": "2.0.0-alpha.3", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-2.0.0-alpha.3.tgz", - "integrity": "sha512-hFPAhMUjJD9BSyCANEISPOogeXC9Zo9ZQl7L6vKnaVsMkCtzznaW/naYypeyl0Gv5rYfWYsZbpixTMpjDJzQeA==", - "dev": true, - "license": "MIT", - "optional": true, + "peer": true, "dependencies": { - "tslib": "^2.4.0" + "@cacheable/utils": "^2.5.0", + "@keyv/bigmap": "^1.3.1", + "hookified": "^1.15.1", + "keyv": "^5.6.0" } }, - "node_modules/@emnapi/wasi-threads": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-2.0.1.tgz", - "integrity": "sha512-9DsSk+o5NBX0CCJT8s0EROGSGxjR/tKu6aBTaVyq+SjAEQH4XcdcRxPBRzsBLizTTJ49MJjF+jgu3qnO9GLQcQ==", + "node_modules/@cacheable/utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@cacheable/utils/-/utils-2.5.0.tgz", + "integrity": "sha512-buipgOVDkkPXNR5+xBpDw7Zk2n1EvU7qBJCNUcL7rhQ//kfpOXPAvQ511Os0vpLYJ1pZnvudNytkQt2hst3wqA==", "dev": true, "license": "MIT", - "optional": true, + "peer": true, "dependencies": { - "tslib": "^2.4.0" + "hashery": "^1.5.1", + "keyv": "^5.6.0" } }, "node_modules/@esbuild/aix-ppc64": { @@ -883,9 +875,9 @@ } }, "node_modules/@eslint/plugin-kit": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.2.tgz", - "integrity": "sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==", + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.3.tgz", + "integrity": "sha512-IkO+/KEUvwbVpiURZg+P7zF74z5Jxe0UgJxVni+RtoHQ6IZieXaO02kmadomap/q+l6bc/jdPGGqTjhuZnuz1Q==", "dev": true, "license": "Apache-2.0", "peer": true, @@ -1018,42 +1010,63 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, - "node_modules/@napi-rs/wasm-runtime": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.2.tgz", - "integrity": "sha512-JfB4kuJQjaoHuCTseIINHtHWeJnvgEcxjwA5t/Y00ZgaOO1Crz3fjT/p8kT28zA/Caz7oiUMn3d6H2yOVCVwuw==", + "node_modules/@keyv/bigmap": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@keyv/bigmap/-/bigmap-1.3.1.tgz", + "integrity": "sha512-WbzE9sdmQtKy8vrNPa9BRnwZh5UF4s1KTmSK0KUVLo3eff5BlQNNWDnFOouNpKfPKDnms9xynJjsMYjMaT/aFQ==", "dev": true, "license": "MIT", - "optional": true, + "peer": true, "dependencies": { - "@tybys/wasm-util": "^0.10.3" + "hashery": "^1.4.0", + "hookified": "^1.15.0" }, "engines": { - "node": "^20.19.0 || ^22.13.0 || >=23.5.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Brooooooklyn" + "node": ">= 18" }, "peerDependencies": { - "@emnapi/core": "^1.7.1 || ^2.0.0-alpha.3", - "@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.3" + "keyv": "^5.6.0" } }, + "node_modules/@keyv/serialize": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@keyv/serialize/-/serialize-1.1.1.tgz", + "integrity": "sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/@oxc-project/types": { - "version": "0.142.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.142.0.tgz", - "integrity": "sha512-7W+2q5AKQVU36fkaryontrHn3YDt1RyUYXatw9i5H8ocYe2sPKSFB6eS8WNPeRKiN1qAWWZUPm7gwFzJGrccqQ==", + "version": "0.153.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.153.0.tgz", + "integrity": "sha512-jSNrRIyvPVLL5E1z/tS5zLAprosGz/FuI1PWwEOz59j3E4TTTzSpqKb/RDGqJhhW0QHYISGXxq582wGx6T11Tw==", "dev": true, "license": "MIT", "funding": { - "url": "https://github.com/sponsors/Boshen" + "url": "https://github.com/sponsors/oxc-project" + } + }, + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.13.tgz", + "integrity": "sha512-Vu/NICrgdRvUOeaJZIyB2FP63s6fO/4ud5l8dXmxDx9yDyn+mN0y+gsvsVviSli+T361Aqo0h46EuMoobrwawA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" } }, "node_modules/@rolldown/binding-android-arm64": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.1.tgz", - "integrity": "sha512-02hOeOSryYxVrOIphmLAsqnCJWxwlzFk+pEt/N/i6OgT3lShHO7xGCU5cpgchRDHboAEbSjzgGh+O/u1GswQmA==", + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.13.tgz", + "integrity": "sha512-6LEoIrGy5vvCd2boNnv2soPSUZj/PRvhp8R/t9KlX+mIZxThIE/ZqxrGXxmuNX0FuvQFvLdZr7S4HrYpYBVhEQ==", "cpu": [ "arm64" ], @@ -1068,9 +1081,9 @@ } }, "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.1.tgz", - "integrity": "sha512-fMsTOnN0OjFm3CyppWPitKnc8UlliVARUULW6cfU6AIqjdtgmSFWSk9vecHzZduv/yMWIHDlRhM1e8Iff9uAfA==", + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.13.tgz", + "integrity": "sha512-CaJSRzlQ6Q+zkDMVA9OkVxBjVoPRCDEE3v0FSJ9kIQBgRjFApA3Si4ijhbNVUji7ZJt+PfO0e46qy4nPInHUPA==", "cpu": [ "arm64" ], @@ -1085,9 +1098,9 @@ } }, "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.1.tgz", - "integrity": "sha512-1wjKdz/XLGKHaTNHjQveQ/B23TKx4ItAqm1JbyVuvNPc4Ze0Fb48s49TAd/2zcplPl8okE/UbTgmlVfwT7eFeQ==", + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.13.tgz", + "integrity": "sha512-e4r340I5hxSzJkD3YJBxgzq/rJ3wwxwdLLb2FPt1Zpyem20nrQPT11DDEEkazwxaHumH8wpglsp/Ozgj/wT6mw==", "cpu": [ "x64" ], @@ -1102,9 +1115,9 @@ } }, "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.1.tgz", - "integrity": "sha512-Fa0jHR07E7YBN4vOEsbVf2briYNsuOowfLJaXULZM0ldMlaCaj2LJgLMbMe4iacRyZmvR8efFhgR9wKuGclQUg==", + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.13.tgz", + "integrity": "sha512-hoBWDZ4/K/mJKVMbsgUd1jfpA2DiOrJ54mpegObU9TBHBBrtX4FLIXJJQRDPZ/2Dn91GvPob+Z2hwKJzf4E5SQ==", "cpu": [ "x64" ], @@ -1119,9 +1132,9 @@ } }, "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.1.tgz", - "integrity": "sha512-pzkgu1SSHGgRRyRZ4fbmSgmajbVt+epaLP99NDjFft69v/ypfTi6swBMiVdh2EkQ0OSnHE1lZDM7DRGkyAzUpA==", + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.13.tgz", + "integrity": "sha512-oF9fZu3vTWnTiVAkDFxU9PJLt1nh20K9RRpuHsR7ZnE2L7XookBbDBVLojCVSLGbvtSlxeUNnjAj6DbNsKyzbg==", "cpu": [ "arm" ], @@ -1136,13 +1149,16 @@ } }, "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.1.tgz", - "integrity": "sha512-QI5SEDY8cbiYWHx0VO4vIc3UlS6a32vXHjU8Qy/17adEmZIPuByJg13UEvo9c/UCiUkdcVWY83C+b+JrwnNyUg==", + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.13.tgz", + "integrity": "sha512-DTpb/+jMiqWMPgphn4ksW1zW+XLJ6dJJ9FMvnIk9I291LhCctMPhMYJrP5t9p6C9qa3KXadDdUEZHBaPh3kNxQ==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1153,13 +1169,16 @@ } }, "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.1.tgz", - "integrity": "sha512-Sm41FyCeXqmYcERoYOCbGIL5hNfd8w9LQ7Y61Bev48HkcjaJqV/iiVOaiDxjVTRMS+QKrZmD8cfPt4uMVnvM+A==", + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.13.tgz", + "integrity": "sha512-xU+Kvv9N2A3SY35zFh1FWWNjxbadE/BKeqnkXe7vScU4GZsw757BlVmDLITZEq8DPpk6m7uoDYkK7P+x/uU5Ow==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -1170,13 +1189,16 @@ } }, "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.1.tgz", - "integrity": "sha512-2x+WhXTGl9yJYPbltW/BSEPTVz9OIWQyER4N+gJEDWkkn904eRcBzELqh/Hf7K0w/ubGbKNMv0ZC+94QK/IFEg==", + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.13.tgz", + "integrity": "sha512-XMk/hnhh25oQ756Lclb1Q11h4ll0xC0Esa5w4Vzp+mAE2S3AGJgeM0SonrIgY8fHhjarb6hcoTuwoFG0VVP2ig==", "cpu": [ "ppc64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1187,13 +1209,16 @@ } }, "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.1.tgz", - "integrity": "sha512-eEjmQpuRQayHPWWnywaWHkFT3ToPbP3RYy42VVd/B9aBGDA+Ol25EIWHxKQST3IiWJjikCWUF7KtbfqwZrzVwQ==", + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.13.tgz", + "integrity": "sha512-U1BoTHYmlywE2n3HUWjzZ+8EQsKr+OwpxYuDWaubx42tUL8QSL7nOqRz/NVHanibbFP3M1Rxuprz/wNcbMKjqw==", "cpu": [ "s390x" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1204,13 +1229,16 @@ } }, "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.1.tgz", - "integrity": "sha512-/Orga1fZYkLc/56jBICcHrKchl8Z2UKdDSr3LG9ToWO1lQ6a4Livk9Xz+9WN91zsz5QR3XQz2NNoSDEvP6qadw==", + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.13.tgz", + "integrity": "sha512-J4cm80Pc6wEwV5AZ42o62sIQgDk+Y7R2s646jIwuvO+4V46QB58N4vVuMPWCBaNgursXLpmUWJV//WQAkPvHBQ==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1221,13 +1249,16 @@ } }, "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.1.tgz", - "integrity": "sha512-xxBJRL+0q0Kce7orznGWLuylHDY65vuARXZRpX+hPdv+DqK2c3NlCsVA98tlWzWNEE7yPqA/1NQ5nnCrj49Y5A==", + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.13.tgz", + "integrity": "sha512-W0euZS40U4V6JkmEnVCIX9mcsL6cbXQ4X8c2w+MMOIjFg68TXUW+u3RT+2e1t1pv9mg/EP3pDtK3POXdQ7Rxyw==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -1238,9 +1269,9 @@ } }, "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.1.tgz", - "integrity": "sha512-M6AdXIXw3s+/8XpKMzdGDEXGS1S7kwUsy+rcTIUIOx5Ge4nXKCtAFHFV9YKkXvGcC5WMoTjAteLzlsQROVI0Yw==", + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.13.tgz", + "integrity": "sha512-do/AF/4n9MRshia3a5aXoy8xM2c6mgfT6D7H/dY6my5z+hfCIR4ab7mk4IRj++nsEQpKy39kd0DQnDq+r692tw==", "cpu": [ "arm64" ], @@ -1254,26 +1285,10 @@ "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.2.1.tgz", - "integrity": "sha512-/TX0SoRGojHzSAHpfVBbavRVSazg5U3h3Y3VXfcc0cdugq6kxdqw8LPGFiPr+/7gE/60zRcsOY2Vi9b9eT0jww==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/core": "2.0.0-alpha.3", - "@emnapi/runtime": "2.0.0-alpha.3", - "@napi-rs/wasm-runtime": "^1.2.0" - }, - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=23.5.0" - } - }, "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.1.tgz", - "integrity": "sha512-EvRrivJieyHG+AO9lleZWgq+g0+S7oV2C51yuqlcyU/R9net+sI4Pj0F+lUoP2bEr6TWX3SqFaaS0SzfLxSzkw==", + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.13.tgz", + "integrity": "sha512-6FtUf2cJZobutJdxnGYX8z0vtbgqU0b2v8vfBhkXb9wp2MppMFtkunE+IaOntyi01rkIQoPW2GHABtfzsC0BRQ==", "cpu": [ "arm64" ], @@ -1288,9 +1303,9 @@ } }, "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.1.tgz", - "integrity": "sha512-Z4eCmn5QJ/5+azF9knpLWKfVd9aidn0mAe9TpJgvBLId9Ax3t0+JVxBmT25Bv7NBbVW1TZyKjQjQReouMeH5UQ==", + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.13.tgz", + "integrity": "sha512-UKhFaZTeTcbKrb3iU82m17jVcPEAoqDkCN5MBqgPyZSwp/cQmib21crTS0DdH7T1B7wZRWEI89YQAOJMet5yhg==", "cpu": [ "x64" ], @@ -1661,17 +1676,6 @@ "win32" ] }, - "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/esrecurse": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", @@ -1696,9 +1700,9 @@ "peer": true }, "node_modules/@types/react": { - "version": "19.2.18", - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz", - "integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==", + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz", + "integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==", "devOptional": true, "license": "MIT", "dependencies": { @@ -1885,6 +1889,21 @@ "node": ">=8" } }, + "node_modules/cacheable": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/cacheable/-/cacheable-2.5.0.tgz", + "integrity": "sha512-60cyAOytib/OzBw1JNSoSV/boK1AtHryDIjvVBk7XbN4ugfkM3+Sry7fEjNgPMGgOjuaZPAp8ruZ0Cxafwyq9g==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@cacheable/memory": "^2.2.0", + "@cacheable/utils": "^2.5.0", + "hookified": "^1.15.0", + "keyv": "^5.6.0", + "qified": "^0.10.1" + } + }, "node_modules/caniuse-lite": { "version": "1.0.30001810", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001810.tgz", @@ -2185,9 +2204,9 @@ } }, "node_modules/eslint": { - "version": "10.8.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.8.0.tgz", - "integrity": "sha512-nuKKvN+oIBO0koN7Tm7dlkmnkc21mtt0QJLwAKzjLq14y6lRTdVG36MZHJ8eQHwdJMwZbQNMlPOYedMq/oVJvQ==", + "version": "10.12.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.12.0.tgz", + "integrity": "sha512-npHjrHb2o4ehH3mE+YuxABR+0gyb0aVWCIolgJDArwCiHEIsonBm3ZKjel5b0U5+a+MbEvIyRCGCzD3eKKK2NA==", "dev": true, "license": "MIT", "peer": true, @@ -2200,7 +2219,7 @@ "@eslint/config-array": "^0.23.5", "@eslint/config-helpers": "^0.7.0", "@eslint/core": "^1.2.1", - "@eslint/plugin-kit": "^0.7.2", + "@eslint/plugin-kit": "^0.7.3", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", @@ -2215,7 +2234,7 @@ "esquery": "^1.7.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", - "file-entry-cache": "^8.0.0", + "file-entry-cache": "11.1.5 || >11.1.6 <12", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", @@ -2465,17 +2484,14 @@ } }, "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==", + "version": "11.1.5", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-11.1.5.tgz", + "integrity": "sha512-+PFTHITI08JIGhnNpGNI8T8inUpgZfk3GNEqfT9R2zZV2iFXg3CvqzSl/uEhs7TSGujYRELEANyDvS8Fj7+S7Q==", "dev": true, "license": "MIT", "peer": true, "dependencies": { - "flat-cache": "^4.0.0" - }, - "engines": { - "node": ">=16.0.0" + "flat-cache": "^6.1.23" } }, "node_modules/find-up": { @@ -2509,24 +2525,22 @@ } }, "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==", + "version": "6.1.23", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-6.1.23.tgz", + "integrity": "sha512-f++BY9pTk+983xK1FLzlLpmM0i0z+jHmx3QESGkURMXujQZz1k5wzwX6hjnQ8goaD0B+sYnDK1yZ6MTyZfUaqA==", "dev": true, "license": "MIT", "peer": true, "dependencies": { - "flatted": "^3.2.9", - "keyv": "^4.5.4" - }, - "engines": { - "node": ">=16" + "cacheable": "^2.5.0", + "flatted": "^3.4.2", + "hookified": "^1.15.0" } }, "node_modules/flatted": { - "version": "3.4.2", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", - "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.4.tgz", + "integrity": "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==", "dev": true, "license": "ISC", "peer": true @@ -2582,6 +2596,20 @@ "node": ">=10.13.0" } }, + "node_modules/hashery": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/hashery/-/hashery-1.5.1.tgz", + "integrity": "sha512-iZyKG96/JwPz1N55vj2Ie2vXbhu440zfUfJvSwEqEbeLluk7NnapfGqa7LH0mOsnDxTF85Mx8/dyR6HfqcbmbQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "hookified": "^1.15.0" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/hermes-estree": { "version": "0.25.1", "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", @@ -2599,6 +2627,14 @@ "hermes-estree": "0.25.1" } }, + "node_modules/hookified": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/hookified/-/hookified-1.15.1.tgz", + "integrity": "sha512-MvG/clsADq1GPM2KGo2nyfaWVyn9naPiXrqIe4jYjXNZQt238kWyOGrsyc/DmRAQ+Re6yeo6yX/yoNCG5KAEVg==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -2683,9 +2719,9 @@ } }, "node_modules/ink-form": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/ink-form/-/ink-form-2.0.1.tgz", - "integrity": "sha512-vo0VMwHf+HOOJo7026K4vJEN8xm4sP9iWlQLx4bngNEEY5K8t30CUvVjQCCNAV6Mt2ODt2Aq+2crCuBONReJUg==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/ink-form/-/ink-form-2.0.2.tgz", + "integrity": "sha512-dAXnXjAip5EGyYo9TwMuY6ONd5WWVo/KgSxtaC4V9fIkcXzxIvpsagwLZbKsT0VBOTBMAGefOJMZX7tVNO9VtQ==", "license": "MIT", "dependencies": { "ink-select-input": "^5.0.0", @@ -2868,14 +2904,6 @@ "node": ">=6" } }, - "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", - "peer": true - }, "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", @@ -2898,14 +2926,14 @@ } }, "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==", + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.6.0.tgz", + "integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==", "dev": true, "license": "MIT", "peer": true, "dependencies": { - "json-buffer": "3.0.1" + "@keyv/serialize": "^1.1.1" } }, "node_modules/levn": { @@ -3310,9 +3338,9 @@ } }, "node_modules/nanoid": { - "version": "3.3.18", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", - "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "version": "3.3.20", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz", + "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==", "dev": true, "funding": [ { @@ -3484,9 +3512,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": { @@ -3519,9 +3547,9 @@ } }, "node_modules/postcss": { - "version": "8.5.25", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.25.tgz", - "integrity": "sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==", + "version": "8.5.30", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.30.tgz", + "integrity": "sha512-zkd+e9S1AQHhmaBKye9zlFHWjOwVgqXl1q3ECYy8561K7qxR2cREf/STLskulBFd78k6AOFeA+ff1TtEzk9bAA==", "dev": true, "funding": [ { @@ -3539,9 +3567,9 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.16", + "nanoid": "^3.3.20", "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" + "source-map-js": "^1.2.2" }, "engines": { "node": "^10 || ^12 || >=14" @@ -3612,10 +3640,32 @@ "node": ">=6" } }, + "node_modules/qified": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/qified/-/qified-0.10.1.tgz", + "integrity": "sha512-+Owyggi9IxT1ePKGafcI87ubSmxol6smwJ+RAHDQlx9+9cPwFWDiKFFCPuWhr9ignlGpZ9vDQLw67N4dcTVFEA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "hookified": "^2.1.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/qified/node_modules/hookified": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/hookified/-/hookified-2.2.0.tgz", + "integrity": "sha512-p/LgFzRN5FeoD3DLS6bkUapeye6E4SI6yJs6KetENd18S+FBthqYq2amJUWpt5z0EQwwHemidjY5OqJGEKm5uA==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/react": { - "version": "19.2.8", - "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", - "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==", + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/react/-/react-19.3.0.tgz", + "integrity": "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==", "license": "MIT", "peer": true, "engines": { @@ -3678,13 +3728,13 @@ } }, "node_modules/rolldown": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.1.tgz", - "integrity": "sha512-4FKJhg8d3OiyQOA6Q1Q0hoFFpW9/OoX+VsHzpECsdsIZoOArrAK90gl59YK/Z+gnDel45bgJZK03ozH/9bCqEw==", + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.13.tgz", + "integrity": "sha512-/5apOkwWxtACcQtZ5WlrAsKS4JwXVThQPJZ3RqIruRY9UG9M8e0VlXN068xum/JRKA4aIUoJ13ghe/kGCvidiQ==", "dev": true, "license": "MIT", "dependencies": { - "@oxc-project/types": "=0.142.0", + "@oxc-project/types": "=0.153.0", "@rolldown/pluginutils": "^1.0.0" }, "bin": { @@ -3694,21 +3744,21 @@ "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.2.1", - "@rolldown/binding-darwin-arm64": "1.2.1", - "@rolldown/binding-darwin-x64": "1.2.1", - "@rolldown/binding-freebsd-x64": "1.2.1", - "@rolldown/binding-linux-arm-gnueabihf": "1.2.1", - "@rolldown/binding-linux-arm64-gnu": "1.2.1", - "@rolldown/binding-linux-arm64-musl": "1.2.1", - "@rolldown/binding-linux-ppc64-gnu": "1.2.1", - "@rolldown/binding-linux-s390x-gnu": "1.2.1", - "@rolldown/binding-linux-x64-gnu": "1.2.1", - "@rolldown/binding-linux-x64-musl": "1.2.1", - "@rolldown/binding-openharmony-arm64": "1.2.1", - "@rolldown/binding-wasm32-wasi": "1.2.1", - "@rolldown/binding-win32-arm64-msvc": "1.2.1", - "@rolldown/binding-win32-x64-msvc": "1.2.1" + "@rolldown/binding-android-arm-eabi": "1.2.13", + "@rolldown/binding-android-arm64": "1.2.13", + "@rolldown/binding-darwin-arm64": "1.2.13", + "@rolldown/binding-darwin-x64": "1.2.13", + "@rolldown/binding-freebsd-x64": "1.2.13", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.13", + "@rolldown/binding-linux-arm64-gnu": "1.2.13", + "@rolldown/binding-linux-arm64-musl": "1.2.13", + "@rolldown/binding-linux-ppc64-gnu": "1.2.13", + "@rolldown/binding-linux-s390x-gnu": "1.2.13", + "@rolldown/binding-linux-x64-gnu": "1.2.13", + "@rolldown/binding-linux-x64-musl": "1.2.13", + "@rolldown/binding-openharmony-arm64": "1.2.13", + "@rolldown/binding-win32-arm64-msvc": "1.2.13", + "@rolldown/binding-win32-x64-msvc": "1.2.13" } }, "node_modules/rollup": { @@ -4005,14 +4055,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", @@ -4067,9 +4109,9 @@ } }, "node_modules/tsx": { - "version": "4.22.4", - "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.4.tgz", - "integrity": "sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg==", + "version": "4.23.15", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.15.tgz", + "integrity": "sha512-Yiex1Ovn8z2xPpOWckIiysV1SSyRMY9BkLF++q0yKiDxCqRhosKfMg3janKkiLBwZ5c/YryloKwGZcrEmtwxKw==", "dev": true, "license": "MIT", "dependencies": { @@ -4164,16 +4206,16 @@ } }, "node_modules/vite": { - "version": "8.2.0", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.0.tgz", - "integrity": "sha512-pn+CFpM0lwDeKwmOq1ZaBK/9sjorZcgqxki6MbY/jPEVd9vichIlmlD4HmQ5wdP5EgqQCFRaACBxMC7uEGc6lQ==", + "version": "8.3.4", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.4.tgz", + "integrity": "sha512-IftLE8phJhdNRIGNEV4JCCvfNScoZQVfcXaN8TQropWGJtOHcu+iAB9pHcuGvmEb/kddw0LBe/jS8ztdC6xdiQ==", "dev": true, "license": "MIT", "dependencies": { "lightningcss": "^1.33.0", - "picomatch": "^4.0.5", - "postcss": "^8.5.23", - "rolldown": "~1.2.0", + "picomatch": "^4.0.7", + "postcss": "^8.5.29", + "rolldown": "~1.2.12", "tinyglobby": "^0.2.17" }, "bin": { @@ -4190,7 +4232,7 @@ }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", - "@vitejs/devtools": "^0.4.0", + "@vitejs/devtools": "^0.7.1", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", @@ -4400,9 +4442,9 @@ "license": "MIT" }, "node_modules/zod": { - "version": "4.6.5", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.5.tgz", - "integrity": "sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q==", + "version": "4.6.7", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.7.tgz", + "integrity": "sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg==", "dev": true, "license": "MIT", "funding": { diff --git a/clients/tui/package.json b/clients/tui/package.json index 81c6ddb922..eba5b4578f 100644 --- a/clients/tui/package.json +++ b/clients/tui/package.json @@ -27,9 +27,13 @@ "format": "prettier --write src __tests__ \"*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}\"", "format:check": "prettier --check src __tests__ \"*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}\"" }, + "allowScripts": { + "esbuild": false, + "fsevents": false + }, "dependencies": { "ink": "^6.0.0", - "ink-form": "^2.0.1", + "ink-form": "^2.0.2", "ink-scroll-view": "^0.3.6" }, "overrides": { diff --git a/clients/tui/src/App.tsx b/clients/tui/src/App.tsx index 754cd859e0..43c1e740ed 100644 --- a/clients/tui/src/App.tsx +++ b/clients/tui/src/App.tsx @@ -121,6 +121,16 @@ const APP_VERSION = readInspectorVersion(import.meta.url); /** Client identity name the TUI reports to servers. */ const TUI_CLIENT_NAME = "inspector-tui"; +/** + * The TUI has no settings form and no per-run timeout flag, so a connect-time + * timeout points at the one knob it does read: the server entry's + * `connectionTimeout`. That entry may live in a `--catalog` or a `--config` + * file at any path, or not exist yet for an ad-hoc target, so the wording + * names neither a filename nor a single flag (#2615). + */ +export const TUI_CONNECTION_TIMEOUT_REMEDY = + "To accommodate a slower server, set connectionTimeout on the server's entry in a catalog (--catalog) or config (--config) file."; + /** * Roots snapshot for `useStoreSnapshot`: module scope so both the reader and * the fallback are referentially stable across renders (#2432). @@ -191,6 +201,10 @@ function App({ // global accelerators below stand down then, or typing a query would switch // tabs, connect, or quit on Esc. Reported by `useListFilter`. const [listFilterEditing, setListFilterEditing] = useState(false); + // True while a kept (non-empty, no longer edited) list filter has the + // keyboard (#2647). Esc then clears the filter instead of quitting; only the + // exit key stands down, since a kept filter exists so the rest still work. + const [listFilterClaimsEscape, setListFilterClaimsEscape] = useState(false); const [tabCounts, setTabCounts] = useState<{ info?: number; resources?: number; @@ -400,6 +414,7 @@ function App({ environment, clientIdentity: { name: TUI_CLIENT_NAME, version: APP_VERSION }, pipeStderr: true, + connectionTimeoutRemedy: TUI_CONNECTION_TIMEOUT_REMEDY, ...(savedSettings && savedSettings.requestTimeout > 0 && { timeout: savedSettings.requestTimeout, @@ -1639,8 +1654,10 @@ function App({ return; } - // Exit accelerators + // Exit accelerators. A kept list filter takes Esc first — the tab clears + // it — so quitting is the second press (#2647). if (key.escape) { + if (listFilterClaimsEscape) return; exit(); } @@ -1806,7 +1823,10 @@ function App({ }); // Calculate layout dimensions - const headerHeight = 1; + // Two rows: the title line plus the header's bottom border, which Ink sizes + // inside the box. At 1 the border took the only row, leaving the title zero + // height, so Ink clipped the version off the right edge (#2656). + const headerHeight = 2; const serverListWidth = Math.floor(dimensions.width * 0.3); const contentWidth = dimensions.width - serverListWidth; // Derived, not assumed. The bar wraps once the visible tabs exceed the @@ -1884,13 +1904,22 @@ function App({ justifyContent="space-between" alignItems="center" > - - - {APP_NAME} + {/* On a narrow terminal only the description gives way: wrapped, it + spilled into the border row and squeezed the version out. */} + + + + {APP_NAME} + + + + {" "} + - {APP_DESCRIPTION} - - {APP_DESCRIPTION} - v{APP_VERSION} + + v{APP_VERSION} + {/* Main content area */} @@ -2209,6 +2238,7 @@ function App({ / {query} - (/ to edit) + + {focused ? " (/ edit · Esc clear)" : " (/ to edit)"} + ) : focused ? ( / to filter diff --git a/clients/tui/src/components/PromptsTab.tsx b/clients/tui/src/components/PromptsTab.tsx index c43008bdb4..6392ab06e6 100644 --- a/clients/tui/src/components/PromptsTab.tsx +++ b/clients/tui/src/components/PromptsTab.tsx @@ -30,6 +30,8 @@ interface PromptsTabProps { modalOpen?: boolean; /** Told when the list filter starts or stops capturing keys (#2430). */ onFilterEditingChange?: (editing: boolean) => void; + /** Told when a kept list filter starts or stops claiming Esc (#2647). */ + onFilterEscapeClaimChange?: (claimed: boolean) => void; } /** What the `/` filter matches a prompt against (#2430). */ @@ -48,11 +50,13 @@ export function PromptsTab({ onAuthRecoveryRequired, modalOpen = false, onFilterEditingChange, + onFilterEscapeClaimChange, }: PromptsTabProps) { const visibleCount = Math.max(1, height - 7 - LIST_FILTER_ROWS); const filter = useListFilter(prompts, promptFilterFields, { enabled: !modalOpen && focusedPane === "list", onEditingChange: onFilterEditingChange, + onEscapeClaimChange: onFilterEscapeClaimChange, }); const shownPrompts = filter.items; const { selectedIndex, firstVisible, setSelection } = useSelectableList( diff --git a/clients/tui/src/components/ResourcesTab.tsx b/clients/tui/src/components/ResourcesTab.tsx index 689c4babf5..6c0ce35297 100644 --- a/clients/tui/src/components/ResourcesTab.tsx +++ b/clients/tui/src/components/ResourcesTab.tsx @@ -40,6 +40,8 @@ interface ResourcesTabProps { modalOpen?: boolean; /** Told when the list filter starts or stops capturing keys (#2430). */ onFilterEditingChange?: (editing: boolean) => void; + /** Told when a kept list filter starts or stops claiming Esc (#2647). */ + onFilterEscapeClaimChange?: (claimed: boolean) => void; } type ResourceListItem = @@ -72,6 +74,7 @@ export function ResourcesTab({ onAuthRecoveryRequired, modalOpen = false, onFilterEditingChange, + onFilterEscapeClaimChange, }: ResourcesTabProps) { const [error, setError] = useState(null); const [resourceContent, setResourceContent] = @@ -99,6 +102,7 @@ export function ResourcesTab({ const filter = useListFilter(allItems, resourceFilterFields, { enabled: !modalOpen && focusedPane === "list", onEditingChange: onFilterEditingChange, + onEscapeClaimChange: onFilterEscapeClaimChange, }); const shownItems = filter.items; const { selectedIndex, firstVisible, setSelection } = useSelectableList( diff --git a/clients/tui/src/components/SkillsTab.tsx b/clients/tui/src/components/SkillsTab.tsx index 3eb3863f97..361140db83 100644 --- a/clients/tui/src/components/SkillsTab.tsx +++ b/clients/tui/src/components/SkillsTab.tsx @@ -43,6 +43,7 @@ import { } from "@inspector/core/mcp/skillsSchemas.js"; import { verifySkills, + type SkillBudgetHint, type SkillFileReport, type SkillVerifyReport, } from "@inspector/core/mcp/skillsVerification.js"; @@ -69,6 +70,8 @@ interface SkillsTabProps { modalOpen?: boolean; /** Told when the list filter starts or stops capturing keys (#2430). */ onFilterEditingChange?: (editing: boolean) => void; + /** Told when a kept list filter starts or stops claiming Esc (#2647). */ + onFilterEscapeClaimChange?: (claimed: boolean) => void; } /** @@ -113,6 +116,18 @@ const FILE_COLOR: Record = { "read-error": "red", }; +/** + * The pane's remedies for a skill `v` left unread under the catalog budget + * (#2633): the TUI raises it with its launch flags, and verifies one skill with + * Enter — which always reads that skill in full, since a run of one is never + * past the budget. + */ +const TUI_SKILL_BUDGET_HINT: SkillBudgetHint = { + raise: + "with --skill-catalog-max-skills / --skill-catalog-max-bytes at launch", + verifyOne: "by selecting it and pressing Enter", +}; + /** * The status line for each verification outcome. * @@ -252,12 +267,14 @@ export function SkillsTab({ onAuthRecoveryRequired, modalOpen = false, onFilterEditingChange, + onFilterEscapeClaimChange, }: SkillsTabProps) { // One more row than the bare list: the catalog summary line (#2590). const visibleCount = Math.max(1, height - 8 - LIST_FILTER_ROWS); const filter = useListFilter(skills, skillFilterFields, { enabled: !modalOpen && focusedPane === "list", onEditingChange: onFilterEditingChange, + onEscapeClaimChange: onFilterEscapeClaimChange, }); const shownSkills = filter.items; const { selectedIndex, firstVisible, setSelection } = useSelectableList( @@ -314,7 +331,12 @@ export function SkillsTab({ // this key handler — which cannot await — to own. void (async () => { try { - const results = await verifySkills(inspectorClient, entries); + const results = await verifySkills( + inspectorClient, + entries, + undefined, + TUI_SKILL_BUDGET_HINT, + ); // Read AFTER the run, so a listing that changed while it was in // flight is the one pruned against. const live = new Set(skillsRef.current.map(skillEntryKey)); diff --git a/clients/tui/src/components/ToolsTab.tsx b/clients/tui/src/components/ToolsTab.tsx index 5bab4c5f23..55c1bb27a5 100644 --- a/clients/tui/src/components/ToolsTab.tsx +++ b/clients/tui/src/components/ToolsTab.tsx @@ -56,6 +56,8 @@ interface ToolsTabProps { modalOpen?: boolean; /** Told when the list filter starts or stops capturing keys (#2430). */ onFilterEditingChange?: (editing: boolean) => void; + /** Told when a kept list filter starts or stops claiming Esc (#2647). */ + onFilterEscapeClaimChange?: (claimed: boolean) => void; } /** What the `/` filter matches a tool against (#2430). */ @@ -73,11 +75,13 @@ export function ToolsTab({ onViewDetails, modalOpen = false, onFilterEditingChange, + onFilterEscapeClaimChange, }: ToolsTabProps) { const visibleCount = Math.max(1, height - 7 - LIST_FILTER_ROWS); const filter = useListFilter(tools, toolFilterFields, { enabled: !modalOpen && focusedPane === "list", onEditingChange: onFilterEditingChange, + onEscapeClaimChange: onFilterEscapeClaimChange, }); const shownTools = filter.items; const { selectedIndex, firstVisible, setSelection } = useSelectableList( diff --git a/clients/tui/src/hooks/useCopyKeys.ts b/clients/tui/src/hooks/useCopyKeys.ts index 6e5631939d..9ee8e2267e 100644 --- a/clients/tui/src/hooks/useCopyKeys.ts +++ b/clients/tui/src/hooks/useCopyKeys.ts @@ -57,7 +57,10 @@ export function useCopyKeys(value: string | undefined, label: string) { if (key === SAVE_KEY) { try { const file = writeCopyFile(value); - setStatus({ tone: "success", message: `Saved ${label} to ${file}` }); + setStatus({ + tone: "success", + message: `Saved ${label} to ${file} (deleted when the TUI exits)`, + }); } catch (err) { setStatus({ tone: "error", diff --git a/clients/tui/src/hooks/useListFilter.ts b/clients/tui/src/hooks/useListFilter.ts index 9cbf7ebf82..978f7002e1 100644 --- a/clients/tui/src/hooks/useListFilter.ts +++ b/clients/tui/src/hooks/useListFilter.ts @@ -14,9 +14,10 @@ * the list as you type; * - **Enter keeps** the query and leaves editing, so the arrows, Enter and the * app-wide accelerators work on the narrowed list; - * - **Esc clears** the query and leaves editing. A kept filter is cleared the - * same way — `/` then Esc — because a bare Esc outside editing is the app's - * exit key and stays that. + * - **Esc clears** the query and leaves editing. Esc also clears a **kept** + * filter while the list has the keyboard (#2647): a user who has just kept + * a filter expects Esc to undo it, not to quit, so Esc is the app's exit key + * only once no filter is left to clear — the second press. * * Up/Down are deliberately **not** consumed while editing, so the selection can * be moved without leaving the query. @@ -31,6 +32,11 @@ * focus or a modal opening (`enabled` going false), or the tab unmounting — * and App can never be left deaf to the keyboard. * + * A kept filter mutes App's Esc alone, not the other accelerators — the whole + * point of keeping it is that they work on the narrowed list. That is a second, + * narrower report, `onEscapeClaimChange`, withdrawn the same way: once the + * query is cleared, or the pane loses the keyboard, Esc exits again. + * * A match is a case-insensitive substring of any of the strings `fields` * returns for an item. `fields` is called during render, so it must be pure; * declare it at module scope so the memoized result is not rebuilt every @@ -58,6 +64,13 @@ export interface UseListFilterOptions { * setter is), since it is an effect dependency. */ onEditingChange?: (editing: boolean) => void; + /** + * Told whenever a kept (non-editing, non-empty) filter starts or stops + * claiming Esc while the list has the keyboard, so App can hold off its exit + * key until the filter is cleared (#2647). Must be referentially stable, + * like `onEditingChange`. + */ + onEscapeClaimChange?: (claimed: boolean) => void; } export interface ListFilter { @@ -90,7 +103,7 @@ function printable(input: string): string { export function useListFilter( items: readonly T[], fields: ListFilterFields, - { enabled, onEditingChange }: UseListFilterOptions, + { enabled, onEditingChange, onEscapeClaimChange }: UseListFilterOptions, ): ListFilter { const [query, setQuery] = useState(""); const [editingRequested, setEditingRequested] = useState(false); @@ -104,6 +117,16 @@ export function useListFilter( return () => onEditingChange?.(false); }, [editing, onEditingChange]); + const active = query.trim() !== ""; + // While editing, App is already muted entirely; this covers the kept filter. + const claimsEscape = enabled && active && !editingRequested; + + useEffect(() => { + if (!claimsEscape) return; + onEscapeClaimChange?.(true); + return () => onEscapeClaimChange?.(false); + }, [claimsEscape, onEscapeClaimChange]); + const { filtered, indices } = useMemo(() => { const needle = query.trim().toLowerCase(); const filtered: T[] = []; @@ -128,6 +151,10 @@ export function useListFilter( setEditingRequested(true); return true; } + if (key.escape && query.trim() !== "") { + setQuery(""); + return true; + } return false; } if (key.escape) { @@ -152,13 +179,13 @@ export function useListFilter( if (text) setQuery((prev) => prev + text); return true; }, - [enabled, editingRequested], + [enabled, editingRequested, query], ); return { query, editing, - active: query.trim() !== "", + active, items: filtered, indices, handleInput, diff --git a/clients/tui/src/utils/clipboard.ts b/clients/tui/src/utils/clipboard.ts index 3c93335d06..08ddd89954 100644 --- a/clients/tui/src/utils/clipboard.ts +++ b/clients/tui/src/utils/clipboard.ts @@ -14,6 +14,14 @@ // for that case is `writeCopyFile`, which saves the raw value to a private temp // file whose path the TUI then shows — readable with `cat` or `scp` from // anywhere, and not bounded by the terminal's line width or scrollback. +// +// That file may hold a bearer token, so it does not outlive the TUI (#2648): +// every directory `writeCopyFile` creates is removed by a process `exit` +// listener. Every way out of the TUI ends in `process.exit` (`tui.tsx` calls it +// once Ink unmounts), so the listener runs on Esc, Ctrl+C and a render error +// alike. A process killed by a signal it does not handle runs no `exit` +// listeners and leaves the file behind — still owner-only, in its 0700 +// directory, which is why the status line names the path either way. import fs from "node:fs"; import os from "node:os"; import path from "node:path"; @@ -82,17 +90,46 @@ export function copyViaOsc52(text: string, sink: EscapeSink): Osc52CopyResult { }; } +/** Directories `writeCopyFile` created that have not been removed yet. */ +const copyDirs = new Set(); +let exitCleanupRegistered = false; + +/** + * Remove every directory `writeCopyFile` created in this process. Synchronous, + * because it runs from a process `exit` listener, where nothing asynchronous + * gets to finish. Best-effort: a directory the user already deleted, or one + * that cannot be removed, must not turn the TUI's exit into a crash. + */ +export function removeCopyFiles(): void { + for (const dir of copyDirs) { + try { + fs.rmSync(dir, { recursive: true, force: true }); + } catch { + // Nothing useful to do at exit; the directory stays owner-only. + } + copyDirs.delete(dir); + } +} + /** * Save `text` to a fresh, owner-only temp file and return its path — the * fallback for a terminal that ignores OSC 52. The directory is created with * `mkdtemp` (mode 0700) and the file with mode 0600, because the value may be - * a bearer token. + * a bearer token; for the same reason the directory is removed when the + * process exits (see {@link removeCopyFiles}). */ export function writeCopyFile( text: string, baseDir: string = os.tmpdir(), ): string { const dir = fs.mkdtempSync(path.join(baseDir, "mcp-inspector-copy-")); + // Tracked before the write, so a directory whose file write fails is still + // cleaned up rather than left empty in the temp directory. + copyDirs.add(dir); + if (!exitCleanupRegistered) { + process.on("exit", removeCopyFiles); + exitCleanupRegistered = true; + } const file = path.join(dir, "value.txt"); fs.writeFileSync(file, text, { encoding: "utf8", mode: 0o600 }); return file; diff --git a/clients/tui/src/utils/keybindings.ts b/clients/tui/src/utils/keybindings.ts index ca42c9cb4e..03d9feb21e 100644 --- a/clients/tui/src/utils/keybindings.ts +++ b/clients/tui/src/utils/keybindings.ts @@ -46,7 +46,10 @@ export function globalBindings( ): readonly KeyBinding[] { return [ { keys: "?", action: "Show or hide this help" }, - { keys: "Esc / Ctrl+C", action: "Exit (Esc closes a dialog first)" }, + { + keys: "Esc / Ctrl+C", + action: "Exit (Esc closes a dialog or clears a kept filter first)", + }, { keys: "Tab / Shift+Tab", action: "Move focus: servers → tabs → list → details", diff --git a/clients/tui/tsup.config.ts b/clients/tui/tsup.config.ts index 4d2814ab6f..9868942f6c 100644 --- a/clients/tui/tsup.config.ts +++ b/clients/tui/tsup.config.ts @@ -1,76 +1,10 @@ -import { defineConfig, type Options } from "tsup"; -import { readFile } from "node:fs/promises"; +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, "../.."); -/** - * `ink-form` hardcodes a misspelled hint under an incomplete form — "you have - * not competed yet" — with no prop to override it. It is upstream's string - * (`ink-form/lib/SubmitButton.js`), last published in 2024, but it renders in - * the Inspector's tool/prompt/resource test forms, so we correct it on the way - * into the bundle. Reported upstream as lukasbach/ink-form#14; drop this patch - * if a release ever carries the fix. - * - * This is only possible because `ink-form` is inlined (see `noExternal` below). - */ -export const INK_FORM_INCOMPLETE_HINT = { - typo: "There are still required inputs you have not competed yet.", - fixed: "There are still required inputs you have not completed yet.", -}; - -/** - * Applies the correction above, throwing if the string is no longer there. - * - * Failing loudly is the point: a silent no-op would let an `ink-form` upgrade - * (or a fixed upstream, or a reworded label) quietly retire this patch with - * nobody noticing it had stopped applying — or, worse, leave a patch here for a - * string that no longer exists. If this throws, check whether upstream fixed - * the typo; if so, delete this patch rather than re-targeting it. - */ -export function fixInkFormIncompleteHint(source: string, file: string): string { - if (!source.includes(INK_FORM_INCOMPLETE_HINT.typo)) { - throw new Error( - `${file} no longer contains the ink-form label this build patches ` + - `(${JSON.stringify(INK_FORM_INCOMPLETE_HINT.typo)}). If upstream fixed ` + - `the typo, remove fixInkFormIncompleteHint from tsup.config.ts.`, - ); - } - return source.replaceAll( - INK_FORM_INCOMPLETE_HINT.typo, - INK_FORM_INCOMPLETE_HINT.fixed, - ); -} - -/** - * Which module the patch above is applied to. - * - * Exported so the tests can check it against the *resolved* path of the real - * `ink-form` module — a filter that stops matching is the one way this patch - * can silently no-op, since `fixInkFormIncompleteHint` would then never run. - */ -export const INK_FORM_SUBMIT_BUTTON = /ink-form[\\/]lib[\\/]SubmitButton\.js$/; - -// The plugin type is derived from tsup rather than imported from `esbuild`: -// `esbuild` is tsup's transitive dependency, not a declared one of this client, -// so a direct import typechecks only while npm happens to hoist it. -type EsbuildPlugin = NonNullable[number]; - -export const inkFormLabelPatch: EsbuildPlugin = { - name: "ink-form-label-patch", - setup(build) { - build.onLoad( - { filter: INK_FORM_SUBMIT_BUTTON }, - async ({ path: file }) => ({ - contents: fixInkFormIncompleteHint(await readFile(file, "utf8"), file), - loader: "js", - }), - ); - }, -}; - export default defineConfig({ entry: ["index.ts"], format: ["esm"], @@ -165,7 +99,6 @@ export default defineConfig({ "chokidar", "hono", ], - esbuildPlugins: [inkFormLabelPatch], esbuildOptions(options) { options.alias = { "@inspector/core": path.join(repoRoot, "core"), diff --git a/clients/tui/tui.tsx b/clients/tui/tui.tsx index 4d3d604fc0..acf6a87888 100644 --- a/clients/tui/tui.tsx +++ b/clients/tui/tui.tsx @@ -10,6 +10,7 @@ import { } from "@inspector/core/mcp/node/index.js"; import type { ServerProtocolEra } from "@inspector/core/mcp/types.js"; import { loadRunnerClientConfig } from "@inspector/core/client/runner.js"; +import { readInspectorVersion } from "@inspector/core/node/version.js"; import { parseRunnerOAuthCallbackUrl, DEFAULT_RUNNER_OAUTH_CALLBACK_URL, @@ -23,6 +24,12 @@ export async function runTui(args?: string[]): Promise { program .name("mcp-inspector-tui") .description("Terminal UI for MCP Inspector") + // Prints the root package version and exits before Ink renders (#2632). + .version( + readInspectorVersion(import.meta.url), + "-V, --version", + "Print the Inspector version", + ) .option( "--catalog ", "Writable catalog file (created if missing; default: ~/.mcp-inspector/mcp.json, or MCP_CATALOG_PATH)", diff --git a/clients/web/package-lock.json b/clients/web/package-lock.json index ff27af3554..2aa6033f92 100644 --- a/clients/web/package-lock.json +++ b/clients/web/package-lock.json @@ -335,15 +335,41 @@ "dev": true, "license": "MIT" }, + "node_modules/@cacheable/memory": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@cacheable/memory/-/memory-2.2.0.tgz", + "integrity": "sha512-CTLKqLItRCEixEAewD3/j9DB3/o96gpTPD4eJ1v+DGOlxZRZncRQkGYqqnAGCscYd6RNeXfGeiuCphsPtqyIfQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@cacheable/utils": "^2.5.0", + "@keyv/bigmap": "^1.3.1", + "hookified": "^1.15.1", + "keyv": "^5.6.0" + } + }, + "node_modules/@cacheable/utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@cacheable/utils/-/utils-2.5.0.tgz", + "integrity": "sha512-buipgOVDkkPXNR5+xBpDw7Zk2n1EvU7qBJCNUcL7rhQ//kfpOXPAvQ511Os0vpLYJ1pZnvudNytkQt2hst3wqA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "hashery": "^1.5.1", + "keyv": "^5.6.0" + } + }, "node_modules/@chromatic-com/storybook": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/@chromatic-com/storybook/-/storybook-5.2.1.tgz", - "integrity": "sha512-z6I7NJk/0VngA64y5TNYaB4Hc2X8+90n4op6lBt9PvWk5TmIlFLDqdX33rlrwbNRkkYijVgA/wO04rVYXi5Mlg==", + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/@chromatic-com/storybook/-/storybook-5.5.1.tgz", + "integrity": "sha512-DOtUEBIJHmqsOWg+Hi1t5m5FTNlyzL0NpZUwQob+ex30nDvL4p+MrYctmvEqH/QT72mf9mLXbz3Uj8aoWD2OYA==", "dev": true, "license": "MIT", "dependencies": { "@neoconfetti/react": "^1.0.0", - "chromatic": "16.10.0", + "chromatic": "^18.0.1", "jsonfile": "^6.1.0", "strip-ansi": "^7.1.0" }, @@ -352,7 +378,7 @@ "yarn": ">=1.22.18" }, "peerDependencies": { - "storybook": "^0.0.0-0 || ^10.1.0 || ^10.1.0-0 || ^10.2.0-0 || ^10.3.0-0 || ^10.4.0-0 || ^10.5.0-0 || ^10.6.0-0" + "storybook": "^0.0.0-0 || ^10.1.0 || ^10.1.0-0 || ^10.2.0-0 || ^10.3.0-0 || ^10.4.0-0 || ^10.5.0-0 || ^10.6.0-0 || ^11.0.0-0" } }, "node_modules/@dnd-kit/accessibility": { @@ -1097,9 +1123,9 @@ } }, "node_modules/@eslint/plugin-kit": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.2.tgz", - "integrity": "sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==", + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.3.tgz", + "integrity": "sha512-IkO+/KEUvwbVpiURZg+P7zF74z5Jxe0UgJxVni+RtoHQ6IZieXaO02kmadomap/q+l6bc/jdPGGqTjhuZnuz1Q==", "dev": true, "license": "Apache-2.0", "peer": true, @@ -1286,9 +1312,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==", "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { @@ -1301,6 +1327,32 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@keyv/bigmap": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@keyv/bigmap/-/bigmap-1.3.1.tgz", + "integrity": "sha512-WbzE9sdmQtKy8vrNPa9BRnwZh5UF4s1KTmSK0KUVLo3eff5BlQNNWDnFOouNpKfPKDnms9xynJjsMYjMaT/aFQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "hashery": "^1.4.0", + "hookified": "^1.15.0" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "keyv": "^5.6.0" + } + }, + "node_modules/@keyv/serialize": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@keyv/serialize/-/serialize-1.1.1.tgz", + "integrity": "sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/@mantine/core": { "version": "8.3.18", "resolved": "https://registry.npmjs.org/@mantine/core/-/core-8.3.18.tgz", @@ -1784,9 +1836,9 @@ } }, "node_modules/@oxc-resolver/binding-android-arm-eabi": { - "version": "11.24.2", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-android-arm-eabi/-/binding-android-arm-eabi-11.24.2.tgz", - "integrity": "sha512-y09e0L0SRI2OA2tUIrjBgoV3eH5hvUKXNkJqXmNo5V2WxIjyC7I7aJfRLMEVpA8yi95f90gFDvO0VMgrDw+vwA==", + "version": "11.21.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-android-arm-eabi/-/binding-android-arm-eabi-11.21.2.tgz", + "integrity": "sha512-xQoCRv+gKax9KTdwdaQNnAFOai8neay7g3jExDIORzhbrejwGSJaZNTdOJHR5ziLg2joMxOCMOFMo4zuxza2uQ==", "cpu": [ "arm" ], @@ -1798,9 +1850,9 @@ ] }, "node_modules/@oxc-resolver/binding-android-arm64": { - "version": "11.24.2", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-android-arm64/-/binding-android-arm64-11.24.2.tgz", - "integrity": "sha512-cl4icWaZFnLdg8m6qtnh5rBMuGbxc/ptStFHLeCNwr+2cZjkjNwQu/jYRS0CHlnPecOJMpuS5M6/BH+0J/YkEg==", + "version": "11.21.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-android-arm64/-/binding-android-arm64-11.21.2.tgz", + "integrity": "sha512-HF5oiE2L05yInPYCFD/4uxSrEZW4SuIfn99Y6L1xnJnzl066JR+MJs2rIdstw8A2MPlAKH+13dpFPNycjqzvGg==", "cpu": [ "arm64" ], @@ -1812,9 +1864,9 @@ ] }, "node_modules/@oxc-resolver/binding-darwin-arm64": { - "version": "11.24.2", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-darwin-arm64/-/binding-darwin-arm64-11.24.2.tgz", - "integrity": "sha512-At29QEMF6HajbQvgY8K6OXnHD1x9rad74xBEfmCB6ZqCGsdq75aK7tOYcTbOanMy8qdIBrfL3SMr3p/lfSlb9w==", + "version": "11.21.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-darwin-arm64/-/binding-darwin-arm64-11.21.2.tgz", + "integrity": "sha512-UX4u49CVCAD8QZNELaW8eMGgMAGwFWYEPbvNsh+3r/gs4NX3KfpiACMVwRQT0EuH3uat9hM5Zl+Ppm9pJD8tgg==", "cpu": [ "arm64" ], @@ -1826,9 +1878,9 @@ ] }, "node_modules/@oxc-resolver/binding-darwin-x64": { - "version": "11.24.2", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-darwin-x64/-/binding-darwin-x64-11.24.2.tgz", - "integrity": "sha512-A5Kqr1EUj4oIL5CF4WRssq/o5P0Y11cwoFouMRmQ7YnC/A8V93nv1nb7aSU8HwcgmXropjLNkVTl4MN87cu28Q==", + "version": "11.21.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-darwin-x64/-/binding-darwin-x64-11.21.2.tgz", + "integrity": "sha512-J9xPx7YBkrRmJ+xl561ztnMWEc1aOyjEIxBiGX1dVb3u7bGSnfObfcZk+Pd+uM0HZAPNsQ1xvD8j52A/uOSqNQ==", "cpu": [ "x64" ], @@ -1840,9 +1892,9 @@ ] }, "node_modules/@oxc-resolver/binding-freebsd-x64": { - "version": "11.24.2", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-freebsd-x64/-/binding-freebsd-x64-11.24.2.tgz", - "integrity": "sha512-R5xkRBRRz7ceH/P5Jrc6G7FmdUdgpLYyESFAUDVTNQ9K0sGPxcp4ljiwEwEqsvNcQ4sYbMRrWcHHBCu7ksAJVw==", + "version": "11.21.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-freebsd-x64/-/binding-freebsd-x64-11.21.2.tgz", + "integrity": "sha512-fRlt7OvSaQkWj6+EDTVxawVxOlqJB2QSnBfkeCyK4RTvsGctbw3BiH2Tb7DzMs7bikc4BRBpvWP5zF9K8b54Zg==", "cpu": [ "x64" ], @@ -1854,9 +1906,9 @@ ] }, "node_modules/@oxc-resolver/binding-linux-arm-gnueabihf": { - "version": "11.24.2", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-11.24.2.tgz", - "integrity": "sha512-k/RuYL4L/R58IBn3wT5ma3Wh4k62bp1eYCFRWCmMsasUOqL+H6sW0VGFadEzKWXFFlz+2uIMoeMk9ySSZJHgbg==", + "version": "11.21.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-11.21.2.tgz", + "integrity": "sha512-gK+vPUcPQITkGwBKpZGrcDHSlU6eDGl7AQacxS2CEKAZIBHWkOVFeJwLZ4tYnA1acJqRM5lt7yYwPCVqGHIJ7A==", "cpu": [ "arm" ], @@ -1868,9 +1920,9 @@ ] }, "node_modules/@oxc-resolver/binding-linux-arm-musleabihf": { - "version": "11.24.2", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-11.24.2.tgz", - "integrity": "sha512-bnHAak3ujYfH5pKk4NieFNbvYvernfoQDgwLddbZ3OtMYrem87/qjlA+u+aKG0oZcqSLGCful/6/CEA+aeAgaA==", + "version": "11.21.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-11.21.2.tgz", + "integrity": "sha512-L/Rgas7SrOKy/z7IH+HxSFRqVO4PuLDKLEGKvnhoCBBq3UJ0YzGBou3qMzaAGgkJwsIvX2pBF+7ojzfUhLiZxQ==", "cpu": [ "arm" ], @@ -1882,13 +1934,16 @@ ] }, "node_modules/@oxc-resolver/binding-linux-arm64-gnu": { - "version": "11.24.2", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-11.24.2.tgz", - "integrity": "sha512-vDT3KHgzYp47gmtNOqL2VNhCyl5Zv643eyxm//A68J8DeUGXrvD1pZFiaT4jSfe+RInfnn1R2yVHye4enx6RnA==", + "version": "11.21.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-11.21.2.tgz", + "integrity": "sha512-CUEYvlX1Fk7E9kUMzuswru1J9HLxMwnpDeQGjQuI4ZH+iNCoa2X9T+pvyzrbsgl7WnIeTFTlNlHsRfVdf5g9/g==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1896,13 +1951,16 @@ ] }, "node_modules/@oxc-resolver/binding-linux-arm64-musl": { - "version": "11.24.2", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm64-musl/-/binding-linux-arm64-musl-11.24.2.tgz", - "integrity": "sha512-+kMlQvbzfyEYtu5FcjE4p+ttBLpKW4d/AsAsuE69BxV6V4twZJeIQZFfD8gh/wqglY0MkPSezWXQH0jBV13MUw==", + "version": "11.21.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm64-musl/-/binding-linux-arm64-musl-11.21.2.tgz", + "integrity": "sha512-ViN1ZibQyxwC67GpoP33oo+S9UyUnkog13vzQb9+v9bCNvrVzJuk0MRdacjtv/9xfRMVF/eqHFyl8YOeykI10Q==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -1910,13 +1968,16 @@ ] }, "node_modules/@oxc-resolver/binding-linux-ppc64-gnu": { - "version": "11.24.2", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-11.24.2.tgz", - "integrity": "sha512-shjfMhmZ3gq9fv/w7bi3PnZlgOPG+2QAOFf0BJF0EgBSIGZ6PMLN2zbGEblTUYB/NKVDRyYhE2ff3dJ1QqNPkA==", + "version": "11.21.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-11.21.2.tgz", + "integrity": "sha512-CU1sCqWnhGqYD5I1HedHk5pujrn7ssDkNB/AEQd/pd3D/EojVSgJUlpbafwIbyhia3PgIfkvdFpRPWSALzVumA==", "cpu": [ "ppc64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1924,13 +1985,16 @@ ] }, "node_modules/@oxc-resolver/binding-linux-riscv64-gnu": { - "version": "11.24.2", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-11.24.2.tgz", - "integrity": "sha512-zGelwFR5oRo+b69k8Lrzun86DyUHzfKN6cnjbR9l7Z7NIRznOE/2ZvPa1IUKqAL2PzAXOdwkfVqNvO1H2RlpAw==", + "version": "11.21.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-11.21.2.tgz", + "integrity": "sha512-jWVyZtIHca4Gb96x7dag+y69vlei7ffjrsveLkmf2ZhqEAz6ZSBnY1GWvgZUaZlwZP62A3xD092BW97Q5VGc+g==", "cpu": [ "riscv64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1938,13 +2002,16 @@ ] }, "node_modules/@oxc-resolver/binding-linux-riscv64-musl": { - "version": "11.24.2", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-11.24.2.tgz", - "integrity": "sha512-qxZ1SWCXJY0eyhAlP6Lmo9F2Nrtx7EkYj9oCgL8apDPCwXwCEDA2U697bbT81JIc2IrVjxO4KX6WU2N+oN9Z4w==", + "version": "11.21.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-11.21.2.tgz", + "integrity": "sha512-LF29obFqNgBUgDX7rmUK7M4D0JQG5LxhYzn3xXmECcHU9aQAdWG7NiY052qybtesEdwHQXKNTWYQ7mTsybNvWg==", "cpu": [ "riscv64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -1952,13 +2019,16 @@ ] }, "node_modules/@oxc-resolver/binding-linux-s390x-gnu": { - "version": "11.24.2", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-11.24.2.tgz", - "integrity": "sha512-sGCecF3cx2DFlH4t/z7ApnOnXqN48p5p5mlHDEnHTAukQa2P+qMVE4CwyWE9W+q/m3QJ7kKfGrIjax31f44oFQ==", + "version": "11.21.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-11.21.2.tgz", + "integrity": "sha512-+NYcm+cCHBbtdQQ3A4phQTSuVRYnNHz7wrl9XRAPEovcdoqi0mb1K5ZOl+jN54ZD+q1zz3V0vltbFJmzecJKmw==", "cpu": [ "s390x" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1966,13 +2036,16 @@ ] }, "node_modules/@oxc-resolver/binding-linux-x64-gnu": { - "version": "11.24.2", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-x64-gnu/-/binding-linux-x64-gnu-11.24.2.tgz", - "integrity": "sha512-k/VlMMcSzMlahb3/fENM4rTlsJ0s3fFROA0KXPBmKggqmTSaE383sl8F3KCOXPLmVsYfW6hCitMhXCEtNeZxxg==", + "version": "11.21.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-x64-gnu/-/binding-linux-x64-gnu-11.21.2.tgz", + "integrity": "sha512-UQqZDdG2r2HhAOsZEgufkIWHPQ886IUyuJQkoZByvzhW8j51R4UNzGBJFkTiTnLhQnggwJRdJgFWK4uY6ZVIMw==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1980,13 +2053,16 @@ ] }, "node_modules/@oxc-resolver/binding-linux-x64-musl": { - "version": "11.24.2", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-x64-musl/-/binding-linux-x64-musl-11.24.2.tgz", - "integrity": "sha512-8hbnZyNi97b/8wapYaIF9+t9GmZKBW2vunaOc3h9HGJptH7b7XpvZqOTBSm/MpTjr7H497BlgOaSfLUdhmy2bw==", + "version": "11.21.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-x64-musl/-/binding-linux-x64-musl-11.21.2.tgz", + "integrity": "sha512-3Q9PMRjalWkT6NZ4jfujuqTCFwoWErg3y3BnOgb544B8IMw4PktiwWOigMfOHNRLMghZeJ7hpfpZf4CP7rV7Og==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -1994,9 +2070,9 @@ ] }, "node_modules/@oxc-resolver/binding-openharmony-arm64": { - "version": "11.24.2", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-openharmony-arm64/-/binding-openharmony-arm64-11.24.2.tgz", - "integrity": "sha512-MvyGik3a6pVgZ0t/kWlbmFxFLmXQJwgLsY2eYFHLpy0wGwRbfzeIGgDwQ3kXqE30z+kSXennRkCrT7TUvkptNg==", + "version": "11.21.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-openharmony-arm64/-/binding-openharmony-arm64-11.21.2.tgz", + "integrity": "sha512-Eljeq3ndtyKhM+Es8LITi4Zl2htzuRZcrPMF3kMCsrILztvU6AjZ3FuEhHHKobPUB9rMpzLpJP5bDqXI7r+iog==", "cpu": [ "arm64" ], @@ -2008,9 +2084,9 @@ ] }, "node_modules/@oxc-resolver/binding-wasm32-wasi": { - "version": "11.24.2", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-wasm32-wasi/-/binding-wasm32-wasi-11.24.2.tgz", - "integrity": "sha512-vHcssMPwO08RTvj/c0iOBz90attxyG3wQJ0dTcyEQK43LRpcdLWZlV5feBhv6Isn6ahbQIzHbCgfa81+RiML0Q==", + "version": "11.21.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-wasm32-wasi/-/binding-wasm32-wasi-11.21.2.tgz", + "integrity": "sha512-HGDbNsIywqc4LxU38+CTJNnB/6BF7rheWOJ259b4eE0aEnelYblC8x+1tEd63bp31fYne63RQz9Jb4yVnC7Yig==", "cpu": [ "wasm32" ], @@ -2018,18 +2094,18 @@ "license": "MIT", "optional": true, "dependencies": { - "@emnapi/core": "1.11.2", - "@emnapi/runtime": "1.11.2", - "@napi-rs/wasm-runtime": "^1.1.6" + "@emnapi/core": "1.11.0", + "@emnapi/runtime": "1.11.0", + "@napi-rs/wasm-runtime": "^1.1.5" }, "engines": { "node": ">=14.0.0" } }, "node_modules/@oxc-resolver/binding-wasm32-wasi/node_modules/@emnapi/core": { - "version": "1.11.2", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.2.tgz", - "integrity": "sha512-TC8MkTuZUtcTSiFeuC0ksCh9QIJ5+F21MvZ4Wn4ORfYaFJ/0dsiudv5tVkejgwZlwQ39jL9WWDe2lz8x0WglOA==", + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.0.tgz", + "integrity": "sha512-l9Oo58x0HOP5znGzVhYW9U3e5wVuA4LAZU2AGezTmkhO1CgQRFDhDg4nneHsu/t3WniXg9QrG2nIXL/ZS8ln8Q==", "dev": true, "license": "MIT", "optional": true, @@ -2039,9 +2115,9 @@ } }, "node_modules/@oxc-resolver/binding-wasm32-wasi/node_modules/@emnapi/runtime": { - "version": "1.11.2", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.2.tgz", - "integrity": "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==", + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.0.tgz", + "integrity": "sha512-55coeOFKHv1ywEcUXJtWU5f+Jr/W5tZDvZig8DLKSwUN1JpROQ4rk/SNOQiFWmaR/VKF4zuFyW1B8JduOSv6Pg==", "dev": true, "license": "MIT", "optional": true, @@ -2061,9 +2137,9 @@ } }, "node_modules/@oxc-resolver/binding-win32-arm64-msvc": { - "version": "11.24.2", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-11.24.2.tgz", - "integrity": "sha512-uokJqro2iBqkFvJdKQLP7d8/BUmFwESQFVmIJUQKj1Xn1a/LysJoe1vmeECLF5b3jsV8CAL5sEMJXX6SdK9Nhg==", + "version": "11.21.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-11.21.2.tgz", + "integrity": "sha512-iWx25CBEgH49iE9q5coEGI/jb1jl5kkCY9z6U5Og67xCkQ/WFMDc2J5U78+AE91SUxM2NqSLhJC8/PLfWnImww==", "cpu": [ "arm64" ], @@ -2075,9 +2151,9 @@ ] }, "node_modules/@oxc-resolver/binding-win32-x64-msvc": { - "version": "11.24.2", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-win32-x64-msvc/-/binding-win32-x64-msvc-11.24.2.tgz", - "integrity": "sha512-UqGPmo56KDfLlfXFAFIrNflHT8tFxWGEivWg3Zeyp4Uy2NlKN1FGPr6/BxcLGG3+kZ6Wp14g5Uj+n71boqZfiw==", + "version": "11.21.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-win32-x64-msvc/-/binding-win32-x64-msvc-11.21.2.tgz", + "integrity": "sha512-VPoCAhKvCQTlG7vxqaBXcmuvbh77BfnGXj8g0pbvVXpm1F/R8rDVwqIWcEbMzrI1JvJlm8v7T9uMVQb6UctMRg==", "cpu": [ "x64" ], @@ -2095,10 +2171,27 @@ "dev": true, "license": "MIT" }, + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.13.tgz", + "integrity": "sha512-Vu/NICrgdRvUOeaJZIyB2FP63s6fO/4ud5l8dXmxDx9yDyn+mN0y+gsvsVviSli+T361Aqo0h46EuMoobrwawA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, "node_modules/@rolldown/binding-android-arm64": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.1.tgz", - "integrity": "sha512-02hOeOSryYxVrOIphmLAsqnCJWxwlzFk+pEt/N/i6OgT3lShHO7xGCU5cpgchRDHboAEbSjzgGh+O/u1GswQmA==", + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.13.tgz", + "integrity": "sha512-6LEoIrGy5vvCd2boNnv2soPSUZj/PRvhp8R/t9KlX+mIZxThIE/ZqxrGXxmuNX0FuvQFvLdZr7S4HrYpYBVhEQ==", "cpu": [ "arm64" ], @@ -2113,9 +2206,9 @@ } }, "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.1.tgz", - "integrity": "sha512-fMsTOnN0OjFm3CyppWPitKnc8UlliVARUULW6cfU6AIqjdtgmSFWSk9vecHzZduv/yMWIHDlRhM1e8Iff9uAfA==", + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.13.tgz", + "integrity": "sha512-CaJSRzlQ6Q+zkDMVA9OkVxBjVoPRCDEE3v0FSJ9kIQBgRjFApA3Si4ijhbNVUji7ZJt+PfO0e46qy4nPInHUPA==", "cpu": [ "arm64" ], @@ -2130,9 +2223,9 @@ } }, "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.1.tgz", - "integrity": "sha512-1wjKdz/XLGKHaTNHjQveQ/B23TKx4ItAqm1JbyVuvNPc4Ze0Fb48s49TAd/2zcplPl8okE/UbTgmlVfwT7eFeQ==", + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.13.tgz", + "integrity": "sha512-e4r340I5hxSzJkD3YJBxgzq/rJ3wwxwdLLb2FPt1Zpyem20nrQPT11DDEEkazwxaHumH8wpglsp/Ozgj/wT6mw==", "cpu": [ "x64" ], @@ -2147,9 +2240,9 @@ } }, "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.1.tgz", - "integrity": "sha512-Fa0jHR07E7YBN4vOEsbVf2briYNsuOowfLJaXULZM0ldMlaCaj2LJgLMbMe4iacRyZmvR8efFhgR9wKuGclQUg==", + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.13.tgz", + "integrity": "sha512-hoBWDZ4/K/mJKVMbsgUd1jfpA2DiOrJ54mpegObU9TBHBBrtX4FLIXJJQRDPZ/2Dn91GvPob+Z2hwKJzf4E5SQ==", "cpu": [ "x64" ], @@ -2164,9 +2257,9 @@ } }, "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.1.tgz", - "integrity": "sha512-pzkgu1SSHGgRRyRZ4fbmSgmajbVt+epaLP99NDjFft69v/ypfTi6swBMiVdh2EkQ0OSnHE1lZDM7DRGkyAzUpA==", + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.13.tgz", + "integrity": "sha512-oF9fZu3vTWnTiVAkDFxU9PJLt1nh20K9RRpuHsR7ZnE2L7XookBbDBVLojCVSLGbvtSlxeUNnjAj6DbNsKyzbg==", "cpu": [ "arm" ], @@ -2181,13 +2274,16 @@ } }, "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.1.tgz", - "integrity": "sha512-QI5SEDY8cbiYWHx0VO4vIc3UlS6a32vXHjU8Qy/17adEmZIPuByJg13UEvo9c/UCiUkdcVWY83C+b+JrwnNyUg==", + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.13.tgz", + "integrity": "sha512-DTpb/+jMiqWMPgphn4ksW1zW+XLJ6dJJ9FMvnIk9I291LhCctMPhMYJrP5t9p6C9qa3KXadDdUEZHBaPh3kNxQ==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -2198,13 +2294,16 @@ } }, "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.1.tgz", - "integrity": "sha512-Sm41FyCeXqmYcERoYOCbGIL5hNfd8w9LQ7Y61Bev48HkcjaJqV/iiVOaiDxjVTRMS+QKrZmD8cfPt4uMVnvM+A==", + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.13.tgz", + "integrity": "sha512-xU+Kvv9N2A3SY35zFh1FWWNjxbadE/BKeqnkXe7vScU4GZsw757BlVmDLITZEq8DPpk6m7uoDYkK7P+x/uU5Ow==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -2215,13 +2314,16 @@ } }, "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.1.tgz", - "integrity": "sha512-2x+WhXTGl9yJYPbltW/BSEPTVz9OIWQyER4N+gJEDWkkn904eRcBzELqh/Hf7K0w/ubGbKNMv0ZC+94QK/IFEg==", + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.13.tgz", + "integrity": "sha512-XMk/hnhh25oQ756Lclb1Q11h4ll0xC0Esa5w4Vzp+mAE2S3AGJgeM0SonrIgY8fHhjarb6hcoTuwoFG0VVP2ig==", "cpu": [ "ppc64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -2232,13 +2334,16 @@ } }, "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.1.tgz", - "integrity": "sha512-eEjmQpuRQayHPWWnywaWHkFT3ToPbP3RYy42VVd/B9aBGDA+Ol25EIWHxKQST3IiWJjikCWUF7KtbfqwZrzVwQ==", + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.13.tgz", + "integrity": "sha512-U1BoTHYmlywE2n3HUWjzZ+8EQsKr+OwpxYuDWaubx42tUL8QSL7nOqRz/NVHanibbFP3M1Rxuprz/wNcbMKjqw==", "cpu": [ "s390x" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -2249,13 +2354,16 @@ } }, "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.1.tgz", - "integrity": "sha512-/Orga1fZYkLc/56jBICcHrKchl8Z2UKdDSr3LG9ToWO1lQ6a4Livk9Xz+9WN91zsz5QR3XQz2NNoSDEvP6qadw==", + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.13.tgz", + "integrity": "sha512-J4cm80Pc6wEwV5AZ42o62sIQgDk+Y7R2s646jIwuvO+4V46QB58N4vVuMPWCBaNgursXLpmUWJV//WQAkPvHBQ==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -2266,13 +2374,16 @@ } }, "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.1.tgz", - "integrity": "sha512-xxBJRL+0q0Kce7orznGWLuylHDY65vuARXZRpX+hPdv+DqK2c3NlCsVA98tlWzWNEE7yPqA/1NQ5nnCrj49Y5A==", + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.13.tgz", + "integrity": "sha512-W0euZS40U4V6JkmEnVCIX9mcsL6cbXQ4X8c2w+MMOIjFg68TXUW+u3RT+2e1t1pv9mg/EP3pDtK3POXdQ7Rxyw==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -2283,9 +2394,9 @@ } }, "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.1.tgz", - "integrity": "sha512-M6AdXIXw3s+/8XpKMzdGDEXGS1S7kwUsy+rcTIUIOx5Ge4nXKCtAFHFV9YKkXvGcC5WMoTjAteLzlsQROVI0Yw==", + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.13.tgz", + "integrity": "sha512-do/AF/4n9MRshia3a5aXoy8xM2c6mgfT6D7H/dY6my5z+hfCIR4ab7mk4IRj++nsEQpKy39kd0DQnDq+r692tw==", "cpu": [ "arm64" ], @@ -2299,60 +2410,10 @@ "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.2.1.tgz", - "integrity": "sha512-/TX0SoRGojHzSAHpfVBbavRVSazg5U3h3Y3VXfcc0cdugq6kxdqw8LPGFiPr+/7gE/60zRcsOY2Vi9b9eT0jww==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/core": "2.0.0-alpha.3", - "@emnapi/runtime": "2.0.0-alpha.3", - "@napi-rs/wasm-runtime": "^1.2.0" - }, - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=23.5.0" - } - }, - "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/core": { - "version": "2.0.0-alpha.3", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-2.0.0-alpha.3.tgz", - "integrity": "sha512-AZypUeJ/yByuxyS7BlSNRDOMLMlROYtjYdIAuBmJssVz1UJDSeYxLrdizhXCFYhedC5bqd/ASy8EuNXbVVXp9g==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/wasi-threads": "2.0.1", - "tslib": "^2.4.0" - } - }, - "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/runtime": { - "version": "2.0.0-alpha.3", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-2.0.0-alpha.3.tgz", - "integrity": "sha512-hFPAhMUjJD9BSyCANEISPOogeXC9Zo9ZQl7L6vKnaVsMkCtzznaW/naYypeyl0Gv5rYfWYsZbpixTMpjDJzQeA==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/wasi-threads": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-2.0.1.tgz", - "integrity": "sha512-9DsSk+o5NBX0CCJT8s0EROGSGxjR/tKu6aBTaVyq+SjAEQH4XcdcRxPBRzsBLizTTJ49MJjF+jgu3qnO9GLQcQ==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.1.tgz", - "integrity": "sha512-EvRrivJieyHG+AO9lleZWgq+g0+S7oV2C51yuqlcyU/R9net+sI4Pj0F+lUoP2bEr6TWX3SqFaaS0SzfLxSzkw==", + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.13.tgz", + "integrity": "sha512-6FtUf2cJZobutJdxnGYX8z0vtbgqU0b2v8vfBhkXb9wp2MppMFtkunE+IaOntyi01rkIQoPW2GHABtfzsC0BRQ==", "cpu": [ "arm64" ], @@ -2367,9 +2428,9 @@ } }, "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.1.tgz", - "integrity": "sha512-Z4eCmn5QJ/5+azF9knpLWKfVd9aidn0mAe9TpJgvBLId9Ax3t0+JVxBmT25Bv7NBbVW1TZyKjQjQReouMeH5UQ==", + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.13.tgz", + "integrity": "sha512-UKhFaZTeTcbKrb3iU82m17jVcPEAoqDkCN5MBqgPyZSwp/cQmib21crTS0DdH7T1B7wZRWEI89YQAOJMet5yhg==", "cpu": [ "x64" ], @@ -2772,9 +2833,9 @@ "peer": true }, "node_modules/@storybook/addon-a11y": { - "version": "10.5.5", - "resolved": "https://registry.npmjs.org/@storybook/addon-a11y/-/addon-a11y-10.5.5.tgz", - "integrity": "sha512-nsMnSRe7pzepXIkUUqI/rL7sp8juXOluyypU4Dz0UuYHhw/cKaxfzuO+3WJN5EtEr/gcnKYb4awxH/duPGavUw==", + "version": "10.6.2", + "resolved": "https://registry.npmjs.org/@storybook/addon-a11y/-/addon-a11y-10.6.2.tgz", + "integrity": "sha512-zleS+btwrTRcUf3opbZntKsEt6PTqAtxsek08tx5jh5fRvd6wWmtnwW3uaBcypfIKnheTB3KGljJwFX+Mc+Umg==", "dev": true, "license": "MIT", "dependencies": { @@ -2786,23 +2847,23 @@ "url": "https://opencollective.com/storybook" }, "peerDependencies": { - "storybook": "^10.5.5" + "storybook": "^10.6.2" } }, "node_modules/@storybook/addon-docs": { - "version": "10.5.5", - "resolved": "https://registry.npmjs.org/@storybook/addon-docs/-/addon-docs-10.5.5.tgz", - "integrity": "sha512-0YpKlimS4XE0kQ8Maa5coeefQxdyDrBHg1wOP3WTPuBe4FolFSCDveR0ge2+vuUBk+fZfn2+l+3Q2jmAWaRGDg==", + "version": "10.6.2", + "resolved": "https://registry.npmjs.org/@storybook/addon-docs/-/addon-docs-10.6.2.tgz", + "integrity": "sha512-f7Rv/rzX1sk4lUDTeoIRkXWcWvNwLv7vBnfpFGb3f88MliB+M4ZekGKNiKtrmi44uLFt52gzwwPzj2eGDu0DQQ==", "dev": true, "license": "MIT", "dependencies": { "@mdx-js/react": "^3.0.0", - "@storybook/csf-plugin": "10.5.5", "@storybook/icons": "^2.0.2", - "@storybook/react-dom-shim": "10.5.5", + "@storybook/react-dom-shim": "10.6.2", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", - "ts-dedent": "^2.0.0" + "ts-dedent": "^2.0.0", + "unplugin": "^2.3.5" }, "funding": { "type": "opencollective", @@ -2810,7 +2871,7 @@ }, "peerDependencies": { "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", - "storybook": "^10.5.5" + "storybook": "^10.6.2" }, "peerDependenciesMeta": { "@types/react": { @@ -2819,9 +2880,9 @@ } }, "node_modules/@storybook/addon-onboarding": { - "version": "10.5.5", - "resolved": "https://registry.npmjs.org/@storybook/addon-onboarding/-/addon-onboarding-10.5.5.tgz", - "integrity": "sha512-QF8mn5QDJdY1LI/RqpxlruaETrPspVFVMRl0oFLSYOIQENIKLY+9Jy48zBkEeiy2vVY8aTNeWdkrnFpDc9G2qA==", + "version": "10.6.2", + "resolved": "https://registry.npmjs.org/@storybook/addon-onboarding/-/addon-onboarding-10.6.2.tgz", + "integrity": "sha512-3X+PD7MB/wHX8TkTfkkmKtYeVsrxwPZY3T0GkedJ5N8RQfdr5A3QrhTBiAnk1L1K1WdpAjdFxxmYivtvq7s8ig==", "dev": true, "license": "MIT", "funding": { @@ -2829,13 +2890,13 @@ "url": "https://opencollective.com/storybook" }, "peerDependencies": { - "storybook": "^10.5.5" + "storybook": "^10.6.2" } }, "node_modules/@storybook/addon-vitest": { - "version": "10.5.5", - "resolved": "https://registry.npmjs.org/@storybook/addon-vitest/-/addon-vitest-10.5.5.tgz", - "integrity": "sha512-Ymq9ErkSkYiIDuqpJ2+hE5GCQ5J6TCLOWhutqArvwaeAO+HAibM82XNExpJ1/kvPqk9y961GDPkv2W15I88JIw==", + "version": "10.6.2", + "resolved": "https://registry.npmjs.org/@storybook/addon-vitest/-/addon-vitest-10.6.2.tgz", + "integrity": "sha512-gyyBzncvUyEgWQR3LinnGuI4UsWIUYLyw1u8i244aTxmY4uxCYjsDmUNkaSr8xQzNz8nh2Ckh3G4bO4SvJ6nJQ==", "dev": true, "license": "MIT", "dependencies": { @@ -2847,11 +2908,11 @@ "url": "https://opencollective.com/storybook" }, "peerDependencies": { - "@vitest/browser": "^3.0.0 || ^4.0.0", - "@vitest/browser-playwright": "^4.0.0", - "@vitest/runner": "^3.0.0 || ^4.0.0", - "storybook": "^10.5.5", - "vitest": "^3.0.0 || ^4.0.0" + "@vitest/browser": "^3.0.0 || ^4.0.0 || ^5.0.0", + "@vitest/browser-playwright": "^4.0.0 || ^5.0.0", + "@vitest/runner": "^3.0.0 || ^4.0.0 || ^5.0.0", + "storybook": "^10.6.2", + "vitest": "^3.0.0 || ^4.0.0 || ^5.0.0" }, "peerDependenciesMeta": { "@vitest/browser": { @@ -2869,13 +2930,12 @@ } }, "node_modules/@storybook/builder-vite": { - "version": "10.5.5", - "resolved": "https://registry.npmjs.org/@storybook/builder-vite/-/builder-vite-10.5.5.tgz", - "integrity": "sha512-dQoJ7gUl8y0z5rV9cE0mz6qTBNmN9R4GOLIZk98rJ8CwduNJOb9eGZXusDzzvnYcp8TnNkqDtyx4tXQSUDInPQ==", + "version": "10.6.2", + "resolved": "https://registry.npmjs.org/@storybook/builder-vite/-/builder-vite-10.6.2.tgz", + "integrity": "sha512-SzeQEGjvybdhP2coF4aIX6JX/7rZTrQCpRU3vAEO/R98Vr3xM8jdg7a3nvt91TeF/P8iWqljLIpaDjTdk2r/sQ==", "dev": true, "license": "MIT", "dependencies": { - "@storybook/csf-plugin": "10.5.5", "ts-dedent": "^2.0.0" }, "funding": { @@ -2883,45 +2943,10 @@ "url": "https://opencollective.com/storybook" }, "peerDependencies": { - "storybook": "^10.5.5", + "storybook": "^10.6.2", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" } }, - "node_modules/@storybook/csf-plugin": { - "version": "10.5.5", - "resolved": "https://registry.npmjs.org/@storybook/csf-plugin/-/csf-plugin-10.5.5.tgz", - "integrity": "sha512-/euibhRFqklYCZqUseokojmfYcQpXshVY2QmA1qCuxMz9SzVFD3iSTw+aFLTxpsJGGdcZJk8fnm/rEthLzZ9jA==", - "dev": true, - "license": "MIT", - "dependencies": { - "unplugin": "^2.3.5" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/storybook" - }, - "peerDependencies": { - "esbuild": "*", - "rollup": "*", - "storybook": "^10.5.5", - "vite": "*", - "webpack": "*" - }, - "peerDependenciesMeta": { - "esbuild": { - "optional": true - }, - "rollup": { - "optional": true - }, - "vite": { - "optional": true - }, - "webpack": { - "optional": true - } - } - }, "node_modules/@storybook/global": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/@storybook/global/-/global-5.0.0.tgz", @@ -2940,14 +2965,14 @@ } }, "node_modules/@storybook/react": { - "version": "10.5.5", - "resolved": "https://registry.npmjs.org/@storybook/react/-/react-10.5.5.tgz", - "integrity": "sha512-T2Xj0ey7a9RHU6coYLC0L5lhjcdyhLCs9wNv15FvHvgmrRobkynEV72kq5vGW8tFkahNWI1X9+GZPQ6r8Nm38w==", + "version": "10.6.2", + "resolved": "https://registry.npmjs.org/@storybook/react/-/react-10.6.2.tgz", + "integrity": "sha512-kNWUU6Q8TrLmEqAzYmNYhC9A3i3kbjFxqIcLL3eu+o4gDu0afp1Of6Sy6jIF2qPGH05A3r6FWMzD03y4Mm5xRw==", "dev": true, "license": "MIT", "dependencies": { "@storybook/global": "^5.0.0", - "@storybook/react-dom-shim": "10.5.5", + "@storybook/react-dom-shim": "10.6.2", "react-docgen": "^8.0.2", "react-docgen-typescript": "^2.2.2" }, @@ -2960,7 +2985,7 @@ "@types/react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", - "storybook": "^10.5.5", + "storybook": "^10.6.2", "typescript": ">= 4.9.x" }, "peerDependenciesMeta": { @@ -2976,9 +3001,9 @@ } }, "node_modules/@storybook/react-dom-shim": { - "version": "10.5.5", - "resolved": "https://registry.npmjs.org/@storybook/react-dom-shim/-/react-dom-shim-10.5.5.tgz", - "integrity": "sha512-PIk7N3LLrZIxfNxmkvmQN1d5UQ70XEedT8n0GhBiXnM6XL09xPGB8n8TZXeJBRYluKhDQcAyQeT0/OZmcDVQJg==", + "version": "10.6.2", + "resolved": "https://registry.npmjs.org/@storybook/react-dom-shim/-/react-dom-shim-10.6.2.tgz", + "integrity": "sha512-k0262Vqxjk3wHeg3g/QO/ENDrkC7kkbuwsFSXOdYr7Sl888tsF1DAS7uQ/gr+s/BRz1FLYRX7wBY3smEUV4MJg==", "dev": true, "license": "MIT", "funding": { @@ -2990,7 +3015,7 @@ "@types/react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", - "storybook": "^10.5.5" + "storybook": "^10.6.2" }, "peerDependenciesMeta": { "@types/react": { @@ -3002,18 +3027,18 @@ } }, "node_modules/@storybook/react-vite": { - "version": "10.5.5", - "resolved": "https://registry.npmjs.org/@storybook/react-vite/-/react-vite-10.5.5.tgz", - "integrity": "sha512-Uy7VV72kVSkw6aDTAPQupXUeZX5LF6e4zqNvTZ+36qxsXAkaFgw7HPEm7L1tsaRfiV+s9anU7UvX47tfJpYGuQ==", + "version": "10.6.2", + "resolved": "https://registry.npmjs.org/@storybook/react-vite/-/react-vite-10.6.2.tgz", + "integrity": "sha512-RfYSW9eefxRXk7IXKBTBkBYAspHKaxNVBR7mlXQAt+Z2bJZzj+S21mv6kBcpFIfAIT9wSIq8xMg0RbeR7CneVQ==", "dev": true, "license": "MIT", "dependencies": { "@joshwooding/vite-plugin-react-docgen-typescript": "^0.7.0", "@rollup/pluginutils": "^5.0.2", - "@storybook/builder-vite": "10.5.5", - "@storybook/react": "10.5.5", + "@storybook/builder-vite": "10.6.2", + "@storybook/react": "10.6.2", "empathic": "^2.0.0", - "magic-string": "^0.30.0", + "magic-string": "^1.1.0", "react-docgen": "^8.0.2", "resolve": "^1.22.8", "tsconfig-paths": "^4.2.0" @@ -3025,7 +3050,7 @@ "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", - "storybook": "^10.5.5", + "storybook": "^10.6.2", "typescript": ">= 4.9.x", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" }, @@ -3035,6 +3060,16 @@ } } }, + "node_modules/@storybook/react-vite/node_modules/magic-string": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.3.tgz", + "integrity": "sha512-z12OxmaPGE0F4xlpGdjuAUckipLpQlTAuAmyGhNoD7ODpYUzvDfvtxUbjM/jwznyP178oju/KDdyi220wNJ0RQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.6.0" + } + }, "node_modules/@testing-library/dom": { "version": "10.4.1", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", @@ -3083,9 +3118,9 @@ "license": "MIT" }, "node_modules/@testing-library/react": { - "version": "16.3.2", - "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", - "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==", + "version": "16.3.3", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.3.tgz", + "integrity": "sha512-Uo193NgQbPMz6lrrhtRQQFcMC6Re/ELLFbbuVL30WDlZxlpZf9/lMHTAVxPRLw1q1iu9OJmR1c2BLiENRstdBg==", "dev": true, "license": "MIT", "dependencies": { @@ -3111,9 +3146,9 @@ } }, "node_modules/@testing-library/user-event": { - "version": "14.6.1", - "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz", - "integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==", + "version": "14.6.7", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.7.tgz", + "integrity": "sha512-MPCpX8bxe8zS+JmmTwLp8jd0dy1rAm60Te/SL8JrQM3qvQJcBOs1d7IefJMyZzqM3EWBrDn/LWDt1BCGu4ASfg==", "dev": true, "license": "MIT", "engines": { @@ -3337,13 +3372,13 @@ "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==", + "version": "24.19.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.19.2.tgz", + "integrity": "sha512-93d49wCWJkCyN87MxLpNN6KXPCwFw7bVUg79mGUV1OS40JGWwVCr8n61DXCSqz7CV6/VfPkb7xec7uP2M+QHWQ==", "dev": true, "license": "MIT", "dependencies": { - "undici-types": "~7.18.0" + "undici-types": ">=7.24.0 <7.24.7" } }, "node_modules/@types/papaparse": { @@ -3383,22 +3418,22 @@ "license": "MIT" }, "node_modules/@types/react": { - "version": "19.2.18", - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz", - "integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==", + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz", + "integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==", "license": "MIT", "dependencies": { "csstype": "^3.2.2" } }, "node_modules/@types/react-dom": { - "version": "19.2.4", - "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.4.tgz", - "integrity": "sha512-Bsc+QHgp+P/F02XDzNCY9jnZNCUuLki36KT7VKrTXXLdHf+vHMNZnW1rVu5DNW/rCK+fya3DATySbLM4yhtKUw==", + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.3.0.tgz", + "integrity": "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==", "dev": true, "license": "MIT", "peerDependencies": { - "@types/react": "^19.2.0" + "@types/react": "^19.3.0" } }, "node_modules/@types/react-syntax-highlighter": { @@ -3610,9 +3645,9 @@ "license": "ISC" }, "node_modules/@vitejs/plugin-react": { - "version": "6.1.1", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.1.1.tgz", - "integrity": "sha512-yxLaQV9gkhS8ezJqCM6+ndU7mDY6gqAg75NQ+0IjwEI8IYOmQCgkRwHKVSfWXW076DsqMo0Dk+0FK1U+M5RgFw==", + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.1.2.tgz", + "integrity": "sha512-fhJNGUdscQa4HkI0xFxwonWyhnVReCPDbXI7FH7jmzpnVCrFQI7PRrhWecO263610vOrs9tr2Y4xcf8uAdFxbw==", "dev": true, "license": "MIT", "dependencies": { @@ -3624,7 +3659,7 @@ "peerDependencies": { "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", "babel-plugin-react-compiler": "^1.0.0", - "oxc-transform-react": "^0.145.0", + "oxc-transform-react": "^0.152.0", "vite": "^8.0.0" }, "peerDependenciesMeta": { @@ -4115,6 +4150,21 @@ "node": ">=8" } }, + "node_modules/cacheable": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/cacheable/-/cacheable-2.5.0.tgz", + "integrity": "sha512-60cyAOytib/OzBw1JNSoSV/boK1AtHryDIjvVBk7XbN4ugfkM3+Sry7fEjNgPMGgOjuaZPAp8ruZ0Cxafwyq9g==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@cacheable/memory": "^2.2.0", + "@cacheable/utils": "^2.5.0", + "hookified": "^1.15.0", + "keyv": "^5.6.0", + "qified": "^0.10.1" + } + }, "node_modules/callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", @@ -4239,9 +4289,9 @@ } }, "node_modules/chromatic": { - "version": "16.10.0", - "resolved": "https://registry.npmjs.org/chromatic/-/chromatic-16.10.0.tgz", - "integrity": "sha512-nFsztmnu7rFiGafUJgXvLUNpqmRylz92eNvzBoJNTKKQj4EQUyxznwnfpf1dTs7hXtWD8JwcH92jADydaHA1sw==", + "version": "18.12.0", + "resolved": "https://registry.npmjs.org/chromatic/-/chromatic-18.12.0.tgz", + "integrity": "sha512-ACi49naCDfR/nCC716LrpjndBzCVhON7T5g1VY5CmonTJIOlXL/kMfya81InoSM0KtlY534MjfIS01UPlxiCqg==", "dev": true, "license": "MIT", "dependencies": { @@ -4252,6 +4302,9 @@ "chromatic": "dist/bin.cjs", "chromatic-cli": "dist/bin.cjs" }, + "engines": { + "node": ">=22.0.0" + }, "peerDependencies": { "@chromatic-com/cypress": "^0.*.* || ^1.0.0", "@chromatic-com/playwright": "^0.*.* || ^1.0.0", @@ -4651,9 +4704,9 @@ } }, "node_modules/eslint": { - "version": "10.8.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.8.0.tgz", - "integrity": "sha512-nuKKvN+oIBO0koN7Tm7dlkmnkc21mtt0QJLwAKzjLq14y6lRTdVG36MZHJ8eQHwdJMwZbQNMlPOYedMq/oVJvQ==", + "version": "10.12.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.12.0.tgz", + "integrity": "sha512-npHjrHb2o4ehH3mE+YuxABR+0gyb0aVWCIolgJDArwCiHEIsonBm3ZKjel5b0U5+a+MbEvIyRCGCzD3eKKK2NA==", "dev": true, "license": "MIT", "peer": true, @@ -4666,7 +4719,7 @@ "@eslint/config-array": "^0.23.5", "@eslint/config-helpers": "^0.7.0", "@eslint/core": "^1.2.1", - "@eslint/plugin-kit": "^0.7.2", + "@eslint/plugin-kit": "^0.7.3", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", @@ -4681,7 +4734,7 @@ "esquery": "^1.7.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", - "file-entry-cache": "^8.0.0", + "file-entry-cache": "11.1.5 || >11.1.6 <12", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", @@ -4731,9 +4784,9 @@ } }, "node_modules/eslint-plugin-react-refresh": { - "version": "0.5.3", - "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.5.3.tgz", - "integrity": "sha512-5EMmLCV98Pi4o/f/3DP/v/tNqLHMIc9I8LKClNDWhZ9JTho89/kQcitCXQBMG7sAfVRK0Ie3T2EDOzp1YXYiVA==", + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.5.7.tgz", + "integrity": "sha512-XhJSzLljuYD4UjNuFGJu2v7aD3sqNVK12w5/DCUfrNfHWegZo2+TsavNx8MuxMwEquuFAzNbNfXKU0WoZ+YUIg==", "dev": true, "license": "MIT", "peerDependencies": { @@ -4741,9 +4794,9 @@ } }, "node_modules/eslint-plugin-storybook": { - "version": "10.5.5", - "resolved": "https://registry.npmjs.org/eslint-plugin-storybook/-/eslint-plugin-storybook-10.5.5.tgz", - "integrity": "sha512-xGCrddoZ8pMmwc7M5mp+habWbvqBjRuyGVTJ6RpavpoAP6NBUfGSRXHuBs7cHRDpDSuErYeDiEo4XHTonRp68g==", + "version": "10.6.2", + "resolved": "https://registry.npmjs.org/eslint-plugin-storybook/-/eslint-plugin-storybook-10.6.2.tgz", + "integrity": "sha512-lECiiizxoE03N/AujmGN0I/yLuDPweTFuQYowkWhtPHD0bWlc2qYCJxG8HbiGulA9nnALXiaZXFW8t1r8EFBKw==", "dev": true, "license": "MIT", "dependencies": { @@ -4751,8 +4804,7 @@ "@typescript-eslint/utils": "^8.60.0" }, "peerDependencies": { - "eslint": ">=8", - "storybook": "^10.5.5" + "eslint": ">=8" } }, "node_modules/eslint-scope": { @@ -4993,17 +5045,14 @@ } }, "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==", + "version": "11.1.5", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-11.1.5.tgz", + "integrity": "sha512-+PFTHITI08JIGhnNpGNI8T8inUpgZfk3GNEqfT9R2zZV2iFXg3CvqzSl/uEhs7TSGujYRELEANyDvS8Fj7+S7Q==", "dev": true, "license": "MIT", "peer": true, "dependencies": { - "flat-cache": "^4.0.0" - }, - "engines": { - "node": ">=16.0.0" + "flat-cache": "^6.1.23" } }, "node_modules/find-root": { @@ -5043,18 +5092,16 @@ } }, "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==", + "version": "6.1.23", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-6.1.23.tgz", + "integrity": "sha512-f++BY9pTk+983xK1FLzlLpmM0i0z+jHmx3QESGkURMXujQZz1k5wzwX6hjnQ8goaD0B+sYnDK1yZ6MTyZfUaqA==", "dev": true, "license": "MIT", "peer": true, "dependencies": { - "flatted": "^3.2.9", - "keyv": "^4.5.4" - }, - "engines": { - "node": ">=16" + "cacheable": "^2.5.0", + "flatted": "^3.4.2", + "hookified": "^1.15.0" } }, "node_modules/flatted": { @@ -5175,6 +5222,20 @@ "node": ">=20.0.0" } }, + "node_modules/hashery": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/hashery/-/hashery-1.5.1.tgz", + "integrity": "sha512-iZyKG96/JwPz1N55vj2Ie2vXbhu440zfUfJvSwEqEbeLluk7NnapfGqa7LH0mOsnDxTF85Mx8/dyR6HfqcbmbQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "hookified": "^1.15.0" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/hasown": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", @@ -5298,6 +5359,14 @@ "react-is": "^16.7.0" } }, + "node_modules/hookified": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/hookified/-/hookified-1.15.1.tgz", + "integrity": "sha512-MvG/clsADq1GPM2KGo2nyfaWVyn9naPiXrqIe4jYjXNZQt238kWyOGrsyc/DmRAQ+Re6yeo6yX/yoNCG5KAEVg==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/html-url-attributes": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz", @@ -5551,14 +5620,6 @@ "node": ">=6" } }, - "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", - "peer": true - }, "node_modules/json-parse-even-better-errors": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", @@ -5607,14 +5668,14 @@ } }, "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==", + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.6.0.tgz", + "integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==", "dev": true, "license": "MIT", "peer": true, "dependencies": { - "json-buffer": "3.0.1" + "@keyv/serialize": "^1.1.1" } }, "node_modules/klona": { @@ -6964,9 +7025,9 @@ } }, "node_modules/nanoid": { - "version": "3.3.18", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", - "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "version": "3.3.20", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz", + "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==", "dev": true, "funding": [ { @@ -7101,34 +7162,34 @@ } }, "node_modules/oxc-resolver": { - "version": "11.24.2", - "resolved": "https://registry.npmjs.org/oxc-resolver/-/oxc-resolver-11.24.2.tgz", - "integrity": "sha512-FY91FiDBj7ls5MsFS9jN3tjz2o0/zsdSsymlakySaBwVJZorHhkWyICLZMKxlu1R9vYo+sd3z1jwb4J8x7bNDw==", + "version": "11.21.2", + "resolved": "https://registry.npmjs.org/oxc-resolver/-/oxc-resolver-11.21.2.tgz", + "integrity": "sha512-w5tLwYN3Zo24w5EeWJjJWZOwhYqTtC8PS2B1tIt7BZUuqTIcU07sQValbDw+rq7+AuAGzOHklgK+ifsy4lpXfw==", "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/Boshen" }, "optionalDependencies": { - "@oxc-resolver/binding-android-arm-eabi": "11.24.2", - "@oxc-resolver/binding-android-arm64": "11.24.2", - "@oxc-resolver/binding-darwin-arm64": "11.24.2", - "@oxc-resolver/binding-darwin-x64": "11.24.2", - "@oxc-resolver/binding-freebsd-x64": "11.24.2", - "@oxc-resolver/binding-linux-arm-gnueabihf": "11.24.2", - "@oxc-resolver/binding-linux-arm-musleabihf": "11.24.2", - "@oxc-resolver/binding-linux-arm64-gnu": "11.24.2", - "@oxc-resolver/binding-linux-arm64-musl": "11.24.2", - "@oxc-resolver/binding-linux-ppc64-gnu": "11.24.2", - "@oxc-resolver/binding-linux-riscv64-gnu": "11.24.2", - "@oxc-resolver/binding-linux-riscv64-musl": "11.24.2", - "@oxc-resolver/binding-linux-s390x-gnu": "11.24.2", - "@oxc-resolver/binding-linux-x64-gnu": "11.24.2", - "@oxc-resolver/binding-linux-x64-musl": "11.24.2", - "@oxc-resolver/binding-openharmony-arm64": "11.24.2", - "@oxc-resolver/binding-wasm32-wasi": "11.24.2", - "@oxc-resolver/binding-win32-arm64-msvc": "11.24.2", - "@oxc-resolver/binding-win32-x64-msvc": "11.24.2" + "@oxc-resolver/binding-android-arm-eabi": "11.21.2", + "@oxc-resolver/binding-android-arm64": "11.21.2", + "@oxc-resolver/binding-darwin-arm64": "11.21.2", + "@oxc-resolver/binding-darwin-x64": "11.21.2", + "@oxc-resolver/binding-freebsd-x64": "11.21.2", + "@oxc-resolver/binding-linux-arm-gnueabihf": "11.21.2", + "@oxc-resolver/binding-linux-arm-musleabihf": "11.21.2", + "@oxc-resolver/binding-linux-arm64-gnu": "11.21.2", + "@oxc-resolver/binding-linux-arm64-musl": "11.21.2", + "@oxc-resolver/binding-linux-ppc64-gnu": "11.21.2", + "@oxc-resolver/binding-linux-riscv64-gnu": "11.21.2", + "@oxc-resolver/binding-linux-riscv64-musl": "11.21.2", + "@oxc-resolver/binding-linux-s390x-gnu": "11.21.2", + "@oxc-resolver/binding-linux-x64-gnu": "11.21.2", + "@oxc-resolver/binding-linux-x64-musl": "11.21.2", + "@oxc-resolver/binding-openharmony-arm64": "11.21.2", + "@oxc-resolver/binding-wasm32-wasi": "11.21.2", + "@oxc-resolver/binding-win32-arm64-msvc": "11.21.2", + "@oxc-resolver/binding-win32-x64-msvc": "11.21.2" } }, "node_modules/p-limit": { @@ -7166,9 +7227,9 @@ } }, "node_modules/papaparse": { - "version": "5.5.4", - "resolved": "https://registry.npmjs.org/papaparse/-/papaparse-5.5.4.tgz", - "integrity": "sha512-SwzWD9gl/ElwYLCI0nUja1mFJzjq2D8ziShfNBa7zCHzkOozeOGDwHWQ+tvCzEZcewecWZ5U7kUopDnG+DFYEQ==", + "version": "5.7.0", + "resolved": "https://registry.npmjs.org/papaparse/-/papaparse-5.7.0.tgz", + "integrity": "sha512-qBGxg/7Q3Kl9Wfhrz2Z74UnvnHTXLNG6jmKJFeBvP2+y4lV7So+7SR62+Zd47JvdrCkX+nDcnr0ObPzek/+6RA==", "license": "MIT" }, "node_modules/parent-module": { @@ -7314,9 +7375,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": { @@ -7349,28 +7410,25 @@ } }, "node_modules/playwright": { - "version": "1.62.1", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz", - "integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==", + "version": "1.64.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.64.0.tgz", + "integrity": "sha512-kVzTnYFYEyQ9RGzBjNCd2+yEqWmVbxl3AHPynv3qQ6JTjv4a+kwRcnqQwIpuP3NnrnIrE/Q6wWAIAvMfIE2/Yg==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright-core": "1.62.1" + "playwright-core": "1.64.0" }, "bin": { "playwright": "cli.js" }, "engines": { "node": ">=20" - }, - "optionalDependencies": { - "fsevents": "2.3.2" } }, "node_modules/playwright-core": { - "version": "1.62.1", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz", - "integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==", + "version": "1.64.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.64.0.tgz", + "integrity": "sha512-T9r+MZkTECl2+oUcZ26YgZzTrYidTsIhYYkJix+6iDOymOb1FB2RLLSOxRGnZGv5DStt+aduIVbt+E8WygXYpg==", "dev": true, "license": "Apache-2.0", "bin": { @@ -7391,9 +7449,9 @@ } }, "node_modules/postcss": { - "version": "8.5.25", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.25.tgz", - "integrity": "sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==", + "version": "8.5.30", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.30.tgz", + "integrity": "sha512-zkd+e9S1AQHhmaBKye9zlFHWjOwVgqXl1q3ECYy8561K7qxR2cREf/STLskulBFd78k6AOFeA+ff1TtEzk9bAA==", "dev": true, "funding": [ { @@ -7411,9 +7469,9 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.16", + "nanoid": "^3.3.20", "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" + "source-map-js": "^1.2.2" }, "engines": { "node": "^10 || ^12 || >=14" @@ -7549,10 +7607,32 @@ "node": ">=6" } }, + "node_modules/qified": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/qified/-/qified-0.10.1.tgz", + "integrity": "sha512-+Owyggi9IxT1ePKGafcI87ubSmxol6smwJ+RAHDQlx9+9cPwFWDiKFFCPuWhr9ignlGpZ9vDQLw67N4dcTVFEA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "hookified": "^2.1.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/qified/node_modules/hookified": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/hookified/-/hookified-2.2.0.tgz", + "integrity": "sha512-p/LgFzRN5FeoD3DLS6bkUapeye6E4SI6yJs6KetENd18S+FBthqYq2amJUWpt5z0EQwwHemidjY5OqJGEKm5uA==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/react": { - "version": "19.2.8", - "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", - "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==", + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/react/-/react-19.3.0.tgz", + "integrity": "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==", "license": "MIT", "engines": { "node": ">=0.10.0" @@ -7575,9 +7655,9 @@ } }, "node_modules/react-docgen": { - "version": "8.0.3", - "resolved": "https://registry.npmjs.org/react-docgen/-/react-docgen-8.0.3.tgz", - "integrity": "sha512-aEZ9qP+/M+58x2qgfSFEWH1BxLyHe5+qkLNJOZQb5iGS017jpbRnoKhNRrXPeA6RfBrZO5wZrT9DMC1UqE1f1w==", + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/react-docgen/-/react-docgen-8.0.4.tgz", + "integrity": "sha512-gIYjtVTU4NCWZnb/LXVSWfClZXnjzX3+xxi69Tkfu4YsmYuqiOk8giLkKt/WwxWqh+U66CTh8Kqbha1uPv8/qQ==", "dev": true, "license": "MIT", "dependencies": { @@ -7607,15 +7687,15 @@ } }, "node_modules/react-dom": { - "version": "19.2.8", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz", - "integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==", + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.3.0.tgz", + "integrity": "sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q==", "license": "MIT", "dependencies": { - "scheduler": "^0.27.0" + "scheduler": "^0.28.0" }, "peerDependencies": { - "react": "^19.2.8" + "react": "^19.3.0" } }, "node_modules/react-icons": { @@ -7740,9 +7820,9 @@ } }, "node_modules/react-syntax-highlighter": { - "version": "16.1.1", - "resolved": "https://registry.npmjs.org/react-syntax-highlighter/-/react-syntax-highlighter-16.1.1.tgz", - "integrity": "sha512-PjVawBGy80C6YbC5DDZJeUjBmC7skaoEUdvfFQediQHgCL7aKyVHe57SaJGfQsloGDac+gCpTfRdtxzWWKmCXA==", + "version": "16.1.2", + "resolved": "https://registry.npmjs.org/react-syntax-highlighter/-/react-syntax-highlighter-16.1.2.tgz", + "integrity": "sha512-4dM0+mYtfnhgwYWAvFx56fgyjIYk2yFopfiICFvMDhRfeSGRNd+ngFALdPD9q7llyGAmiE6pzOSOwI8Lf9ca1w==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.28.4", @@ -7973,13 +8053,13 @@ } }, "node_modules/rolldown": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.1.tgz", - "integrity": "sha512-4FKJhg8d3OiyQOA6Q1Q0hoFFpW9/OoX+VsHzpECsdsIZoOArrAK90gl59YK/Z+gnDel45bgJZK03ozH/9bCqEw==", + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.13.tgz", + "integrity": "sha512-/5apOkwWxtACcQtZ5WlrAsKS4JwXVThQPJZ3RqIruRY9UG9M8e0VlXN068xum/JRKA4aIUoJ13ghe/kGCvidiQ==", "dev": true, "license": "MIT", "dependencies": { - "@oxc-project/types": "=0.142.0", + "@oxc-project/types": "=0.153.0", "@rolldown/pluginutils": "^1.0.0" }, "bin": { @@ -7989,31 +8069,31 @@ "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.2.1", - "@rolldown/binding-darwin-arm64": "1.2.1", - "@rolldown/binding-darwin-x64": "1.2.1", - "@rolldown/binding-freebsd-x64": "1.2.1", - "@rolldown/binding-linux-arm-gnueabihf": "1.2.1", - "@rolldown/binding-linux-arm64-gnu": "1.2.1", - "@rolldown/binding-linux-arm64-musl": "1.2.1", - "@rolldown/binding-linux-ppc64-gnu": "1.2.1", - "@rolldown/binding-linux-s390x-gnu": "1.2.1", - "@rolldown/binding-linux-x64-gnu": "1.2.1", - "@rolldown/binding-linux-x64-musl": "1.2.1", - "@rolldown/binding-openharmony-arm64": "1.2.1", - "@rolldown/binding-wasm32-wasi": "1.2.1", - "@rolldown/binding-win32-arm64-msvc": "1.2.1", - "@rolldown/binding-win32-x64-msvc": "1.2.1" + "@rolldown/binding-android-arm-eabi": "1.2.13", + "@rolldown/binding-android-arm64": "1.2.13", + "@rolldown/binding-darwin-arm64": "1.2.13", + "@rolldown/binding-darwin-x64": "1.2.13", + "@rolldown/binding-freebsd-x64": "1.2.13", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.13", + "@rolldown/binding-linux-arm64-gnu": "1.2.13", + "@rolldown/binding-linux-arm64-musl": "1.2.13", + "@rolldown/binding-linux-ppc64-gnu": "1.2.13", + "@rolldown/binding-linux-s390x-gnu": "1.2.13", + "@rolldown/binding-linux-x64-gnu": "1.2.13", + "@rolldown/binding-linux-x64-musl": "1.2.13", + "@rolldown/binding-openharmony-arm64": "1.2.13", + "@rolldown/binding-win32-arm64-msvc": "1.2.13", + "@rolldown/binding-win32-x64-msvc": "1.2.13" } }, "node_modules/rolldown/node_modules/@oxc-project/types": { - "version": "0.142.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.142.0.tgz", - "integrity": "sha512-7W+2q5AKQVU36fkaryontrHn3YDt1RyUYXatw9i5H8ocYe2sPKSFB6eS8WNPeRKiN1qAWWZUPm7gwFzJGrccqQ==", + "version": "0.153.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.153.0.tgz", + "integrity": "sha512-jSNrRIyvPVLL5E1z/tS5zLAprosGz/FuI1PWwEOz59j3E4TTTzSpqKb/RDGqJhhW0QHYISGXxq582wGx6T11Tw==", "dev": true, "license": "MIT", "funding": { - "url": "https://github.com/sponsors/Boshen" + "url": "https://github.com/sponsors/oxc-project" } }, "node_modules/rollup": { @@ -8076,9 +8156,9 @@ } }, "node_modules/scheduler": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", - "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz", + "integrity": "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==", "license": "MIT" }, "node_modules/semver": { @@ -8188,17 +8268,17 @@ "peer": true }, "node_modules/storybook": { - "version": "10.5.5", - "resolved": "https://registry.npmjs.org/storybook/-/storybook-10.5.5.tgz", - "integrity": "sha512-UscBIBJDloUeqntukHOhP1a5W/vouePDJbzPSxj466WK801FZtzQiMffMtkjzJiWSuj20wfaYlB2QQKh9aOYAg==", + "version": "10.6.2", + "resolved": "https://registry.npmjs.org/storybook/-/storybook-10.6.2.tgz", + "integrity": "sha512-qHDINMiapxFUxYb+xC3j8u7Y+85X3u+/PlytE/wmnyg+bU0agCW3jUmWP4vIh2MioO2jTK/2B3Lcw6IpRFzQBA==", "dev": true, "license": "MIT", "dependencies": { "@storybook/global": "^5.0.0", "@storybook/icons": "^2.0.2", "@testing-library/dom": "^10.4.1", - "@testing-library/jest-dom": "^6.9.1", - "@testing-library/user-event": "^14.6.1", + "@testing-library/jest-dom": "6.9.1", + "@testing-library/user-event": "^14.6.3", "@vitest/expect": "3.2.4", "@vitest/spy": "3.2.4", "@webcontainer/env": "^1.1.1", @@ -8206,7 +8286,7 @@ "jsonc-parser": "^3.3.1", "open": "^10.2.0", "oxc-parser": "^0.127.0", - "oxc-resolver": "^11.19.1", + "oxc-resolver": "11.21.2", "recast": "^0.23.5", "semver": "^7.7.3", "use-sync-external-store": "^1.5.0", @@ -8685,9 +8765,9 @@ "license": "MIT" }, "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==", + "version": "7.24.6", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", + "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", "dev": true, "license": "MIT" }, @@ -8973,16 +9053,16 @@ } }, "node_modules/vite": { - "version": "8.2.0", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.0.tgz", - "integrity": "sha512-pn+CFpM0lwDeKwmOq1ZaBK/9sjorZcgqxki6MbY/jPEVd9vichIlmlD4HmQ5wdP5EgqQCFRaACBxMC7uEGc6lQ==", + "version": "8.3.4", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.4.tgz", + "integrity": "sha512-IftLE8phJhdNRIGNEV4JCCvfNScoZQVfcXaN8TQropWGJtOHcu+iAB9pHcuGvmEb/kddw0LBe/jS8ztdC6xdiQ==", "dev": true, "license": "MIT", "dependencies": { "lightningcss": "^1.33.0", - "picomatch": "^4.0.5", - "postcss": "^8.5.23", - "rolldown": "~1.2.0", + "picomatch": "^4.0.7", + "postcss": "^8.5.29", + "rolldown": "~1.2.12", "tinyglobby": "^0.2.17" }, "bin": { @@ -8999,7 +9079,7 @@ }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", - "@vitejs/devtools": "^0.4.0", + "@vitejs/devtools": "^0.7.1", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", @@ -9306,9 +9386,9 @@ "license": "ISC" }, "node_modules/yaml": { - "version": "2.9.0", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", - "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.1.tgz", + "integrity": "sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw==", "dev": true, "license": "ISC", "bin": { @@ -9336,9 +9416,9 @@ } }, "node_modules/zod": { - "version": "4.6.5", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.5.tgz", - "integrity": "sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q==", + "version": "4.6.7", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.7.tgz", + "integrity": "sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg==", "dev": true, "license": "MIT", "funding": { diff --git a/clients/web/package.json b/clients/web/package.json index 1c84c32503..08e07ad01a 100644 --- a/clients/web/package.json +++ b/clients/web/package.json @@ -33,6 +33,10 @@ "preview": "vite preview", "storybook": "storybook dev -p 6006" }, + "allowScripts": { + "esbuild": false, + "fsevents": false + }, "dependencies": { "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^8.0.0", @@ -79,6 +83,7 @@ "yaml": "^2.9.0" }, "overrides": { + "@types/node": "^24.12.4", "esbuild": "^0.28.2", "vitest": "4.1.11" } diff --git a/clients/web/server/run-web.ts b/clients/web/server/run-web.ts index a76791faed..7df47c9bde 100644 --- a/clients/web/server/run-web.ts +++ b/clients/web/server/run-web.ts @@ -27,6 +27,7 @@ import { import { startViteDevServer } from "./start-vite-dev-server.js"; import { startHonoServer } from "./server.js"; import { ensureWebBuild } from "./ensure-web-build.js"; +import { readInspectorVersionSafe } from "../../../core/node/version.ts"; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -71,6 +72,15 @@ export async function runWeb(argv: string[]): Promise { program .name("mcp-inspector-web") .description("Web UI for MCP Inspector") + // Prints the root package version and exits before any server binds — the + // web mode is the launcher's default, so a bare `mcp-inspector --version` + // lands here (#2632). The safe reader matches web-server-config.ts: an + // unresolvable manifest must not take startup down. + .version( + readInspectorVersionSafe(import.meta.url) ?? "unknown", + "-V, --version", + "Print the Inspector version", + ) .allowExcessArguments() .allowUnknownOption() .option( diff --git a/clients/web/server/web-server-config.ts b/clients/web/server/web-server-config.ts index d8f8a24f5a..29ca4c1515 100644 --- a/clients/web/server/web-server-config.ts +++ b/clients/web/server/web-server-config.ts @@ -267,7 +267,14 @@ export function openBrowser(url: string): void { // event on the child, which no `.catch` here could see and which crashed // the server at startup (#2533). `openUrl` turns it into a rejection. openUrl(url).catch((err: unknown) => { - console.warn("Could not open a browser automatically:", err); + // The message only, on one line, matching the CLI's wording + // (`core/cli/cli-oauth-navigation.ts`). Logging the error object printed + // a stack plus `spawnargs`, which carry the tokenized launch URL — a + // second copy of the token for anyone pasting the error (#2645). + const reason = err instanceof Error ? err.message : String(err); + console.warn( + `Could not open a browser automatically (${reason}). Open the URL above manually.`, + ); }); } diff --git a/clients/web/src/App.test.tsx b/clients/web/src/App.test.tsx index 67a0ffc2f5..93eea2dcc4 100644 --- a/clients/web/src/App.test.tsx +++ b/clients/web/src/App.test.tsx @@ -4068,6 +4068,7 @@ describe("App background command rejections (#2049)", () => { tools: [{ name: "get_acts", inputSchema: { type: "object" } }], error: null, listChanged: false, + loaded: true, refresh: vi.fn().mockResolvedValue([]), clearListChanged: clearToolsListChangedSpy, }); @@ -4120,6 +4121,7 @@ describe("App background command rejections (#2049)", () => { tools: [], error: LIST_FAILURE, listChanged: false, + loaded: true, refresh: vi.fn().mockRejectedValue(LIST_FAILURE), clearListChanged: clearToolsListChangedSpy, }); @@ -4352,6 +4354,7 @@ function managedResourcesResult( error: null, resources, listChanged: false, + loaded: true, refresh: vi.fn().mockResolvedValue(resources), clearListChanged: vi.fn(), }; diff --git a/clients/web/src/App.tsx b/clients/web/src/App.tsx index 90f233ebb7..ebce84677d 100644 --- a/clients/web/src/App.tsx +++ b/clients/web/src/App.tsx @@ -109,6 +109,8 @@ import { } from "./components/groups/PendingClientRequestModal/PendingClientRequestModal"; import { downloadJsonFile } from "./lib/downloadFile"; import { serverWithDraftSettings } from "./utils/serverWithDraftSettings"; +import { offersRunAsTask } from "./utils/toolUtils"; +import { listCountOf } from "./utils/listCount"; import { enrichProtocolEntries } from "./utils/correlateTransportErrors"; import { visibleMalformedListItems } from "./utils/malformedListReport"; import { parseDeepLink, deepLinkParseStatus } from "./utils/deepLink"; @@ -1069,6 +1071,7 @@ function App() { onPaginatedChange: onTogglePaginatedLists, canLoadMore: toolsPagination.canLoadMore, loadedPages: toolsPagination.loadedPages, + loaded: toolsPagination.loaded, onLoadMore: onLoadMoreTools, }; const promptsPaginationControls: ListPaginationControlsProps = { @@ -1076,6 +1079,7 @@ function App() { onPaginatedChange: onTogglePaginatedLists, canLoadMore: promptsPagination.canLoadMore, loadedPages: promptsPagination.loadedPages, + loaded: promptsPagination.loaded, onLoadMore: onLoadMorePrompts, }; const resourcesPaginationControls: ListPaginationControlsProps = { @@ -1866,9 +1870,7 @@ function App() { toolsUi: ui.toolsUi, toolCallState, toolsPagination: toolsPaginationControls, - serverSupportsTaskToolCalls: - !!capabilities?.tasks?.requests?.tools?.call || - (inspectorClient?.isTasksExtensionNegotiated() ?? false), + serverSupportsTaskToolCalls: offersRunAsTask(protocolEra, capabilities), onToolsUiChange, onCallTool: dispatchCallTool, onCancelToolCall, @@ -2126,6 +2128,11 @@ function App() { protocolEra={protocolEra} discoverResult={discoverResult} diagnostics={connectionDiagnostics} + listCounts={{ + tools: listCountOf(toolsPagination), + prompts: listCountOf(promptsPagination), + resources: listCountOf(resourcesPagination), + }} oauth={connectionInfoOAuth} onClearOAuth={ connectionInfoCanClearOAuth ? handleClearConnectionOAuth : undefined diff --git a/clients/web/src/components/elements/CapabilityItem/CapabilityItem.stories.tsx b/clients/web/src/components/elements/CapabilityItem/CapabilityItem.stories.tsx index 1c7fb4259d..c0a3c758ec 100644 --- a/clients/web/src/components/elements/CapabilityItem/CapabilityItem.stories.tsx +++ b/clients/web/src/components/elements/CapabilityItem/CapabilityItem.stories.tsx @@ -24,6 +24,16 @@ export const SupportedWithCount: Story = { }, }; +// Paginated mode with a `nextCursor` still unloaded: the count is a lower bound. +export const SupportedWithPartialCount: Story = { + args: { + capability: "tools", + supported: true, + count: 10, + partial: true, + }, +}; + export const NotSupported: Story = { args: { capability: "completions", diff --git a/clients/web/src/components/elements/CapabilityItem/CapabilityItem.test.tsx b/clients/web/src/components/elements/CapabilityItem/CapabilityItem.test.tsx index ba04dc232a..cd76f0f7c9 100644 --- a/clients/web/src/components/elements/CapabilityItem/CapabilityItem.test.tsx +++ b/clients/web/src/components/elements/CapabilityItem/CapabilityItem.test.tsx @@ -24,6 +24,13 @@ describe("CapabilityItem", () => { expect(screen.getByText("Resources (5)")).toBeInTheDocument(); }); + it("marks a partial count as a lower bound", () => { + renderWithMantine( + , + ); + expect(screen.getByText("Tools (10+)")).toBeInTheDocument(); + }); + it("falls back to capability key for unknown labels", () => { renderWithMantine( , diff --git a/clients/web/src/components/elements/CapabilityItem/CapabilityItem.tsx b/clients/web/src/components/elements/CapabilityItem/CapabilityItem.tsx index 34cf5b48e3..e7b8ad3d38 100644 --- a/clients/web/src/components/elements/CapabilityItem/CapabilityItem.tsx +++ b/clients/web/src/components/elements/CapabilityItem/CapabilityItem.tsx @@ -3,13 +3,20 @@ import type { ClientCapabilities, ServerCapabilities, } from "@modelcontextprotocol/client"; +import { formatCountedLabel } from "../../../utils/listCount"; export type CapabilityKey = keyof ServerCapabilities | keyof ClientCapabilities; export interface CapabilityItemProps { capability: CapabilityKey; supported: boolean; + /** Length of the capability's list result (`tools/list`, …) (#2607). */ count?: number; + /** + * True when `count` covers only the pages loaded so far — the server has a + * `nextCursor` still unloaded — so it renders as a lower bound (`10+`). + */ + partial?: boolean; } const displayLabel: Record = { @@ -29,9 +36,13 @@ export function CapabilityItem({ capability, supported, count, + partial = false, }: CapabilityItemProps) { const name = displayLabel[capability] ?? String(capability); - const label = count != null ? `${name} (${count})` : name; + const label = formatCountedLabel( + name, + count != null ? { count, partial } : undefined, + ); return ( diff --git a/clients/web/src/components/elements/EmbeddableScrollArea/EmbeddableScrollArea.test.tsx b/clients/web/src/components/elements/EmbeddableScrollArea/EmbeddableScrollArea.test.tsx index a718f797cc..df092b575b 100644 --- a/clients/web/src/components/elements/EmbeddableScrollArea/EmbeddableScrollArea.test.tsx +++ b/clients/web/src/components/elements/EmbeddableScrollArea/EmbeddableScrollArea.test.tsx @@ -32,6 +32,28 @@ describe("EmbeddableScrollArea", () => { expect(ref.current).toBeInstanceOf(HTMLElement); }); + it("always constrains the content width when embedded (#2675)", () => { + renderWithMantine( + +
sidebar content
+
, + ); + // No opt-in: every monitoring-sidebar panel gets the constraint, so a + // long non-wrapping row can't widen the list past the column. + const content = document.querySelector(".mantine-ScrollArea-content"); + expect((content as HTMLElement).style.minWidth).toBe("0"); + }); + + it("leaves the content width unconstrained full-size by default", () => { + renderWithMantine( + +
full-size content
+
, + ); + const content = document.querySelector(".mantine-ScrollArea-content"); + expect((content as HTMLElement).style.minWidth).toBe(""); + }); + it.each([false, true])( "constrains the content width when asked (embedded=%s)", (embedded) => { diff --git a/clients/web/src/components/elements/EmbeddableScrollArea/EmbeddableScrollArea.tsx b/clients/web/src/components/elements/EmbeddableScrollArea/EmbeddableScrollArea.tsx index ad9e87f9f8..d021ddc926 100644 --- a/clients/web/src/components/elements/EmbeddableScrollArea/EmbeddableScrollArea.tsx +++ b/clients/web/src/components/elements/EmbeddableScrollArea/EmbeddableScrollArea.tsx @@ -19,18 +19,24 @@ export interface EmbeddableScrollAreaProps { children: ReactNode; /** * Constrain the scrolled content to the viewport width instead of letting it - * grow to its own `max-content`. Mantine's ScrollArea `content` slot defaults - * to `min-width: max-content`, so a row with non-wrapping content (e.g. a long - * network URL) stretches every card past the column and it bleeds out (#1623). - * When true, the content can shrink to the viewport and each row must manage - * its own overflow (the Network URL scrolls inside its own inner ScrollArea). - * Left off for panels whose rows already wrap/truncate (Logs, Protocol), where - * the default lets a long line scroll the list horizontally instead. + * grow to its own intrinsic width. Mantine's autosize ScrollArea gives its + * `content` slot `min-width: min-content`, so a row with non-wrapping content + * (a long network URL, a long resource URI in a Protocol target) stretches + * every card past the column and it bleeds out (#1623, #2675). When + * constrained, the content can shrink to the viewport and each row must + * manage its own overflow (the Network URL and the Protocol target each + * scroll inside their own inner ScrollArea). + * + * Always on when `embedded`: nothing in a monitoring-sidebar entry may clip, + * whatever the tab, so the sidebar does not leave it to each panel to opt in + * (#2675 — Protocol never had, and clipped). This prop only matters + * full-size, where leaving it off lets a long line scroll the whole list + * horizontally instead. */ constrainContentWidth?: boolean; } -// Relax the `content` slot's default `min-width: max-content` so the list can't +// Relax the `content` slot's default intrinsic `min-width` so the list can't // grow wider than its viewport; see `constrainContentWidth`. const CONSTRAIN_CONTENT_STYLES = { content: { minWidth: 0 } } as const; @@ -57,7 +63,8 @@ export function EmbeddableScrollArea({ children, constrainContentWidth = false, }: EmbeddableScrollAreaProps) { - const styles = constrainContentWidth ? CONSTRAIN_CONTENT_STYLES : undefined; + const styles = + embedded || constrainContentWidth ? CONSTRAIN_CONTENT_STYLES : undefined; if (embedded) { return ( diff --git a/clients/web/src/components/elements/ListPaginationControls/ListPaginationControls.tsx b/clients/web/src/components/elements/ListPaginationControls/ListPaginationControls.tsx index 3ef192118b..9e2dad6cd1 100644 --- a/clients/web/src/components/elements/ListPaginationControls/ListPaginationControls.tsx +++ b/clients/web/src/components/elements/ListPaginationControls/ListPaginationControls.tsx @@ -14,6 +14,12 @@ export interface ListPaginationControlsProps { loadedPages: number; /** Paginated mode only: fetch the next page. */ onLoadMore: () => void; + /** + * Whether the list has finished its first load this connection. Not read by + * this control — it rides along so the list header beside it can withhold + * its count until there is one to show (#2607). Absent means loaded. + */ + loaded?: boolean; } // Switch fully left, "Load next page" fully right; the page-count sits on its diff --git a/clients/web/src/components/elements/LogEntry/LogEntry.tsx b/clients/web/src/components/elements/LogEntry/LogEntry.tsx index 3fb53a649a..b53472ada5 100644 --- a/clients/web/src/components/elements/LogEntry/LogEntry.tsx +++ b/clients/web/src/components/elements/LogEntry/LogEntry.tsx @@ -15,8 +15,9 @@ export interface LogEntryProps { entry: LogEntryData; /** * Compact two-line layout for the narrow monitoring sidebar (#1661): the - * timestamp, level, and logger sit on the first line and the message wraps - * onto the line below, so a long message isn't clipped by the column width. + * timestamp, level, and logger sit on the first line (a long logger wraps + * below the level) and the message wraps onto the line below, so neither is + * clipped by the column width. * The default (false) is the single-line row used on the full Logs screen. */ compact?: boolean; @@ -59,6 +60,15 @@ const LoggerText = Text.withProps({ c: "dimmed", }); +// The logger name in the compact (sidebar) row: breaks inside a long name via +// the `consoleLine` variant instead of squeezing the level badge (#2675). +const CompactLoggerText = Text.withProps({ + size: "xs", + ff: "monospace", + c: "dimmed", + variant: "consoleLine", +}); + // Single-line message (full Logs screen): sits inline with the meta on one row. const MessageText = Text.withProps({ size: "sm", @@ -73,11 +83,12 @@ const CompactMessageText = Text.withProps({ variant: "consoleLine", }); -// The compact meta row: timestamp + level + logger on one line above the -// message. `wrap: nowrap` keeps them on a single line; the message wraps below. +// The compact meta row: timestamp + level + logger above the message. It +// wraps, so a logger name too long for the line moves below the level badge +// rather than squeezing it until its label clips (#2675). const MetaRow = Group.withProps({ gap: "xs", - wrap: "nowrap", + wrap: "wrap", align: "center", }); @@ -90,9 +101,6 @@ const LogRow = Group.withProps({ export function LogEntry({ entry, compact = false }: LogEntryProps) { const { receivedAt, params } = entry; const message = formatData(params.data); - const logger = params.logger ? ( - {formatLogger(params.logger)} - ) : null; if (compact) { return ( @@ -100,7 +108,9 @@ export function LogEntry({ entry, compact = false }: LogEntryProps) { {formatTimestamp(receivedAt)} - {logger} + {params.logger && ( + {formatLogger(params.logger)} + )} {formatTimestamp(receivedAt)} - {logger} + {params.logger && {formatLogger(params.logger)}} {message} diff --git a/clients/web/src/components/groups/ConnectionInfoContent/ConnectionInfoContent.test.tsx b/clients/web/src/components/groups/ConnectionInfoContent/ConnectionInfoContent.test.tsx index 00d6b61d9b..954af8ce31 100644 --- a/clients/web/src/components/groups/ConnectionInfoContent/ConnectionInfoContent.test.tsx +++ b/clients/web/src/components/groups/ConnectionInfoContent/ConnectionInfoContent.test.tsx @@ -293,6 +293,46 @@ describe("ConnectionInfoContent", () => { expect(capabilityMark("Tasks")).toBe(UNSUPPORTED_MARK); }); + // #2607: the list-backed capabilities show how many entries the server + // returned, so a server author can check a per-server tool cap at a glance. + it("shows list counts beside the list-backed server capabilities", () => { + renderWithMantine( + , + ); + expect(capabilityMark("Tools (15)")).toBe(SUPPORTED_MARK); + expect(capabilityMark("Prompts (10+)")).toBe(SUPPORTED_MARK); + expect(capabilityMark("Resources (0)")).toBe(SUPPORTED_MARK); + // A capability with no list carries no count. + expect(capabilityMark("Logging")).toBe(SUPPORTED_MARK); + }); + + it("shows the bare name when a list has no count, or is unsupported", () => { + renderWithMantine( + , + ); + expect(capabilityMark("Tools")).toBe(SUPPORTED_MARK); + // Prompts is unsupported, so its stale count is not shown. + expect(capabilityMark("Prompts")).toBe(UNSUPPORTED_MARK); + expect(screen.queryByText("Prompts (3)")).not.toBeInTheDocument(); + }); + it("renders instructions when present", () => { renderWithMantine( void; } +/** The server capabilities whose support is backed by a list result (`tools/list`, …). */ +export type ListCounts = Partial< + Record<"tools" | "prompts" | "resources", ListCount> +>; + // Label/value pairs in this modal read label-bold, value-normal: the label is // the fixed scaffolding a reader scans down, and the value is the thing that // differs per connection. The reverse (which this was until #2328) bolded every @@ -302,6 +314,20 @@ function getServerCapabilityEntries( }); } +function getListCount( + listCounts: ListCounts | undefined, + capability: CapabilityKey, +): ListCount | undefined { + if ( + capability !== "tools" && + capability !== "prompts" && + capability !== "resources" + ) { + return undefined; + } + return listCounts?.[capability]; +} + export function ConnectionInfoContent({ initializeResult, serverInfoReported = true, @@ -310,6 +336,7 @@ export function ConnectionInfoContent({ protocolEra, discoverResult, diagnostics, + listCounts, oauth, onClearOAuth, }: ConnectionInfoContentProps) { @@ -429,13 +456,22 @@ export function ConnectionInfoContent({ Server Capabilities - {serverCaps.map((cap) => ( - - ))} + {serverCaps.map((cap) => { + // A count only means something beside a ✓ — an unsupported + // capability has no list to count. + const listCount = cap.supported + ? getListCount(listCounts, cap.capability) + : undefined; + return ( + + ); + })} Client Capabilities diff --git a/clients/web/src/components/groups/ConnectionInfoModal/ConnectionInfoModal.test.tsx b/clients/web/src/components/groups/ConnectionInfoModal/ConnectionInfoModal.test.tsx index 041745f39b..81eb63f3a1 100644 --- a/clients/web/src/components/groups/ConnectionInfoModal/ConnectionInfoModal.test.tsx +++ b/clients/web/src/components/groups/ConnectionInfoModal/ConnectionInfoModal.test.tsx @@ -56,6 +56,21 @@ describe("ConnectionInfoModal", () => { expect(screen.getByText("Be excellent to each other.")).toBeInTheDocument(); }); + it("forwards list counts to ConnectionInfoContent (#2607)", () => { + renderWithMantine( + , + ); + expect(screen.getByText("Tools (70)")).toBeInTheDocument(); + }); + it("forwards connection diagnostics to ConnectionInfoContent (#2318)", () => { renderWithMantine( void; } @@ -55,6 +58,7 @@ export function ConnectionInfoModal({ protocolEra, discoverResult, diagnostics, + listCounts, oauth, onClearOAuth, }: ConnectionInfoModalProps) { @@ -84,6 +88,7 @@ export function ConnectionInfoModal({ protocolEra={protocolEra} discoverResult={discoverResult} diagnostics={diagnostics} + listCounts={listCounts} oauth={oauth} onClearOAuth={onClearOAuth} /> diff --git a/clients/web/src/components/groups/LogStreamPanel/LogStreamPanel.stories.tsx b/clients/web/src/components/groups/LogStreamPanel/LogStreamPanel.stories.tsx index a7545dc375..1002394cb5 100644 --- a/clients/web/src/components/groups/LogStreamPanel/LogStreamPanel.stories.tsx +++ b/clients/web/src/components/groups/LogStreamPanel/LogStreamPanel.stories.tsx @@ -3,7 +3,11 @@ import type { LoggingLevel } from "@modelcontextprotocol/client"; import { fn } from "storybook/test"; import { LogStreamPanel } from "./LogStreamPanel"; import type { LogEntryData } from "../../elements/LogEntry/LogEntry"; -import { expectScrollbarGutterIdleHidden } from "../../../test/scrollAreaStoryAssertions"; +import { + expectNoHorizontalOverflow, + expectScrollbarGutterIdleHidden, +} from "../../../test/scrollAreaStoryAssertions"; +import { narrowSidebarDecorator } from "../../../test/narrowSidebarDecorator"; const meta: Meta = { title: "Groups/LogStreamPanel", @@ -80,3 +84,29 @@ export const WithEntries: Story = { await expectScrollbarGutterIdleHidden(canvasElement); }, }; + +// A long unbroken token (a URI, a hash) and a long logger name in the sidebar +// must wrap or shrink inside the row, never widen the list (#2675). +export const EmbeddedLongContent: Story = { + args: { + embedded: true, + entries: [ + { + receivedAt: new Date("2026-03-17T10:00:01Z"), + params: { + level: "info", + logger: "resources.minted.gzip-file-as-resource", + data: "Minted demo://resource/minted/c05df264-ed9c-44a4-a81d-c2d7f3b9e1a0/gzip-file-as-resource/compressed-payload.gz", + }, + }, + { + receivedAt: new Date("2026-03-17T10:00:02Z"), + params: { level: "debug", data: "short line" }, + }, + ], + }, + decorators: [narrowSidebarDecorator], + play: async ({ canvasElement }) => { + await expectNoHorizontalOverflow(canvasElement); + }, +}; diff --git a/clients/web/src/components/groups/NetworkEntry/NetworkEntry.tsx b/clients/web/src/components/groups/NetworkEntry/NetworkEntry.tsx index b19fe362e1..a1ab12fb13 100644 --- a/clients/web/src/components/groups/NetworkEntry/NetworkEntry.tsx +++ b/clients/web/src/components/groups/NetworkEntry/NetworkEntry.tsx @@ -89,16 +89,36 @@ const UrlScroll = Text.withProps({ variant: "nowrap", }); -// Left / right clusters for a compact header line (mirrors ProtocolEntry). -const HeaderCluster = Group.withProps({ +// The wide header's right-hand cluster. +const ControlsCluster = Group.withProps({ gap: "sm", wrap: "nowrap", +}); + +// The compact header's first line. It wraps, so at the narrowest sidebar +// (320px), where the badges and the duration/status cannot share one line, the +// status drops onto a line of its own instead of squeezing every badge until +// its label clips (#2675). +const CompactHeaderLine = Group.withProps({ + justify: "space-between", + wrap: "wrap", + gap: "xs", +}); + +// The compact first line's timestamp + badges. Every item is short, so the +// cluster wraps them rather than truncating the badges when they outgrow the +// line (#2675). +const CompactHeaderCluster = Group.withProps({ + gap: "sm", + wrap: "wrap", miw: 0, }); -const ControlsCluster = Group.withProps({ +// Right-aligned even when the line wraps it below the badges. +const CompactControlsCluster = Group.withProps({ gap: "sm", wrap: "nowrap", + ml: "auto", }); const DurationText = Text.withProps({ @@ -571,17 +591,17 @@ export function NetworkEntry({ {embedded ? ( // Compact two-line header for the narrow column. - - + + {formatTimestampCompact(entry.timestamp)} {phaseBadge} - - {metaBadges} - + + {metaBadges} + diff --git a/clients/web/src/components/groups/NetworkStreamPanel/NetworkStreamPanel.stories.tsx b/clients/web/src/components/groups/NetworkStreamPanel/NetworkStreamPanel.stories.tsx index e006e26132..1ffa4cb9b9 100644 --- a/clients/web/src/components/groups/NetworkStreamPanel/NetworkStreamPanel.stories.tsx +++ b/clients/web/src/components/groups/NetworkStreamPanel/NetworkStreamPanel.stories.tsx @@ -2,7 +2,11 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import { fn } from "storybook/test"; import type { FetchRequestEntry } from "../../../../../../core/mcp/types.js"; import { NetworkStreamPanel } from "./NetworkStreamPanel"; -import { expectScrollbarGutterIdleHidden } from "../../../test/scrollAreaStoryAssertions"; +import { + expectNoHorizontalOverflow, + expectScrollbarGutterIdleHidden, +} from "../../../test/scrollAreaStoryAssertions"; +import { narrowSidebarDecorator } from "../../../test/narrowSidebarDecorator"; const meta: Meta = { title: "Groups/NetworkStreamPanel", @@ -82,14 +86,12 @@ const longUrlSample: FetchRequestEntry[] = [ ]; // Reproduces the pinned-column overflow: a long URL must scroll horizontally -// inside its own area rather than widening the card past the column (#1623). +// inside its own area rather than widening the card past the column (#1623), +// held to the sidebar's narrowest width (#2675). export const EmbeddedLongUrls: Story = { args: { entries: longUrlSample, embedded: true }, - decorators: [ - (Story) => ( -
- -
- ), - ], + decorators: [narrowSidebarDecorator], + play: async ({ canvasElement }) => { + await expectNoHorizontalOverflow(canvasElement); + }, }; diff --git a/clients/web/src/components/groups/PromptControls/PromptControls.test.tsx b/clients/web/src/components/groups/PromptControls/PromptControls.test.tsx index 3fef265399..cc2570e411 100644 --- a/clients/web/src/components/groups/PromptControls/PromptControls.test.tsx +++ b/clients/web/src/components/groups/PromptControls/PromptControls.test.tsx @@ -50,7 +50,7 @@ function ControlledPromptControls(props: Partial) { describe("PromptControls", () => { it("renders the title and search input", () => { renderWithMantine(); - expect(screen.getByText("Prompts")).toBeInTheDocument(); + expect(screen.getByText("Prompts (5)")).toBeInTheDocument(); expect( screen.getByPlaceholderText("Search prompts..."), ).toBeInTheDocument(); @@ -157,10 +157,48 @@ describe("PromptControls", () => { it("renders empty list when no prompts", () => { renderWithMantine(); - expect(screen.getByText("Prompts")).toBeInTheDocument(); + expect(screen.getByText("Prompts (0)")).toBeInTheDocument(); expect(screen.queryByText("translate")).not.toBeInTheDocument(); }); + // The header counts the server's whole list, so a search narrowing the view + // must not change it (#2607). + it("counts every prompt, not just the ones the search matches", () => { + renderWithMantine(); + expect(screen.getByText("Prompts (5)")).toBeInTheDocument(); + }); + + it("marks the count as a lower bound while more pages remain", () => { + renderWithMantine( + , + ); + expect(screen.getByText("Prompts (5+)")).toBeInTheDocument(); + }); + + // The connect-time load is still in flight: the empty list is not a total. + it("omits the count until the first load completes", () => { + renderWithMantine( + , + ); + expect(screen.getByText("Prompts")).toBeInTheDocument(); + expect(screen.queryByText("Prompts (0)")).not.toBeInTheDocument(); + }); + + it("omits the count after a failed load", () => { + renderWithMantine( + , + ); + expect(screen.getByText("Prompts")).toBeInTheDocument(); + expect(screen.queryByText("Prompts (0)")).not.toBeInTheDocument(); + }); + // A failed load is rendered above the list instead of leaving the panel // empty, which is indistinguishable from a server that has none (#1953). it("renders a failed load above the list and retries via onRefreshList", async () => { diff --git a/clients/web/src/components/groups/PromptControls/PromptControls.tsx b/clients/web/src/components/groups/PromptControls/PromptControls.tsx index 2fbb5633d6..896d3ade1c 100644 --- a/clients/web/src/components/groups/PromptControls/PromptControls.tsx +++ b/clients/web/src/components/groups/PromptControls/PromptControls.tsx @@ -11,6 +11,7 @@ import { } from "../../elements/ListPaginationControls/ListPaginationControls"; import { PromptListItem } from "../PromptListItem/PromptListItem"; import { useScrollMemory } from "../../../hooks/useScrollMemory"; +import { formatCountedLabel, listCountOf } from "../../../utils/listCount"; // Fill the full-height `sidebar` Card (a flex column) so the list runs to the // bottom of the card before it scrolls, instead of being capped short by a @@ -77,10 +78,23 @@ export function PromptControls({ (p.description?.toLowerCase().includes(query) ?? false), ); + // The server's total, not the search-filtered view (#2607); omitted until the + // first load completes and after a failed one, where "(0)" would read as an + // empty server. + const title = formatCountedLabel( + "Prompts", + listCountOf({ + items: prompts, + canLoadMore: pagination.canLoadMore, + error: loadError ?? null, + loaded: pagination.loaded ?? true, + }), + ); + return ( - Prompts + {title} {id}; + return ( + + sub + + {scrollable ? {idText} : idText} + + ); +} + export function ProtocolEntry({ entry, isPinned, @@ -389,11 +451,7 @@ export function ProtocolEntry({ ); const subscriptionBadge = subscriptionId && ( - - sub - - {subscriptionId} - + ); const durationText = entry.duration != null && ( {formatDuration(entry.duration)} @@ -413,14 +471,14 @@ export function ProtocolEntry({ {embedded ? ( // Compact two-line header for the narrow column. - + {formatTimestampCompact(entry.timestamp)} {directionBadge} - + {durationText} {resultTypeBadge} {statusBadge} @@ -429,29 +487,29 @@ export function ProtocolEntry({ method badge on the line below gets the full column width and doesn't truncate against the pin control (#1630). */} {subscriptionBadge} - - - - + + + + {target && ( - <> + {resourceUri && } {target} - + )} - - + + {replayControls} setIsExpanded((v) => !v)} /> - - + + ) : ( <> diff --git a/clients/web/src/components/groups/ProtocolListPanel/ProtocolListPanel.stories.tsx b/clients/web/src/components/groups/ProtocolListPanel/ProtocolListPanel.stories.tsx index 861f23c249..d4c0e38c1a 100644 --- a/clients/web/src/components/groups/ProtocolListPanel/ProtocolListPanel.stories.tsx +++ b/clients/web/src/components/groups/ProtocolListPanel/ProtocolListPanel.stories.tsx @@ -2,7 +2,11 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import type { MessageEntry } from "../../../../../../core/mcp/types.js"; import { fn } from "storybook/test"; import { ProtocolListPanel } from "./ProtocolListPanel.js"; -import { expectScrollbarGutterIdleHidden } from "../../../test/scrollAreaStoryAssertions"; +import { + expectNoHorizontalOverflow, + expectScrollbarGutterIdleHidden, +} from "../../../test/scrollAreaStoryAssertions"; +import { narrowSidebarDecorator } from "../../../test/narrowSidebarDecorator"; const meta: Meta = { title: "Groups/ProtocolListPanel", @@ -190,3 +194,69 @@ export const ModernWithMrtr: Story = { compact: false, }, }; + +// The reported case (#2675): a `resources/read` of a minted resource whose URI +// is far wider than the sidebar, beside short entries, plus a push +// notification carrying a long subscription id. The URI must scroll +// inside its own row; no card — short ones included — may widen past the +// column, and every card's controls must stay on screen. +const LONG_URI = + "demo://resource/minted/c05df264-ed9c-44a4-a81d-c2d7f3b9e1a0/gzip-file-as-resource/compressed-payload-with-a-very-long-name.gz"; + +const longTargetEntries: MessageEntry[] = [ + sampleEntries[0], + { + id: "req-long-uri", + timestamp: new Date("2026-03-17T10:03:00Z"), + direction: "request", + origin: "client", + message: { + jsonrpc: "2.0", + id: 5, + method: "resources/read", + params: { uri: LONG_URI }, + }, + response: { + jsonrpc: "2.0", + id: 5, + result: { contents: [{ uri: LONG_URI, text: "H4sIAAAAAAAAA..." }] }, + }, + duration: 12, + }, + sampleEntries[3], + { + id: "notif-long-sub", + timestamp: new Date("2026-03-17T10:04:00Z"), + direction: "notification", + origin: "server", + message: { + jsonrpc: "2.0", + method: "notifications/resources/updated", + params: { + uri: LONG_URI, + _meta: { + "io.modelcontextprotocol/subscriptionId": + "listen:c05df264-ed9c-44a4-a81d-c2d7f3b9e1a0", + }, + }, + }, + }, +]; + +export const EmbeddedLongTarget: Story = { + args: { entries: longTargetEntries, embedded: true }, + decorators: [narrowSidebarDecorator], + play: async ({ canvasElement }) => { + await expectNoHorizontalOverflow(canvasElement); + }, +}; + +// Same entries expanded, so the request/response JSON — which repeats the URI — +// is held to the same rule as the header. +export const EmbeddedLongTargetExpanded: Story = { + args: { entries: longTargetEntries, embedded: true, compact: false }, + decorators: [narrowSidebarDecorator], + play: async ({ canvasElement }) => { + await expectNoHorizontalOverflow(canvasElement); + }, +}; diff --git a/clients/web/src/components/groups/SchemaForm/SchemaForm.stories.tsx b/clients/web/src/components/groups/SchemaForm/SchemaForm.stories.tsx index 345513b76f..5b767e5d1d 100644 --- a/clients/web/src/components/groups/SchemaForm/SchemaForm.stories.tsx +++ b/clients/web/src/components/groups/SchemaForm/SchemaForm.stories.tsx @@ -259,3 +259,48 @@ export const RawJsonToggle: Story = { await expect(canvas.getByLabelText(/^Name$/)).toBeVisible(); }, }; + +/** + * An array whose items are objects, edited as rows (#2561). Each row is the + * item schema's own sub-form, so field names, types, required markers and + * descriptions are all on screen instead of behind a blank JSON editor. + */ +export const ArrayOfObjects: Story = { + args: { + schema: { + type: "object", + properties: { + email: { type: "string", title: "Email" }, + items: { + type: "array", + title: "Items", + description: "The line items to return.", + items: { + type: "object", + properties: { + sku: { type: "string", description: "Product SKU" }, + quantity: { type: "integer", description: "Units to return" }, + }, + required: ["sku", "quantity"], + }, + }, + }, + required: ["email", "items"], + }, + values: { + email: "customer@example.com", + items: [ + { sku: "A-100", quantity: 2 }, + { sku: "B-200", quantity: 1 }, + ], + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.getByText("Item 2")).toBeVisible(); + await expect(canvas.getAllByText("Product SKU")).toHaveLength(2); + await expect( + canvas.getByRole("button", { name: "Add item to Items" }), + ).toBeEnabled(); + }, +}; diff --git a/clients/web/src/components/groups/SchemaForm/SchemaForm.test.tsx b/clients/web/src/components/groups/SchemaForm/SchemaForm.test.tsx index 45df1ddc6e..3c10c63e2f 100644 --- a/clients/web/src/components/groups/SchemaForm/SchemaForm.test.tsx +++ b/clients/web/src/components/groups/SchemaForm/SchemaForm.test.tsx @@ -3565,3 +3565,434 @@ describe("SchemaForm enlarge keyboard access (#2138)", () => { expect(noteField().tagName).toBe("INPUT"); }); }); + +describe("SchemaForm array-of-object fields (#2561)", () => { + // The issue's own example, as Rust `schemars` emits it: the item shape lives + // under `$defs` and the array points at it. Through `toFormSchema`, as the + // Tools panel does, so the local `$ref` is inlined first. + const issueSchema = toFormSchema({ + type: "object", + properties: { + email: { type: "string" }, + items: { type: "array", items: { $ref: "#/$defs/Item" } }, + }, + required: ["email", "items"], + $defs: { + Item: { + type: "object", + properties: { + sku: { type: "string", description: "Product SKU" }, + quantity: { type: "integer", description: "Units to return" }, + }, + required: ["sku", "quantity"], + }, + }, + })!; + + function Harness({ + schema, + initial = {}, + onValues, + onValidityChange, + }: { + schema: InspectorFormSchema; + initial?: Record; + onValues?: (values: Record) => void; + onValidityChange?: (invalid: boolean) => void; + }) { + const [values, setValues] = useState>(initial); + return ( + { + setValues(next); + onValues?.(next); + }} + onValidityChange={onValidityChange} + /> + ); + } + + it("renders a row per item with the item schema's labels and descriptions", () => { + renderWithMantine( + , + ); + expect(screen.getByText("Item 1")).toBeInTheDocument(); + expect(screen.getByRole("textbox", { name: /sku/ })).toHaveValue("A1"); + expect(screen.getByRole("textbox", { name: /quantity/ })).toHaveValue("2"); + expect(screen.getByText("Product SKU")).toBeInTheDocument(); + expect(screen.getByText("Units to return")).toBeInTheDocument(); + expect(screen.queryByLabelText("items")).not.toBeInTheDocument(); + }); + + it("shows an empty list with an add button when there are no items", () => { + renderWithMantine( + , + ); + expect(screen.getByText("No items")).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Add item to items" }), + ).toBeEnabled(); + }); + + it("treats an explicit null as an empty list", () => { + renderWithMantine( + , + ); + expect(screen.getByText("No items")).toBeInTheDocument(); + }); + + it("adds, edits and removes items", async () => { + const user = userEvent.setup(); + const onValues = vi.fn(); + renderWithMantine(); + + await user.click(screen.getByRole("button", { name: "Add item to items" })); + expect(onValues).toHaveBeenLastCalledWith({ items: [{}] }); + + await user.type(screen.getByRole("textbox", { name: /sku/ }), "A1"); + await user.type(screen.getByRole("textbox", { name: /quantity/ }), "3"); + expect(onValues).toHaveBeenLastCalledWith({ + items: [{ sku: "A1", quantity: 3 }], + }); + + await user.click(screen.getByRole("button", { name: "Add item to items" })); + expect(onValues).toHaveBeenLastCalledWith({ + items: [{ sku: "A1", quantity: 3 }, {}], + }); + + await user.click( + screen.getByRole("button", { name: "Remove item 1 from items" }), + ); + expect(onValues).toHaveBeenLastCalledWith({ items: [{}] }); + expect(screen.getAllByRole("textbox", { name: /sku/ })[0]).toHaveValue(""); + }); + + it("seeds a new item with the item schema's defaults and constants", async () => { + const user = userEvent.setup(); + const onValues = vi.fn(); + const schema: InspectorFormSchema = { + type: "object", + properties: { + lines: { + type: "array", + items: { + type: "object", + properties: { + kind: { type: "string", const: "line" }, + quantity: { type: "integer", default: 1 }, + }, + required: ["kind"], + }, + }, + }, + }; + renderWithMantine(); + await user.click(screen.getByRole("button", { name: "Add item to lines" })); + expect(onValues).toHaveBeenLastCalledWith({ + lines: [{ kind: "line", quantity: 1 }], + }); + }); + + it("keeps a row's local state with that row when one above is removed", async () => { + const user = userEvent.setup(); + renderWithMantine( + , + ); + // Enlarging a string field is state the row's own form holds, so it shows + // whether the row that survives is the same mounted row. + await user.click(screen.getAllByRole("textbox", { name: /sku/ })[1]); + await user.keyboard("{Enter}"); + expect(screen.getAllByRole("textbox", { name: /sku/ })[1].tagName).toBe( + "TEXTAREA", + ); + + await user.click( + screen.getByRole("button", { name: "Remove item 1 from items" }), + ); + const remaining = screen.getByRole("textbox", { name: /sku/ }); + expect(remaining.tagName).toBe("TEXTAREA"); + expect(remaining).toHaveValue("second\n"); + }); + + it("honors maxItems and minItems on the add and remove buttons", async () => { + const user = userEvent.setup(); + const schema: InspectorFormSchema = { + type: "object", + properties: { + pair: { + type: "array", + minItems: 1, + maxItems: 2, + items: { type: "object", properties: { x: { type: "string" } } }, + }, + }, + }; + renderWithMantine(); + const add = screen.getByRole("button", { name: "Add item to pair" }); + expect( + screen.getByRole("button", { name: "Remove item 1 from pair" }), + ).toBeDisabled(); + + await user.click(add); + expect(add).toBeDisabled(); + expect( + screen.getByRole("button", { name: "Remove item 1 from pair" }), + ).toBeEnabled(); + }); + + it("disables the add and remove buttons when the form is disabled", () => { + renderWithMantine( + , + ); + expect( + screen.getByRole("button", { name: "Add item to items" }), + ).toBeDisabled(); + expect( + screen.getByRole("button", { name: "Remove item 1 from items" }), + ).toBeDisabled(); + }); + + it("follows an external change to the array's length", () => { + const { rerender } = renderWithMantine( + , + ); + rerender( + , + ); + expect(screen.getAllByRole("textbox", { name: /sku/ })).toHaveLength(3); + rerender( + , + ); + expect(screen.getByText("No items")).toBeInTheDocument(); + }); + + it("surfaces a row's invalid draft through the outer form, and clears it when the row goes", async () => { + const user = userEvent.setup(); + const onValidityChange = vi.fn(); + renderWithMantine( + , + ); + await user.type(screen.getByRole("textbox", { name: /quantity/ }), "-"); + expect(onValidityChange).toHaveBeenLastCalledWith(true); + + await user.click( + screen.getByRole("button", { name: "Remove item 1 from items" }), + ); + expect(onValidityChange).toHaveBeenLastCalledWith(false); + }); + + it("renders rows for a nullable item schema", () => { + const schema: InspectorFormSchema = { + type: "object", + properties: { + rows: { + type: "array", + items: { + anyOf: [ + { type: "object", properties: { name: { type: "string" } } }, + { type: "null" }, + ], + }, + }, + }, + }; + renderWithMantine( + , + ); + expect(screen.getByRole("textbox", { name: /name/ })).toHaveValue("n"); + }); + + it("seeds every row that mounts missing a default, not just the last", async () => { + // Two rows seeding themselves in the same commit would each report through + // a closure over the original array, so only the last correction survived + // while both rows displayed the default. + const onValues = vi.fn(); + const schema: InspectorFormSchema = { + type: "object", + properties: { + lines: { + type: "array", + items: { + type: "object", + properties: { + kind: { type: "string", const: "line" }, + quantity: { type: "integer", default: 1 }, + }, + required: ["kind"], + }, + }, + }, + }; + renderWithMantine( + , + ); + await waitFor(() => + expect(onValues).toHaveBeenLastCalledWith({ + lines: [ + { quantity: 1, kind: "line" }, + { quantity: 1, kind: "line" }, + ], + }), + ); + }); + + it("seeds nested defaults in every row holding a partial nested object", async () => { + const onValues = vi.fn(); + const schema: InspectorFormSchema = { + type: "object", + properties: { + lines: { + type: "array", + items: { + type: "object", + properties: { + shipping: { + type: "object", + properties: { + method: { type: "string", default: "ground" }, + note: { type: "string" }, + }, + }, + }, + }, + }, + }, + }; + renderWithMantine( + , + ); + await waitFor(() => + expect(onValues).toHaveBeenLastCalledWith({ + lines: [ + { shipping: { method: "ground", note: "a" } }, + { shipping: { method: "ground", note: "b" } }, + ], + }), + ); + }); + + it("seeds defaults from the union branch each row's values select", async () => { + const onValues = vi.fn(); + const schema: InspectorFormSchema = { + type: "object", + properties: { + contacts: { + type: "array", + items: { + type: "object", + properties: { label: { type: "string" } }, + oneOf: [ + { + type: "object", + properties: { + kind: { type: "string", const: "email" }, + format: { type: "string", default: "html" }, + }, + required: ["kind"], + }, + { + type: "object", + properties: { + kind: { type: "string", const: "sms" }, + carrier: { type: "string", default: "any" }, + }, + required: ["kind"], + }, + ], + }, + }, + }, + }; + renderWithMantine( + , + ); + await waitFor(() => + expect(onValues).toHaveBeenLastCalledWith({ + contacts: [{ kind: "sms", label: "x", carrier: "any" }], + }), + ); + expect(onValues.mock.lastCall?.[0].contacts[0]).not.toHaveProperty( + "format", + ); + }); + + it("names each row's card after its visible title", () => { + renderWithMantine( + , + ); + const second = screen.getByRole("group", { name: "Item 2" }); + expect(second).toContainElement( + screen.getAllByRole("textbox", { name: /sku/ })[1], + ); + }); + + it("keeps the JSON editor for a value the rows cannot show", () => { + renderWithMantine( + , + ); + expect( + screen.queryByRole("button", { name: "Add item to items" }), + ).not.toBeInTheDocument(); + expect(getAceTextByLabel(/items/)).toContain("7"); + }); +}); diff --git a/clients/web/src/components/groups/SchemaForm/SchemaForm.tsx b/clients/web/src/components/groups/SchemaForm/SchemaForm.tsx index bfaade84c6..89ee5be8cd 100644 --- a/clients/web/src/components/groups/SchemaForm/SchemaForm.tsx +++ b/clients/web/src/components/groups/SchemaForm/SchemaForm.tsx @@ -1,8 +1,11 @@ import { + Button, Checkbox, Group, + Input, MultiSelect, NumberInput, + Paper, Select, Stack, Switch, @@ -15,6 +18,7 @@ import { import { useCallback, useEffect, + useId, useLayoutEffect, useRef, useState, @@ -52,6 +56,7 @@ import { import { applySchemaConstants, collectSchemaDefaults, + seedSchemaValues, } from "../../../utils/jsonUtils"; const FieldLabel = Text.withProps({ @@ -617,6 +622,271 @@ function SchemaNumberInput({ ); } +// One row of an array-of-objects field (#2561): a bordered card holding the +// item's own sub-form, so where one item ends and the next begins is visible. +const ItemCard = Paper.withProps({ withBorder: true, p: "sm" }); + +// The row's header line — its ordinal on the left, its remove button on the +// right. +const ItemHeader = Group.withProps({ + justify: "space-between", + wrap: "nowrap", +}); + +const ItemTitle = Text.withProps({ size: "xs", fw: 500, c: "dimmed" }); + +const RemoveItemButton = Button.withProps({ + variant: "subtle", + color: "red", + size: "compact-xs", +}); + +const AddItemButton = Button.withProps({ variant: "light", size: "xs" }); + +// Column holding the rows and the add button, below the field's own label. +const ItemList = Stack.withProps({ gap: "xs", mt: "xs" }); + +/** + * Whether a value can be shown as rows of an array-of-objects field: absent, + * `null` (a nullable array with no answer yet), or an array whose every member + * is a plain object. Anything else — a scalar member, a nested array, a value + * that is not an array at all — has no faithful row rendering, so the field + * keeps its JSON editor rather than displaying something other than what would + * be sent. + */ +function isObjectList(value: unknown): boolean { + if (value === undefined || value === null) return true; + return Array.isArray(value) && value.every(isJsonObject); +} + +/** + * Stable React keys for an array-of-objects field's rows, held alongside the + * array rather than derived from the index. Index keys would hand a removed + * row's local state — a half-typed number, an unparsed JSON draft — to the row + * that slides up into its place. + */ +interface RowKeys { + keys: number[]; + next: number; +} + +/** Grow or shrink the row keys to match `length`, keeping the ones that stay. */ +function fitRowKeys(state: RowKeys, length: number): RowKeys { + if (state.keys.length === length) return state; + if (state.keys.length > length) { + return { ...state, keys: state.keys.slice(0, length) }; + } + const added = Array.from( + { length: length - state.keys.length }, + (_, offset) => state.next + offset, + ); + return { keys: [...state.keys, ...added], next: state.next + added.length }; +} + +/** + * One row's values with the item schema's defaults filled in where absent — + * recursively into nested objects, and from the branch the row's values + * select when the item is a union — then its constants applied. The same + * seeding each row's own `SchemaForm` would otherwise do for itself on mount, + * done here for every row at once (see {@link ObjectArrayField}). + */ +function seedItem( + itemSchema: InspectorFormSchema, + item: Record, +): Record { + return applySchemaConstants(itemSchema, seedSchemaValues(itemSchema, item)); +} + +interface ObjectArrayFieldProps { + fieldName: string; + label: string; + description?: string; + withAsterisk: boolean; + disabled: boolean; + /** The field's value with its default resolved; see {@link isObjectList}. */ + value: unknown; + /** The `items` schema, already known to be an object with properties. */ + itemSchema: InspectorFormSchema; + minItems?: number; + maxItems?: number; + /** Prefix for each row's nested form `resetKey`. */ + resetKey?: string; + onChange: (value: Record[]) => void; + onValidityChange: DraftValidityReporter; +} + +/** + * An array whose items are objects, edited as a list of rows (#2561). + * + * Before this, such a field fell through to the JSON editor, which shows + * nothing of the item shape — no field names, types, required markers or + * descriptions — although the schema carries all of them, and list-shaped + * inputs are often a tool's most important argument. Each row is the same + * indented sub-form a nested object field already renders, so every item gets + * those labels for free. + * + * Rows are seeded with the item schema's defaults and constants here, for the + * whole array at once, rather than left for each row's own form to seed on + * mount: that seeding reports through a closure over the array as it was when + * the row rendered, so two rows seeding in the same commit would each + * overwrite the other's correction — leaving a row displaying a default the + * submitted payload does not carry. Each row's form is handed its seeded + * values, so it finds nothing to seed itself. + * + * `minItems`/`maxItems` gate the remove and add buttons, so the list cannot be + * taken outside the length the schema states. + */ +function ObjectArrayField({ + fieldName, + label, + description, + withAsterisk, + disabled, + value, + itemSchema, + minItems, + maxItems, + resetKey, + onChange, + onValidityChange, +}: ObjectArrayFieldProps) { + const items = Array.isArray(value) + ? (value as Record[]) + : []; + const seededItems = items.map((item) => seedItem(itemSchema, item)); + // Compared as JSON: `applySchemaConstants` returns a fresh object whenever + // the schema pins a field, even when the value already matches. + const seededJson = serializeJson(seededItems); + const needsSeed = seededJson !== serializeJson(items); + + // Report the seeded array upward when what needs seeding changes. An effect, + // not a render-time call, because `onChange` belongs to the parent; read + // through a ref so the parent's fresh closure each render is not a + // dependency — the same shape `SchemaForm`'s own seeding uses. + const latestSeed = useRef({ seededItems, onChange }); + useEffect(() => { + latestSeed.current = { seededItems, onChange }; + }); + useEffect(() => { + if (needsSeed) latestSeed.current.onChange(latestSeed.current.seededItems); + }, [needsSeed, seededJson]); + + // Names each row's card after its visible "Item N" title, so a screen reader + // announces which row a repeated "sku" or "quantity" control belongs to. + const titleIdPrefix = useId(); + + const [rowKeys, setRowKeys] = useState(() => + fitRowKeys({ keys: [], next: 0 }, items.length), + ); + // An external change of length — a cleared form, a loaded example, the raw + // JSON editor — re-fits the keys. The add and remove handlers fit them + // themselves, so for those this finds nothing to do. + useValueChange(items.length, (length) => + setRowKeys((previous) => fitRowKeys(previous, length)), + ); + + // Which rows hold a draft that cannot be sent. Keyed by row key, not index, + // so removing a row above an invalid one cannot move the block onto the + // wrong row. + const [invalidRows, setInvalidRows] = useState>( + () => new Set(), + ); + const reportRowValidity = useCallback((rowKey: number, invalid: boolean) => { + setInvalidRows((previous) => { + if (previous.has(rowKey) === invalid) return previous; + const next = new Set(previous); + if (invalid) next.add(rowKey); + else next.delete(rowKey); + return next; + }); + }, []); + useDraftValidity(fieldName, invalidRows.size === 0, onValidityChange); + + const canAdd = maxItems === undefined || items.length < maxItems; + const canRemove = minItems === undefined || items.length > minItems; + + function addItem() { + setRowKeys((previous) => fitRowKeys(previous, items.length + 1)); + onChange([...seededItems, seedItem(itemSchema, {})]); + } + + function removeItem(index: number) { + setRowKeys((previous) => ({ + ...previous, + keys: previous.keys.filter((_, position) => position !== index), + })); + onChange(seededItems.filter((_, position) => position !== index)); + } + + function updateItem(index: number, item: Record) { + onChange( + seededItems.map((existing, position) => + position === index ? item : existing, + ), + ); + } + + return ( + // A wrapper rather than a bare label: it gives the field the same label, + // required marker and description every other field has. Rendered as a + // `div` because there is no single control for a `