diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 569593243..352977432 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -78,8 +78,10 @@ Both exist and do different jobs. Theme files (`src/theme/.ts`) custo - **The MCP SDK packages (`@modelcontextprotocol/client`, `core`, `server`, `server-legacy`, `ext-apps`) belong in the root `package.json` only.** Adding one to a `clients/*/package.json` is a review finding: Node resolution walks up, so the root install already serves every client, and a per-client entry installs a second copy that drifts (it produced two versions of `ext-apps`, and of the transitive v1 SDK, at once — #1970) and reintroduces the duplicate-copy failure `vitest.shared.mts` has a `dedupe` workaround for. - **The v1 `@modelcontextprotocol/sdk` is not a dependency of this repo** and must not become one. It is a peer of `ext-apps`, present in lock files only. - Dependencies reached only through **root-owned code with no manifest** (`test-servers/src`, `core/`) are declared at the root and aliased to the **repo root** in `vitest.shared.mts` — as `express` and `yaml` are — not to `/node_modules` like the other pins there. -- **A dependency that renders React components must be bundled into the client that uses it, and is then not a root dependency.** An externalized package resolves its own `react` from wherever npm placed it in the *consumer's* tree, beside a React satisfying *its* peer range — looser than ours, which is all it takes to split React. `ink-form`/`ink-scroll-view` declare `">=18"`, so a consumer's React 18 satisfies them, the TUI ends up with two React instances, and it crashes on the first hook (#1952). Both are inlined via `noExternal` in `clients/tui/tsup.config.ts`. **`ink` is the one exemption, justified by cost (~1.4MB) — never by a peer range**: flag any claim that `">=19"` keeps npm from misplacing it, which is false and was in this repo once. What keeps it safe is the **root `react` range staying open to the whole major (`^19.0.0`)** so npm can dedupe with a consumer's pinned React 19; treat narrowing that range as reopening the bug. `clients/tui/__tests__/tsupConfig.test.ts` enforces the split, the root-declaration of exempt packages, and that range. +- **A dependency that renders React components must be bundled into the client that uses it, and is then not a root dependency.** An externalized package resolves its own `react` from wherever npm placed it in the _consumer's_ tree, beside a React satisfying _its_ peer range — looser than ours, which is all it takes to split React. `ink-form`/`ink-scroll-view` declare `">=18"`, so a consumer's React 18 satisfies them, the TUI ends up with two React instances, and it crashes on the first hook (#1952). Both are inlined via `noExternal` in `clients/tui/tsup.config.ts`. **`ink` is the one exemption, justified by cost (~1.4MB) — never by a peer range**: flag any claim that `">=19"` keeps npm from misplacing it, which is false and was in this repo once. What keeps it safe is the **root `react` range staying open to the whole major (`^19.0.0`)** so npm can dedupe with a consumer's pinned React 19; treat narrowing that range as reopening the bug. `clients/tui/__tests__/tsupConfig.test.ts` enforces the split, the root-declaration of exempt packages, and that range. - **Which section is a separate question from which manifest.** A package `core/` imports at runtime must be in root **`dependencies`**: client builds externalize npm packages, so a published install resolves them from the root manifest and devDependencies are absent there. Only test/build-only packages (`express`) belong in `devDependencies`. Flag a runtime `core/` import added to `devDependencies` — it passes every local check and breaks the published package. +- **A root-declared package that `core/` imports at runtime must also be named in each client's bundler `external` list** — `clients/{cli,tui}/tsup.config.ts` and `clients/web/tsup.runner.config.ts`, all three. Bundlers externalize what the _client's_ manifest declares, and these packages are root-only by rule, so omitting them means they get bundled. For a CJS package inlined into an ESM bundle that is fatal: esbuild's `Dynamic require of "path" is not supported` shim throws at import time and the binary dies before parsing a flag (#2082, `proper-lockfile`). Flag a new root runtime dependency that is not added to all three. This has broken twice — `undici` slipped past #2082 because it _was_ declared in `clients/cli/package.json`, so the CLI looked fine while the web and TUI bundles inlined 1.05MB of it, unloadably (#2067). `npm run verify:bundle-externals` now guards it from the **built output**; flag any change that would weaken or skip that guard. +- **Pin a transitive dependency past its parent's declared range with an `overrides` entry — never with `npm audit fix`.** When the advisory range has no upward escape inside the parent's range, `npm audit fix` resolves it by silently *downgrading* (esbuild 0.27.7 → 0.27.2 across three installs, #2058/#2062). Flag that in a diff. An override puts a bundler on a major it does not declare, so the gate for one is `npm run build`, not `npm audit`; a lockfile diff accompanying it should touch that package's entries only. Flag an override added without a note saying when it can be dropped. ## Tests and the coverage gate @@ -94,19 +96,20 @@ Both exist and do different jobs. Theme files (`src/theme/.ts`) custo ### Rendering components in tests -- **Always render through `renderWithMantine`** from `src/test/renderWithMantine.tsx`. A hand-rolled bare `MantineProvider` skips the project theme and the helper's options, and drifts from every other test. (It does *not* reintroduce the timer-leak class — an older version of this rule said so; the leaked-timer net in `setup.ts` is global and covers every unit test regardless of how it renders.) +- **Always render through `renderWithMantine`** from `src/test/renderWithMantine.tsx`. A hand-rolled bare `MantineProvider` skips the project theme and the helper's options, and drifts from every other test. (It does _not_ reintroduce the timer-leak class — an older version of this rule said so; the leaked-timer net in `setup.ts` is global and covers every unit test regardless of how it renders.) - For a forced color scheme, pass the option — `renderWithMantine(ui, { colorScheme: "dark" })` — rather than a hand-rolled `defaultColorScheme` provider. - Only when asserting _mid-flight_ transition state, use `renderWithMantineTransitions`, passing `settleMs` derived from the component's real animation duration. Do **not** combine it with `vi.useFakeTimers()`, and use the `unmount()` it returns if the test unmounts the tree itself. ## Gates and PR hygiene -- `npm run format` before committing; **`npm run ci` before pushing** (`validate` → `coverage` → `verify:build-gate` → `smoke` → Storybook). `npm run validate` is the fast inner-loop check and is **not** a substitute — it runs `test`, not `test:coverage`, so it does zero coverage gating. +- `npm run format` before committing; **`npm run ci` before pushing** (`validate` → `coverage` → `verify:build-gate` → `verify:bundle-externals` → `smoke` → Storybook). `npm run validate` is the fast inner-loop check and is **not** a substitute — it runs `test`, not `test:coverage`, so it does zero coverage gating. - **A dependency bump must land in every install that declares it.** v2 is not a workspace — the root and each `clients/*` have their own `node_modules`, and a client's test project compiles `core/` and `test-servers/src` (which resolve from the **root**) alongside the client's own sources. Bumping a shared dependency in one manifest only puts two versions of it in one `tsc` program; for a recursive-generic surface like zod that exhausts the tsc heap (#1896). `verify:dep-lockstep` fails the build on this — it derives its candidates from what each `tsc` program actually resolves (`tsc --listFilesOnly`, keeping packages that reach one program from two installs), so a package reached only through another package's `.d.ts` counts too (#1965) — so a PR bumping a package the shared sources pull in should update the root **and every client that already lists it** — not every client unconditionally, since a package absent from an install can't skew and adding it there would be a spurious dependency. -- **A test or smoke must not touch real user state.** The web smokes run against a throwaway catalog via the shared `scripts/lib/prod-web-server.mjs` helper, never the developer's `~/.mcp-inspector/mcp.json` (#1977); the cli/tui smokes drive a temp `--catalog`. A new smoke spawning its own server, or teardown that removes a work dir without first awaiting `stopChild` (the #1801 race — `child-cleanup.mjs` exports both halves and both are required), should be flagged. -- **Build output is never a gate target.** Lint, format, and typecheck read first-party source only; everything a build writes (`clients/*/build`, `clients/web/dist`, `storybook-static`, `coverage`, `test-servers/build`, `core/**/{build,dist}`, `*.tsbuildinfo`) stays out via each scope's `globalIgnores`, `format` globs, and tsconfig `include`. Gating generated code reports defects in vendored third-party source that nobody can fix, and a rule promotion turns that warning into a `validate` failure (#2043). Flag a PR that adds a build location without ignoring it in the same change, that widens an ignore to silence a finding in first-party code, or that adds a build directory to a tsconfig `include` to make a generated `.d.ts` resolve. Note the coverage guards don't catch this — they assert source is *covered*, not that output is *excluded*. +- **A test or smoke must not touch real user state.** The web smokes run against a throwaway catalog via the shared `scripts/lib/prod-web-server.mjs` helper, never the developer's `~/.mcp-inspector/mcp.json` (#1977); the cli/tui smokes drive a temp `--catalog`; `pack:verify`'s `--web` child sets its own `MCP_CATALOG_PATH` for the same reason (#2003 — its App deep link persists a server row). Anything that boots the web backend and then *navigates* it needs that isolation, not just the scripts named `smoke:*`. A new smoke spawning its own server, or teardown that removes a work dir without first awaiting `stopChild` (the #1801 race — `child-cleanup.mjs` exports both halves and both are required), should be flagged. +- **Build output is never a gate target.** Lint, format, and typecheck read first-party source only; everything a build writes (`clients/*/build`, `clients/web/dist`, `storybook-static`, `coverage`, `test-servers/build`, `core/**/{build,dist}`, `*.tsbuildinfo`) stays out via each scope's `globalIgnores`, `format` globs, and tsconfig `include`. Gating generated code reports defects in vendored third-party source that nobody can fix, and a rule promotion turns that warning into a `validate` failure (#2043). Flag a PR that adds a build location without ignoring it in the same change, that widens an ignore to silence a finding in first-party code, or that adds a build directory to a tsconfig `include` to make a generated `.d.ts` resolve. Note the coverage guards don't catch this — they assert source is _covered_, not that output is _excluded_. +- **Lint has no warning tier.** Every `lint` script runs `--max-warnings 0`, so a warning fails `validate` exactly as an error does (#2085) — a `warn`-level `react-hooks/exhaustive-deps` finding otherwise let a stale-closure bug pass the pre-push gate and reach review. Flag a PR that silences a finding to make the gate pass (widening a `globalIgnores`, dropping a rule, or an inline disable with no justification comment); the fix is the defect, not the message. A rule meant to be enforced should be set to `error` rather than left at `warn` and carried by the flag. - **Every PR references an issue**, first body line `Closes #`. - **Every PR carries exactly one version label**, `v1` or `v2`, matching its base branch. -- **Commits carry a `Signed-off-by:` trailer.** The DCO check is a hard merge gate and fails on a single unsigned commit; it matches the trailer against the author *or* committer, and skips only merge and bot commits. Use `git commit -s` — note `format.signOff` does *not* sign `git commit` (only `format-patch`). Repairing pushed commits means `git rebase HEAD~ --signoff` + `git push --force-with-lease`; remediation commits are not enabled on this repo. +- **Commits carry a `Signed-off-by:` trailer.** The DCO check is a hard merge gate and fails on a single unsigned commit; it matches the trailer against the author _or_ committer, and skips only merge and bot commits. Use `git commit -s` — note `format.signOff` does _not_ sign `git commit` (only `format-patch`). Repairing pushed commits means `git rebase HEAD~ --signoff` + `git push --force-with-lease`; remediation commits are not enabled on this repo. - Update the relevant `README.md` / `AGENTS.md` when a change adds, removes, renames, or repurposes a file or folder, changes the structure or tech stack, or introduces a command, dependency, or architectural pattern. ## What to prioritize in review diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml deleted file mode 100644 index f68ae5275..000000000 --- a/.github/workflows/claude.yml +++ /dev/null @@ -1,130 +0,0 @@ -name: Claude Code - -on: - issue_comment: - types: [created] - pull_request_review_comment: - types: [created] - issues: - types: [opened, assigned] - pull_request_review: - types: [submitted] - -jobs: - claude: - if: | - ( - (github.event_name == 'issue_comment' && - contains(github.event.comment.body, '@claude') && - contains(fromJSON('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.comment.author_association)) || - (github.event_name == 'pull_request_review_comment' && - contains(github.event.comment.body, '@claude') && - contains(fromJSON('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.comment.author_association)) || - (github.event_name == 'pull_request_review' && - contains(github.event.review.body, '@claude') && - contains(fromJSON('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.review.author_association)) || - (github.event_name == 'issues' && - (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')) && - contains(fromJSON('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.issue.author_association)) - ) - runs-on: ubuntu-latest - permissions: - contents: read - pull-requests: read - issues: read - id-token: write - actions: read - steps: - # Actions are pinned to full commit SHAs rather than mutable major tags: - # this job holds ANTHROPIC_API_KEY and grants the agent Bash, so a - # force-moved tag would be an unreviewed code change inside a - # secret-holding job. The trailing `# vX.Y.Z` comment is the form - # Dependabot reads, so pinning costs us no upgrade automation. (#1882) - - name: Get PR details - if: | - (github.event_name == 'issue_comment' && github.event.issue.pull_request) || - github.event_name == 'pull_request_review_comment' || - github.event_name == 'pull_request_review' - id: pr - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 - with: - script: | - let prNumber; - if (context.eventName === 'issue_comment') { - prNumber = context.issue.number; - } else { - prNumber = context.payload.pull_request.number; - } - - const pr = await github.rest.pulls.get({ - owner: context.repo.owner, - repo: context.repo.repo, - pull_number: prNumber - }); - - // A fork PR's head lives in a different repository — or in none at - // all, if the fork was deleted after the PR was opened (`head.repo` - // is then null). Neither is ours to check out, so both count as a - // fork here and the steps below decline rather than guess. - const headRepo = pr.data.head.repo?.full_name ?? null; - const isFork = headRepo !== `${context.repo.owner}/${context.repo.repo}`; - - core.setOutput('sha', pr.data.head.sha); - core.setOutput('is_fork', String(isFork)); - - # A fork PR's head is untrusted code, and checking it out would put it in - # reach of a tool-enabled agent run holding ANTHROPIC_API_KEY. Reviewing - # the base tree instead would only trade that for a confident review of - # the wrong tree, so decline visibly and leave the reason in the run. - - name: Decline fork PR - if: steps.pr.outcome == 'success' && steps.pr.outputs.is_fork == 'true' - run: | - { - echo "### Claude Code declined this pull request" - echo - echo "The head branch lives in a fork, so its code is not checked out and Claude is not run." - echo "Push the branch to this repository and re-trigger if a review is needed." - } >> "$GITHUB_STEP_SUMMARY" - - # No `repository:` — a same-repo head is all that reaches this step, and - # checkout defaults to `github.repository`, which no PR can influence. - - name: Checkout PR branch - if: steps.pr.outcome == 'success' && steps.pr.outputs.is_fork == 'false' - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - ref: ${{ steps.pr.outputs.sha }} - fetch-depth: 0 - - # `skipped` means the trigger was an issue or a non-PR comment, so there - # is no head to check out and the base tree is the right one. The lookup - # having *failed* is deliberately not included: this condition carries no - # status-check function, so GitHub applies an implicit `success()` and - # skips the step after a failed prior step anyway. Spelling out `skipped` - # keeps that from having to be re-derived by the next reader. - - name: Checkout repository - if: steps.pr.outcome == 'skipped' - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - fetch-depth: 0 - - - name: Run Claude Code - # Runs only against a tree this workflow actually checked out: the base - # repo (non-PR trigger) or a same-repo PR head. A fork PR reaches here - # with `is_fork == 'true'` and both disjuncts false, so it is skipped. - if: steps.pr.outcome == 'skipped' || steps.pr.outputs.is_fork == 'false' - id: claude - uses: anthropics/claude-code-action@5ef2e550a465a721f4f45e4a7d3c340c873e1dcc # v1.0.190 - with: - anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} - - # Allow Claude to read CI results on PRs - additional_permissions: | - actions: read - - # Trigger when assigned to an issue - assignee_trigger: "claude" - - claude_args: | - --mcp-config .mcp.json - --allowedTools "Bash,mcp__mcp-docs" - --append-system-prompt "If posting a comment to GitHub, give a concise summary of the comment at the top and put all the details in a
block. When working on MCP-related code or reviewing MCP-related changes, use the mcp-docs MCP server to look up the latest protocol documentation. For schema details, reference https://github.com/modelcontextprotocol/modelcontextprotocol/tree/main/schema which contains versioned schemas in JSON (schema.json) and TypeScript (schema.ts) formats." diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index f7ddb49a1..a71918807 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -73,6 +73,17 @@ jobs: # gate. It restores the mutated entry afterward (see the script). run: npm run verify:build-gate + - name: Verify no externalized dependency was inlined (#2067) + # The mirror of the "must be bundled" rule. `undici` was declared only in + # the root and `clients/cli` manifests, so tsup — which auto-externalizes + # what the NEAREST manifest declares — inlined 1.05MB of it into the web + # and TUI bundles. Inlined CommonJS in an ESM bundle throws + # `Dynamic require of "assert" is not supported` on first use, and the + # rewritten relative specifier meant no user-side install could fix it. + # This reads the built output rather than the config, because those two + # disagreed for four releases. + run: npm run verify:bundle-externals + # Playwright chromium is installed BEFORE the smokes because # `smoke:web:browser` (the headless-browser boot smoke, #1615) drives the # prod web bundle in chromium — restoring/installing it here lets that diff --git a/AGENTS.md b/AGENTS.md index 0771175ec..e854e9deb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,6 +15,34 @@ v2/main/ │ │ │ # start-vite-dev-server.ts (in-process Vite starter for the launcher), │ │ │ # web-server-config.ts (env parsing + initial-config payload + banner), │ │ │ # sandbox-controller.ts (MCP Apps sandbox HTTP server), +│ │ # app-origin-controller.ts (the DEDICATED APP ORIGIN for +│ │ # `_meta.ui.domain` — #2056. The default render hands an +│ │ # app's HTML to the proxy as `srcdoc` in a frame with no +│ │ # `allow-same-origin`, so the document has an OPAQUE origin +│ │ # and every request it makes sends `Origin: null`; no CORS / +│ │ # OAuth-callback / API-key allowlist can admit that, which is +│ │ # what the spec field exists to fix. `domain` is explicitly +│ │ # HOST-DEPENDENT and the Inspector owns no domain +│ │ # infrastructure, so it treats any non-empty value as a +│ │ # REQUEST, not an address, and answers with a real loopback +│ │ # origin on its own port (MCP_APP_ORIGIN_PORT, default 6278). +│ │ # ONE SHARED origin, path-keyed per document — a real, +│ │ # allowlistable origin without a port or DNS name per app, +│ │ # and therefore NOT a per-app isolation boundary (two such +│ │ # apps share its localStorage/cookies). The inner frame is +│ │ # granted `allow-same-origin` on THIS path only, which is +│ │ # what makes the origin real; that is not a #1565 regression +│ │ # because the listener is on its own port, so the app stays +│ │ # cross-origin to both the proxy and the Inspector — the +│ │ # proxy refuses the grant if the URL's origin equals its own, +│ │ # and it is never reachable from the server-supplied +│ │ # `sandbox` string, which is still stripped. The browser +│ │ # hands the wrapped bytes back through the authenticated +│ │ # `POST /api/app-document` (core/mcp/remote/node/server.ts) +│ │ # because only it holds the MCP connection. EVERY failure — +│ │ # no listener, port never bound, older backend, network +│ │ # error — falls back to the srcdoc render with a warning +│ │ # rather than blanking the app), │ │ │ # inject-auth-token.ts (embeds the API token into served index.html), │ │ │ # vite-base-config.ts (shared optimizeDeps exclusions), │ │ │ # resolve-bind-host.ts (bind-host POLICY: defaults to @@ -33,7 +61,47 @@ v2/main/ ├── core/ # Shared core code (no package.json — consumed via the `@inspector/core` vite alias) │ ├── auth/ # OAuth: providers, discovery, OAuthStorage + persist backends; │ │ # mid-session recovery (challenge.ts WWW-Authenticate -│ │ # parsing, scopes.ts SEP-2350 scope union, oauthUx.ts +│ │ # parsing — including the RFC 9728 +│ │ # `resource_metadata` URL, carried on +│ │ # AuthChallenge as a STRING because the +│ │ # web client's challenge crosses the +│ │ # remote-backend boundary as JSON, and +│ │ # converted to a URL at the OAuth +│ │ # boundary by challengeResourceMetadataUrl +│ │ # (malformed values ignored, matching the +│ │ # SDK's own parser) so discovery targets +│ │ # the advertised document rather than a +│ │ # location derived from the MCP server +│ │ # URL — #2071; +│ │ # discovery.ts the authorization-server +│ │ # URL POLICY — when protected-resource +│ │ # metadata names no authorization +│ │ # server, the MCP server URL stands in, +│ │ # and its PATH is load-bearing: the +│ │ # SDK's buildDiscoveryUrls derives the +│ │ # path-scoped well-known locations from +│ │ # that pathname, so reducing it to the +│ │ # origin can only ever probe the domain +│ │ # root. getAuthorizationServerUrlCandidates +│ │ # therefore yields the path-scoped URL +│ │ # FIRST and the bare origin second, and +│ │ # discoverAuthorizationServerMetadataForServer +│ │ # walks them — the second candidate is +│ │ # what keeps a server that merely lives +│ │ # under a path while publishing metadata +│ │ # at the root working, so do not drop it +│ │ # in favour of a straight swap. EVERY +│ │ # consumer of the fallback must walk: +│ │ # discoverScopes, the CIMD probe, and +│ │ # the CLI's refreshStoredAuthToken, +│ │ # which is why the walk is also exposed +│ │ # as ...FromCandidates(candidates, +│ │ # discover) — the CLI injects its own +│ │ # discovery function as a test seam and +│ │ # needs to know WHICH candidate +│ │ # answered, since that is the base its +│ │ # token request is made against — #2110; +│ │ # scopes.ts SEP-2350 scope union, oauthUx.ts │ │ # shared copy, mcpAuth.ts force-reauthorization, │ │ # issuerBinding.ts SEP-2352 callback-leg failure │ │ # classification — separates a recoverable @@ -51,10 +119,51 @@ v2/main/ │ │ # wrapper that rewrites the discovered AS │ │ # metadata document, the one seam SDK v2 routes │ │ # BOTH endpoints through (neither reaches the -│ │ # OAuthClientProvider) — #1906) +│ │ # OAuthClientProvider) — #1906; +│ │ # secret-storage-info.ts browser-safe +│ │ # descriptor of WHERE a typed secret lands +│ │ # — kind/plaintext/durable plus the label, +│ │ # tone, caveat and summary helpers shared +│ │ # by the startup banner and the web UI, so +│ │ # terminal and browser cannot describe the +│ │ # same store differently — #1950) │ │ ├── browser/ # Browser-side OAuth (sessionStorage, BrowserNavigation) │ │ ├── node/ # Node-side OAuth (NodeOAuthStorage, OAuthCallbackServer, -│ │ │ # runner-interactive-oauth loopback callback flow) +│ │ │ # runner-interactive-oauth loopback callback flow); +│ │ │ # plus the SecretStore backends and their selection — +│ │ │ # secret-store.ts (KeyringSecretStore + InMemorySecretStore, +│ │ │ # the SecretStoreUnavailableError base every store's `set` +│ │ │ # throws and the routes turn into a 503, and the keychain +│ │ │ # probe), file-secret-store.ts (0600 JSON, AES-256-GCM when +│ │ │ # MCP_INSPECTOR_SECRET_KEY is set — refuses to overwrite a +│ │ │ # file it cannot decrypt rather than destroying it), +│ │ │ # file-lock.ts (withSecretFileLock: the cross-process +│ │ │ # mutual exclusion #2082 settled on — proper-lockfile, +│ │ │ # borrowed rather than hand-rolled. Read its header before +│ │ │ # citing it: it makes two LIVE Inspectors exclusive, and +│ │ │ # does NOT make stale takeover single-winner. proper-lockfile +│ │ │ # detects a takeover only on its 5s refresh tick, which an +│ │ │ # ordinary sub-second mutation never reaches, and its release +│ │ │ # is an unconditional rmdir (as is its signal-exit handler) — +│ │ │ # so withSecretFileLock passes a GUARDED options.fs, the one +│ │ │ # seam both removal paths share, refusing to delete a lock +│ │ │ # that is no longer ours (inode+birthtime). That NARROWS the +│ │ │ # window, it does not close it — still check-then-act across +│ │ │ # processes. BEST-EFFORT throughout: do not write that a +│ │ │ # compromised holder is always told, or that the winner's +│ │ │ # lock is always preserved. +│ │ │ # DEGRADES when no lock CAN be taken (read-only $HOME etc), +│ │ │ # since this store exists for boxes missing the usual +│ │ │ # mechanism; but THROWS on ELOCKED — a lock held by a live +│ │ │ # writer is evidence the lock works, not licence to bypass +│ │ │ # it — after waiting past the stale window), +│ │ │ # and +│ │ │ # secret-store-selection.ts (the POLICY: explicit +│ │ │ # MCP_INSPECTOR_SECRET_STORE wins, else probe the keychain, +│ │ │ # else fall back LOUDLY — to memory in a container with +│ │ │ # nothing mounted, to file otherwise; cached per process so +│ │ │ # the banner, /api/config and the store doing the writing +│ │ │ # cannot disagree — #1950) │ │ └── remote/ # Remote OAuth storage (delegates to the remote server) │ ├── client/ # Install-level client config (`client.json`): browser-safe │ │ # parse/validate (config-parse.ts) + Node load/save @@ -76,7 +185,27 @@ v2/main/ │ │ # Shared by BOTH form builders — web SchemaForm │ │ # and TUI schemaToForm — since each dispatches on │ │ # a single `type` string and would otherwise miss -│ │ # a nullable field entirely — #1928/#2015) +│ │ # a nullable field entirely — #1928/#2015; +│ │ # schemaLint.ts: tool-schema PORTABILITY lint — +│ │ # constructs that are legal JSON Schema and are +│ │ # refused or mishandled by real MCP clients (a bare +│ │ # `true` in `properties`, an array-form `type`, a +│ │ # remote `$ref`, a schema carrying no constraining +│ │ # keyword). Deliberately NOT a validator: a census of +│ │ # 617 public servers found 0 that fail the SDK's own +│ │ # parse, so a conformance check reports nothing on +│ │ # real servers. Equally deliberately it has NO +│ │ # "inputSchema must be an object" rule, even though +│ │ # MCP requires that: the SDK types inputSchema with +│ │ # `type: literal("object")`, so such a tool fails +│ │ # ListToolsResultSchema and salvageListItems drops it +│ │ # before any client sees it — a rule that cannot fire +│ │ # is worse than none, because the docs then claim a +│ │ # check the tool does not perform. ONE verdict for all +│ │ # three clients — the CLI's `--strict` report (exit 6), +│ │ # the TUI's tool detail pane, the web Tools tab — so +│ │ # they cannot disagree about what is portable; the +│ │ # report TEXT is shared too — #1005) │ ├── logging/ # Silent pino logger singleton │ ├── mcp/ # InspectorClient runtime + state stores │ │ # (uriTemplate.ts: RFC 6570 parse/classify/expand. @@ -112,13 +241,27 @@ v2/main/ │ │ # listSalvage.ts: per-item salvage for list results — │ │ # keeps the valid entries when one is non-conforming │ │ # instead of losing the whole list, and owns the -│ │ # shared isClientDecodeRejection predicate — #1909) +│ │ # shared isClientDecodeRejection predicate — #1909; +│ │ # appElicitation.ts: app-rendered form elicitation +│ │ # (#1854) — the four negotiation gates, the +│ │ # `_meta.ui.resourceUri` reader/validator, and the +│ │ # result validator. Mirrors ext-apps#733 / SEP-3118 +│ │ # because the released ext-apps (1.7.5) predates it; +│ │ # the host-side renderer is supplied by the CLIENT +│ │ # (`InspectorClientOptions.appElicitation`), and +│ │ # supplying one is what advertises the nested +│ │ # `elicitation` setting — so web opts in and +│ │ # cli/tui, which cannot host an App, do not) │ │ ├── import/ # Config import strategies (#1348): client-config parsers │ │ │ # (Claude Desktop/Cursor/Cline/VS Code), registry │ │ │ # server.json parser, strategy registry + well-known │ │ │ # paths, strategy-agnostic merge. Pure/isomorphic; │ │ │ # used by the web file-upload path + /api/import-source. -│ │ ├── node/ # Node stdio transport factory +│ │ ├── node/ # Node stdio transport factory, plus proxyFetch.ts +│ │ │ # (the shared HTTPS_PROXY/HTTP_PROXY/NO_PROXY +│ │ │ # fetch every Node client installs as its +│ │ │ # `environment.fetch`, and the web backend as the +│ │ │ # createTransportNode default — #2067) │ │ ├── remote/ # Browser HTTP/SSE transport + remote logger/fetch │ │ │ └── node/ # Hono-based remote server backend (used by remote/ above) │ │ └── state/ # InspectorClient state stores consumed by core/react/ @@ -155,7 +298,31 @@ v2/main/ │ # pack-and-verify.mjs, and lib/ shared helpers │ # (tsc-program.mjs is the `tsc --listFilesOnly` │ # measurement both coverage guards read a program -│ # through — #1965; resolve-node-bin.mjs resolves a +│ # through — #1965; announced-child.mjs owns the +│ # spawn-and-wait-for-a-readiness-line step, publishing +│ # the child via `onSpawn` BEFORE the wait so the +│ # caller's teardown reaches it on every throw path — +│ # the readiness timeout included, which is what used +│ # to orphan a live server holding its port — #2000; +│ # ensure-test-servers.mjs is the ONE place +│ # `test-servers/build` is produced for a script +│ # consumer, and it builds UNCONDITIONALLY (once per +│ # process per repo root): the five hand-rolled copies +│ # it replaced each returned early when the output was +│ # already on disk, so after the first run an edit to +│ # `test-servers/src` was never picked up and the smoke +│ # silently drove the previous fixture — reported not as +│ # staleness but as a product failure in whatever the +│ # smoke was checking. Unconditional emit is still not a +│ # clean, and that is the one hole it does NOT close: a +│ # DELETED src file leaves its stale `.js` behind, the +│ # existence checks pass against it, and anything still +│ # importing that module SILENTLY runs the old code — the +│ # same failure class. So `rm -rf test-servers/build` +│ # after deleting or renaming a source file; the +│ # `.tsbuildinfo` is pinned inside `build/` so that +│ # clean actually invalidates the cache — #2111; +│ # resolve-node-bin.mjs resolves a │ # package's bin through its own package.json, so the │ # verify/smoke scripts spawn it with `process.execPath` │ # rather than the `npx` `.cmd` shim Node refuses to @@ -163,7 +330,9 @@ v2/main/ │ # the exception — its children are `npm` itself and the │ # installed `.bin` shim, neither resolvable that way, so │ # it keeps a Windows shell and quotes its generated -│ # paths via win-shell-args.mjs — #1939). Prettier-gated via +│ # paths via win-shell-args.mjs — #1939, though its +│ # test-server build now goes through the shared helper +│ # like everyone else's). Prettier-gated via │ # `format:check:scripts`; its own pure parsers are │ # unit-tested by `npm run test:scripts` (node --test). ├── specification/ # Build specification @@ -192,10 +361,21 @@ The same **placement** rule covers anything reached only through **root-owned co - A package only the tests, the test servers, or the build tooling need belongs in **`devDependencies`** — `express`, added there by #1970. - `yaml` is in `dependencies` today even though its only importer is `test-servers/src/load-config.ts`. Left as-is deliberately (moving it changes what ships, which is not a docs change); if you touch it, confirm no published path reads YAML first. +**A root-declared package that `core/` imports at runtime must also be named in each client's bundler `external` list.** tsup and Vite externalize what the *client's* `package.json` declares, and a root-only dependency is in none of them — so it gets **bundled**, silently, and the placement rule above is what guarantees every such package is root-only. For a CJS package inlined into an ESM bundle that is fatal rather than merely wasteful: esbuild leaves a `Dynamic require of "path" is not supported` shim that throws at *import* time, so the binary dies before it parses a flag. `proper-lockfile` hit exactly that in #2082; `@napi-rs/keyring` is listed in all three for the same reason. The three lists are `clients/cli/tsup.config.ts`, `clients/tui/tsup.config.ts`, and `clients/web/tsup.runner.config.ts` — add a new package to **all** of them, since which client reaches it is a function of what `core/` imports, not of what the client's own code names. + +**That rule has now been broken twice, so it is enforced by a guard rather than by review.** `undici` predates #2082 and was missed by it: it *was* declared in `clients/cli/package.json`, so tsup externalized it for the CLI and the CLI looked fine, while the web and TUI bundles silently inlined 1.05MB of it (#2067). The failure mode is slightly worse than the `proper-lockfile` one, because the package is `import()`ed lazily rather than at startup: the binary boots normally and only the proxied code path dies — with esbuild's rewritten specifier (`import("./undici-HXPKCIY3.js")`) meaning **no user-side install can ever satisfy it**, so the published clients told every proxy user to install a package that was already present and could not have helped. + +**`npm run verify:bundle-externals`** (`scripts/verify-bundle-externals.mjs`, in `npm run ci` and the GitHub workflow) is that guard. It reads the **built output**, not the config — the two disagreed for four releases — and checks the union of two independently-derived candidate lists: each client's own `external` array, **and the root manifest's `dependencies`**. The second is what makes it cover #2067 rather than only its regression: `undici` was *missing* from the web and TUI `external` lists — that omission was the bug — so a guard driven by those lists alone would have inspected the inlined 1.05MB bundle and reported success. Removing a package from an `external` list must not remove it from scrutiny. Deriving both ways also means a newly added root dependency, or a newly externalized package, is covered without editing the guard. Detection keys off esbuild's `// ` module banners, which covers both shapes an inlining can take: a separate `-HASH.js` chunk (what a dynamically `import()`ed CommonJS package produces, i.e. #2067) and a statically-imported package folded straight into `index.js`, which emits no chunk and a chunk-name check would miss. Reading banners means the guard needs unminified output, which it asserts rather than assumes — a bundle with no banners fails loudly instead of reporting clean, so turning on `minify` surfaces here. It **fails closed** on the other way it could lose sight of a bundle, too: parsing the `external` array keys off literal config text, so if a tsup refactor leaves it parsing nothing, that is an error rather than a vacuous pass. Two more things worth knowing: + +- ⚠️ **Nothing in the ordinary test tiers catches this class.** Unit and integration tests run against **source**, where the real package is on the resolution path; the smokes run the built tree but never take the code path (for `undici`, no proxy env var is set), so the lazy `import()` never executes. The defect exists only in bundled output, only on a path the smokes don't walk. +- ⚠️ **Probing it by hand with `node -e` falsely succeeds.** `node -e 'import("./undici-XXXX.js")'` resolves fine, because `node -e` exposes a global `require` that satisfies esbuild's `typeof require !== "undefined"` guard. Reproduce from a real `.mjs` file, or you will conclude the chunk is loadable when it is not. + **A dependency that renders React components must be bundled into the client that uses it, and is then not a root dependency.** An externalized package resolves its own `react` from wherever npm placed **it** in the consumer's tree, and npm places a package beside a React satisfying *that package's* peer range — looser than ours in every case here, which is all it takes to split React. `ink-form` and `ink-scroll-view` declare `">=18"`, satisfied by a consumer's React 18 while our React 19 nests underneath: the bundle renders through one React, those packages call hooks on another, and the TUI crashes on the first hook (#1952). Both are inlined by `clients/tui/tsup.config.ts` (`noExternal`) and declared only in `clients/tui/package.json`, where the build resolves them — declaring an inlined package at the root would just make consumers install a second, unused copy. **`ink` is the single exemption, and it is justified by cost, not by safety.** Bundling it works but adds ~1.4MB (`react-reconciler` + `yoga-layout`, plus a `createRequire` banner, since inlined CJS calls `require` at runtime and esbuild's ESM interop rejects that without a real `require` in scope). **Never justify an exemption by a peer range** — `ink` briefly carried "its `">=19"` peer keeps npm honest", which is false: a consumer pinning React 19.0 satisfies `">=19"` while a narrower range of ours nests underneath. What actually makes the exemption safe is a *different* lever: the **root `react` range stays open to the whole major (`^19.0.0`)**, so npm can dedupe our React with whatever React 19 a consumer pins and an external `ink` lands on the same copy the bundle uses. Narrowing it (e.g. back to `^19.2.4`) silently reopens the crash for the renderer itself, which breaks TUI *startup*, not just its forms. `clients/tui/__tests__/tsupConfig.test.ts` enforces all of it: React-rendering deps inlined, each exempt package both external and root-declared, and the root range pinned to `ink`'s peer floor. +**An `overrides` entry is how a transitive dependency gets pinned past its parent's declared range — reach for it before `npm audit fix`.** `clients/{web,cli,tui}/package.json` each override `esbuild` to `^0.28.2` (#2062). `tsup@8.5.1` declares `esbuild: ^0.27.0`, and GHSA-g7r4-m6w7-qqqr covers `0.27.3 - 0.28.0` with `0.27.7` the last 0.27.x — so there is no *upward* escape inside `tsup`'s range, and `npm audit fix` "resolves" it by silently **downgrading** to `0.27.2` across three installs (~700 lines of lockfile churn for a low-severity dev-only advisory; tried and reverted in #2058). The override forces the single deduped copy above the range instead, which also collapses the nested `tsup/node_modules/esbuild` and `tsx/node_modules/esbuild` copies into it. Two things this costs: it puts `tsup` on an esbuild **major it does not declare**, so `npm run build` for web/cli/tui — not `npm audit` — is the real gate on such a pin; and it is invisible to the audit once clean, so **when `tsup` widens its range to `^0.28`, drop the override rather than carrying it forever**. The same file's `ink-select-input` entry is a different case entirely (it pins a bundled dep's transitive resolution, which npm ignores for a consumer install — see above), so don't read one as precedent for the other. + The v1 SDK (`@modelcontextprotocol/sdk`) is **not** a dependency of this repo and must not become one — v2 uses the packages above. It appears in the lock files only as a `"peer": true` entry pulled in by `ext-apps`. ## Contributing @@ -729,21 +909,24 @@ The ⚠️ option-deletion hazard, the snapshot rule, and the recovery recipe ab ### Mandatory pre-push gate - ALWAYS do `npm run format` before committing — the **root** `format` auto-fixes `core/` (`format:core`), the root `scripts/` tooling (`format:scripts`), the root "shared" surface (`format:shared` — `test-servers/src/**`, `vitest.shared.mts`, the root `eslint.config.js`), and every client's scope in one shot. Every **client** format glob uses the uniform extension set `*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}` (#1792) so a new-extension file can't slip the gate; `core/` stays `{ts,tsx}` and the shared surface `{ts,tsx,mts,cts}` (their surfaces can't hold the other extensions), and `npm run verify:format-coverage` (the first step of `validate`, #1792) is the backstop — it fails if any tracked source file is left uncovered by a `format:check` glob regardless of which glob was expected to catch it. `validate` runs `format:check` (the non-fixing variant, including `format:check:core`, `format:check:scripts`, and `format:check:shared`) and will fail in CI on any unformatted file, so always run the auto-fixer first rather than letting `format:check` catch it. -- **`npm run ci` is the mandatory pre-push command** — it mirrors `.github/workflows/main.yml` (minus `npm install`): `validate` → `coverage` → `verify:build-gate` (the #1769 browser-externalized-builtin build gate) → `smoke` → Storybook play-function tests (installs Playwright chromium if needed). It now runs **`npm run coverage`**, the per-file ≥90 gate (lines/statements/functions/branches) that CI enforces — so `npm run ci` is a true superset of GitHub CI, and passing it locally means CI's gates will pass. Expect several minutes. **`npm run validate`** remains the fast inner-loop check during development (unit tests only — no coverage gate, no smoke, no Storybook), but it is **NOT** an acceptable substitute for `npm run ci` before pushing: `validate` runs `test`, not `test:coverage`, so it does **zero** coverage gating. Skipping the gate is how a push passes every fast local check and still fails CI (this exact gap broke PR #1601 on a function-coverage regression). +- **`npm run ci` is the mandatory pre-push command** — it mirrors `.github/workflows/main.yml` (minus `npm install`): `validate` → `coverage` → `verify:build-gate` (the #1769 browser-externalized-builtin build gate) → `verify:bundle-externals` (the #2067 must-not-bundle gate) → `smoke` → Storybook play-function tests (installs Playwright chromium if needed). It now runs **`npm run coverage`**, the per-file ≥90 gate (lines/statements/functions/branches) that CI enforces — so `npm run ci` is a true superset of GitHub CI, and passing it locally means CI's gates will pass. Expect several minutes. **`npm run validate`** remains the fast inner-loop check during development (unit tests only — no coverage gate, no smoke, no Storybook), but it is **NOT** an acceptable substitute for `npm run ci` before pushing: `validate` runs `test`, not `test:coverage`, so it does **zero** coverage gating. Skipping the gate is how a push passes every fast local check and still fails CI (this exact gap broke PR #1601 on a function-coverage regression). - ALWAYS do `npm run format` before committing, then **`npm run ci`** before pushing. From the repo root, `validate` runs **`verify:format-coverage` first** (the #1792 guard — asserts every tracked source file is covered by a `format:check` glob), then **`verify:typecheck-coverage`** (the #1791 guard — asserts every tracked `.ts`/`.tsx`/`.mts`/`.cts` in each gated Node client, plus the non-client first-party TS like `core/` and `test-servers/src`, lands in a tsconfig project), then **`verify:dep-lockstep`** (the #1896 guard — asserts no dependency that reaches a single `tsc` program from two installs resolves to two different versions across them), then **`test:scripts`** (the guards' own parser unit tests, `node --test`), then the **`core/` gate** (`validate:core`), then chains the four per-client validations (`validate:web` → `validate:cli` → `validate:tui` → `validate:launcher`); each client delegates to its own `npm run validate` in its own folder (no coverage — fast). Every client is self-validating and the top level just chains them, building each client's bundle along the way (no cross-client build dependencies). - **`validate:core` is the root-owned format + lint gate (#1689, widened in #1778 and #1767).** Each client's `prettier`/`eslint` is scoped to its own dir, so nothing reached `core/`, the root `scripts/`, or the root "shared" surface before — `validate:core` closes that: it runs `format:check:core` (`prettier --check "core/**/*.{ts,tsx}"`) + `format:check:scripts` (`prettier --check "scripts/**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}"`, the root build/verify tooling — #1778) + `format:check:shared` + `lint:core` (`eslint "core/**/*.{ts,tsx}"` via the **root** `eslint.config.js`) + `lint:shared`. Use `npm run format:core` / `npm run format:scripts` / `npm run format:shared` to auto-fix (all folded into the root `format`). The **shared surface** (#1767) is `test-servers/src/**/*.{ts,tsx,mts,cts}`, the root `vitest.shared.mts`, and the root `eslint.config.js` — first-party code no client's `eslint .` / `prettier` reaches; it is both prettier-gated (`format:check:shared`) and eslint-gated (`lint:shared`, via a second `files` block in the root `eslint.config.js` scoped to Node globals). The `scripts/` gate is prettier-only — the root has no eslint config for `.mjs`. The root carries prettier/eslint as devDependencies for this; `core/` is isomorphic (browser + Node globals, no JSX today — the `{ts,tsx}` glob future-proofs against a `core/**/*.tsx`). The root `eslint.config.js` honors an `_`-prefix as the intentionally-unused marker (`argsIgnorePattern`/`varsIgnorePattern`/`caughtErrorsIgnorePattern: '^_'`). **prettier is pinned to an exact version** (not a caret) in all five `package.json`s (#1790) so the gate's verdict can't shift with an in-range patch bump. - **cli and tui now typecheck their `src` (#1689).** Their `build`/`test` run through esbuild (no type check), so each has a `typecheck` script folded into `validate`. Their `tsconfig.json` matches `clients/web/tsconfig.app.json`'s module/lib _resolution_ options — DOM lib, `moduleResolution: bundler`, and **no** `noUncheckedIndexedAccess` (web's app config does not extend `tsconfig.base`, so re-enabling it would surface `core/` issues web never gates) — so the imported `core/` sources are validated the same way web validates them. It does **not** mirror web's extra strictness flags (`noUnusedLocals`, `verbatimModuleSyntax`, ES2023 target, …), so cli/tui's own `src` is checked slightly more loosely than web's. `core/` itself still typechecks through web's `tsc -b`. - **The `__tests__` dirs are typechecked too (#1791).** The src-only `tsconfig.json` excludes `**/*.test.*`, so each of cli, tui, and launcher carries a **`tsconfig.test.json`** — extending the build config, `noEmit`, including `__tests__/**/*` (only the tests root the project; tsc pulls in the `src` they import, and the src-only config already validates all of `src` without the test-only aliases) and adding the test-only path aliases that resolve what vitest resolves via `vitest.shared.mts`. The alias set differs per client: **cli's is the widest** (`@modelcontextprotocol/inspector-test-server` → `test-servers/src`, the `@inspector/core/*` deep paths, express/vitest — cli is the only one importing the test-server package); **tui's** carries only the `@inspector/core/*` + react/vitest redirects; **launcher's** has **no** `paths` at all — it's a plain `rootDir: "."` sibling of the build config (whose `rootDir: ./src` is what rejects the tests). Each client's `typecheck` script runs **both** projects (`tsc -p tsconfig.json && tsc -p tsconfig.test.json`) so running it standalone means the same thing everywhere (launcher's `build` also `tsc`s `src`, but `typecheck` doesn't rely on that). cli additionally carries `@types/express` (devDep) so the transitively-aliased test-server source typechecks, mirroring `clients/web` (cli's `tsconfig.test.json` also names `test-servers/src/server-composable.ts` explicitly — a bin entry the barrel doesn't import, so nothing else gives it a tsc pass). The client **config files** are typechecked too: cli's/tui's (`vitest.config.ts`, `tsup.config.ts`, tui `dev.ts`) are folded into each src `tsconfig.json`'s `include`; launcher's `vitest.config.ts` goes in its `tsconfig.test.json` instead (again the `rootDir: ./src` reason). Note the gate checks mock **implementations and return types** (typing a `vi.fn()` against a real signature keeps its `mockResolvedValue`/impl in sync) but **not** `toHaveBeenCalledWith(...)` arguments — vitest types those to accept anything regardless of the mock's type parameter. **`npm run verify:typecheck-coverage`** (`scripts/verify-typecheck-coverage.mjs`, run as the second step of `validate` right after `verify:format-coverage`) is the durable guard for this invariant: it runs each client's `typecheck` projects with `tsc --listFilesOnly`, unions them, and fails on any tracked `.ts`/`.tsx`/`.mts`/`.cts` that lands in no project — for every gated Node client, which it discovers from disk (each `clients/*` is enrolled through its `typecheck` script's projects, or — for a `tsc -b` client like `clients/web` with no `typecheck` script — through its `tsconfig.json` `references`), so a new client is covered without editing the guard — the typecheck analog of `verify:format-coverage`, since a project only reaches the files its `include` names plus their transitive imports, so a new top-level file (launcher especially, whose build `rootDir: ./src` rejects package-root files) can otherwise fall out silently. Like its sibling it also asserts the gate is _wired_ (each client's typecheck pass is reachable from its `validate` — its `typecheck` script for cli/tui/launcher, or a real `tsc -b` for web — and the root chain runs each client's `validate`), so it can't stay green while measuring a pass nothing invokes. It asserts the same of **`test:scripts`** — its own parser tests — on three axes: reachable from the root `validate`, a **non-empty** tracked `scripts/**/*.{test,spec}.*` set, and **every one of those files matched by a glob harvested across the scripts reachable from `test:scripts`** (so a delegating `test:scripts` still measures correctly). The third axis exists because `node --test` silently _skips_ a file its glob misses and still exits 0 — a rename to `*.spec.mjs` would shrink the suite with a green run. Beyond the clients it also covers, **deny-by-default**, the first-party TS no client owns — everything tracked outside `clients/*` (`test-servers/src/**`, the root `vitest.shared.mts`, **all of `core/`**, and any new top-level TS location) must land in the _global_ union of client projects (cli aliases the test-server source; web's enrolled projects include `core/`). So a `core` `*.tsx` web's `include` doesn't reach, or an unimported `test-servers/src` bin entry, can't ship uncompiled-but-unchecked. The one "listed but unchecked" tier the guard structurally can't see — a per-file `// @ts-nocheck` — is owned by a different gate: `@typescript-eslint/ban-ts-comment` rejects it across every surface (`lint:core`, `lint:shared`, and each client's `eslint .`). The guard's own pure parsers (`scripts/lib/npm-scripts.mjs` + the exported helpers of `verify-typecheck-coverage.mjs`, whose execution is behind a `main()` so importing it for tests doesn't run it) are **unit-tested** — `npm run test:scripts` (node's built-in `node --test`, in `validate`; the root has no vitest harness by design) runs table-driven cases, one per rule the guard's parsers encode, and the guard itself enforces that this stays wired (above). - The one CLI nuance: `clients/cli`'s out-of-process `e2e.test.ts` spawns the built binary, so its `test` **builds first** via `pretest` (`test-servers:build && build`). To avoid building it twice, `clients/cli`'s `validate` folds that in — it is `format:check && lint && typecheck && test` with **no** separate `build` step (the other clients, whose tests don't spawn their bundle, keep an explicit `build`). `validate:web`/`validate:tui`/`validate:launcher` are the uniform `format:check && lint && (typecheck &&) build && test`. (#1778, #1789, #1792) `clients/web`'s `format`/`format:check` covers `src`, `server`, `.storybook`, and its top-level configs (the uniform `*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}` glob — `vite.config.ts`, `tsup.runner.config.ts`, `eslint.config.js`, …), not just `src`, so the Node backend, Storybook config, and Vite/build config are prettier-gated too; `clients/launcher`'s covers `src`, `__tests__`, `scripts`, and its top-level configs (the `*.` top-level glob is non-recursive, so each nested dir — `.storybook`, `scripts` — is named explicitly). The `verify:format-coverage` guard (#1792) enforces that this coverage stays complete. - **One version per install-crossing dependency (#1896).** Because v2 is not a workspace, the root and each `clients/*` carry their own `node_modules` — and a client's `tsconfig.test.json` compiles first-party sources that live *outside* the client (`test-servers/src`, `core/`), which resolve their dependencies from the **root** install while the client's own sources resolve from the client install. So the same package can appear **twice in one `tsc` program**. At the same version that duplication is harmless; on a skew, TypeScript must relate two structurally-distinct declarations of the same type. For a deeply recursive-generic surface that is exponential: zod `4.3.6` (root) against zod `4.4.3` (`clients/web`) made `clients/web`'s `tsc -b` exhaust the 4GB default heap outright via `TS2589 Type instantiation is excessively deep`, because every `@modelcontextprotocol/*` schema is built out of zod generics. **Raising the heap with `--max-old-space-size` hides this class rather than fixing it — align the versions instead.** `npm run verify:dep-lockstep` (`scripts/verify-dep-lockstep.mjs`, in `validate`) is the durable guard: it **derives** the candidate set from **what actually enters each `tsc` program** (#1965) — every client tsconfig project is listed with `tsc --listFilesOnly` through the shared `scripts/lib/tsc-program.mjs` helper (the same machinery `verify:typecheck-coverage` reads a program through, so the two guards can't disagree about what one contains), each resolved `node_modules` file is mapped to its owning install and package, and a package reaching **one** program from **two** installs is a candidate. That is precisely the set that can put two structurally-distinct copies of a type in front of one checker — and it needs no editing when a new dependency arrives. It replaced a derivation that read the packages the shared sources named *directly*, which could not see one whose declarations arrive only through another package's `.d.ts`: `@modelcontextprotocol/sdk` is never written in first-party code (the shared sources import the split `@modelcontextprotocol/client|core|…`) yet 16 of its `.d.ts` files land in `clients/web`'s test program, so a second copy under `clients/web/node_modules` skewed unseen. Two properties are worth knowing: a package present in two installs but reached from only one in a given program is correctly **not** a candidate, and TypeScript's package-identity redirect collapses two copies at the *same* name@version (so an aligned package's own transitive dependencies load once) — the redirect stops applying the moment they skew, which is exactly when the guard needs to see both. Versions still come from the committed lockfiles, but from the entry for the **exact install path the program resolved** (`node_modules/zod`, `node_modules/a/node_modules/zod`) rather than from the install's top-level entry: a *nested* duplicate inside one install is still not a candidate on its own — folding it onto its outermost install is what keeps the set small — but once a program has loaded one, pricing it from a top-level entry that may be absent or differently versioned would let a real pair pass (Copilot). Only the installs that actually **met in one program** are compared, so a third install's copy that no program loads beside another is not evidence of anything. Any co-occurrence whose copies disagree fails, **deny-by-default**; a resolved copy with no lockfile entry fails too, since the tree and the lockfile then disagree about what was loaded. The escape hatch is `TOLERATED_SKEW` in that file, an allowlist of *names* (not version pairs, so an ordinary patch float doesn't churn it), each entry carrying why that package's types can't blow up; it is **empty today** — the four names it used to carry (`react`, `hono`, `jose`, `@modelcontextprotocol/ext-apps`) were admitted under the old derivation and none is a candidate under this one, so each would be a rationale for a skew that cannot occur. **Being listed is not a blanket exemption** — it tolerates skew only *within a major version*, since a rationale about patch-level differences says nothing about a React 18-vs-19 split, where the type surface itself changes; a cross-major skew fails even for a listed package. **When bumping a dependency that the shared sources pull in, bump it in every install that declares it** — that's the root plus whichever clients list it, not all four unconditionally (launcher declares no zod, for instance, and a package absent from an install can't skew, so the guard ignores it there). Don't add a dependency to a client just to satisfy this. Its pure helpers are unit-tested via `test:scripts` (its own, plus `scripts/lib/tsc-program.test.mjs` for the shared derivation), and it vouches — with `verify:format-coverage` and `verify:typecheck-coverage` — that its siblings are still wired into `validate`. It runs the same `tsc --listFilesOnly` pass its sibling does, in its own process, costing ~14s: the listing is deliberately **not** cached to disk between the two, because a fingerprint that missed an input would make a guard measure a program that no longer exists and pass on a real miss. - - **`npm run coverage`** is the per-file ≥90 gate and is now part of `npm run ci` — never treat it as optional before a push. It supersedes the old standalone `test:integration` step: web's `test:coverage` runs the `unit` **and** `integration` projects under v8 instrumentation, so `coverage` both enforces the ≥90 gate and exercises the same web integration paths CI covers. -- **`smoke` is NOT part of `validate`** — it is included in `npm run ci`. It runs `smoke:launcher` (`--help` dispatch) plus the prod `smoke:cli` / `smoke:tui` / `smoke:web` / `smoke:web:browser` / `smoke:web:app`, and contains **no build commands** — it assumes the cli/tui/launcher bundles already exist (a full `validate` builds them; `smoke:web` builds `clients/web/dist` on demand). CI runs `validate`, then the `coverage` gate (which also covers the web integration project), then `verify:build-gate` (the #1769 build gate — see below), then `smoke` (with Playwright chromium installed just before it, since `smoke:web:browser` needs it). GitHub CI runs this same chain as separate workflow steps, with the Storybook play-function tests last (see below). + - **`npm run verify:bundle-externals`** runs after `verify:build-gate` in `npm run ci`. It asserts that no package a client declares `external` was inlined into that client's bundle anyway — the must-not-bundle invariant above (#2067). It reads `clients/*/build`, so it needs a build to have run (`validate` provides one). +- **`npm run coverage`** is the per-file ≥90 gate and is now part of `npm run ci` — never treat it as optional before a push. It supersedes the old standalone `test:integration` step: web's `test:coverage` runs the `unit` **and** `integration` projects under v8 instrumentation, so `coverage` both enforces the ≥90 gate and exercises the same web integration paths CI covers. +- **`smoke` is NOT part of `validate`** — it is included in `npm run ci`. It runs `smoke:launcher` (`--help` dispatch) plus the prod `smoke:cli` / `smoke:tui` / `smoke:web` / `smoke:web:browser` / `smoke:web:app` / `smoke:web:elicit`, and contains **no build commands** — it assumes the cli/tui/launcher bundles already exist (a full `validate` builds them; `smoke:web` builds `clients/web/dist` on demand). CI runs `validate`, then the `coverage` gate (which also covers the web integration project), then `verify:build-gate` (the #1769 build gate — see below), then `verify:bundle-externals` (the #2067 must-not-bundle gate), then `smoke` (with Playwright chromium installed just before it, since `smoke:web:browser` needs it). GitHub CI runs this same chain as separate workflow steps, with the Storybook play-function tests last (see below). - `smoke:launcher` (`scripts/smoke-launcher.mjs`) runs the built launcher with `--help`, `--cli --help`, and `--tui --help`, asserting each exits 0 and prints that mode's usage banner (which also proves the launcher resolved and loaded the right client build). It's the cheap dispatch check before the heavier prod smokes below. -- `smoke:web` (`scripts/smoke-web.mjs`) starts `mcp-inspector --web` (prod, no `--dev`) against the built `clients/web/dist` and asserts `GET /` serves the SPA (HTTP 200) with the injected `__INSPECTOR_API_TOKEN__`. Prod `--web` serves from `clients/web/dist`, which ships in the published package but is absent in a fresh checkout — the runner builds it on demand (`build:client` = `vite build`) on first launch, or exits with an actionable error if that build can't run (see `clients/web/server/ensure-web-build.ts` and the launcher README). `--dev` runs Vite directly and never needs `dist`. It shares the spawn/readiness/teardown helper (`scripts/lib/prod-web-server.mjs`) with **`smoke:web:browser` and `smoke:web:app`**, so the three can't drift. +- `smoke:web` (`scripts/smoke-web.mjs`) starts `mcp-inspector --web` (prod, no `--dev`) against the built `clients/web/dist` and asserts `GET /` serves the SPA (HTTP 200) with the injected `__INSPECTOR_API_TOKEN__`. Prod `--web` serves from `clients/web/dist`, which ships in the published package but is absent in a fresh checkout — the runner builds it on demand (`build:client` = `vite build`) on first launch, or exits with an actionable error if that build can't run (see `clients/web/server/ensure-web-build.ts` and the launcher README). `--dev` runs Vite directly and never needs `dist`. It shares the spawn/readiness/teardown helper (`scripts/lib/prod-web-server.mjs`) with **`smoke:web:browser`, `smoke:web:app` and `smoke:web:elicit`**, so the four can't drift. **Every web smoke runs against a throwaway catalog (#1977).** The helper mints a temp dir per run and passes it as `MCP_CATALOG_PATH`; without it the web backend falls back to the developer's real `~/.mcp-inspector/mcp.json`, which made these smokes both destructive and non-deterministic — `smoke:web:app`'s deep link persists a `deep-link` server row, so a *second* run found it already on disk, raced hydration, and drew a spurious (swallowed, non-fatal) 409 that was really just residue from the previous run. CI never saw it: a fresh `HOME` per run made every CI run look like a first run. This matches `smoke:cli` / `smoke:tui`, which have always driven a temp `--catalog`. Only the **catalog** is redirected — other per-user state under `~/.mcp-inspector` (OAuth tokens, `storage/`) stays shared, because isolating it means redirecting `HOME` wholesale, which would also move the npx and Playwright caches these smokes depend on. Teardown uses **both** halves of `scripts/lib/child-cleanup.mjs` (`stopChild` to await the child's exit, then `removeSafe` to delete the dir) — a bare `kill()` only *delivers* the signal, so removing synchronously re-enters the #1801 ENOTEMPTY race. That makes `stop()` **async**, so every caller must `await` it (and a caller's own `fail()`/`shutdown()` becomes async in turn, or execution runs past the intended exit). The isolation contract is unit-tested in `scripts/lib/prod-web-server.test.mjs` via `test:scripts`, since the smokes exit immediately after teardown and so cannot detect a regression that silently reshared the catalog or stopped cleaning up: `createTempCatalog` and `buildWebServerEnv` cover *which* catalog the server gets, and `teardownWebServer` — extracted from `stop()` for exactly this reason — is driven against a stand-in child process so the teardown asserts on the real directory rather than a spy. - `smoke:web:browser` (`scripts/smoke-web-browser.mjs`, #1615) goes a step further than `smoke:web`: it boots the same prod `--web` server and then actually **runs** the bundle in headless Chromium (Playwright — already a `clients/web` devDependency for the Storybook tests), asserting the app renders its first meaningful frame (the "Add Servers" control) with **no uncaught error**. `smoke:web` only checks the served HTML, so a Node built-in reaching the browser bundle slipped through it; this smoke catches that regression as a _class_ (e.g. #1612). The mechanism is the uncaught error, not a magic string: under Vite the excluded module becomes an empty stub and the first _call_ into it (e.g. `fs.readFileSync(...)` during a transitive module's init) throws a `TypeError` that aborts app mount. A _synchronous_ such throw fires `pageerror`; its _async_ twin (the same `TypeError` via `await`/`.then()`, or a failed dynamic import) is logged on the console channel as `Uncaught (in promise) …` / `Failed to fetch dynamically imported module` — the smoke hard-fails on both. The literal `Module "…" has been externalized` text is, **in a prod build**, a build-time warning (`vite build` / `npm run build`), not a runtime message, so the browser never sees it (under `npm run dev` Vite's stub is instead a `Proxy` that `console.warn`s that string at runtime); and an externalized import that is never _called_ ships a harmless `{}` and is invisible here by design. Every _other_ console error is printed as a diagnostic, not a failure (so a benign font-CDN or React-warning `console.error` doesn't flake CI). Playwright is resolved via `createRequire` based at `clients/web/package.json` — a bare `import("playwright")` would resolve relative to `scripts/`, not the cwd, so it can't be reached that way (it only appears to work when an ancestor `node_modules` carries playwright, and fails in CI, which has none). The npm script's `cd clients/web` exists only so `npx playwright install chromium` finds the local playwright bin (a no-op when already installed). -- `smoke:web:app` (`scripts/smoke-web-app.mjs`, #1859) goes one step further again: `smoke:web:browser` stops at first paint and never connects to a server, so the Apps tab, the sandbox controller, and the UI-protocol bridge were unexercised by any smoke. This one boots the same prod `--web` server, spawns the `mcp-app-http.json` composable test server (the `mcp_app_demo` tool + its `mcp_app_demo_widget` UI resource), and drives the whole **connect → open app → widget ready** chain through a single deep-link navigate (`?serverUrl=…&autoConnect=&openApp=…&appArgs=…&autoOpen=`). The assertion is the `data-app-status="ready"` contract from [clients/web/README.md](clients/web/README.md) — the renderer reports `ready` only once the widget has loaded inside the sandbox iframe _and_ fired `notifications/initialized` back through the bridge, so one attribute covers the sandbox proxy being served, the UI resource loading, and the handshake completing. Two mechanics are load-bearing and easy to get wrong: the test server announces readiness on **stderr** (`console.error` in `server-composable.ts`), so both child streams are piped and scanned — watching stdout alone times out with an empty diagnostic; and its bound port is **not** the config's, because `createTestServerHttp` resolves through `findAvailablePort()`, which walks upward when the configured port is taken — so the smoke parses the announced URL rather than assuming `3130`. **Scope note:** this runs against the repo build tree like every other smoke, so it would _not_ have caught #1859 itself (a packaging failure — the file is always present in-repo); `pack:verify` owns that dimension. It does carry a cheap structural pre-check that the proxy page exists at the path `sandbox-controller.ts` resolves, so a move/rename fails fast with a clear cause instead of an opaque render timeout. +- `smoke:web:app` (`scripts/smoke-web-app.mjs`, #1859) goes one step further again: `smoke:web:browser` stops at first paint and never connects to a server, so the Apps tab, the sandbox controller, and the UI-protocol bridge were unexercised by any smoke. This one boots the same prod `--web` server, spawns the `mcp-app-http.json` composable test server (the `mcp_app_demo` tool + its `mcp_app_demo_widget` UI resource), and drives the whole **connect → open app → widget ready** chain through a single deep-link navigate (`?serverUrl=…&autoConnect=&openApp=…&appArgs=…&autoOpen=`). The assertion is the `data-app-status="ready"` contract from [clients/web/README.md](clients/web/README.md) — the renderer reports `ready` only once the widget has loaded inside the sandbox iframe _and_ fired `notifications/initialized` back through the bridge, so one attribute covers the sandbox proxy being served, the UI resource loading, and the handshake completing. Two mechanics are load-bearing and easy to get wrong: the test server announces readiness on **stderr** (`console.error` in `server-composable.ts`), so both child streams are piped and scanned — watching stdout alone times out with an empty diagnostic; and its bound port is **not** the config's, because `createTestServerHttp` resolves through `findAvailablePort()`, which walks upward when the configured port is taken — so the smoke parses the announced URL rather than assuming `3130`. Both mechanics now live in `scripts/lib/announced-child.mjs` rather than in the smoke, so the failure path is testable: this smoke's happy path always receives the announcement, so nothing it could assert would prove that a child alive *through* the 30s timeout is still reachable by `shutdown()` — the case that orphaned a live server (#2000). The helper publishes the child via `onSpawn` before waiting, and `scripts/lib/announced-child.test.mjs` drives real `node -e` children (not spies) to assert it is published, still alive when the throw lands, and actually killable. Same reason `teardownWebServer` was extracted from `prod-web-server.mjs`'s `stop()`. **Scope note:** this runs against the repo build tree like every other smoke, so it would _not_ have caught #1859 itself (a packaging failure — the file is always present in-repo); `pack:verify` owns that dimension, and since #2003 it owns it *properly* — it drives this smoke's first phase against the installed tarball rather than only asserting the proxy page exists. The flow therefore lives in `scripts/lib/mcp-app-flow.mjs` (deep-link construction, the staged assertions, the Chromium/diagnostics plumbing) and is **shared, not copied**: two copies of the deep-link shape would drift, and a drifted link fails as a silent timeout rather than a mismatch. Keep both consumers — `pack:verify` is network-bound and local/release-only, so it does not run in `npm run ci`, where this smoke is the only thing exercising the App path at all, and the **`_meta.ui.domain` phase (#2056) is this smoke's alone**: the dedicated app origin is served by the same runner the packaging checks already cover, so driving it a second time from the tarball would cost a browser launch for no new packaging signal. It does carry a cheap structural pre-check that the proxy page exists at the path `sandbox-controller.ts` resolves, so a move/rename fails fast with a clear cause instead of an opaque render timeout. +- `smoke:web:elicit` (`scripts/smoke-web-elicitation.mjs`, #1854) is the app-rendered **elicitation** counterpart of `smoke:web:app`: same prod `--web` server and the same deep-link connect, but it then calls `app_choose_option` from the Tools tab, waits for `[data-testid="app-elicitation"][data-app-elicitation-status="ready"]`, clicks a choice **inside the sandboxed app** (two `frameLocator` hops — the trusted sandbox proxy, then the untrusted app), and asserts the app's standard `ElicitResult` comes back in the *tool result*, i.e. that it reached the server rather than merely the host. It then repeats against `app-elicitation-native-http.json` — the same tool and app on a server that never advertised the nested MCP Apps `elicitation` capability — and asserts the **native** elicitation dialog takes it and no app modal is rendered. That second half is the more valuable one: the failure this feature can produce is not "the app doesn't render" but "an app renders when it should not have been offered one", which strands every user of a server that never opted in. Set `SMOKE_SCREENSHOT_DIR` to capture PNGs of the three states (used for PR proof); unset, it asserts only. Two mechanics worth knowing: the main-view tabs are a Mantine `SegmentedControl`, so there is no `role="tab"` — the clickable element is the sibling `label[for$="-Tools"]`; and the prompt string also appears in the (hidden) Protocol-tab payload, so the fallback assertion is scoped to the dialog rather than a bare text lookup. + - **The build gate for the browser-externalized-builtin class (#1769)** is the earlier, more complete companion to `smoke:web:browser`. A Vite plugin in `clients/web/vite.config.ts` (logic in `clients/web/server/browser-externalized-builtin-gate.ts`, unit-tested) turns Vite 8's _browser-externalization warning_ (`Module "node:*" has been externalized for browser compatibility`) into a hard `vite build` error, so a Node built-in in the browser graph now **fails `npm run build` / `validate`** instead of shipping a `{}` stub. This catches **both** the _called-at-init_ case (which `smoke:web:browser` also catches, but later/at runtime) **and** the _imported-but-never-called_ case (the `{}` stub that is invisible to the runtime smoke "by design" — see above). Because rolldown **swallows a throw inside `onLog`** (the one hook where a thrown error doesn't abort — verified against vite@8.0.0), the plugin _records_ the warning in `onLog` and re-throws in `buildEnd`. There is **no stable log `code`**, so the gate keys off the documented message phrasing; `npm run verify:build-gate` (`scripts/verify-build-gate.mjs`, in `npm run ci` and the GitHub workflow) runs a real build with a `node:fs` probe forced into `src/main.tsx` and asserts the build fails via the gate — the only check that catches the message phrasing **drifting** in a future Vite bump and silently disabling the gate. The gate is scoped to `vite build` (`apply: 'build'`) — never `vite dev` or the vitest projects — **and** to the browser (`client`) environment (`applyToEnvironment`), so a future SSR/node environment built from this config isn't failed for a legitimate `node:*` import; the Node runner build (tsup, `build:runner`) is a separate config where built-ins are legitimate. `smoke:web:browser` stays as the runtime backstop for crashes the build can't reason about. - `smoke:cli` (`scripts/smoke-cli.mjs`) drives `mcp-inspector --cli` through the built launcher against the bundled stdio test server via a temp `--catalog`: it asserts `tools/list` returns the server's tools (real connect over stdio), the default writable catalog is seeded empty on first run, a missing read-only `--config` errors without seeding, and `--catalog` + `--config` is rejected. `smoke:tui` (`scripts/smoke-tui.mjs`) launches `mcp-inspector --tui --catalog ` and asserts the Ink app renders its first frame (the "MCP Servers" panel) within a timeout, then SIGTERMs it — a shallow boot/render check, not full interaction. **`smoke:tui` is local-only: it self-skips when `process.env.CI` is set**, because the Ink TUI needs a real TTY (raw mode) that headless CI lacks — so run it (via `npm run smoke`) on your own machine before pushing. Both build `test-servers/build` on demand if it's missing. - Storybook play-function tests (`clients/web` `test:storybook`) run in headless Chromium via `@vitest/browser-playwright` (~10s). They are part of `npm run ci` (which installs Playwright chromium first); kept out of `validate` because they need the browser binary and are slower than the unit suite. @@ -761,6 +944,17 @@ Why it matters, given that these paths are all gitignored and the findings are u The two coverage guards do **not** catch this, and adding a third is not the fix. `verify:format-coverage` and `verify:typecheck-coverage` assert that first-party source is *covered*; neither asserts that generated output is *excluded* — an asymmetry that is deliberate, since a guard can't distinguish "generated" from "source" without being told, and the ignore lists are already that statement. So this class drifts silently and the check is a human one: **when a build starts writing to a new location, add it to that scope's ignore list in the same change.** The reverse of the guards' rule also holds — never widen an ignore to silence a finding in first-party code, and never add a build directory to a tsconfig `include` to make a generated `.d.ts` resolve (import the source, or fix the build's types). +### Lint has no warning tier + +**Every `lint` script runs with `--max-warnings 0`, so a warning fails `validate` exactly as an error does (#2085).** All six scopes carry the flag — each of `clients/{web,cli,tui,launcher}`'s `eslint .`, plus the root's `lint:core` and `lint:shared`. + +This exists because the gate's promise — that passing `npm run ci` locally means CI's gates pass — was kept while a real bug walked through it. `react-hooks/exhaustive-deps` ships at `warn` in the recommended set, and two `useCallback`s in `App.tsx` omitted a non-stable `refresh` from their dependency arrays; ESLint printed the right message on both lines on every run, nothing consumed it, and the stale closure was caught only by a review round on #2076. It is the same argument [Build output is never a gate target](#build-output-is-never-a-gate-target) makes from the other direction: a channel nobody fails on is one people learn to skim. + +Two consequences worth stating: + +- **Do not silence a finding to satisfy the gate.** A warning is now a defect to fix. If a rule genuinely must be waived on a line, use its inline disable comment **with a one-line justification** — the same standard this document sets for `v8 ignore` and for `void` on a floating promise. Widening a `globalIgnores` or dropping a rule to make `lint` pass is not an acceptable fix. +- **A rule left at `warn` still reads wrong in an editor.** The flag makes severity irrelevant to the *gate*, not to the developer looking at a squiggle. `react-hooks/exhaustive-deps` is therefore set to **`error`** in both React scopes (`clients/web`, `clients/tui`) rather than relying on the CLI flag alone. Prefer `error` for any rule you actually intend to enforce. + ### Typescript instructions - Use TypeScript for all new code diff --git a/Dockerfile b/Dockerfile index 244553985..84f40d291 100644 --- a/Dockerfile +++ b/Dockerfile @@ -32,11 +32,14 @@ ENV HOST=0.0.0.0 \ DANGEROUSLY_BIND_ALL_INTERFACES=true \ CLIENT_PORT=6274 \ MCP_SANDBOX_PORT=6275 \ + MCP_APP_ORIGIN_PORT=6278 \ MCP_AUTO_OPEN_ENABLED=false -# 6275 is the MCP Apps sandbox, a second listener the browser reaches directly. -# It is only needed for the Apps tab, so it is EXPOSEd but publishing it is +# 6275 is the MCP Apps sandbox and 6278 the dedicated app origin (#2056) — two +# further listeners the browser reaches DIRECTLY, so neither works through the +# 6274 publish alone. Both are only needed for the Apps tab (6278 only for an +# App declaring `_meta.ui.domain`), so they are EXPOSEd but publishing them is # optional — see the Docker section of the root README. -EXPOSE 6274 6275 +EXPOSE 6274 6275 6278 # Run as the non-root `node` user the base image ships. The inspector resolves # its runtime-state dir (default catalog, OAuth token storage) from `HOME` diff --git a/README.md b/README.md index 2c420c00a..1272b2b0d 100644 --- a/README.md +++ b/README.md @@ -30,10 +30,13 @@ inspector/ │ ├── tui/ # TUI client (Ink + React, tsup bundle) │ └── launcher/ # Shared launcher — provides the `mcp-inspector` bin, dispatches to web/cli/tui ├── core/ # Shared code consumed via the `@inspector/core` alias (no package.json) -│ ├── auth/ # OAuth: providers, discovery, storage, endpoint overrides, mid-session recovery (browser/node/remote backends) +│ ├── auth/ # OAuth: providers, discovery, storage, endpoint overrides, mid-session recovery (browser/node/remote backends); +│ │ # plus per-server secret storage — the keychain/file/memory SecretStore +│ │ # implementations, the selection policy, and the descriptor the banner and UI report │ ├── client/ # Install-level client config (`client.json`): browser-safe parse/validate + Node load/save, remote backend, secrets -│ ├── json/ # JSON + parameter/argument conversion utilities, and the nullable-union -│ │ # schema collapse shared by the web and TUI form builders +│ ├── json/ # JSON + parameter/argument conversion utilities, the nullable-union +│ │ # schema collapse shared by the web and TUI form builders, and the +│ │ # tool-schema portability lint all three clients report from │ ├── logging/ # Silent pino logger singleton │ ├── mcp/ # InspectorClient runtime, state stores, transports, config import, │ │ # and the RFC 6570 URI-template helpers the web form and TUI expand through @@ -137,9 +140,11 @@ This is what lets an Inspector connection negotiating `protocolEra: "auto" | "mo Each config below is a ready-made server for exercising one feature by hand. Load one with `--config`, and unless noted, connect with **Protocol Era = Modern**. -| Config | Demonstrates | Issue | -| ----------------------------------------- | -------------------------------------------------- | ---------------------------------------------------------------------- | +| Config | Demonstrates | Issue | +| ----------------------------------------- | --------------------------------------------------- | ---------------------------------------------------------------------- | | `mcp-app-http.json` **(legacy era)** | An MCP App (UI resource + app tool) in the Apps tab | [#1859](https://github.com/modelcontextprotocol/inspector/issues/1859) | +| `app-elicitation-http.json` **(legacy era)** | An MCP App rendering a form elicitation | [#1854](https://github.com/modelcontextprotocol/inspector/issues/1854) | +| `mcp-app-domain-http.json` **(legacy era)** | An MCP App asking for a dedicated origin (`_meta.ui.domain`) | [#2056](https://github.com/modelcontextprotocol/inspector/issues/2056) | | `modern-mrtr-http.json` | A single MRTR round-trip | — | | `mrtr-showcase-http.json` | Every MRTR preset in one server | [#1860](https://github.com/modelcontextprotocol/inspector/issues/1860) | | `modern-network-http.json` | Network tab: `Mcp-*` headers + error taxonomy | [#1628](https://github.com/modelcontextprotocol/inspector/issues/1628) | @@ -148,11 +153,13 @@ Each config below is a ready-made server for exercising one feature by hand. Loa | `structured-output-http.json` | Tools tab: a result's `structuredContent` section | [#1908](https://github.com/modelcontextprotocol/inspector/issues/1908) | | `duplicate-tool-names-http.json` | A `tools/list` that repeats a tool name | [#1957](https://github.com/modelcontextprotocol/inspector/issues/1957) | | `nullable-fields-http.json` | Tools tab: nullable (`anyOf` + `null`) arguments | [#1928](https://github.com/modelcontextprotocol/inspector/issues/1928) | +| `unportable-schemas-http.json` **(legacy era)** | Tool schemas a real client rejects, flagged in all three clients | [#1005](https://github.com/modelcontextprotocol/inspector/issues/1005) | | `rfc6570-templates-http.json` | Resources tab: RFC 6570 resource-template expansion | [#1919](https://github.com/modelcontextprotocol/inspector/issues/1919) | -| `advertised-extensions-http.json` | Tool registration gated on advertised extensions | [#1739](https://github.com/modelcontextprotocol/inspector/issues/1739) | -| `logging-{legacy,modern}-http.json` | Logging, both eras | [#1629](https://github.com/modelcontextprotocol/inspector/issues/1629) | -| `subscriptions-{legacy,modern}-http.json` | Resource subscriptions, both eras | [#1630](https://github.com/modelcontextprotocol/inspector/issues/1630) | -| `tasks-{legacy,modern}-http.json` | Tasks, both eras | [#1631](https://github.com/modelcontextprotocol/inspector/issues/1631) | +| `advertised-extensions-http.json` | Tool registration gated on advertised extensions | [#1739](https://github.com/modelcontextprotocol/inspector/issues/1739) | +| `oauth-custom-resource-metadata-http.json` **(legacy era)** | OAuth discovery driven by the challenge's `resource_metadata` | [#2071](https://github.com/modelcontextprotocol/inspector/issues/2071) | +| `logging-{legacy,modern}-http.json` | Logging, both eras | [#1629](https://github.com/modelcontextprotocol/inspector/issues/1629) | +| `subscriptions-{legacy,modern}-http.json` | Resource subscriptions, both eras | [#1630](https://github.com/modelcontextprotocol/inspector/issues/1630) | +| `tasks-{legacy,modern}-http.json` | Tasks, both eras | [#1631](https://github.com/modelcontextprotocol/inspector/issues/1631) | #### MCP Apps @@ -162,6 +169,35 @@ Open the Apps tab, select `mcp_app_demo`, give it a title and click **Open App** For the scripted version of the same flow (`--app-info` probe → deep link → rendered widget), see [Reviewing an MCP App](./docs/mcp-app-review.md). +#### An App's dedicated origin + +`mcp-app-domain-http.json` serves the same `mcp_app_demo` widget as above, with one addition: its UI resource declares `_meta.ui.domain`. Plain streamable-HTTP; connect with the **default (legacy)** protocol era. + +That field is how a server asks its host for a stable, dedicated origin. Without one, an App renders into a `srcdoc` frame sandboxed without `allow-same-origin`, so its document has an *opaque* origin and every request it makes carries `Origin: null` — which no CORS policy, OAuth callback, or API-key allowlist can admit ([#2056](https://github.com/modelcontextprotocol/inspector/issues/2056)). + +Open the Apps tab and run `mcp_app_demo`. The widget renders identically to `mcp-app-http.json` — the difference is not visual. Inspect the inner iframe in devtools: on this server it is served from `http://127.0.0.1:6278/app-document/` and `location.origin` is that real origin, where on `mcp-app-http.json` it is `about:srcdoc` with an origin of `null`. (The host is whatever the Inspector bound to — `127.0.0.1` by default, an *address* rather than the name `localhost`, for the reason `resolve-bind-host.ts` documents.) + +The spec makes `domain`'s format **host-dependent**, and the Inspector owns no domain infrastructure — so it reads any non-empty value as a *request* rather than an address, and answers with a real loopback origin of its own. See [MCP App dedicated origins](./clients/web/README.md#mcp-app-dedicated-origins-metauidomain) for the full contract, including what the one shared origin does and does not isolate, and how every failure falls back to the default render rather than blanking the app. + +#### App-rendered form elicitations + +`app-elicitation-http.json` serves `app_choose_option` alongside the `choose_option_app` UI resource (`ui://demo/choose-option.html`). The tool sends a completely ordinary form `elicitation/create` — the only thing added is `_meta.ui.resourceUri` naming that app. Plain streamable-HTTP; connect with the **default (legacy)** protocol era. + +Run `app_choose_option` from the Tools tab. The server's app renders in a modal instead of the built-in elicitation form, and clicking **Option A**, **Decline**, or **Cancel** returns the standard `ElicitResult` straight to the server, which echoes it into the tool result. + +App rendering is selected only when **all four** conditions hold ([#1854](https://github.com/modelcontextprotocol/inspector/issues/1854), per [ext-apps#733](https://github.com/modelcontextprotocol/ext-apps/pull/733) / SEP-3118): + +1. the client advertises `elicitation.form`; +2. the client advertises `extensions["io.modelcontextprotocol/ui"].mimeTypes` including `text/html;profile=mcp-app`; +3. **both** the client and the server advertise the nested `elicitation` setting on that same extension; +4. the request carries a valid absolute `ui://` URI in `_meta.ui.resourceUri`. + +Only the **web** client advertises the nested client-side setting, and only because it has a sandbox renderer to back it; the CLI and TUI advertise the MIME type (they know what an App is) but never claim they can resolve an elicitation through one, so the same server falls back to their native prompts. Turning **Server Settings → Advertised Extensions → MCP Apps UI** off, or turning form elicitation off, removes the claim on web too. + +Everything else falls back to the built-in elicitation form, by design: metadata that is absent or not an absolute `ui://` URI, a resource that fails to load, a sandbox or bridge that fails to initialize, an app that did not advertise `elicitation`, a request that times out, and any result that is not a valid `ElicitResult` for the requested schema. An explicit `decline` or `cancel` is **not** a fallback — it is a completed elicitation and goes back to the server as-is. + +> The Inspector speaks the ext-apps#733 wire protocol but does not yet consume its helpers: the released `@modelcontextprotocol/ext-apps` (1.7.5) predates that PR. `core/mcp/appElicitation.ts` and `clients/web/src/components/elements/AppRenderer/requestAppElicitation.ts` mirror it exactly and are marked for deletion in favour of the package's own exports once a release containing it ships. + #### MRTR `modern-mrtr-http.json` serves the `mrtr_confirm` tool (preset `mrtr_confirm`, `createMrtrTool`) over the modern leg. Its handler returns `inputRequired(...)` embedding a form elicitation, so invoking it produces a real round-trip: `input_required` → the client fulfils the embedded elicitation and retries with a new id → `complete`. @@ -252,6 +288,62 @@ Open the Tools tab and select `record_shipment`: `direction` must render as a ** The **TUI** had the same gap and is worth checking against the same server (`--tui`, then test `record_shipment`): `direction` is a select, `quantity` an integer field, `express` a boolean. Both clients now share one collapse step — `normalizeNullableUnion` in [`core/json/nullableUnion.ts`](./core/json/nullableUnion.ts) — precisely so they cannot drift on which schemas they can render. +#### Unportable tool schemas + +`unportable-schemas-http.json` serves four tools, three of whose advertised +schemas carry constructs that are legal JSON Schema and are refused or +mishandled by real MCP clients: + +| Tool | What it carries | +| --- | --- | +| `get_temp` | `outputSchema.properties.data` as a bare `true` — what Go's `jsonschema` package emits for `interface{}`, the case reported in [#1005](https://github.com/modelcontextprotocol/inspector/issues/1005) | +| `echo` | an array-form `"type": ["null","boolean"]`, and an `opts` property that constrains nothing | +| `add` | a property pointing at a remote `$ref` | +| `get_weather` | nothing — left clean, so a flagged tool sits beside an unflagged one | + +Plain streamable-HTTP — connect with the **default (legacy)** protocol era. +Every tool here is still **runnable**: the override replaces only the +*advertised* schema, and the flagship bare-`true` rides `get_temp` (which +returns structured content) rather than `echo` — see the ⚠️ caveat at the end +of this section for why that placement matters. + +The Inspector is where a server author looks first, so a construct that will +fail downstream is named here rather than passed through silently. All three +clients report the same verdict from +[`core/json/schemaLint.ts`](./core/json/schemaLint.ts), each with the room it +has: + +```bash +mcp-inspector --cli http://127.0.0.1:6603/mcp --method tools/list --strict # exits 6 +``` + +- **CLI** — `--strict` prints the full report (path, issue, suggested fix) on + stderr and exits `6` on an error-severity finding; without it, one summary + line. See [Schema portability](./clients/cli/README.md#schema-portability---strict). +- **TUI** — the tools list marks `get_temp` with a red `!` and `echo`/`add` + with a yellow `?`; the detail pane lists each finding under **Schema + Portability**. +- **Web** — the Tools sidebar row carries the same flag as a hover-labelled + icon, and selecting the tool shows a **Schema portability** section above the + argument form. + +This is deliberately **not** a JSON Schema validator. A census of 617 public +servers (14,804 tool schemas) reported on that issue found **0** that fail the +SDK's own `ListToolsResultSchema.safeParse`, so a conformance check would +report nothing on essentially every real server. What bites is the narrower +subset each consumer accepts, and each rule here is a construct known to be +refused or degraded by a shipping client. The schemas are supplied through the +test server's `rawToolSchemas` override, because the Zod-built presets cannot +express any of them — which is the same reason a real server hits this only +when its schemas come from another generator. + +⚠️ **If you add an `outputSchema` override of your own, put it on a tool that +returns structured content.** A conforming client validates a tool result +against the advertised output schema, so an override on a preset that returns +none makes every call to it fail with "declares an output schema but returned +no structured content" — a confusing thing to hit from a fixture. That is why +the bare `true` rides `get_temp` here and not `echo`. + #### RFC 6570 resource templates `rfc6570-templates-http.json` serves two resource templates straight out of [#1919](https://github.com/modelcontextprotocol/inspector/issues/1919) — `events_by_topic` (`foobar://events/{topic}`) and `events_by_query` (`foobar://events{?topic}`) — each echoing the URI it was matched against, plus a plain `foobar://events` resource (see below). Plain streamable-HTTP; connect with the **default (legacy)** protocol era. @@ -264,23 +356,23 @@ Open the Resources tab and pick **events_by_topic**, then enter `foo/bar`. The r The web client and the TUI expand through one shared helper, [`core/mcp/uriTemplate.ts`](./core/mcp/uriTemplate.ts) — the web Resources form directly, the TUI via `InspectorClient.readResourceFromTemplate` — and both derive their **form fields** from its parser too, which is the half that makes the sharing real: a form submits values under the names it rendered, so a parser that mangles a name silently drops the value at expansion time. (The CLI is not a consumer: it has no template form, and its `resources/read` passes the already-expanded `--uri` straight through.) -The SDK's `UriTemplate` is still used, but only to *validate* a template (constructing it is what rejects an unclosed expression). Its expander is not, because it is incomplete in five ways — each measured against the pinned SDK, not inferred: +The SDK's `UriTemplate` is still used, but only to _validate_ a template (constructing it is what rejects an unclosed expression). Its expander is not, because it is incomplete in five ways — each measured against the pinned SDK, not inferred: -| Shape | SDK behavior | -| --- | --- | -| `{a,b}` | raw-joins the values — no encoding, operator prefix dropped | -| `{;id}` | `;` is missing from its operator list, so the variable parses as `;id` | -| `{id:3}` | the prefix modifier is folded into the name, giving `id:3` | +| Shape | SDK behavior | +| --------------- | -------------------------------------------------------------------------------------------------------------- | +| `{a,b}` | raw-joins the values — no encoding, operator prefix dropped | +| `{;id}` | `;` is missing from its operator list, so the variable parses as `;id` | +| `{id:3}` | the prefix modifier is folded into the name, giving `id:3` | | `{+v}` / `{#v}` | `encodeURI` mangles reserved `[`/`]` (`[::1]` → `%5B::1%5D`) and double-encodes pct-triplets (`%2F` → `%252F`) | -| `{v}` | `encodeURIComponent` leaves the sub-delims `!'()*` bare, which RFC 6570 requires encoded | +| `{v}` | `encodeURIComponent` leaves the sub-delims `!'()*` bare, which RFC 6570 requires encoded | The `;` and `:3` rows are the ones a user sees directly: on the SDK's parse the form renders fields literally labelled `;id` and `id:3`. The `+`/`#` row is silent corruption rather than over-escaping — an IPv6 literal or an already-encoded path arrives at the server altered. A template that cannot be expanded at all — an out-of-grammar modifier (`{id:abc}`), or an expression declaring no variable (`{}`, `{a,}`, `{?}`) — **withholds the read** rather than sending something. Pick **events_malformed** (`foobar://events/{topic:abc}`) to see it: Read Resource is disabled, the reason is printed under the form, and the preview shows the template as the server declared it. The alternative is worse than it looks: `x://{}` would otherwise expand to `x://` with no inputs rendered, so the form's "everything required is filled" check passes vacuously and it reads a URI that is not the template the server published. -Literals are pct-encoded on expansion too (RFC 6570 §3.1): `café/{var}` sends `caf%C3%A9/value`, not raw UTF-8 in the path — something the SDK's expander does not do either. And the *names* a template may use are RFC 6570's `varchar` plus a labelled tolerance for `-` and `~`: the conformance suite rejects `{default-graph-uri}`, but real servers publish such names and the SDK's matcher round-trips them, so the Inspector expands them and marks the variable `conforming: false` rather than refusing a resource that demonstrably works. +Literals are pct-encoded on expansion too (RFC 6570 §3.1): `café/{var}` sends `caf%C3%A9/value`, not raw UTF-8 in the path — something the SDK's expander does not do either. And the _names_ a template may use are RFC 6570's `varchar` plus a labelled tolerance for `-` and `~`: the conformance suite rejects `{default-graph-uri}`, but real servers publish such names and the SDK's matcher round-trips them, so the Inspector expands them and marks the variable `conforming: false` rather than refusing a resource that demonstrably works. -An **undefined** variable is what omits its expression — a variable defined as the empty string expands (`x{?q}` gives `x?q=`, `x{;q}` gives `x;q`, per RFC 6570 §3.2.7). The expander honors that distinction, so a caller such as `readResourceFromTemplate` can request either URI. Collapsing the two is a *form* concern, not a template one: both clients seed every declared variable with `""` and a text input cannot express "defined but empty", so each form drops its blanks (`definedValues`) on the way in. +An **undefined** variable is what omits its expression — a variable defined as the empty string expands (`x{?q}` gives `x?q=`, `x{;q}` gives `x;q`, per RFC 6570 §3.2.7). The expander honors that distinction, so a caller such as `readResourceFromTemplate` can request either URI. Collapsing the two is a _form_ concern, not a template one: both clients seed every declared variable with `""` and a text input cannot express "defined but empty", so each form drops its blanks (`definedValues`) on the way in. Requiredness is a property of the **expression**, not the variable: RFC 6570 drops undefined names from a multi-name expression, so `{a,b}` with only `a` filled is expandable and a form must not block it. `requiredGroups` returns one entry per non-omittable expression and `hasRequiredValues` asks that each be satisfied by any one of its names — which no per-variable flag can express once a name recurs across expressions (`{a,b}{a,c}` is satisfied by filling `b` and `c`). @@ -294,6 +386,23 @@ Requiredness is a property of the **expression**, not the variable: RFC 6570 dro This is the debugging knob for a server legitimately changing tool registration based on what the client advertises. Legacy stateful leg only — the modern per-request leg has no persistent `oninitialized`. +#### OAuth `resource_metadata` at a non-default path + +`oauth-custom-resource-metadata-http.json` is an OAuth-protected server (combined AS + resource, DCR enabled) that serves its RFC 9728 protected-resource metadata document **only** from `/custom/protected-resource`, and advertises it on every 401: + +```http +HTTP/1.1 401 Unauthorized +WWW-Authenticate: Bearer resource_metadata="http://127.0.0.1:8082/custom/protected-resource" +``` + +The default `/.well-known/oauth-protected-resource` route is deliberately left unserved, so a client that ignores the advertised URL cannot discover the document at all. Plain streamable-HTTP — connect with the **default (legacy)** protocol era. + +Add the server, click **Connect**, and watch the Inspector's first protected-resource metadata request in the Network tab: it must go to `/custom/protected-resource`. On the broken build the challenge's `resource_metadata` was parsed and then dropped before the SDK's `auth()` ever saw it ([#2071](https://github.com/modelcontextprotocol/inspector/issues/2071)), so discovery probed locations derived from the MCP server URL, 404'd, and authorization failed for any server that puts the document somewhere other than the well-known path. + +The same server is worth running against `--cli` / `--tui`, which reach it by a different route: with no stored token in the legacy era the Inspector connects with no auth provider (so the SDK cannot open a browser before the callback server is listening), the 401 surfaces as the SDK's headerless `UnauthorizedError`, and the client calls `authenticate()` with no challenge in hand. The transport therefore *observes* every 401/403 passively, so the advertised URL is still available on that path. + +The value now rides the normalized `AuthChallenge` as a string — it has to be serializable, because the web client's challenge crosses the remote-backend boundary as JSON — and is converted to a `URL` at the OAuth boundary, where it is handed to `auth()` as `resourceMetadataUrl` and to the CIMD pre-registration probe, which runs *before* `auth()` and would otherwise do its own default-location discovery. A malformed value is ignored rather than surfaced, matching the SDK's own `WWW-Authenticate` parser: discovery falls back to the default locations instead of failing the whole authorization on a bad header. The callback leg needs nothing extra — SDK `auth()` persists the URL in its discovery state, so it survives both the web full-page redirect and the CLI/TUI loopback callback. + #### Logging, both eras `logging-legacy-http.json` and `logging-modern-http.json` both serve `logging: true` plus a `send_notification` tool that emits a `notifications/message` at a chosen level. The legacy one is a plain streamable-HTTP server; the modern one sets `transport.modern: true`. @@ -343,19 +452,22 @@ Each client self-validates from its own folder; the root scripts chain them. The | ----------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `npm run validate` | Runs the three durable guards first — `verify:format-coverage` (every tracked source file is format-gated), `verify:typecheck-coverage` (every one lands in a tsconfig project), `verify:dep-lockstep` (no dependency reaching one `tsc` program from two installs skews across them) — then `test:scripts` (the guards' own parser unit tests), then `validate:core` (the shared `core/` `format:check` + `lint` gate), then per client: `format:check` + `lint` + **`typecheck`** (cli/tui/launcher; web typechecks via `tsc -b` inside its `build`) + `build` + fast unit tests. The quick inner-loop check. | | `npm run coverage` | The **per-file ≥90% gate** (lines/statements/functions/branches) under v8 instrumentation, per client. CI-enforced. For web this also runs the integration project and covers the shared `core/` runtime (including `core/json` and `core/client`). | -| `npm run smoke` | End-to-end smokes through the built launcher (`--help` dispatch + prod cli/tui/web), plus two headless-Chromium smokes: a boot smoke that runs the prod web bundle and asserts a clean first render (no uncaught error — sync exception or unhandled rejection, how a Node built-in reaching the browser bundle manifests), and an **MCP Apps** smoke (`smoke:web:app`) that drives connect → open app → `data-app-status="ready"` against a composable App server, covering the sandbox proxy and UI-protocol bridge. | +| `npm run smoke` | End-to-end smokes through the built launcher (`--help` dispatch + prod cli/tui/web), plus three headless-Chromium smokes: a boot smoke that runs the prod web bundle and asserts a clean first render (no uncaught error — sync exception or unhandled rejection, how a Node built-in reaching the browser bundle manifests), and an **MCP Apps** smoke (`smoke:web:app`) that drives connect → open app → `data-app-status="ready"` against a composable App server, covering the sandbox proxy and UI-protocol bridge, and an **app-rendered elicitation** smoke (`smoke:web:elicit`) that drives one end to end — call the tool, answer inside the sandboxed app, see the app's `ElicitResult` reach the server — and then the same tool against a server that never advertised the capability, which must fall back to the native elicitation form. | | `npm run verify:build-gate` | Runs a real `vite build` with a Node built-in forced into the browser graph and asserts the build **fails** via the #1769 gate (which turns Vite's browser-externalization warning into a hard error). Guards against the warning phrasing drifting in a Vite bump and silently disabling the gate. Part of `npm run ci`. | +| `npm run verify:bundle-externals` | Guards the must-not-bundle invariant (#2067): for each tsup-bundled client it reads the **built** `build/` output and fails if any package that must stay external was inlined anyway. Candidates are the union of the client's own `external` array **and the root manifest's `dependencies`** — the latter because #2067 was a *missing* `external` entry, which a self-referential check would have passed. Detection is via esbuild's `// ` module banners, so it covers both shapes — a separate `-HASH.js` chunk (what a dynamically `import()`ed CommonJS package produces) and a statically-imported package folded straight into `index.js`, which emits no chunk at all. A build with no banners fails as such rather than passing clean, so enabling `minify` cannot silently retire the check. `undici` was declared only in the root and `clients/cli` manifests, and tsup auto-externalizes only what the _nearest_ manifest declares, so the web and TUI bundles inlined 1.05MB of it — and CommonJS inlined into an ESM bundle throws `Dynamic require of "assert" is not supported` on first use, from a specifier no user-side install can satisfy. Reads the output rather than the config because those two disagreed for four releases. Part of `npm run ci`. | | `npm run verify:format-coverage` | Parses the `format:check` globs out of every `package.json` (only those reachable from `validate`), enumerates all tracked source files, and **fails** listing any not covered by a glob — the durable guard for the "every first-party source file is format-gated" invariant (#1792). Runs first in `validate`. | -| `npm run test:scripts` | Table-driven unit tests (`node --test`) for the guard's own pure parsers (`scripts/lib/npm-scripts.mjs`, `scripts/lib/tsc-program.mjs` + the exported helpers of `verify-typecheck-coverage.mjs` and `verify-dep-lockstep.mjs`), one case per rule they encode, plus `scripts/lib/resolve-node-bin.test.mjs` — the cross-platform bin resolver (#1939), pinned against the real `bin`/`exports` shapes of the packages the scripts actually spawn. Runs in `validate` — and `verify:typecheck-coverage` guards *this* gate in turn (reachable from `validate`, non-empty test set, every test file matched by the `test:scripts` glob), since `node --test` silently skips a file its glob misses and still exits 0. | +| `npm run test:scripts` | Table-driven unit tests (`node --test`) for the guard's own pure parsers (`scripts/lib/npm-scripts.mjs`, `scripts/lib/tsc-program.mjs` + the exported helpers of `verify-typecheck-coverage.mjs` and `verify-dep-lockstep.mjs`), one case per rule they encode, plus two suites over shared `scripts/lib` helpers that no smoke can check itself: `resolve-node-bin.test.mjs` — the cross-platform bin resolver (#1939), pinned against the real `bin`/`exports` shapes of the packages the scripts actually spawn — and `announced-child.test.mjs` — the spawn/readiness ownership helper (#2000), which drives real `node -e` children to prove a child that never announces is still published to the caller before the timeout throws, and so is reachable by teardown rather than orphaned. Two more do the same: `mcp-app-flow.test.mjs` covers the shared MCP Apps flow (#2003) — the deep link's two CSRF gates and `appArgs` encoding, plus `driveAppFlow`'s failure branches against a stand-in page, all of which are dead code from the happy-path smokes' point of view and would otherwise surface only as opaque timeouts; and `ensure-test-servers.test.mjs` pins the [#2111](https://github.com/modelcontextprotocol/inspector/issues/2111) invariant — that `test-servers/build` is rebuilt **even when it already exists** — which no smoke can assert about itself, since one driving a stale fixture reports a product failure rather than a staleness one. Runs in `validate` — and `verify:typecheck-coverage` guards *this* gate in turn (reachable from `validate`, non-empty test set, every test file matched by the `test:scripts` glob), since `node --test` silently skips a file its glob misses and still exits 0. | | `npm run verify:typecheck-coverage` | The typecheck-coverage analog of the above (#1791): for each Node client (auto-discovered from disk — enrolled via its `typecheck` script's projects, or for a `tsc -b` client like `clients/web` via its `tsconfig.json` `references`) it runs those projects with `tsc --listFilesOnly`, unions them, and **fails** listing any tracked `.ts`/`.tsx`/`.mts`/`.cts` under the client that lands in no project (so a new top-level config/helper can't silently go untypechecked). It also requires, deny-by-default, the first-party TS no client owns (`test-servers/src`, the root `vitest.shared.mts`, all of `core/`, and any new top-level location) to land in some client project's tsc pass — so a `core` `*.tsx` web's projects don't reach is caught too. Also asserts the gate is wired (each client's typecheck pass — its `typecheck` script, or web's `tsc -b` — is reachable from its `validate`, and the root chain runs each client's `validate`). Runs in `validate`. | | `npm run verify:dep-lockstep` | Guards the "one version per install-crossing dependency" invariant (#1896). v2 is not a workspace, so a client's test project compiles the shared first-party TypeScript — `core/`, `test-servers/src`, and the root-owned `vitest.shared.mts`, all of which resolve their dependencies from the **root** install — alongside the client's own sources, putting the same package in one `tsc` program twice. At the same version that's harmless; skewed, TypeScript must relate two structurally-distinct copies of every type, which for a recursive-generic surface is exponential (zod `4.3.6` vs `4.4.3` exhausted the 4GB tsc heap in `clients/web`). Derives its candidate set from **what actually enters each program** (#1965) — every client tsconfig project listed with `tsc --listFilesOnly` via the shared `scripts/lib/tsc-program.mjs`, each resolved `node_modules` file mapped to its owning install, keeping the packages that reach one program from two installs (a package whose declarations arrive only through another package's `.d.ts`, as `@modelcontextprotocol/sdk`'s do, is invisible to a scan of first-party imports). Prices each copy from the lockfile entry for the exact install path the program resolved, compares only the installs that met in one program, and **fails deny-by-default** on any disagreement not in the annotated `TOLERATED_SKEW` allowlist — empty today — with an allowlisted package tolerated only *within a major version*. Runs in `validate`. -| `npm run ci` | **Mandatory pre-push command.** `validate` → `coverage` → `verify:build-gate` → `smoke` → Storybook. A true superset of GitHub CI. | +| `npm run ci` | **Mandatory pre-push command.** `validate` → `coverage` → `verify:build-gate` → `verify:bundle-externals` → `smoke` → Storybook. A true superset of GitHub CI. | | `npm run pack:verify` | Publish smoke — see [Publishing](#publishing). | Per-client scripts exist too (`validate:web`, `coverage:cli`, `smoke:tui`, …), plus root `validate:core` / `format:core` for the shared `core/` package, `format:scripts` for the root `scripts/` tooling, and `format:shared` / `lint:shared` for the root "shared" surface (`test-servers/src/**`, `vitest.shared.mts`, the root `eslint.config.js`). Run `npm run format` before committing — the root `format` fixes `core/`, the root `scripts/`, the shared surface, and every client; `validate` runs the non-fixing `format:check` and fails CI on any unformatted file. **Linting is type-aware.** All five ESLint scopes (`clients/{web,cli,tui,launcher}` plus the root `core/` + shared gate) enable `@typescript-eslint/no-floating-promises` at `error`, so a promise that is neither awaited, returned, `.catch(…)`-terminated, nor explicitly discarded with `void` fails `lint` — and therefore `validate` ([#1959](https://github.com/modelcontextprotocol/inspector/issues/1959)). The rule needs type information, so each scope's config names a parser project; the root scope's is **`tsconfig.lint.json`**, a lint-only project covering `core/**`, `test-servers/src/**`, and `vitest.shared.mts`, which have no tsconfig of their own. It emits nothing and changes no typecheck — but a new first-party TS location added to the root lint scope must be added to its `include`. See **TypeScript instructions** in [`AGENTS.md`](./AGENTS.md) for when `void` is acceptable. +**And lint has no warning tier.** Every `lint` script runs with `--max-warnings 0`, so a warning fails `validate` exactly as an error does ([#2085](https://github.com/modelcontextprotocol/inspector/issues/2085)) — a `warn`-level `react-hooks/exhaustive-deps` finding had otherwise let a stale-closure bug pass the mandatory pre-push gate and reach review. Fix the finding rather than silencing it; if a rule genuinely must be waived, use its inline disable comment with a one-line justification. + For the full testing rules — the ≥90% per-file gate, where test files live, the unit vs. integration vs. storybook projects, and the `v8 ignore` policy — see [`AGENTS.md`](./AGENTS.md). ## Publishing @@ -368,13 +480,13 @@ The root `package.json` `"files"` allowlist is the source of truth for the tarba - **No source maps.** The client bundlers set `sourcemap: false` (`clients/{cli,tui}/tsup.config.ts`, `clients/web/tsup.runner.config.ts`); Vite and the launcher's `tsc` already emit none. Maps are ~half the unpacked size and aren't needed at runtime — debug via `npm run dev` on the source. - **`clients/web/build` ships via `clients/web/.npmignore`.** `clients/web/.gitignore` lists `build/`, and npm's packlist honors that nested `.gitignore` over the root `"files"` allowlist — so the prod web-server runner was silently missing from the tarball while `clients/web/dist` slipped through (its `.gitignore` only lists `dist-ssr`). `clients/web/.npmignore` overrides the `.gitignore` for publishing so both `build/` (runner) and `dist/` (SPA) ship. The other clients don't need this — none ship a nested `.gitignore`. -- **`clients/web/static` ships the MCP Apps sandbox proxy.** `clients/web/static/sandbox_proxy.html` is a committed source file (not a build artifact), read from disk at runtime by `clients/web/server/sandbox-controller.ts` as `/../static/sandbox_proxy.html`. It was missing from the root `"files"` allowlist entirely, so every published build failed the Apps tab with **"Sandbox not loaded"** ([#1859](https://github.com/modelcontextprotocol/inspector/issues/1859)) while working fine in the repo. Because the path is resolved _relative to_ `clients/web/build`, the directory must ship at that exact location — `pack:verify` asserts both the tarball entry and the installed-on-disk path. -- **A dependency that renders React is bundled, not externalized.** An externalized package resolves its own `react` from wherever npm placed **it** in the consumer's tree, which is not necessarily where the bundle resolves ours — npm places a package beside a React satisfying *its* peer range, and those ranges are looser than ours. `ink-form` and `ink-scroll-view` declare `">=18"`, so a project holding React 18 satisfies them and gets them hoisted while the Inspector's React 19 nests underneath: two React copies, and the TUI dies with `TypeError: Cannot read properties of null (reading 'useState')` the moment a tool test form or a scroll view mounts ([#1952](https://github.com/modelcontextprotocol/inspector/issues/1952)). Both are therefore inlined by `clients/tui/tsup.config.ts` and are **not** root dependencies: the tarball ships their code inside `clients/tui/build/index.js` rather than having consumers install them. Bundling also pins their transitive deps to what this repo's install resolved (notably `ink-select-input@6` via `overrides`, which npm ignores for a package installed as a dependency). **`ink` is the one exception, on cost:** bundling it works but adds ~1.4 MB (`react-reconciler` and `yoga-layout` come along, plus a `createRequire` banner for the inlined CJS), so it stays external — *not* because its `">=19"` peer makes it safe, which it does not. What keeps that tolerable is the root `react` range: `"^19.0.0"` is deliberately open to the whole major so npm can dedupe our React with whatever React 19 a consumer pins, leaving an external `ink` on the same copy the bundle uses. **Narrowing that range reopens the bug for the renderer itself** — `clients/tui/__tests__/tsupConfig.test.ts` pins it to `ink`'s peer floor, and guards the rest of the split; see the [TUI README](./clients/tui/README.md#bundling-react-rendering-dependencies-must-be-inlined-1952). +- **`clients/web/static` ships the MCP Apps sandbox proxy.** `clients/web/static/sandbox_proxy.html` is a committed source file (not a build artifact), read from disk at runtime by `clients/web/server/sandbox-controller.ts` as `/../static/sandbox_proxy.html`. It was missing from the root `"files"` allowlist entirely, so every published build failed the Apps tab with **"Sandbox not loaded"** ([#1859](https://github.com/modelcontextprotocol/inspector/issues/1859)) while working fine in the repo. Because the path is resolved _relative to_ `clients/web/build`, the directory must ship at that exact location — `pack:verify` asserts the tarball entry, the installed-on-disk path, and (since [#2003](https://github.com/modelcontextprotocol/inspector/issues/2003)) that a widget actually *loads through* it on the installed bin. Presence and reachability are different properties: a rename with a stale reader ships a file that is there and unusable. +- **A dependency that renders React is bundled, not externalized.** An externalized package resolves its own `react` from wherever npm placed **it** in the consumer's tree, which is not necessarily where the bundle resolves ours — npm places a package beside a React satisfying _its_ peer range, and those ranges are looser than ours. `ink-form` and `ink-scroll-view` declare `">=18"`, so a project holding React 18 satisfies them and gets them hoisted while the Inspector's React 19 nests underneath: two React copies, and the TUI dies with `TypeError: Cannot read properties of null (reading 'useState')` the moment a tool test form or a scroll view mounts ([#1952](https://github.com/modelcontextprotocol/inspector/issues/1952)). Both are therefore inlined by `clients/tui/tsup.config.ts` and are **not** root dependencies: the tarball ships their code inside `clients/tui/build/index.js` rather than having consumers install them. Bundling also pins their transitive deps to what this repo's install resolved (notably `ink-select-input@6` via `overrides`, which npm ignores for a package installed as a dependency). **`ink` is the one exception, on cost:** bundling it works but adds ~1.4 MB (`react-reconciler` and `yoga-layout` come along, plus a `createRequire` banner for the inlined CJS), so it stays external — _not_ because its `">=19"` peer makes it safe, which it does not. What keeps that tolerable is the root `react` range: `"^19.0.0"` is deliberately open to the whole major so npm can dedupe our React with whatever React 19 a consumer pins, leaving an external `ink` on the same copy the bundle uses. **Narrowing that range reopens the bug for the renderer itself** — `clients/tui/__tests__/tsupConfig.test.ts` pins it to `ink`'s peer floor, and guards the rest of the split; see the [TUI README](./clients/tui/README.md#bundling-react-rendering-dependencies-must-be-inlined-1952). - **A single version number, read from the root `package.json`.** The Inspector ships as one package with one version, so only the **root** `package.json` carries a `version` — the four `clients/*/package.json`s deliberately have none. Every Node client (CLI, TUI, and the web backend) resolves the version through the shared `readInspectorVersion()` reader in `core/node/version.ts`, which walks up to the root manifest (always present in the tarball). No client `package.json` is read at runtime, so none needs to ship. The web **browser** can't read the filesystem; it gets its version from the backend via `GET /api/config` (see [#1639](https://github.com/modelcontextprotocol/inspector/issues/1639)). ### `npm run pack:verify` — publish smoke against the real tarball -The `smoke:*` scripts run against the in-repo build tree, which is **not** the published package. `npm run pack:verify` (`scripts/pack-and-verify.mjs`) closes that gap: it builds, `npm pack`s the publishable tarball (asserting no source maps ship and that the runtime-required files are present), installs the tarball into a **clean throwaway consumer** — a fresh temp directory where it runs a real `npm install ` (pulls runtime deps, runs `postinstall`), exactly as `npx @modelcontextprotocol/inspector` would — and drives the installed `mcp-inspector` bin end to end: `--help` dispatch, a real `--cli tools/list` over stdio, and a prod `--web` boot that must serve `/` from the shipped `dist`. It catches "works in `--dev`, breaks under `npx …`" path/packaging failures. It requires network access (the install pulls deps), so it is a local / release check, **not** part of the fast `validate`/`ci` loop. +The `smoke:*` scripts run against the in-repo build tree, which is **not** the published package. `npm run pack:verify` (`scripts/pack-and-verify.mjs`) closes that gap: it builds, `npm pack`s the publishable tarball (asserting no source maps ship and that the runtime-required files are present), installs the tarball into a **clean throwaway consumer** — a fresh temp directory where it runs a real `npm install ` (pulls runtime deps, runs `postinstall`), exactly as `npx @modelcontextprotocol/inspector` would — and drives the installed `mcp-inspector` bin end to end: `--help` dispatch, a real `--cli tools/list` over stdio, a prod `--web` boot that must serve `/` from the shipped `dist`, and — riding that same boot — an **MCP App rendered in headless Chromium** through the shipped sandbox proxy, connect → open app → `data-app-status="ready"` ([#2003](https://github.com/modelcontextprotocol/inspector/issues/2003)). That last step shares its flow with `smoke:web:app` via `scripts/lib/mcp-app-flow.mjs` rather than copying it, since the deep-link shape is the part that rots silently; the *client* comes from the install while the App test server stays a repo fixture. It catches "works in `--dev`, breaks under `npx …`" path/packaging failures. It requires network access (the install pulls deps), so it is a local / release check, **not** part of the fast `validate`/`ci` loop. ### Cutting a release @@ -401,7 +513,7 @@ npm version minor --no-git-tag-version # or major / patch; bump only, no tag **2. Merge `v2/main` → `main`** through the usual milestone-merge branch. It now carries the bump, so the release lands on `main` with the version already correct. -Between steps 1 and 2 the two branches **do** differ, and that is expected, not drift: `v2/main` reads the version being built while `main` still reads the one currently released. What this ordering removes is *post-release* drift — once the milestone merge lands they agree again, and `v2/main` is never left **behind** `main`. If you see `v2/main` ahead of `main`, a release is in flight; if you see it behind, something went wrong. +Between steps 1 and 2 the two branches **do** differ, and that is expected, not drift: `v2/main` reads the version being built while `main` still reads the one currently released. What this ordering removes is _post-release_ drift — once the milestone merge lands they agree again, and `v2/main` is never left **behind** `main`. If you see `v2/main` ahead of `main`, a release is in flight; if you see it behind, something went wrong. **3. Tag the `main` commit and draft the Release:** @@ -417,7 +529,7 @@ git tag 2.3.0 origin/main && git push origin 2.3.0 The release's target commit selects which workflow runs, so this only publishes when a release is cut from a commit carrying this (v2) workflow. -**Why the bump goes on `v2/main` first ([#2010](https://github.com/modelcontextprotocol/inspector/issues/2010)).** It used to happen on the milestone-merge branch, which is cut from `main` — so the bump existed only *downstream* of `v2/main` and nothing carried it back. `v2/main` sat at `2.0.0` through both the 2.1.0 and 2.2.0 releases. That is not cosmetic: a branch cut from a milestone-merge branch silently carries the bump into an unrelated PR (this happened on [#2009](https://github.com/modelcontextprotocol/inspector/issues/2009), where a container bugfix arrived with a `2.0.0 → 2.2.0` diff), and anything reading the version in development — `readInspectorVersion()`, `--version`, `GET /api/config` — reported a version two releases old. +**Why the bump goes on `v2/main` first ([#2010](https://github.com/modelcontextprotocol/inspector/issues/2010)).** It used to happen on the milestone-merge branch, which is cut from `main` — so the bump existed only _downstream_ of `v2/main` and nothing carried it back. `v2/main` sat at `2.0.0` through both the 2.1.0 and 2.2.0 releases. That is not cosmetic: a branch cut from a milestone-merge branch silently carries the bump into an unrelated PR (this happened on [#2009](https://github.com/modelcontextprotocol/inspector/issues/2009), where a container bugfix arrived with a `2.0.0 → 2.2.0` diff), and anything reading the version in development — `readInspectorVersion()`, `--version`, `GET /api/config` — reported a version two releases old. Do **not** "fix" a future drift by merging `main` back into `v2/main`. `main` carries the entire pre-v2 v1 history (retained through `ec5d8e13 chore: replace main's tree with v2` — ~230 commits `v2/main` does not have), so a back-merge grafts all of it into the develop branch's log permanently in order to deliver a two-file change. Bumping first means there is nothing to back-merge. @@ -441,7 +553,14 @@ docker run --rm -p 127.0.0.1:6274:6274 -p 127.0.0.1:6275:6275 \ ghcr.io/modelcontextprotocol/inspector ``` -Publish it on the **same port number** inside and out. The sandbox URL is handed to the browser via `/api/config` as `http://localhost:/sandbox`, so remapping it (`-p 9000:6275`) advertises a port the browser can't reach; use `-e MCP_SANDBOX_PORT=9000 -p 127.0.0.1:9000:9000` instead. +**And `6278` if your app declares `_meta.ui.domain`.** That is the spec field a server uses to ask its host for a stable, dedicated origin — without one the app runs at an opaque origin and its requests carry `Origin: null`, which no CORS / OAuth-callback / API-key allowlist can admit. The Inspector answers the request with a real loopback origin on a third listener, `MCP_APP_ORIGIN_PORT` (default `6278`); apps that declare no `domain` never touch it. **Publish it if you use one** — this is the one failure that does not fall back: the backend publishes fine (its listener bound inside the container), so it hands the browser a URL on a port the browser cannot reach, and that app's frame stays **blank**. The cross-origin navigation failure is not observable from the page, so there is no opaque-origin fallback and no console warning here; those cover the failures the *backend* can see (no listener, a port that never bound, an older backend). See [MCP App dedicated origins](./clients/web/README.md#mcp-app-dedicated-origins-metauidomain) for the host-specific contract and its isolation trade-offs. + +```bash +docker run --rm -p 127.0.0.1:6274:6274 -p 127.0.0.1:6275:6275 -p 127.0.0.1:6278:6278 \ + ghcr.io/modelcontextprotocol/inspector +``` + +Publish each on the **same port number** inside and out. The sandbox URL is handed to the browser via `/api/config` as `http://localhost:/sandbox`, so remapping it (`-p 9000:6275`) advertises a port the browser can't reach; use `-e MCP_SANDBOX_PORT=9000 -p 127.0.0.1:9000:9000` instead. The same holds for the app origin: the URL a published app document is served from is built from the port the container bound, so remap with `-e MCP_APP_ORIGIN_PORT=9001 -p 127.0.0.1:9001:9001` rather than with `-p` alone. **Keep the `127.0.0.1:` prefix on the published port.** A bare `-p 6274:6274` publishes on **every host interface**, putting the Inspector on your local network. The container's `HOST=0.0.0.0` is a separate concern — it governs the _container's_ interfaces, not the host's — so the `DANGEROUSLY_BIND_ALL_INTERFACES` opt-in that guards a wildcard bind outside a container does not cover this. It matters more here than for an ordinary web app: the backend spawns processes on request, `GET /` embeds the API token into the served HTML, and a request arriving with **no** `Origin` header skips the origin allow-list entirely — so for any non-browser client the API token is the only guard. Publishing wider needs a real access-control boundary in front of the Inspector — a reverse proxy that authenticates, an SSH tunnel, a private network. Setting your own `MCP_INSPECTOR_API_TOKEN` does **not** substitute: `GET /` discloses whatever token is in use, so a custom one is harvested exactly as easily as a generated one. @@ -455,6 +574,45 @@ docker run --rm -p 127.0.0.1:6274:6274 \ The same volume also persists OAuth tokens and stored state, so an authorized server stays authorized across runs. Use `-e MCP_CATALOG_PATH=/some/other/path.json` to put the catalog somewhere else — mount a volume covering whatever directory you point it at. If you **bind-mount a host directory** instead of a named volume (`-v "$PWD/inspector-data:/home/node/.mcp-inspector"`), the directory keeps its host ownership, so on Linux add `--user "$(id -u):$(id -g)"` or `chown` it to uid `1000` — otherwise the non-root `node` user can't write and adding a server fails with `EACCES`. +**Where secrets go, and how to make them survive (#1950).** The Inspector keeps the values it deliberately does _not_ write to `mcp.json` — an OAuth client secret, an enterprise IdP client secret, each stdio `env:` value — in the **OS keychain**. A container has no keychain (the published image has no D-Bus session), so on startup the Inspector probes for one and falls back, saying so in the logs and in a permanent footer at the bottom of the Client Settings and Server Settings dialogs. Which fallback you get depends on whether the directory it would write to is going to survive: + +| Situation | Store | Secrets survive a restart? | +| -------------------------------------------------------------- | -------------------------------------------- | -------------------------- | +| Keychain reachable (a normal desktop install) | OS keychain | Yes | +| Container, **no volume** on `/home/node/.mcp-inspector` | Memory | No — session only | +| Container **with** that volume, or any host without a keychain | `~/.mcp-inspector/secrets.json`, mode `0600` | Yes | + +So the same volume that keeps your server list also switches secrets from session-scoped to durable — nothing extra to configure. The in-memory default for an unmounted container is deliberate: a file in the writable layer is discarded by `--rm` and by every image update, and promising durability it can't deliver is worse than declining to. + +**A file-backed store is unencrypted unless you give it a key.** Set `MCP_INSPECTOR_SECRET_KEY` and the file is encrypted with AES-256-GCM (the passphrase is stretched with scrypt against a per-file random salt). Without it the file is still `0600`, but the values are readable to anyone who can read the file — which the startup log and the settings footer both say, every session, in a warning tone: + +```bash +docker run --rm -p 127.0.0.1:6274:6274 \ + -v mcp-inspector-data:/home/node/.mcp-inspector \ + -e MCP_INSPECTOR_SECRET_KEY="$MY_PASSPHRASE" \ + ghcr.io/modelcontextprotocol/inspector +``` + +**Use a high-entropy passphrase — generated, not chosen.** The random salt stops an attacker precomputing a table across files; it does nothing against _guessing_, and the scrypt cost is deliberately low because the derivation runs on every read and write. Anyone who obtains `secrets.json` can therefore test candidate passphrases quickly and offline, so treat this value like any other credential rather than like a memorable password. + +Setting the passphrase later is safe — the next write upgrades an existing plaintext file in place. Until that write happens the existing values really are still readable, and the banner and footer keep saying so rather than reporting the file as encrypted the moment the variable appears. **Changing or losing the passphrase is not safe**: a file that can no longer be decrypted is read as empty and _refuses to be written_, rather than being silently replaced with a new one holding only your latest secret. Restore the original passphrase, or delete `secrets.json` and re-enter the values. + +The Inspector writes the file `0600` and re-tightens it at startup if something loosened it. If it _cannot_ — the file belongs to another user, or the mount is read-only — it says so in the log rather than continuing to describe the file as protected, since on that box the mode claim above is not true. + +**Two Inspectors, one file.** Within a process, mutations are serialized per file path, so a web session's own concurrent saves cannot lose each other. Across processes — a CLI run beside a web session — each mutation takes an exclusive lock on `secrets.json.lock` for the whole read-modify-write, using [`proper-lockfile`](https://github.com/moxystudio/node-proper-lockfile) (the same library npm itself locks with). The lock expires 10 seconds after its holder stops refreshing it, so an Inspector that is killed mid-save does not leave the file unwritable. + +Two running Inspectors are therefore genuinely serialized. What a lock file cannot make single-winner is the *takeover of a lock whose holder died* — that needs a compare-and-swap on a directory entry (`renameat2`) which Node does not expose, and it is what an earlier hand-rolled attempt failed three review rounds on. `proper-lockfile` does not close that race either. The window opens only after a holder dies without releasing. + +The Inspector adds one thing on top: every lock-directory removal the library makes on its behalf — on release, and from its exit handler — is guarded by a check that the directory is still the one it created (by inode and birth time, which survive the library's own refresh but not a delete-and-recreate). That matters because those removals are otherwise unconditional, so a holder whose lock had been replaced would delete the *winner's* lock on the way out, turning one compromised writer into two unprotected ones. It also surfaces the takeover as a warning. Treat all of this as **best-effort**: the guard is still a check followed by an act, so it makes the destructive case rare rather than impossible, and it rests on filesystem metadata that not every filesystem reports. + +Which is why, underneath the lock, each mutation still reads the file, applies its change, writes, then reads back and compares the whole map; if something wrote in between it re-applies onto what was left and retries, failing loudly after five lost rounds rather than returning as though the value were saved. That check is what still catches a clobber inside that window — and it covers what no lock can, since a lock only orders the writers that *take* it: an editor, a restored backup, or an Inspector older than this release. + +If another process holds the lock and will not let go, the save **fails** rather than going ahead unlocked — waiting past the stale window first, so a crashed Inspector resolves itself rather than failing everyone else's saves. Writing alongside a writer you can see is the one case where degrading would lose the secret it was trying to protect. + +It is also what covers the lock being unavailable. This store exists for boxes where the usual mechanism isn't there, so a directory that can't hold a lock file — a read-only `$HOME`, a mount owned by another uid — makes the save proceed unlocked with a warning, rather than turning every `set` into a failure on exactly the deployments the store was written for. + +Three env vars affect where the file lands. `MCP_INSPECTOR_SECRET_STORE=keyring|file|memory` picks the store outright, bypassing the probe. `MCP_INSPECTOR_SECRET_FILE` names the file. Failing both, the file follows `MCP_STORAGE_DIR` — the same variable that relocates OAuth tokens and `client.json` — so mounting a volume at your configured storage directory is enough to make secrets durable there. + **Upgrading from an image before this fix?** Earlier images did not create `/home/node/.mcp-inspector`, so Docker created the volume's mount point as `root` and the non-root `node` user couldn't write to it. An **empty** volume repairs itself on the first run of a current image (Docker applies the image directory's ownership to an empty volume), but one that already has files in it keeps its old `root` ownership and still fails with `EACCES`. Fix it once: ```bash diff --git a/clients/cli/README.md b/clients/cli/README.md index d5c033df5..918be93f0 100644 --- a/clients/cli/README.md +++ b/clients/cli/README.md @@ -88,9 +88,15 @@ The file is the only durable way to give a run its roots: there is no roots flag ### HTTP proxy support -Connections to remote HTTP/SSE servers honor the conventional proxy environment variables: `HTTPS_PROXY` / `HTTP_PROXY` (and their lowercase forms) select the proxy, and `NO_PROXY` exempts hosts. This applies to the Node transport shared by the CLI and the web backend — no inspector-specific flag is needed. When a proxy variable is set, outbound requests are routed through undici's `EnvHttpProxyAgent`. +Connections to remote HTTP/SSE servers honor the conventional proxy environment variables: `HTTPS_PROXY` / `HTTP_PROXY` (and their lowercase forms) select the proxy, and `NO_PROXY` exempts hosts. This applies to every Node client — CLI, TUI, and the web backend — with no inspector-specific flag. It also covers **OAuth discovery and token requests**, which run through the same fetch. -Proxy routing is powered by the [`undici`](https://www.npmjs.com/package/undici) package (`^8.5.0`, which requires Node `>= 22.19.0` — the inspector's supported floor). It is imported lazily only when a proxy variable is set, so runs without a proxy configured pay no cost. +Proxy routing is powered by the [`undici`](https://www.npmjs.com/package/undici) package (declared in the **root** manifest only; `^8.x` requires Node `>= 22.19.0`, the Inspector's supported floor). It is imported lazily on the first request and only when a proxy variable is set, so runs without a proxy configured pay no cost. + +**Both halves of the pair come from userland undici — its `fetch` _and_ its `EnvHttpProxyAgent` ([#2067](https://github.com/modelcontextprotocol/inspector/issues/2067)).** The obvious-looking alternative is to hand the agent to Node's built-in `fetch` as a `dispatcher`, which is what v2.0.0–2.3.0 did. That couples two _different copies_ of undici at the dispatcher handler interface, and that interface is not stable across majors: Node 22 embeds undici 6, Node 24 embeds 7, Node 26 embeds 8. A userland undici 8 agent handed a built-in undici 7 handler is rejected outright — `fetch failed: invalid onRequestStart method` — and the request never leaves the process. Keeping both sides inside one copy is what makes proxying work unchanged from the Node 22.19 floor through Node 26. (Node's own `NODE_USE_ENV_PROXY` is not a substitute: it is unsupported at our 22.19 engine floor.) + +Because undici's `Response` is a different class from `globalThis.Response`, the proxied fetch re-wraps each response as a genuine global `Response`, streaming preserved. Without that, `res instanceof Response` is `false` for callers that test it — including the MCP SDK, whose OAuth error formatter would degrade every message to `Raw body: [object Response]`. + +`undici` is declared **only** in the root `package.json`, and every client's tsup config lists it as `external`. Both halves matter: tsup auto-externalizes what the _nearest_ manifest declares, so without the explicit entry the web and TUI bundles inlined it — and a CommonJS package inlined into an ESM bundle throws `Dynamic require of "assert" is not supported` the first time it is used. `npm run verify:bundle-externals` is the durable guard. ## Options @@ -110,15 +116,16 @@ Options that specify the MCP server (catalog/config file, ad-hoc command/URL, en | `--prompt-name ` | Prompt name (for `prompts/get`). | | `--prompt-args ` | Prompt arguments; repeat for multiple. | | `--log-level ` | Logging level for `logging/setLevel` (e.g. `debug`, `info`). | -| `--metadata ` | General metadata (key=value); applied to all methods. | -| `--tool-metadata ` | Tool-specific metadata for `tools/call`. | +| `--metadata ` | General `_meta` entries (key=value); applied to all methods. The value is JSON-parsed when it parses, so `trace={"id":"abc"}` sends a real object and `n=3` a number; anything that is not valid JSON is sent as the literal string. Merged **over** the server's persisted `metadata` from `mcp.json`, which is sent on every request without this flag (#2093). | +| `--tool-metadata ` | Tool-specific `_meta` entries for `tools/call`. Same JSON-parsed value handling as `--metadata`. | | `--connect-timeout ` | Connection timeout in ms. Defaults to `15000` for ad-hoc `--server-url`/target runs (so a black-holed host fails fast) and to the file-level timeout for `--catalog`/`--config` runs. `0` disables the timeout. | | `--app-info` | Probe a tool's MCP App UI metadata without invoking it. With `--method tools/call --tool-name `: prints one JSON line (`hasApp`, `resourceUri`, `csp`, `permissions`, `domain`, …) and exits `0` if the tool has an app or `2` (`no_app`) if not. With `--method tools/list`: emits NDJSON — one app-info line per tool over a single connection. | +| `--strict` | With `--method tools/list`: report tool-schema portability problems in full (path, issue, suggested fix) on stderr, and exit `6` if any is error-severity. Without it, a one-line count is printed instead. See [Schema portability](#schema-portability---strict). | | `--format ` | Output format. `text` (default) pretty-prints the result. `json` emits a single JSON object on stdout (`{ "result": … }`, plus `{ "appInfo": … }` as a sibling key for App tools) with no banners, so the whole output pipes cleanly into `jq`. | | `--relogin` | Delete stored OAuth for this server URL from the shared store before connect; interactive login still only runs if the server requires auth. Requires an HTTP/SSE URL (rejected for stdio). Conflicts with `--stored-auth-only` / `--use-stored-auth` / `--wait-for-auth` / catalog short-circuits. | | `--stored-auth-only` | **CI / non-interactive safe:** never start interactive OAuth / step-up (and never auto-open a browser); use the shared store if present, otherwise fail immediately with `auth_required`. Prefer this over a bare pipe/CI run that would otherwise attempt interactive login. | -`servers/show` redacts secret-bearing fields (`env` values, sensitive headers / `settings.metadata` keys, `requestInit` / `eventSourceInit` headers, `oauthClientSecret`). It does **not** scrub credentials embedded in a server `url` (userinfo or query tokens) or in stdio `args` — treat `detail` / raw URL fields as potentially sensitive before pasting into issues. +`servers/show` redacts secret-bearing fields (`env` values, sensitive headers, sensitive `settings.metadata` keys whose whole value is replaced whether or not it is structured, `requestInit` / `eventSourceInit` headers, `oauthClientSecret`). It does **not** scrub credentials embedded in a server `url` (userinfo or query tokens) or in stdio `args` — treat `detail` / raw URL fields as potentially sensitive before pasting into issues. #### App probing (`--app-info`) and machine-readable output (`--format json`) @@ -146,6 +153,84 @@ mcp-inspector --cli --method tools/call --tool-name my_app_tool --forma A `tools/call` that returns `isError:true` still prints its payload but exits `5` (`tool_is_error`) so `&&` chains don't proceed on a failed call. +#### Schema portability (`--strict`) + +A tool schema can be perfectly legal JSON Schema and still be refused by the +client the server is meant to run against. `--strict` (with `--method +tools/list`) names those constructs — path, what is wrong, and a concrete fix — +on **stderr**, and exits `6` if any is error-severity: + +```bash +mcp-inspector --cli --method tools/list --strict +``` + +The complete **report** against the `unportable-schemas-http.json` showcase — +one block per finding, then the summary. On a non-zero exit the shared error +handler adds one more stderr line after this, the +[`ErrorEnvelope`](#exit-codes--error-envelopes) (`{"error":{"code":"schema_unportable",…}}`): + +```text +Error: tool "get_temp" + Path: outputSchema.properties.data + Issue: Bare `true` used where a schema object is expected. + Suggestion: Declare what the value actually is — e.g. `{"type": "object", "additionalProperties": true}` for a free-form object. That is a deliberate change of contract, not an equivalent rewrite: `true` accepts any JSON value at all. + +Warning: tool "echo" + Path: inputSchema.properties.show_ids + Issue: `type` is an array (["null","boolean"]). The array form is legal JSON Schema, but several MCP clients read `type` as a single string and either reject the tool or drop the constraint. + Suggestion: Split it into `anyOf` branches, each with a single `type` — `{"anyOf": [{"type": "null"}, {"type": "boolean"}]}`. (Making the property optional instead is a different contract: absent is not the same as `null`.) + +Warning: tool "echo" + Path: inputSchema.properties.opts + Issue: Schema carries no validation keyword at all, so it accepts any value — the object-literal spelling of a bare `true`. + Suggestion: Declare what the value actually is — e.g. `{"type": "object", "additionalProperties": true}` for a free-form object. That is a deliberate change of contract, which is the point: as written it constrains nothing. + +Warning: tool "add" + Path: inputSchema.properties.a + Issue: `$ref` points outside this document (`https://example.com/schemas/number.json`). Clients do not fetch remote schemas, so the constraint is dropped or the tool is rejected. + Suggestion: Inline the referenced schema, or move it into `$defs` and reference it as `#/$defs/`. + +1 error, 3 warnings across 3 tools. +``` + +Note the shape of that suggestion. Where a replacement genuinely narrows the +schema it says so, rather than implying an equivalent rewrite — and where an +equivalent exists it gives that instead: an array-form `type` becomes `anyOf` +branches (not "drop it from `required`", since absent is not the same as +`null`), and a bare `false` becomes `{"not": {}}` (not "delete the entry", +which would *permit* the property under the default `additionalProperties`). + +Severity decides the exit code, not the report: **errors** are constructs a +shipping MCP client refuses outright (a bare `true` or `false` where a schema +object belongs), **warnings** are ones handled unevenly (an array-form `type`, +a remote `$ref`, a schema carrying no constraining keyword at all). Only errors +fail the run — a `--strict` that failed on warnings would be unusable as a CI +gate against servers that are in fact fine. + +There is deliberately **no "inputSchema must be an object" check**, even though +MCP requires one. The SDK types `inputSchema` with `type: literal("object")`, +so a tool whose input root is anything else fails `ListToolsResultSchema` and +is dropped from the list before the lint could see it — it is reported through +the malformed-items path instead. A rule that cannot fire would only make this +documentation claim a check the CLI does not perform. + +The report goes to stderr, so the result on stdout stays parseable. Under +`--format json` the findings are folded into the same envelope instead +(`{"result":…,"schemaFindings":[…]}`), so a caller reads one document rather +than correlating two. + +**Without `--strict` nothing changes except one line.** A `tools/list` whose +schemas have findings prints a single stderr summary — `Schema portability: 1 +error, 3 warnings across 3 tools. Re-run with --strict for details.` — and +still exits `0`. A clean list prints nothing at all. + +This is deliberately not a JSON Schema validator. A census of 617 public +servers found **zero** that fail the SDK's own parser, so a conformance check +would report nothing on essentially every real server; what bites is the +narrower subset each consumer accepts, which is what these rules encode. The +same verdict drives the TUI's tool detail pane and the web Tools tab — all +three read `core/json/schemaLint`. + ### CLI-specific (OAuth for HTTP servers) The CLI runs the same loopback callback server as the TUI (`http://127.0.0.1:6276/oauth/callback` by default). @@ -244,6 +329,7 @@ prose from stderr: | `3` | Server requires authentication (401/403, `WWW-Authenticate`, OAuth). | | `4` | Server unreachable (DNS, connection refused, timeout, `fetch failed`). | | `5` | Tool error (`tools/call` returned `isError:true`, or the tool was not found). | +| `6` | `--strict` found an error-severity tool-schema portability problem (`schema_unportable` — the schema is valid JSON Schema, just not portable). | On any non-zero exit the CLI also writes a single JSON line to **stderr** — the `ErrorEnvelope`: diff --git a/clients/cli/__tests__/cli.test.ts b/clients/cli/__tests__/cli.test.ts index d50cd861c..020f14466 100644 --- a/clients/cli/__tests__/cli.test.ts +++ b/clients/cli/__tests__/cli.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, vi } from "vitest"; +import { afterAll, beforeAll, describe, it, expect, vi } from "vitest"; import { mkdtempSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -24,8 +24,44 @@ import { createTestServerInfo, } from "@modelcontextprotocol/inspector-test-server"; import type { MCPServerConfig } from "@inspector/core/mcp/index.js"; +import { createServer, request } from "node:http"; describe("CLI Tests", () => { + /** + * Proxy variables are cleared for the WHOLE file, not just the proxy tests. + * + * `createProxyFetch`'s `EnvHttpProxyAgent` is memoized process-wide and captures + * its proxy URLs when it is constructed (only `NO_PROXY` is re-read per + * request). So on a machine with an ambient `HTTP_PROXY`, any earlier test that + * makes a real connection would build that agent against the ambient proxy, and + * a later test setting `HTTP_PROXY` to its own server would be talking to an + * agent that is not listening. Clearing up front means nothing can build the + * singleton before the proxy tests do. + */ + const AMBIENT_PROXY_VARS = [ + "HTTP_PROXY", + "http_proxy", + "HTTPS_PROXY", + "https_proxy", + "NO_PROXY", + "no_proxy", + ] as const; + const ambientProxyEnv: Record = {}; + + beforeAll(() => { + for (const name of AMBIENT_PROXY_VARS) { + ambientProxyEnv[name] = process.env[name]; + delete process.env[name]; + } + }); + + afterAll(() => { + for (const name of AMBIENT_PROXY_VARS) { + const previous = ambientProxyEnv[name]; + if (previous === undefined) delete process.env[name]; + else process.env[name] = previous; + } + }); describe("Basic CLI Mode", () => { it("should execute tools/list successfully", async () => { const { command, args } = getTestMcpServerCommand(); @@ -326,6 +362,75 @@ describe("CLI Tests", () => { }); }); + describe("HTTP proxy (#2067)", () => { + it("routes the MCP connection through HTTP_PROXY (#2067)", async () => { + // Proxy support is one line in cli.ts — `fetch: createProxyFetch()` on the + // environment — and it is what puts the proxy at the BOTTOM of the fetch + // stack, where InspectorClient's own wrappers compose over it rather than + // discarding it. `InspectorClient` sets `fetchFn` unconditionally, so the + // transport-level fallback never fires for this client; delete that line + // and CLI proxy support disappears with every core test still green. + // + // Asserted behaviorally, through a real forwarding proxy, rather than by + // inspecting the environment object: the bug this replaces was a runtime + // dispatch failure that no structural assertion would have caught. + const server = createTestServerHttp({ + serverInfo: createTestServerInfo(), + tools: [createEchoTool()], + }); + const seen: string[] = []; + const 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); + }); + try { + await server.start(); + 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}`; + + const result = await runCli([ + server.url, + "--cli", + "--method", + "tools/list", + ]); + + expectCliSuccess(result); + expect(JSON.stringify(expectValidJson(result))).toContain("echo"); + // The point of the test: it did not go direct. + expect(seen.length).toBeGreaterThan(0); + expect(seen.every((u) => u.startsWith(server.url))).toBe(true); + } finally { + // Only this test's own variable is cleared here. The ambient ones are + // restored by the file-level afterAll, so the memoized agent can never + // be left bound to this now-closed proxy for a later test. + delete process.env.HTTP_PROXY; + await new Promise((r) => proxy.close(() => r())); + await server.stop(); + } + }); + }); + describe("Roots capability (#1797)", () => { it("answers a server's roots/list instead of -32601", async () => { // The CLI used to omit `roots` when constructing its InspectorClient, so diff --git a/clients/cli/__tests__/error-handler.test.ts b/clients/cli/__tests__/error-handler.test.ts index 88b626214..59552a546 100644 --- a/clients/cli/__tests__/error-handler.test.ts +++ b/clients/cli/__tests__/error-handler.test.ts @@ -19,15 +19,19 @@ describe("handleError", () => { vi.restoreAllMocks(); }); - it("emits a JSON error envelope on stderr and exits with the classified code", () => { - const writeSpy = vi - .spyOn(process.stderr, "write") - .mockImplementation((() => true) as never); + it("emits a JSON error envelope on stderr and exits with the classified code", async () => { + const writeSpy = vi.spyOn(process.stderr, "write").mockImplementation((( + _chunk: unknown, + cb?: () => void, + ) => { + cb?.(); + return true; + }) as never); const exitSpy = vi .spyOn(process, "exit") .mockImplementation((() => undefined) as never); - handleError(new Error("boom")); + await handleError(new Error("boom")); expect(exitSpy).toHaveBeenCalledWith(EXIT_CODES.USAGE); const written = writeSpy.mock.calls[0]![0] as string; @@ -38,18 +42,49 @@ describe("handleError", () => { expect(parsed.error.message).toBe("boom"); }); - it("uses a CliExitCodeError's exitCode and envelope code", () => { - vi.spyOn(process.stderr, "write").mockImplementation((() => true) as never); + it("uses a CliExitCodeError's exitCode and envelope code", async () => { + vi.spyOn(process.stderr, "write").mockImplementation((( + _chunk: unknown, + cb?: () => void, + ) => { + cb?.(); + return true; + }) as never); const exitSpy = vi .spyOn(process, "exit") .mockImplementation((() => undefined) as never); - handleError( + await handleError( new CliExitCodeError(EXIT_CODES.NO_APP, "no app", { code: "no_app" }), ); expect(exitSpy).toHaveBeenCalledWith(EXIT_CODES.NO_APP); }); + + it("exits only after stderr has taken the envelope", async () => { + // The whole point of the await: a pipe defers the write, and + // `process.exit()` discards anything still queued. A spy that called back + // synchronously would pass with or without it, so this one defers. + let flushed = false; + vi.spyOn(process.stderr, "write").mockImplementation((( + _chunk: unknown, + cb?: () => void, + ) => { + setTimeout(() => { + flushed = true; + cb?.(); + }, 0); + return false; + }) as never); + const exitSpy = vi + .spyOn(process, "exit") + .mockImplementation((() => undefined) as never); + + await handleError(new Error("boom")); + + expect(flushed).toBe(true); + expect(exitSpy).toHaveBeenCalledWith(EXIT_CODES.USAGE); + }); }); describe("classifyError", () => { @@ -64,6 +99,18 @@ describe("classifyError", () => { expect(envelope.url).toBe("https://x.example/mcp"); }); + it("derives schema_unportable from the exit code when no envelope is given", () => { + // `CliExitCodeError` lets a caller omit the envelope, in which case the + // machine code comes from `codeForExit`. A new exit code that is missing + // there degrades to the generic `error`, which is exactly the kind of + // silent mislabelling the code map exists to prevent (#1005). + const { exitCode, envelope } = classifyError( + new CliExitCodeError(EXIT_CODES.SCHEMA_UNPORTABLE, "1 finding"), + ); + expect(exitCode).toBe(EXIT_CODES.SCHEMA_UNPORTABLE); + expect(envelope.code).toBe("schema_unportable"); + }); + it("classifies a WWW-Authenticate message as AUTH_REQUIRED without a status", () => { const { exitCode } = classifyError( new Error("Dynamic client registration failed: WWW-Authenticate Bearer"), diff --git a/clients/cli/__tests__/helpers/fixtures.ts b/clients/cli/__tests__/helpers/fixtures.ts index abb25f519..ce16e6bb6 100644 --- a/clients/cli/__tests__/helpers/fixtures.ts +++ b/clients/cli/__tests__/helpers/fixtures.ts @@ -3,7 +3,7 @@ import * as path from "path"; import * as os from "os"; import * as crypto from "crypto"; import { getTestMcpServerCommand } from "@modelcontextprotocol/inspector-test-server"; -import type { MCPServerConfig } from "@inspector/core/mcp/index.js"; +import type { StoredMCPServer } from "@inspector/core/mcp/types.js"; /** * Sentinel value for tests that don't need a real server @@ -61,10 +61,16 @@ function cleanupTempDir(dir: string) { } /** - * Create a test config file + * Create a test config file. + * + * Entries are `StoredMCPServer`, not bare `MCPServerConfig` — the on-disk + * `mcp.json` shape carries the Inspector-specific per-server keys (`metadata`, + * `headers`, `roots`, `protocolEra`, …) directly alongside `type`/`url`, and a + * fixture that cannot express them cannot cover the settings that reach the + * client from the catalog (#2093). */ export function createTestConfig(config: { - mcpServers: Record; + mcpServers: Record; }): string { const tempDir = createTempDir("mcp-inspector-config-"); const configPath = path.join(tempDir, "config.json"); diff --git a/clients/cli/__tests__/helpers/oauth-test-fakes.ts b/clients/cli/__tests__/helpers/oauth-test-fakes.ts index b53e15703..8c5e91f9e 100644 --- a/clients/cli/__tests__/helpers/oauth-test-fakes.ts +++ b/clients/cli/__tests__/helpers/oauth-test-fakes.ts @@ -63,7 +63,7 @@ export function makeFakeServerSettings( ): InspectorServerSettings { return { headers: [], - metadata: [], + metadata: {}, env: [], connectionTimeout: 0, requestTimeout: 0, diff --git a/clients/cli/__tests__/metadata.test.ts b/clients/cli/__tests__/metadata.test.ts index b5867d50e..ab05d348c 100644 --- a/clients/cli/__tests__/metadata.test.ts +++ b/clients/cli/__tests__/metadata.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect } from "vitest"; import { runCli } from "./helpers/cli-runner.js"; +import type { StrictJsonObject } from "@inspector/core/json/jsonUtils.js"; import { expectCliSuccess, expectCliFailure, @@ -11,7 +12,11 @@ import { createAddTool, createTestServerInfo, } from "@modelcontextprotocol/inspector-test-server"; -import { NO_SERVER_SENTINEL } from "./helpers/fixtures.js"; +import { + NO_SERVER_SENTINEL, + createTestConfig, + deleteConfigFile, +} from "./helpers/fixtures.js"; describe("Metadata Tests", () => { describe("General Metadata", () => { @@ -373,16 +378,17 @@ describe("Metadata Tests", () => { expectCliSuccess(result); - // Validate metadata values are sent as strings + // A JSON-parseable value goes out as that JSON type, not as its + // string spelling (#1910) — `_meta` takes any JSON. const recordedRequests = server.getRecordedRequests(); const toolsListRequest = recordedRequests.find( (r) => r.method === "tools/list", ); expect(toolsListRequest).toBeDefined(); expect(toolsListRequest?.metadata).toEqual({ - integer_value: "42", - decimal_value: "3.14159", - negative_value: "-10", + integer_value: 42, + decimal_value: 3.14159, + negative_value: -10, }); } finally { await server.stop(); @@ -428,7 +434,7 @@ describe("Metadata Tests", () => { } }); - it("JSON.stringifies object/array metadata (not String → [object Object])", async () => { + it("sends object/array/boolean metadata as real JSON, not stringified (#1910)", async () => { const server = createTestServerHttp({ serverInfo: createTestServerInfo(), tools: [createEchoTool()], @@ -456,9 +462,9 @@ describe("Metadata Tests", () => { (r) => r.method === "tools/list", ); expect(toolsListRequest?.metadata).toEqual({ - nested: '{"key":"value"}', - list: "[1,2,3]", - flag: "true", + nested: { key: "value" }, + list: [1, 2, 3], + flag: true, }); } finally { await server.stop(); @@ -819,7 +825,7 @@ describe("Metadata Tests", () => { ); expect(toolsListRequest).toBeDefined(); expect(toolsListRequest?.metadata).toEqual({ - integration_test: "true", + integration_test: true, test_phase: "all_methods", }); } finally { @@ -866,8 +872,8 @@ describe("Metadata Tests", () => { ); expect(toolCallRequest).toBeDefined(); expect(toolCallRequest?.metadata).toEqual({ - session_id: "12345", - user_id: "67890", + session_id: 12345, + user_id: 67890, timestamp: "2024-01-01T00:00:00Z", request_id: "req-abc-123", tool_session: "session-xyz-789", @@ -879,6 +885,65 @@ describe("Metadata Tests", () => { } }); + it.each([ + ["an overflowing literal", "n=1e400"], + ["a negative overflow", "n=-1e400"], + ["one nested in an object", 'o={"a":1e400}'], + ])("rejects %s rather than sending null", async (_label, pair) => { + // `JSON.parse` accepts these and yields ±Infinity, which + // `JSON.stringify` writes as `null` — so accepting the flag would + // transmit a value the user did not ask for. + const server = createTestServerHttp({ + serverInfo: createTestServerInfo(), + tools: [createEchoTool()], + }); + try { + await server.start(); + const result = await runCli([ + server.url, + "--cli", + "--method", + "tools/list", + "--metadata", + pair, + "--transport", + "http", + ]); + expect(result.exitCode).not.toBe(0); + expect(`${result.stderr}${result.stdout}`).toMatch(/finite/i); + } finally { + await server.stop(); + } + }); + + it("names the key but never the value when rejecting", async () => { + // The pair can carry a credential, and this message reaches stderr and + // CI logs. + const server = createTestServerHttp({ + serverInfo: createTestServerInfo(), + tools: [createEchoTool()], + }); + try { + await server.start(); + const result = await runCli([ + server.url, + "--cli", + "--method", + "tools/list", + "--metadata", + 'credentials={"accessToken":"sk-live-nope","n":1e400}', + "--transport", + "http", + ]); + const output = `${result.stderr}${result.stdout}`; + expect(result.exitCode).not.toBe(0); + expect(output).toContain("credentials"); + expect(output).not.toContain("sk-live"); + } finally { + await server.stop(); + } + }); + it("should handle metadata parsing validation", async () => { const server = createTestServerHttp({ serverInfo: createTestServerInfo(), @@ -919,8 +984,8 @@ describe("Metadata Tests", () => { expect(toolCallRequest).toBeDefined(); expect(toolCallRequest?.metadata).toEqual({ valid_key: "valid_value", - numeric_key: "123", - boolean_key: "true", + numeric_key: 123, + boolean_key: true, json_key: '\'{"test":"value"}\'', // Single quotes are preserved special_key: "!@#$%^&*()", unicode_key: "🚀🎉✨", @@ -968,4 +1033,143 @@ describe("Metadata Tests", () => { } }); }); + describe("Per-server metadata from mcp.json (#2093)", () => { + /** + * Write a one-server catalog pointing at `url`, carrying `metadata` as the + * on-disk per-server key. Returns the catalog path; the caller deletes it. + */ + function writeCatalogWithMetadata( + url: string, + metadata: StrictJsonObject, + ): string { + return createTestConfig({ + mcpServers: { + "meta-server": { + type: "streamable-http", + url, + metadata, + }, + }, + }); + } + + it("applies a server's persisted metadata to every request", async () => { + // The setting belongs to the server, not to the client that reads it — + // web and the TUI already honored it, and the CLI silently did not, + // because `InspectorClient` reads `defaultMetadata` rather than falling + // back to `serverSettings.metadata`. + const server = createTestServerHttp({ + serverInfo: createTestServerInfo(), + tools: [createEchoTool()], + }); + let catalogPath: string | undefined; + + try { + await server.start(); + catalogPath = writeCatalogWithMetadata(server.url, { tenant: "acme" }); + + const result = await runCli([ + "--catalog", + catalogPath, + "--server", + "meta-server", + "--cli", + "--method", + "tools/list", + ]); + + expectCliSuccess(result); + expect(expectValidJson(result)).toHaveProperty("tools"); + + const toolsListRequest = server + .getRecordedRequests() + .find((r) => r.method === "tools/list"); + expect(toolsListRequest).toBeDefined(); + expect(toolsListRequest?.metadata).toEqual({ tenant: "acme" }); + } finally { + if (catalogPath) deleteConfigFile(catalogPath); + await server.stop(); + } + }); + + it("merges --metadata over the persisted defaults", async () => { + // `--metadata` is per-invocation and stays that way: non-colliding keys + // merge with the catalog's, and a colliding one wins (call-time keys + // override defaults in `InspectorClient.mergeMeta`). + const server = createTestServerHttp({ + serverInfo: createTestServerInfo(), + tools: [createEchoTool()], + }); + let catalogPath: string | undefined; + + try { + await server.start(); + catalogPath = writeCatalogWithMetadata(server.url, { + tenant: "acme", + region: "eu", + }); + + const result = await runCli([ + "--catalog", + catalogPath, + "--server", + "meta-server", + "--cli", + "--method", + "tools/list", + "--metadata", + "tenant=override", + ]); + + expectCliSuccess(result); + + const toolsListRequest = server + .getRecordedRequests() + .find((r) => r.method === "tools/list"); + expect(toolsListRequest?.metadata).toEqual({ + tenant: "override", + region: "eu", + }); + } finally { + if (catalogPath) deleteConfigFile(catalogPath); + await server.stop(); + } + }); + + it("sends no _meta when the persisted metadata is empty", async () => { + // `{}` means "no defaults" — an empty map must not put a bare `_meta` on + // the wire, matching what the option-less CLI has always sent. + const server = createTestServerHttp({ + serverInfo: createTestServerInfo(), + tools: [createEchoTool()], + }); + let catalogPath: string | undefined; + + try { + await server.start(); + catalogPath = writeCatalogWithMetadata(server.url, {}); + + const result = await runCli([ + "--catalog", + catalogPath, + "--server", + "meta-server", + "--cli", + "--method", + "tools/list", + ]); + + expectCliSuccess(result); + + const toolsListRequest = server + .getRecordedRequests() + .find((r) => r.method === "tools/list"); + expect(toolsListRequest).toBeDefined(); + expect(toolsListRequest?.metadata).toBeUndefined(); + } finally { + if (catalogPath) deleteConfigFile(catalogPath); + await server.stop(); + } + }); + }); }); diff --git a/clients/cli/__tests__/method-types.test.ts b/clients/cli/__tests__/method-types.test.ts index 42c710256..85230043d 100644 --- a/clients/cli/__tests__/method-types.test.ts +++ b/clients/cli/__tests__/method-types.test.ts @@ -1,7 +1,6 @@ import { describe, it, expect } from "vitest"; import { isOneShotMethod, - metaValueToString, ONE_SHOT_METHODS, SESSION_RPC_METHODS, } from "../src/handlers/method-types.js"; @@ -26,11 +25,3 @@ describe("ONE_SHOT_METHODS", () => { expect(ONE_SHOT_METHODS).not.toContain("logging/tail"); }); }); - -describe("metaValueToString", () => { - it("passes strings through and JSON-encodes structured values", () => { - expect(metaValueToString("plain")).toBe("plain"); - expect(metaValueToString({ a: 1 })).toBe('{"a":1}'); - expect(metaValueToString([1, 2])).toBe("[1,2]"); - }); -}); diff --git a/clients/cli/__tests__/programmatic-ergonomics.test.ts b/clients/cli/__tests__/programmatic-ergonomics.test.ts index 1c6fdee74..88f42f103 100644 --- a/clients/cli/__tests__/programmatic-ergonomics.test.ts +++ b/clients/cli/__tests__/programmatic-ergonomics.test.ts @@ -12,7 +12,7 @@ import { describe("withConnectTimeout", () => { const baseSettings: InspectorServerSettings = { headers: [{ key: "X-Test", value: "1" }], - metadata: [], + metadata: {}, env: [], connectionTimeout: 42, requestTimeout: 0, diff --git a/clients/cli/__tests__/schema-lint-report.test.ts b/clients/cli/__tests__/schema-lint-report.test.ts new file mode 100644 index 000000000..997e98fea --- /dev/null +++ b/clients/cli/__tests__/schema-lint-report.test.ts @@ -0,0 +1,318 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { emitResult, runCli } from "../src/cli.js"; +import { CliExitCodeError, EXIT_CODES } from "../src/error-handler.js"; +import { + lintListResult, + toolsFromResult, + writeSchemaLintReport, +} from "../src/handlers/schema-lint-report.js"; + +/** A `tools/list` result with one bare-`true` property — the issue's case. */ +const DIRTY_LIST = { + tools: [ + { + name: "info", + inputSchema: { type: "object", properties: {} }, + outputSchema: { type: "object", properties: { data: true } }, + }, + ], +}; + +/** Same shape, but every schema portable. */ +const CLEAN_LIST = { + tools: [{ name: "ok", inputSchema: { type: "object", properties: {} } }], +}; + +describe("toolsFromResult", () => { + it("returns the tools array", () => { + expect(toolsFromResult(DIRTY_LIST).map((t) => t.name)).toEqual(["info"]); + }); + + it.each([ + ["a missing tools key", {}], + ["a non-array tools value", { tools: "nope" }], + ])("returns nothing for %s", (_label, result) => { + expect(toolsFromResult(result)).toEqual([]); + }); + + it("skips entries that are not tool-shaped", () => { + const result = { + tools: [null, 3, {}, { name: 7 }, { name: "real", inputSchema: {} }], + }; + expect(toolsFromResult(result).map((t) => t.name)).toEqual(["real"]); + }); +}); + +describe("lintListResult", () => { + it("reports the offending tool", () => { + const results = lintListResult(DIRTY_LIST); + expect(results.map((r) => r.toolName)).toEqual(["info"]); + }); + + it("reports nothing for a clean list", () => { + expect(lintListResult(CLEAN_LIST)).toEqual([]); + }); +}); + +describe("writeSchemaLintReport", () => { + let stderr: string; + let originalWrite: typeof process.stderr.write; + + beforeEach(() => { + stderr = ""; + originalWrite = process.stderr.write; + process.stderr.write = ((chunk: unknown, ...rest: unknown[]): boolean => { + stderr += typeof chunk === "string" ? chunk : String(chunk); + ( + rest.find((r) => typeof r === "function") as (() => void) | undefined + )?.(); + return true; + }) as typeof process.stderr.write; + }); + + afterEach(() => { + process.stderr.write = originalWrite; + }); + + it("writes nothing at all when there are no findings", async () => { + await writeSchemaLintReport([], true); + expect(stderr).toBe(""); + }); + + it("writes a one-line hint without --strict", async () => { + await writeSchemaLintReport(lintListResult(DIRTY_LIST), false); + expect(stderr.trimEnd().split("\n")).toHaveLength(1); + expect(stderr).toContain("Re-run with --strict"); + }); + + it("writes the full report under --strict", async () => { + await writeSchemaLintReport(lintListResult(DIRTY_LIST), true); + expect(stderr).toContain('Error: tool "info"'); + expect(stderr).toContain("Path: outputSchema.properties.data"); + expect(stderr).toContain("Suggestion: "); + expect(stderr).not.toContain("Re-run with --strict"); + }); + + it("resolves only once stderr has taken the write", async () => { + // Both CLI exit paths call `process.exit()` as soon as the work returns, + // which discards anything still buffered on a piped stderr. The report is + // only safe if this settles on the write callback rather than fire-and- + // forget, so the fake defers its callback to a later tick. + let flushed = false; + process.stderr.write = (( + chunk: unknown, + cb?: (err?: Error | null) => void, + ): boolean => { + stderr += typeof chunk === "string" ? chunk : String(chunk); + setTimeout(() => { + flushed = true; + cb?.(); + }, 0); + return false; + }) as typeof process.stderr.write; + + await writeSchemaLintReport(lintListResult(DIRTY_LIST), true); + expect(flushed).toBe(true); + }); +}); + +describe("emitResult — schema lint wiring (#1005)", () => { + let stdout: string; + let stderr: string; + let originalOut: typeof process.stdout.write; + let originalErr: typeof process.stderr.write; + + beforeEach(() => { + stdout = ""; + stderr = ""; + originalOut = process.stdout.write; + originalErr = process.stderr.write; + process.stdout.write = ((chunk: unknown, ...rest: unknown[]): boolean => { + stdout += typeof chunk === "string" ? chunk : String(chunk); + ( + rest.find((r) => typeof r === "function") as (() => void) | undefined + )?.(); + return true; + }) as typeof process.stdout.write; + process.stderr.write = ((chunk: unknown, ...rest: unknown[]): boolean => { + stderr += typeof chunk === "string" ? chunk : String(chunk); + ( + rest.find((r) => typeof r === "function") as (() => void) | undefined + )?.(); + return true; + }) as typeof process.stderr.write; + }); + + afterEach(() => { + process.stdout.write = originalOut; + process.stderr.write = originalErr; + }); + + it("exits 6 under --strict when a finding is error-severity", async () => { + const promise = emitResult(DIRTY_LIST, undefined, { + method: "tools/list", + strict: true, + format: "text", + }); + await expect(promise).rejects.toBeInstanceOf(CliExitCodeError); + await promise.catch((e: CliExitCodeError) => { + expect(e.exitCode).toBe(EXIT_CODES.SCHEMA_UNPORTABLE); + // Not `schema_invalid`: the lint reports schemas that ARE valid JSON + // Schema and are merely unportable, so the code an automated caller + // branches on must not claim otherwise. + expect(e.envelope?.code).toBe("schema_unportable"); + }); + // The result still goes to stdout; only the report is on stderr. + expect(JSON.parse(stdout)).toEqual(DIRTY_LIST); + expect(stderr).toContain("Path: outputSchema.properties.data"); + }); + + it("succeeds under --strict when only warnings were found", async () => { + const warnOnly = { + tools: [ + { + name: "w", + inputSchema: { + type: "object", + properties: { a: { type: ["null", "boolean"] } }, + }, + }, + ], + }; + await expect( + emitResult(warnOnly, undefined, { + method: "tools/list", + strict: true, + format: "text", + }), + ).resolves.toBeUndefined(); + expect(stderr).toContain('Warning: tool "w"'); + }); + + it("prints only the hint, and does not fail, without --strict", async () => { + await expect( + emitResult(DIRTY_LIST, undefined, { + method: "tools/list", + format: "text", + }), + ).resolves.toBeUndefined(); + expect(stderr).toContain("Re-run with --strict"); + expect(stderr).not.toContain("Suggestion:"); + }); + + it("folds findings into the --format json envelope under --strict", async () => { + await emitResult(DIRTY_LIST, undefined, { + method: "tools/list", + strict: true, + format: "json", + }).catch(() => { + // exit 6 is expected here; the envelope is what this test asserts. + }); + const envelope = JSON.parse(stdout) as { + result: unknown; + schemaFindings?: { toolName: string }[]; + }; + expect(envelope.result).toEqual(DIRTY_LIST); + expect(envelope.schemaFindings?.[0]?.toolName).toBe("info"); + }); + + it("leaves the json envelope alone when nothing was found", async () => { + await emitResult(CLEAN_LIST, undefined, { + method: "tools/list", + strict: true, + format: "json", + }); + expect(JSON.parse(stdout)).toEqual({ result: CLEAN_LIST }); + expect(stderr).toBe(""); + }); + + it("does not lint a method other than tools/list", async () => { + // The same payload under `tools/call` must produce no report at all — + // `--strict` is a tools/list flag and the CLI rejects it elsewhere. + await emitResult(DIRTY_LIST, undefined, { + method: "tools/call", + strict: true, + format: "text", + }); + expect(stderr).toBe(""); + }); + + it("does not lint an --app-info run", async () => { + // `runCli` rejects `--strict --app-info` outright (see below), so this is + // the defensive half: any other Node runner reusing `emitResult` gets the + // app-info early return rather than a report interleaved with its NDJSON. + await emitResult( + DIRTY_LIST, + { hasApp: true, toolName: "info" }, + { method: "tools/list", strict: true, appInfo: true, format: "text" }, + ); + expect(stderr).toBe(""); + }); + + it("reports isError before the schema verdict", async () => { + // Both would throw; the tool-level failure is the more specific one and + // must win, so a caller branching on exit 5 is not shadowed by exit 6. + const promise = emitResult({ ...DIRTY_LIST, isError: true }, undefined, { + method: "tools/list", + strict: true, + toolName: "info", + format: "text", + }); + await promise.catch((e: CliExitCodeError) => { + expect(e.exitCode).toBe(EXIT_CODES.TOOL_ERROR); + }); + await expect(promise).rejects.toBeInstanceOf(CliExitCodeError); + }); +}); + +describe("--strict argument validation", () => { + it("is rejected with a method other than tools/list", async () => { + await expect( + runCli([ + "node", + "cli", + "--cli", + "--method", + "tools/call", + "--tool-name", + "x", + "--strict", + "--server-url", + "http://127.0.0.1:1/mcp", + ]), + ).rejects.toThrow("--strict requires --method tools/list."); + }); + + it.each([ + ["servers/list", ["--method", "servers/list"]], + ["--list-stored-auth", ["--method", "servers/list", "--list-stored-auth"]], + ])( + "is rejected on the %s short-circuit path, which never reaches the lint", + async (_label, extra) => { + // These return from `parseArgs` before any connect, so a validation + // placed further down would let `--strict` be accepted and ignored. + await expect( + runCli(["node", "cli", "--cli", "--strict", ...extra]), + ).rejects.toThrow("--strict requires --method tools/list."); + }, + ); + + it("is rejected alongside --app-info rather than silently ignored", async () => { + // `tools/list --app-info` returns NDJSON straight from `runMethod` and + // never reaches `emitResult`, so accepting the pair would hand a CI caller + // a `--strict` gate that can never fail. + await expect( + runCli([ + "node", + "cli", + "--cli", + "--method", + "tools/list", + "--strict", + "--app-info", + "--server-url", + "http://127.0.0.1:1/mcp", + ]), + ).rejects.toThrow("--strict cannot be combined with --app-info"); + }); +}); diff --git a/clients/cli/__tests__/servers-list.test.ts b/clients/cli/__tests__/servers-list.test.ts index 8ac7b599c..daa1c4441 100644 --- a/clients/cli/__tests__/servers-list.test.ts +++ b/clients/cli/__tests__/servers-list.test.ts @@ -219,10 +219,23 @@ describe("showServerEntry / servers/show", () => { { key: "Authorization", value: "Bearer x" }, { key: "X-Custom", value: "ok" }, ], - metadata: [ - { key: "Authorization", value: "Bearer meta" }, - { key: "X-Custom", value: "ok" }, - ], + metadata: { + Authorization: "Bearer meta", + "X-Custom": "ok", + // A structured value under a sensitive key: the redaction must replace + // the whole value, not walk into it (#1910). + "X-Api-Key": { primary: "sk-live-1", fallback: "sk-live-2" }, + nested: { keep: true }, + // A secret buried under a non-sensitive key. A top-level-only check + // would print `accessToken` in full. + trace: { + id: "t-1", + accessToken: "sk-live-3", + deeper: { refresh_token: "sk-live-4", ok: 1 }, + }, + // Objects inside an array are reached too. + attempts: [{ password: "hunter2" }, { attempt: 2 }], + }, env: [ { key: "TOKEN", value: "secret" }, { key: "", value: "still-secret" }, @@ -242,10 +255,20 @@ describe("showServerEntry / servers/show", () => { { key: "Authorization", value: "[redacted]" }, { key: "X-Custom", value: "ok" }, ]); - expect(sanitized.metadata).toEqual([ - { key: "Authorization", value: "[redacted]" }, - { key: "X-Custom", value: "ok" }, - ]); + expect(sanitized.metadata).toEqual({ + Authorization: "[redacted]", + "X-Custom": "ok", + "X-Api-Key": "[redacted]", + // A structured value under a non-sensitive key survives intact... + nested: { keep: true }, + // ...but a sensitive key nested inside one does not. + trace: { + id: "t-1", + accessToken: "[redacted]", + deeper: { refresh_token: "[redacted]", ok: 1 }, + }, + attempts: [{ password: "[redacted]" }, { attempt: 2 }], + }); expect(sanitized.env).toEqual([ { key: "TOKEN", value: "[redacted]" }, { key: "", value: "[redacted]" }, diff --git a/clients/cli/__tests__/stored-auth.test.ts b/clients/cli/__tests__/stored-auth.test.ts index 4a45ec33d..3fb5c0b18 100644 --- a/clients/cli/__tests__/stored-auth.test.ts +++ b/clients/cli/__tests__/stored-auth.test.ts @@ -158,6 +158,97 @@ describe("refreshStoredAuthToken", () => { } }); + // #2110: with no stored metadata the MCP server URL stands in as the + // authorization server. A path-hosted server has two plausible answers, and + // both must keep working — the path-scoped URL for a server that publishes + // its metadata under its own path, the bare origin for one that merely lives + // under a path. The candidate that answers is also the base the token request + // is made against, so the walk has to report *which* one it was. + it("prefers the path-scoped authorization server for a path-hosted MCP server", async () => { + const path = writeOAuthFixture({ + [SERVER]: { + tokens: { refresh_token: "old-refresh", token_type: "Bearer" }, + clientInformation: { client_id: "cid" }, + }, + }); + try { + const refresh = vi.fn().mockResolvedValue(freshTokens); + const pathMetadata = { + issuer: "https://api.example/mcp", + token_endpoint: "https://api.example/mcp/token", + }; + const discover = vi.fn().mockResolvedValue(pathMetadata); + + await refreshStoredAuthToken(SERVER, path, { refresh, discover }); + + expect(discover).toHaveBeenCalledTimes(1); + expect(discover).toHaveBeenCalledWith(new URL("https://api.example/mcp")); + const [authServerUrl, opts] = refresh.mock.calls[0]!; + expect(authServerUrl).toEqual(new URL("https://api.example/mcp")); + expect(opts.metadata).toEqual(pathMetadata); + } finally { + rmSync(path, { force: true }); + } + }); + + it("falls back to the origin when a path-hosted server publishes metadata at the root", async () => { + const path = writeOAuthFixture({ + [SERVER]: { + tokens: { refresh_token: "old-refresh", token_type: "Bearer" }, + clientInformation: { client_id: "cid" }, + }, + }); + try { + const refresh = vi.fn().mockResolvedValue(freshTokens); + const rootMetadata = { + issuer: "https://api.example", + token_endpoint: "https://api.example/token", + }; + const discover = vi + .fn() + .mockResolvedValueOnce(undefined) + .mockResolvedValueOnce(rootMetadata); + + const token = await refreshStoredAuthToken(SERVER, path, { + refresh, + discover, + }); + + expect(token).toBe("refreshed-access-token"); + expect(discover).toHaveBeenNthCalledWith( + 2, + new URL("https://api.example/"), + ); + // The candidate that answered is the one the token request targets. + const [authServerUrl, opts] = refresh.mock.calls[0]!; + expect(authServerUrl).toEqual(new URL("https://api.example/")); + expect(opts.metadata).toEqual(rootMetadata); + } finally { + rmSync(path, { force: true }); + } + }); + + it("keeps the path-scoped candidate as the token-request base when no candidate answers", async () => { + const path = writeOAuthFixture({ + [SERVER]: { + tokens: { refresh_token: "old-refresh", token_type: "Bearer" }, + clientInformation: { client_id: "cid" }, + }, + }); + try { + const refresh = vi.fn().mockResolvedValue(freshTokens); + const discover = vi.fn().mockResolvedValue(undefined); + + await refreshStoredAuthToken(SERVER, path, { refresh, discover }); + + expect(discover).toHaveBeenCalledTimes(2); + const [authServerUrl] = refresh.mock.calls[0]!; + expect(authServerUrl).toEqual(new URL("https://api.example/mcp")); + } finally { + rmSync(path, { force: true }); + } + }); + it("uses the distinct no_client_information code when client info is missing", async () => { const path = writeOAuthFixture({ [SERVER]: { @@ -554,6 +645,7 @@ describe("--print-handoff", () => { MCP_INSPECTOR_API_TOKEN: "tok123", CLIENT_PORT: "16274", MCP_SANDBOX_PORT: "16275", + MCP_APP_ORIGIN_PORT: "16278", MCP_STORAGE_DIR: "/tmp/inspector-storage", MCP_INSPECTOR_OAUTH_STATE_PATH: "", }, @@ -574,6 +666,9 @@ describe("--print-handoff", () => { expect(out.deepLink).toContain("transport=http"); expect(out.portForwardCmd).toContain("--tcp 16274:16274"); expect(out.portForwardCmd).toContain("--tcp 16275:16275"); + // The dedicated app origin (#2056) forwards with the other two — an app + // declaring `_meta.ui.domain` is unreachable without it. + expect(out.portForwardCmd).toContain("--tcp 16278:16278"); expect(out.oauthStatePath).toBe( join("/tmp/inspector-storage", "oauth.json"), ); diff --git a/clients/cli/package-lock.json b/clients/cli/package-lock.json index 30a14836d..96902f129 100644 --- a/clients/cli/package-lock.json +++ b/clients/cli/package-lock.json @@ -14,7 +14,6 @@ "commander": "^13.1.0", "open": "^10.2.0", "pino": "^9.14.0", - "undici": "^8.9.0", "zod": "^4.4.3" }, "bin": { @@ -129,9 +128,9 @@ } }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", - "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", "cpu": [ "ppc64" ], @@ -146,9 +145,9 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", - "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", "cpu": [ "arm" ], @@ -163,9 +162,9 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", - "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", "cpu": [ "arm64" ], @@ -180,9 +179,9 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", - "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", "cpu": [ "x64" ], @@ -197,9 +196,9 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", - "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", "cpu": [ "arm64" ], @@ -214,9 +213,9 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", - "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", "cpu": [ "x64" ], @@ -231,9 +230,9 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", - "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", "cpu": [ "arm64" ], @@ -248,9 +247,9 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", - "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", "cpu": [ "x64" ], @@ -265,9 +264,9 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", - "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", "cpu": [ "arm" ], @@ -282,9 +281,9 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", - "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", "cpu": [ "arm64" ], @@ -299,9 +298,9 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", - "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", "cpu": [ "ia32" ], @@ -316,9 +315,9 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", - "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", "cpu": [ "loong64" ], @@ -333,9 +332,9 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", - "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", "cpu": [ "mips64el" ], @@ -350,9 +349,9 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", - "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", "cpu": [ "ppc64" ], @@ -367,9 +366,9 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", - "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", "cpu": [ "riscv64" ], @@ -384,9 +383,9 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", - "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", "cpu": [ "s390x" ], @@ -401,9 +400,9 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", - "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", "cpu": [ "x64" ], @@ -418,9 +417,9 @@ } }, "node_modules/@esbuild/netbsd-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", - "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", "cpu": [ "arm64" ], @@ -435,9 +434,9 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", - "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", "cpu": [ "x64" ], @@ -452,9 +451,9 @@ } }, "node_modules/@esbuild/openbsd-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", - "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", "cpu": [ "arm64" ], @@ -469,9 +468,9 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", - "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", "cpu": [ "x64" ], @@ -486,9 +485,9 @@ } }, "node_modules/@esbuild/openharmony-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", - "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", "cpu": [ "arm64" ], @@ -503,9 +502,9 @@ } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", - "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", "cpu": [ "x64" ], @@ -520,9 +519,9 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", - "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", "cpu": [ "arm64" ], @@ -537,9 +536,9 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", - "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", "cpu": [ "ia32" ], @@ -554,9 +553,9 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", - "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", "cpu": [ "x64" ], @@ -2526,9 +2525,9 @@ "license": "MIT" }, "node_modules/esbuild": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", - "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -2539,32 +2538,32 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.27.7", - "@esbuild/android-arm": "0.27.7", - "@esbuild/android-arm64": "0.27.7", - "@esbuild/android-x64": "0.27.7", - "@esbuild/darwin-arm64": "0.27.7", - "@esbuild/darwin-x64": "0.27.7", - "@esbuild/freebsd-arm64": "0.27.7", - "@esbuild/freebsd-x64": "0.27.7", - "@esbuild/linux-arm": "0.27.7", - "@esbuild/linux-arm64": "0.27.7", - "@esbuild/linux-ia32": "0.27.7", - "@esbuild/linux-loong64": "0.27.7", - "@esbuild/linux-mips64el": "0.27.7", - "@esbuild/linux-ppc64": "0.27.7", - "@esbuild/linux-riscv64": "0.27.7", - "@esbuild/linux-s390x": "0.27.7", - "@esbuild/linux-x64": "0.27.7", - "@esbuild/netbsd-arm64": "0.27.7", - "@esbuild/netbsd-x64": "0.27.7", - "@esbuild/openbsd-arm64": "0.27.7", - "@esbuild/openbsd-x64": "0.27.7", - "@esbuild/openharmony-arm64": "0.27.7", - "@esbuild/sunos-x64": "0.27.7", - "@esbuild/win32-arm64": "0.27.7", - "@esbuild/win32-ia32": "0.27.7", - "@esbuild/win32-x64": "0.27.7" + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" } }, "node_modules/escape-string-regexp": { @@ -4440,15 +4439,6 @@ "dev": true, "license": "MIT" }, - "node_modules/undici": { - "version": "8.9.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-8.9.0.tgz", - "integrity": "sha512-aWZpUj7XoGonMClx4gdDRfgBjqeA+F473aDmROQQbM9n6PRfK/u1q/a0X4wMTgcHfT8H6fpbt98PFuDUwFg2YA==", - "license": "MIT", - "engines": { - "node": ">=22.19.0" - } - }, "node_modules/undici-types": { "version": "7.18.2", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", diff --git a/clients/cli/package.json b/clients/cli/package.json index 08fa3eb20..291844230 100644 --- a/clients/cli/package.json +++ b/clients/cli/package.json @@ -27,7 +27,7 @@ "test:cli-headers": "vitest run headers.test.ts", "test:cli-metadata": "vitest run metadata.test.ts", "pretest": "npm run test-servers:build && npm run build", - "lint": "eslint .", + "lint": "eslint . --max-warnings 0", "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}\"" }, @@ -38,7 +38,6 @@ "commander": "^13.1.0", "open": "^10.2.0", "pino": "^9.14.0", - "undici": "^8.9.0", "zod": "^4.4.3" }, "devDependencies": { @@ -53,5 +52,8 @@ "typescript": "~5.9.3", "typescript-eslint": "^8.56.1", "vitest": "^4.1.10" + }, + "overrides": { + "esbuild": "^0.28.2" } } diff --git a/clients/cli/src/cli.ts b/clients/cli/src/cli.ts index f4d418aed..af4bed16b 100644 --- a/clients/cli/src/cli.ts +++ b/clients/cli/src/cli.ts @@ -17,6 +17,7 @@ import { clearStoredAuthForRelogin } from "./clear-stored-auth-for-relogin.js"; import { InspectorClient } from "@inspector/core/mcp/index.js"; import { cleanRoots } from "@inspector/core/mcp/serverList.js"; import { + createProxyFetch, createTransportNode, loadServerEntries, selectServerEntry, @@ -24,6 +25,8 @@ import { parseHeaderPair, } from "@inspector/core/mcp/node/index.js"; import type { JsonValue } from "@inspector/core/mcp/index.js"; +import type { StrictJsonValue } from "@inspector/core/json/jsonUtils.js"; +import { isSerializableJson } from "@inspector/core/json/jsonUtils.js"; import { canonicalUrlHost, isAllInterfacesHost, @@ -33,7 +36,6 @@ import { consumeMethodOutcome } from "./handlers/consume-outcome.js"; import { runMethod } from "./handlers/run-method.js"; import { isOneShotMethod, - metaValueToString, ONE_SHOT_METHODS, type MethodArgs, } from "./handlers/method-types.js"; @@ -45,7 +47,11 @@ import { serializeOAuthPersistBlob, type OAuthPersistSnapshot, } from "@inspector/core/auth/oauth-persist.js"; -import { getAuthorizationServerUrl } from "@inspector/core/auth/discovery.js"; +import { + discoverAuthorizationServerMetadataFromCandidates, + getAuthorizationServerUrl, + getAuthorizationServerUrlCandidates, +} from "@inspector/core/auth/discovery.js"; import { writeStoreFile } from "@inspector/core/storage/store-io.js"; import { refreshAuthorization, @@ -122,6 +128,11 @@ async function callMethod( const environment: InspectorClientEnvironment = { transport: createTransportNode, + // Proxy support sits at the bottom of the fetch stack so InspectorClient's + // wrappers compose over it — and so OAuth discovery/token requests, which + // also run through `environment.fetch`, are proxied too (#2067). Undefined + // when no proxy env var is set, which leaves the built-in fetch in place. + fetch: createProxyFetch(), }; const redirectUrlProvider = new MutableRedirectUrlProvider(); // Disarmed until the CLI-owned interactive OAuth flow runs — SDK `auth()` @@ -162,6 +173,17 @@ async function callMethod( // the change at all: the SDK refuses `roots/list_changed` from a client // that never declared it, which `setRoots` logged as a send failure (#1797). roots: cleanRoots(serverSettings?.roots ?? []), + // Per-server default `_meta` from mcp.json, exactly as web (`App.tsx`) and + // the TUI pass it — the setting belongs to the server, not to the client + // that happens to read it, and `InspectorClient` only reads the option + // rather than falling back to `serverSettings.metadata` (#2093). Already a + // JSON object (#1910), so there is no pair-array flattening left to do; + // `{}` means "no defaults". `--metadata` stays per-invocation and wins on a + // key collision, since call-time keys override defaults in `mergeMeta`. + ...(serverSettings?.metadata && + Object.keys(serverSettings.metadata).length > 0 && { + defaultMetadata: serverSettings.metadata, + }), serverSettings, // Per-server protocol era (SEP §7.8) from mcp.json → SDK versionNegotiation. // Absent era defaults to legacy in the InspectorClient constructor (#1626). @@ -329,11 +351,27 @@ export async function refreshStoredAuthToken( ); } - const authServerUrl = found.state.serverMetadata?.issuer - ? new URL(found.state.serverMetadata.issuer) - : getAuthorizationServerUrl(serverUrl); - const metadata = - found.state.serverMetadata ?? (await discover(authServerUrl)) ?? undefined; + // With stored metadata the issuer settles it. Without, the MCP server URL + // stands in as the authorization server — and a path-hosted server has two + // plausible answers, so walk them rather than committing to the path-scoped + // one: a server that merely lives under a path while publishing its metadata + // at the domain root must keep working (#2110). The candidate that *answered* + // becomes `authServerUrl`, since it is also the base the token request below + // is made against. + let authServerUrl: URL; + let metadata = found.state.serverMetadata ?? undefined; + if (found.state.serverMetadata?.issuer) { + authServerUrl = new URL(found.state.serverMetadata.issuer); + } else { + const discovered = await discoverAuthorizationServerMetadataFromCandidates( + getAuthorizationServerUrlCandidates(serverUrl), + discover, + ); + authServerUrl = + discovered?.authorizationServerUrl ?? + getAuthorizationServerUrl(serverUrl); + metadata = discovered?.metadata; + } let tokens: OAuthTokens; try { @@ -445,6 +483,11 @@ function buildHandoff( : canonicalUrlHost(host); const clientPort = process.env.CLIENT_PORT || "6274"; const sandboxPort = process.env.MCP_SANDBOX_PORT || "6275"; + // The dedicated app origin (#2056). Forwarded alongside the other two: an App + // whose UI resource declares `_meta.ui.domain` is served from this port and + // the browser reaches it DIRECTLY, so a handoff that forwards only 6274/6275 + // renders that app from an unreachable origin. + const appOriginPort = process.env.MCP_APP_ORIGIN_PORT || "6278"; // Treat an empty MCP_INSPECTOR_API_TOKEN the same as unset — an empty token // can't satisfy the deep-link autoConnect gate. const apiToken = process.env.MCP_INSPECTOR_API_TOKEN || undefined; @@ -462,7 +505,7 @@ function buildHandoff( return { serverUrl: normalizedUrl, deepLink: `http://${linkHost}:${clientPort}/?${params.toString()}`, - portForwardCmd: `coder port-forward --tcp ${clientPort}:${clientPort} --tcp ${sandboxPort}:${sandboxPort}`, + portForwardCmd: `coder port-forward --tcp ${clientPort}:${clientPort} --tcp ${sandboxPort}:${sandboxPort} --tcp ${appOriginPort}:${appOriginPort}`, oauthStatePath: statePath, apiToken: apiToken ?? null, note: @@ -474,8 +517,8 @@ function buildHandoff( function parseKeyValuePair( value: string, - previous: Record = {}, -): Record { + previous: Record = {}, +): Record { const parts = value.split("="); const key = parts[0]; const val = parts.slice(1).join("="); @@ -486,13 +529,31 @@ function parseKeyValuePair( ); } - let parsedValue: JsonValue; + // `StrictJsonValue`: `JSON.parse` cannot produce `undefined`, and these values + // become `_meta`, which must reach the wire exactly as written (#1910). + let parsedValue: StrictJsonValue; try { - parsedValue = JSON.parse(val) as JsonValue; + parsedValue = JSON.parse(val) as StrictJsonValue; } catch { + // Not JSON at all — a bare word or an unquoted string. Sent as a string, + // which is what the user plainly meant. parsedValue = val; } + // Valid JSON syntax is not the same as sendable JSON: `1e400` parses to + // `Infinity`, which `JSON.stringify` writes as `null`. Rejecting is better + // than accepting the flag and silently transmitting a different value — + // and better than falling back to the literal string, which would also not + // be what was asked for. + if (!isSerializableJson(parsedValue)) { + // Names the key, never the value: the pair can carry a credential + // (`credentials={"accessToken":"…","n":1e400}`) and this message lands in + // stderr and CI logs. + throw new Error( + `Invalid value for "${key}": numbers must be finite (a literal like 1e400 overflows to Infinity and cannot be sent).`, + ); + } + return { ...previous, [key as string]: parsedValue }; } @@ -643,6 +704,10 @@ async function parseArgs(argv?: string[]): Promise { "--app-info", "Probe the tool's MCP App UI metadata (resourceUri, csp, permissions, domain) and emit it as one JSON line; exit 2 when the tool has no app. Use with --method tools/call --tool-name (the tool itself is not invoked) or --method tools/list (one NDJSON line per tool).", ) + .option( + "--strict", + "Report tool-schema portability problems in full (path, issue, suggested fix) on stderr, and exit 6 if any is error-severity. Use with --method tools/list. Without it, a one-line count is printed instead.", + ) .option( "--connect-timeout ", `Connection timeout in ms (default ${DEFAULT_CONNECT_TIMEOUT_MS} for ad-hoc --server-url / target invocations; 0 = no timeout).`, @@ -736,13 +801,14 @@ async function parseArgs(argv?: string[]): Promise { promptName?: string; promptArgs?: Record; logLevel?: LoggingLevel; - metadata?: Record; - toolMetadata?: Record; + metadata?: Record; + toolMetadata?: Record; cwd?: string; transport?: "sse" | "http" | "stdio"; serverUrl?: string; header?: Record; appInfo?: boolean; + strict?: boolean; connectTimeout?: number; format?: OutputFormat; toolArgsJson?: string; @@ -783,6 +849,27 @@ async function parseArgs(argv?: string[]): Promise { } } + // `--strict` is checked HERE, ahead of every short-circuit return below + // (`--list-stored-auth`, `--print-handoff`, `servers/list`, `servers/show`), + // rather than beside the other method-shaped validations further down. Those + // returns never reach the lint, so a later check would let + // `--strict --method servers/list` succeed while silently ignoring a flag + // documented as tools/list-only — the same "accepted but inert" failure the + // `--app-info` pairing rejection exists to prevent. + if (options.strict) { + if (options.method !== "tools/list") { + throw new Error("--strict requires --method tools/list."); + } + // `tools/list --app-info` returns NDJSON straight from `runMethod` and + // never reaches `emitResult`, where the lint runs. Accepting the pair + // would hand a CI caller a gate that can never fail. + if (options.appInfo) { + throw new Error( + "--strict cannot be combined with --app-info; run tools/list twice, once for each.", + ); + } + } + // State-path precedence (getStateFilePath): MCP_INSPECTOR_OAUTH_STATE_PATH → // /oauth.json → ~/.mcp-inspector/storage/oauth.json — the // same file the web backend writes, so tokens are shared across surfaces. @@ -963,6 +1050,10 @@ async function parseArgs(argv?: string[]): Promise { ); } + // NOTE: `--strict`'s validations are deliberately NOT here — they run before + // the short-circuit returns further up, so a `servers/*` invocation cannot + // accept the flag and ignore it. + // --tool-args-json passes arguments verbatim with no key=value coercion (so // `"012"` stays a string and nested objects work without shell escaping). let toolArg = options.toolArg; @@ -999,23 +1090,14 @@ async function parseArgs(argv?: string[]): Promise { promptName: options.promptName, promptArgs: options.promptArgs, logLevel: options.logLevel, - metadata: options.metadata - ? Object.fromEntries( - Object.entries(options.metadata).map(([key, value]) => [ - key, - metaValueToString(value), - ]), - ) - : undefined, - toolMeta: options.toolMetadata - ? Object.fromEntries( - Object.entries(options.toolMetadata).map(([key, value]) => [ - key, - metaValueToString(value), - ]), - ) - : undefined, + // `--metadata`/`--tool-metadata` values are parsed as JSON, and `_meta` + // takes any JSON — so they go through unflattened (#1910). They used to be + // squeezed through `metaValueToString`, which sent `{"a":1}` as the + // *string* `'{"a":1}'`. + metadata: options.metadata, + toolMeta: options.toolMetadata, appInfo: options.appInfo === true, + strict: options.strict === true, format: options.format, }; diff --git a/clients/cli/src/error-handler.ts b/clients/cli/src/error-handler.ts index 84b72d65c..0deaf527b 100644 --- a/clients/cli/src/error-handler.ts +++ b/clients/cli/src/error-handler.ts @@ -1,3 +1,5 @@ +import { awaitableError } from "./utils/awaitable-log.js"; + /** * Exit-code map. Non-zero codes let an automated caller (CI, an agent) branch * on the failure class without regex-scraping stderr: @@ -8,6 +10,12 @@ * - 3: server requires authentication (401 / WWW-Authenticate / OAuth) * - 4: server unreachable (DNS, connect refused, timeout, fetch failure) * - 5: tool error (`tools/call` returned `isError:true`, or tool not found) + * - 6: `--strict` found an error-severity tool-schema portability finding + * + * Note 6 is `SCHEMA_UNPORTABLE`, not "invalid": the whole premise of the lint + * is that these schemas ARE valid JSON Schema and are merely refused by some + * clients. Calling the outcome "invalid" would misreport it to the automated + * callers this map exists for. */ export const EXIT_CODES = { OK: 0, @@ -16,6 +24,7 @@ export const EXIT_CODES = { AUTH_REQUIRED: 3, UNREACHABLE: 4, TOOL_ERROR: 5, + SCHEMA_UNPORTABLE: 6, } as const; /** Machine-readable error envelope written as one JSON line on stderr. */ @@ -184,6 +193,8 @@ function codeForExit(exitCode: number): string { return "unreachable"; case EXIT_CODES.TOOL_ERROR: return "tool_error"; + case EXIT_CODES.SCHEMA_UNPORTABLE: + return "schema_unportable"; default: return "error"; } @@ -209,8 +220,19 @@ export function formatErrorOutput( }; } -export function handleError(error: unknown): never { +/** + * The binary's last-resort error sink: write the envelope, then exit. + * + * **Async, and the await is load-bearing.** On a pipe or a file — as opposed + * to a TTY — `process.stderr.write` is asynchronous, and `process.exit()` + * discards whatever is still queued. Size is not the safeguard it looks like: + * the envelope being small enough for the pipe buffer says nothing about + * whether the write has been *performed* by the time the process goes away. + * So this settles on the write callback first, exactly as the `--strict` + * schema report does. + */ +export async function handleError(error: unknown): Promise { const { exitCode, stderr } = formatErrorOutput(error); - process.stderr.write(stderr); + await awaitableError(stderr); process.exit(exitCode); } diff --git a/clients/cli/src/handlers/collect-app-info.ts b/clients/cli/src/handlers/collect-app-info.ts index aa2df1fc1..d46e37544 100644 --- a/clients/cli/src/handlers/collect-app-info.ts +++ b/clients/cli/src/handlers/collect-app-info.ts @@ -2,6 +2,7 @@ import { InspectorClient } from "@inspector/core/mcp/index.js"; import { extractAppInfo } from "@inspector/core/mcp/apps.js"; import type { AppInfo } from "@inspector/core/mcp/apps.js"; import type { CliAppInfo } from "./method-types.js"; +import type { RequestMetadata } from "@inspector/core/mcp/types.js"; /** * Build the CLI's app-info for a tool. Never throws — failures fold into @@ -10,7 +11,7 @@ import type { CliAppInfo } from "./method-types.js"; export async function collectAppInfo( client: Pick, tool: Parameters[0], - metadata: Record | undefined, + metadata: RequestMetadata | undefined, ): Promise { let base: AppInfo; try { diff --git a/clients/cli/src/handlers/connect-timeout.ts b/clients/cli/src/handlers/connect-timeout.ts index 7d5a7b2cc..8bd8cf655 100644 --- a/clients/cli/src/handlers/connect-timeout.ts +++ b/clients/cli/src/handlers/connect-timeout.ts @@ -22,7 +22,7 @@ export function withConnectTimeout( if (settings) return { ...settings, connectionTimeout }; return { headers: [], - metadata: [], + metadata: {}, env: [], connectionTimeout, requestTimeout: 0, diff --git a/clients/cli/src/handlers/emit-result.ts b/clients/cli/src/handlers/emit-result.ts index b4d370deb..459a6e7e4 100644 --- a/clients/cli/src/handlers/emit-result.ts +++ b/clients/cli/src/handlers/emit-result.ts @@ -1,10 +1,13 @@ import { awaitableLog } from "../utils/awaitable-log.js"; import { CliExitCodeError, EXIT_CODES } from "../error-handler.js"; +import { lintListResult, writeSchemaLintReport } from "./schema-lint-report.js"; +import { countFindings } from "@inspector/core/json/schemaLint.js"; import type { CliAppInfo, McpResponse, MethodArgs } from "./method-types.js"; /** * Write the method result (and any app-info) to stdout, honouring `--format` - * and `--app-info`, then map `isError`/no-app outcomes onto the exit-code map. + * and `--app-info`, then map `isError`/no-app/schema outcomes onto the + * exit-code map. */ export async function emitResult( result: McpResponse, @@ -28,14 +31,26 @@ export async function emitResult( return; } + // Lint before writing, because `--format json --strict` folds the findings + // into the same envelope as the result rather than emitting a second + // document a caller would have to correlate. + const lint = + args.method === "tools/list" ? lintListResult(result) : undefined; + if (json) { const envelope: Record = { result }; if (appInfo?.hasApp) envelope.appInfo = appInfo; + if (args.strict && lint && lint.length > 0) envelope.schemaFindings = lint; await awaitableLog(JSON.stringify(envelope) + "\n"); } else { await awaitableLog(JSON.stringify(result, null, 2) + "\n"); } + // Awaited: the throw below (and the CLI's own exit path) reaches + // `process.exit()` immediately, which discards anything still buffered on a + // piped stderr. + if (lint) await writeSchemaLintReport(lint, args.strict === true); + if ((result as { isError?: unknown }).isError === true) { throw new CliExitCodeError( EXIT_CODES.TOOL_ERROR, @@ -43,4 +58,21 @@ export async function emitResult( { code: "tool_is_error" }, ); } + + // Only `--strict` fails the run. Warnings never do: they mark constructs + // that are handled unevenly rather than refused, so failing on them would + // make `--strict` unusable as a CI gate on servers that are in fact fine. + if (args.strict && lint) { + const { errors } = countFindings(lint); + if (errors > 0) { + // "unportable", not "invalid": these schemas are valid JSON Schema and + // are merely refused by some clients, so an envelope code of + // `schema_invalid` would tell an automated caller the wrong thing. + throw new CliExitCodeError( + EXIT_CODES.SCHEMA_UNPORTABLE, + `${errors} tool schema portability error${errors === 1 ? "" : "s"} found (--strict).`, + { code: "schema_unportable" }, + ); + } + } } diff --git a/clients/cli/src/handlers/method-types.ts b/clients/cli/src/handlers/method-types.ts index b0505425e..51d8657c6 100644 --- a/clients/cli/src/handlers/method-types.ts +++ b/clients/cli/src/handlers/method-types.ts @@ -1,4 +1,5 @@ import type { JsonValue } from "@inspector/core/mcp/index.js"; +import type { RequestMetadata } from "@inspector/core/mcp/types.js"; import type { AppInfo } from "@inspector/core/mcp/apps.js"; import type { LoggingLevel } from "@modelcontextprotocol/client"; import type { OutputFormat } from "./format-output.js"; @@ -20,9 +21,14 @@ export type MethodArgs = { logLevel?: LoggingLevel; toolName?: string; toolArg?: Record; - toolMeta?: Record; - metadata?: Record; + toolMeta?: RequestMetadata; + metadata?: RequestMetadata; appInfo?: boolean; + /** + * `--strict`: report tool-schema portability findings in full and exit + * non-zero when any is error-severity (#1005). `tools/list` only. + */ + strict?: boolean; format?: OutputFormat; /** Task id for tasks/get, tasks/cancel, tasks/result. */ taskId?: string; @@ -110,13 +116,3 @@ export type OneShotMethod = (typeof ONE_SHOT_METHODS)[number]; export function isOneShotMethod(method: string): method is OneShotMethod { return (ONE_SHOT_METHODS as readonly string[]).includes(method); } - -/** - * Encode a metadata / tool-metadata value for MethodArgs (string map). - * Strings pass through; objects/arrays use JSON.stringify so structure is kept - * (avoids String(obj) → "[object Object]"). - */ -export function metaValueToString(value: JsonValue): string { - if (typeof value === "string") return value; - return JSON.stringify(value); -} diff --git a/clients/cli/src/handlers/schema-lint-report.ts b/clients/cli/src/handlers/schema-lint-report.ts new file mode 100644 index 000000000..a38aaa933 --- /dev/null +++ b/clients/cli/src/handlers/schema-lint-report.ts @@ -0,0 +1,63 @@ +import type { Tool } from "@modelcontextprotocol/client"; +import { + formatSchemaLintReport, + lintTools, + summarizeFindings, + type ToolSchemaFindings, +} from "@inspector/core/json/schemaLint.js"; +import { awaitableError } from "../utils/awaitable-log.js"; +import type { McpResponse } from "./method-types.js"; + +/** + * Read the `tools` array out of a `tools/list` result. The result is typed as + * an opaque `McpResponse` at this layer, and a server can return anything, so + * each entry is narrowed to "an object with a string `name`" before it is + * treated as a {@link Tool}. Everything else is skipped rather than crashing + * the report — an unlintable entry is not the CLI's problem to surface here. + */ +export function toolsFromResult(result: McpResponse): Tool[] { + const tools = (result as { tools?: unknown }).tools; + if (!Array.isArray(tools)) return []; + return tools.filter( + (t): t is Tool => + typeof t === "object" && + t !== null && + typeof (t as { name?: unknown }).name === "string", + ); +} + +/** + * Lint the tools in a `tools/list` result (#1005). + * + * Always run, `--strict` or not — it is a pure in-memory walk over a list the + * CLI already holds, and the non-strict hint below needs the count. What + * `--strict` changes is what happens with the result, in + * {@link writeSchemaLintReport}. + */ +export function lintListResult(result: McpResponse): ToolSchemaFindings[] { + return lintTools(toolsFromResult(result)); +} + +/** + * Write the schema-lint outcome to **stderr**, so it never contaminates the + * result on stdout that a `--format json` consumer is parsing. + * + * Under `--strict` this is the full report from the issue — path, issue, + * suggestion, one block per finding. Without it, a single line naming the + * count and how to see the detail: a server author who has not asked for the + * lint should still learn it found something, but a multi-page report nobody + * requested would be worse than silence. + */ +export async function writeSchemaLintReport( + results: readonly ToolSchemaFindings[], + strict: boolean, +): Promise { + if (results.length === 0) return; + if (!strict) { + await awaitableError( + `Schema portability: ${summarizeFindings(results)}. Re-run with --strict for details.\n`, + ); + return; + } + await awaitableError(`${formatSchemaLintReport(results)}\n`); +} diff --git a/clients/cli/src/handlers/servers-list.ts b/clients/cli/src/handlers/servers-list.ts index 78bd896fb..d580f47c0 100644 --- a/clients/cli/src/handlers/servers-list.ts +++ b/clients/cli/src/handlers/servers-list.ts @@ -157,10 +157,10 @@ export function sanitizeServerSettings( key: h.key, value: isSensitiveHeader(h.key) ? REDACTED : h.value, })), - metadata: (settings.metadata ?? []).map((m) => ({ - key: m.key, - value: isSensitiveHeader(m.key) ? REDACTED : m.value, - })), + // Metadata values may be any JSON (#1910), so the redaction has to descend: + // a top-level key that is not itself sensitive can hold a nested one that + // is. See `redactMetadataValue`. + metadata: redactMetadataValue(settings.metadata ?? {}), env: (settings.env ?? []).map((e) => ({ key: e.key, value: REDACTED, @@ -212,6 +212,39 @@ function sanitizeInitRecord( return out; } +/** + * Redact secret-bearing entries anywhere inside a `_meta` payload. + * + * The pre-#1910 version only had to check the top level, because a value was + * always a string — a secret could not hide below one. Now that a value may be + * an object or an array, `{ trace: { accessToken: "…" } }` would sail through a + * top-level-only check: `trace` is not sensitive, so the whole subtree, + * `accessToken` included, would print. `servers/show` output is meant to be + * safe to paste into an issue, so the walk has to reach every key. + * + * A key matching {@link isSensitiveHeader} replaces its **entire** value rather + * than being descended into, so a sensitive key holding an object cannot leak + * through its members either. Arrays are mapped elementwise: their indices are + * not names anyone can mark sensitive, but objects inside them can be. + * + * Recursion is bounded by the payload, which came from `JSON.parse` and so is a + * finite tree with no cycles. + */ +function redactMetadataValue(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map(redactMetadataValue); + } + if (value !== null && typeof value === "object") { + return Object.fromEntries( + Object.entries(value).map(([key, member]) => [ + key, + isSensitiveHeader(key) ? REDACTED : redactMetadataValue(member), + ]), + ); + } + return value; +} + function isSensitiveHeader(key: string): boolean { const k = key.toLowerCase(); return ( diff --git a/clients/cli/src/utils/awaitable-log.ts b/clients/cli/src/utils/awaitable-log.ts index 144f01123..4d148b9a9 100644 --- a/clients/cli/src/utils/awaitable-log.ts +++ b/clients/cli/src/utils/awaitable-log.ts @@ -5,3 +5,21 @@ export function awaitableLog(logValue: string): Promise { }); }); } + +/** + * The stderr twin of {@link awaitableLog}. + * + * Both CLI exit paths call `process.exit()` as soon as the work returns + * (`src/index.ts`, and `handleError` in `src/error-handler.ts`). When stderr + * is a pipe or a file rather than a TTY, `write` is asynchronous, so anything + * still buffered at that moment is discarded — which for a multi-block + * `--strict` report means a truncated or entirely missing diagnostic on + * exactly the redirected-output runs a CI caller uses. + */ +export function awaitableError(logValue: string): Promise { + return new Promise((resolve) => { + process.stderr.write(logValue, () => { + resolve(); + }); + }); +} diff --git a/clients/cli/tsup.config.ts b/clients/cli/tsup.config.ts index 03cf74319..74e70568a 100644 --- a/clients/cli/tsup.config.ts +++ b/clients/cli/tsup.config.ts @@ -20,9 +20,30 @@ export default defineConfig({ // Bundle core source; leave npm deps external. noExternal: [/^@inspector\/core/], external: [ + // `undici` MUST stay external. It is CommonJS, so inlining it rewrites + // `import("undici")` to a relative chunk whose `require("assert")` hits + // esbuild's ESM `__require` shim and throws "Dynamic require of \"assert\" + // is not supported" — and because the specifier was rewritten at build time, + // no user-side install can ever satisfy it (#2067). It is declared in the + // ROOT manifest only, so tsup cannot infer this from a nearest-manifest + // lookup; the entry has to be explicit. + "undici", "@napi-rs/keyring", + // Root-declared (see the repo's dependency-placement rule) and CJS, which + // is the combination that bites: tsup externalizes what the *client's* + // package.json declares, so a root-only dependency is bundled unless named + // here — and inlining a CJS module into an ESM bundle leaves esbuild's + // `Dynamic require of "path" is not supported` shim, which throws at + // import time and takes the whole binary down before it parses a flag. + "proper-lockfile", "@modelcontextprotocol/client", "@modelcontextprotocol/core", + // Root-declared and reached through `core/mcp/apps.ts`, so it must be + // external here like every other root runtime dependency (AGENTS.md). This + // client was actually inlining it — dragging the transitive v1 SDK and + // zod-to-json-schema in with it — which the #2067 guard surfaced. ESM, so it + // was not failing the way `undici` did; the rule is what it violated. + "@modelcontextprotocol/ext-apps", "commander", "pino", ], diff --git a/clients/launcher/package.json b/clients/launcher/package.json index 1f1b110d8..e8942a099 100644 --- a/clients/launcher/package.json +++ b/clients/launcher/package.json @@ -14,7 +14,7 @@ "scripts": { "build": "tsc", "postbuild": "node scripts/make-executable.js", - "lint": "eslint .", + "lint": "eslint . --max-warnings 0", "typecheck": "tsc --noEmit -p tsconfig.json && tsc --noEmit -p tsconfig.test.json", "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}\"", diff --git a/clients/launcher/src/index.ts b/clients/launcher/src/index.ts index faf5e39f9..e42f4e53c 100644 --- a/clients/launcher/src/index.ts +++ b/clients/launcher/src/index.ts @@ -79,10 +79,12 @@ async function run(): Promise { // this `undefined`; fall through to the generic sink with the real message // rather than throwing "handleError is not a function" over it. if (typeof handleError !== "function") throw err; - // write-then-exit like the direct bin; the envelope is a few hundred bytes, - // well inside the pipe buffer, so the truncation risk noted above (which is - // about a large stdout payload) doesn't apply to this stderr line. - handleError(err); // writes the envelope + process.exit(code); never returns + // Write-then-exit like the direct bin — and AWAIT it. The envelope being + // a few hundred bytes is not the safeguard it looks like: on a pipe the + // write is asynchronous, so `process.exit()` discards it whether or not + // it would have fitted in the buffer. `handleError` settles on the write + // callback before exiting; skipping the await puts the truncation back. + await handleError(err); // writes the envelope + process.exit(code) } } else { const { runTui } = await import(clientEntry("tui")); diff --git a/clients/tui/README.md b/clients/tui/README.md index 1ae2fb514..93930003d 100644 --- a/clients/tui/README.md +++ b/clients/tui/README.md @@ -75,7 +75,7 @@ The TUI provides terminal-native tabs and panes for interacting with your MCP se - **Resources**: Browse and read resources exposed by the server. - **Prompts**: List and test prompts. -- **Tools**: View available tools and execute them with form-like inputs. +- **Tools**: View available tools and execute them with form-like inputs. A tool whose advertised schema carries a portability problem is flagged in the list — red `!` for a construct a shipping MCP client refuses, yellow `?` for one handled unevenly — and the detail pane lists each finding under **Schema Portability** with the path, the problem, and a concrete fix. The verdict comes from [`core/json/schemaLint.ts`](../../core/json/schemaLint.ts), shared with the web Tools tab and the CLI's `--strict` report, so the three cannot disagree ([#1005](https://github.com/modelcontextprotocol/inspector/issues/1005)). - **Protocol**: View JSON-RPC request/response/notification history (matches the web Protocol monitor). - **Network**: View HTTP fetch traffic for SSE / Streamable HTTP servers (matches the web Network monitor). - **Console**: View stdio stderr from the connected server process (matches the web Console monitor). diff --git a/clients/tui/__tests__/App.test.tsx b/clients/tui/__tests__/App.test.tsx index a66fe6032..e58d8c775 100644 --- a/clients/tui/__tests__/App.test.tsx +++ b/clients/tui/__tests__/App.test.tsx @@ -102,7 +102,18 @@ const h = vi.hoisted(() => { // handler for the event (the common single-server case). type EventEntry = { client: unknown; fn: (event: unknown) => void }; const clientEvents = new Map>(); - const clientInstances: Array<{ cfg?: { type?: string; url?: string } }> = []; + const clientInstances: Array<{ + cfg?: { type?: string; url?: string }; + // 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 } }; + }> = []; + // What the mocked `createProxyFetch` returns. `undefined` is the real + // behavior with no proxy env var set; a test points this at a sentinel to + // exercise the proxy-configured branch (#2067). + const proxyFetchState: { current: unknown } = { current: undefined }; const fireClientEvent = (event: string, detail?: unknown) => { clientEvents.get(event)?.forEach((e) => e.fn({ detail })); }; @@ -127,8 +138,15 @@ const h = vi.hoisted(() => { } class FakeClient { cfg: { type?: string; url?: string } | undefined; - constructor(config?: { type?: string; url?: string }) { + opts: + | { defaultMetadata?: unknown; environment?: { fetch?: unknown } } + | undefined; + constructor( + config?: { type?: string; url?: string }, + opts?: { defaultMetadata?: unknown; environment?: { fetch?: unknown } }, + ) { this.cfg = config; + this.opts = opts; clientInstances.push(this); } // Derive the transport type from the server config the client was built @@ -183,6 +201,7 @@ const h = vi.hoisted(() => { } return { ctrl, + proxyFetchState, connect, disconnect, openUrl, @@ -232,6 +251,12 @@ vi.mock("@inspector/core/mcp/state/index.js", () => ({ })); vi.mock("@inspector/core/mcp/node/index.js", () => ({ createTransportNode: vi.fn(), + // Defaults to undefined — "no proxy configured", which is what the real one + // returns with no proxy env var set, so `environment.fetch` stays unset and + // the client falls back to the built-in fetch, exactly as before #2067. A + // test can point `h.proxyFetchState.current` at a sentinel to exercise the + // other branch. + createProxyFetch: vi.fn(() => h.proxyFetchState.current), })); vi.mock("@inspector/core/react/useInspectorClient.js", () => ({ useInspectorClient: h.useInspectorClient, @@ -340,7 +365,7 @@ function oneEmaHttp(): Record { config: { type: "streamable-http", url: "http://localhost:8080/mcp" }, settings: { requestTimeout: 0, - metadata: [], + metadata: {}, headers: [], env: [], roots: [], @@ -393,10 +418,10 @@ function httpWithSettings(): Record { config: { type: "streamable-http", url: "http://x" }, settings: { requestTimeout: 5000, - metadata: [ - { key: "team", value: "alpha" }, - { key: " ", value: "ignored" }, - ], + // Object-shaped with a nested value (#1910) — the shape the TUI now + // forwards verbatim. As a pair array this would have reached the wire + // as numeric `_meta` keys. + metadata: { team: "alpha", trace: { id: "abc", hops: [1, 2] } }, oauthClientId: "cid", oauthClientSecret: "secret", oauthScopes: "read write", @@ -633,6 +658,7 @@ beforeEach(() => { h.callbackStop.mockClear(); h.clientEvents.clear(); h.clientInstances.length = 0; + h.proxyFetchState.current = undefined; h.runner.override = null; h.clientSpies.authenticate.mockReset(); h.clientSpies.authenticate.mockResolvedValue( @@ -1114,6 +1140,13 @@ describe("App (input handling, focus, effects)", () => { it("builds a client with saved settings (metadata, oauth, timeout)", async () => { const r = await mount(httpWithSettings()); await expectFrame(r, "MCP Servers"); + // Not just "it mounted": the saved metadata must reach the client as the + // same object, nesting intact (#1910). + const web = h.clientInstances.find((c) => c.cfg?.url === "http://x"); + expect(web?.opts?.defaultMetadata).toEqual({ + team: "alpha", + trace: { id: "abc", hops: [1, 2] }, + }); }); it("passes top-level oauth client credentials into an http client", async () => { @@ -1608,6 +1641,27 @@ describe("App (OAuth result branches)", () => { await expectFrame(r, "Authorization updated. Retry your action"); }); + it("installs the proxy fetch as environment.fetch when a proxy is configured", async () => { + // The bottom-of-stack wiring for #2067. It is one line in App.tsx and it is + // what makes InspectorClient's own wrappers compose OVER the proxy instead + // of discarding it — delete it and TUI proxy support silently disappears + // while every other test stays green. + const sentinel: typeof fetch = async () => new Response(""); + h.proxyFetchState.current = sentinel; + await mount(oneHttp()); + expect(h.clientInstances.length).toBeGreaterThan(0); + expect(h.clientInstances[0]!.opts?.environment?.fetch).toBe(sentinel); + }); + + it("leaves environment.fetch unset when no proxy is configured", async () => { + // The default path must stay on the built-in fetch — a wrapper installed + // unconditionally would put every TUI user behind an undici fetch. + h.proxyFetchState.current = undefined; + await mount(oneHttp()); + expect(h.clientInstances.length).toBeGreaterThan(0); + expect(h.clientInstances[0]!.opts?.environment?.fetch).toBeUndefined(); + }); + it("ignores auth lifecycle events from a non-selected server", async () => { const r = await mount(twoHttp()); // web is selected; api is not await press(r, ["a"]); diff --git a/clients/tui/__tests__/ToolsTab.test.tsx b/clients/tui/__tests__/ToolsTab.test.tsx index 0500a6953..0c1913d61 100644 --- a/clients/tui/__tests__/ToolsTab.test.tsx +++ b/clients/tui/__tests__/ToolsTab.test.tsx @@ -8,7 +8,7 @@ import type { Tool } from "@modelcontextprotocol/client"; // renders children directly and stubs scrollBy/scrollTo/getViewportHeight. vi.mock("ink-scroll-view", () => import("./helpers/inkScrollViewMock.js")); -import { ToolsTab } from "../src/components/ToolsTab.js"; +import { ToolsTab, schemaMarker } from "../src/components/ToolsTab.js"; // Ink processes stdin keypresses asynchronously — await this after stdin.write // and after rerender() before asserting. @@ -205,4 +205,84 @@ describe("ToolsTab", () => { await tick(); expect(onViewDetails).toHaveBeenCalledWith(tools[0]); }); + + describe("schema portability (#1005)", () => { + it("marks nothing when every tool's schema is portable", () => { + const { lastFrame } = render( + , + ); + const frame = lastFrame() ?? ""; + expect(frame).not.toContain("Schema Portability"); + }); + + it("flags an offending tool in the list and details it in the pane", () => { + const dirty = makeTool({ + name: "info", + description: undefined, + outputSchema: { type: "object", properties: { data: true } }, + } as Partial); + const { lastFrame } = render( + , + ); + const frame = lastFrame() ?? ""; + // List row carries the error glyph… + expect(frame).toContain("info !"); + // …and the detail pane names the path, issue and fix. + expect(frame).toContain("Schema Portability (1)"); + expect(frame).toContain("outputSchema.properties.data"); + expect(frame).toContain("Fix:"); + }); + + it("uses the warning glyph when nothing is error-severity", () => { + const warned = makeTool({ + name: "warned", + description: undefined, + inputSchema: { + type: "object", + properties: { a: { type: ["null", "boolean"] } }, + }, + } as Partial); + const { lastFrame } = render( + , + ); + const frame = lastFrame() ?? ""; + expect(frame).toContain("warned ?"); + expect(frame).toContain("Schema Portability (1)"); + }); + }); +}); + +describe("schemaMarker", () => { + it("returns no glyph for undefined or empty findings", () => { + expect(schemaMarker(undefined)).toBeUndefined(); + expect(schemaMarker([])).toBeUndefined(); + }); + + it("prefers the error glyph when both severities are present", () => { + expect( + schemaMarker([ + { + rule: "type-union", + severity: "warning", + schema: "inputSchema", + path: "", + issue: "i", + suggestion: "s", + }, + { + rule: "boolean-schema", + severity: "error", + schema: "inputSchema", + path: "", + issue: "i", + suggestion: "s", + }, + ]), + ).toEqual({ glyph: "!", color: "red" }); + }); }); diff --git a/clients/tui/__tests__/helpers/oauth-test-fakes.ts b/clients/tui/__tests__/helpers/oauth-test-fakes.ts index efff339e1..5e6e0fae6 100644 --- a/clients/tui/__tests__/helpers/oauth-test-fakes.ts +++ b/clients/tui/__tests__/helpers/oauth-test-fakes.ts @@ -19,7 +19,7 @@ export function makeFakeServerSettings( ): InspectorServerSettings { return { headers: [], - metadata: [], + metadata: {}, env: [], connectionTimeout: 0, requestTimeout: 0, diff --git a/clients/tui/__tests__/schemaToForm.test.ts b/clients/tui/__tests__/schemaToForm.test.ts index f47450e3b..0a2d22642 100644 --- a/clients/tui/__tests__/schemaToForm.test.ts +++ b/clients/tui/__tests__/schemaToForm.test.ts @@ -381,5 +381,31 @@ describe("schemaToForm", () => { type: "string", }); }); + + // #1005: the unportable-schemas showcase advertises a remote `$ref` so the + // lint has a `remote-ref` finding to report. A `$ref`-only property falls + // through to a string field here, which the tool's real (numeric) handler + // then rejects — so the fixture keeps a local `type` alongside the ref and + // the tool stays runnable. This pins the half of that contract the TUI + // owns; the wire half is in `raw-tool-schemas.test.ts`. + it("types a property that carries both a remote $ref and a local type", () => { + const form = schemaToForm( + { + properties: { + a: { + type: "number", + $ref: "https://example.com/schemas/number.json", + }, + }, + }, + "refWithLocalType", + ); + // `float` is this form builder's numeric field kind — the point is that + // it is NOT the string fallback a `$ref`-only property would get. + expect(form.sections[0]!.fields[0]).toMatchObject({ + name: "a", + type: "float", + }); + }); }); }); diff --git a/clients/tui/eslint.config.js b/clients/tui/eslint.config.js index d37c6b7d3..97f4446d3 100644 --- a/clients/tui/eslint.config.js +++ b/clients/tui/eslint.config.js @@ -27,7 +27,8 @@ export default defineConfig([ }, rules: { "react-hooks/rules-of-hooks": "error", - "react-hooks/exhaustive-deps": "warn", + // `error`, not `warn` — see the web config (#2085). + "react-hooks/exhaustive-deps": "error", }, }, { diff --git a/clients/tui/package-lock.json b/clients/tui/package-lock.json index 9261589c0..9a9e96d9d 100644 --- a/clients/tui/package-lock.json +++ b/clients/tui/package-lock.json @@ -367,9 +367,9 @@ } }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", - "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", "cpu": [ "ppc64" ], @@ -384,9 +384,9 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", - "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", "cpu": [ "arm" ], @@ -401,9 +401,9 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", - "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", "cpu": [ "arm64" ], @@ -418,9 +418,9 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", - "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", "cpu": [ "x64" ], @@ -435,9 +435,9 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", - "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", "cpu": [ "arm64" ], @@ -452,9 +452,9 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", - "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", "cpu": [ "x64" ], @@ -469,9 +469,9 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", - "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", "cpu": [ "arm64" ], @@ -486,9 +486,9 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", - "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", "cpu": [ "x64" ], @@ -503,9 +503,9 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", - "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", "cpu": [ "arm" ], @@ -520,9 +520,9 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", - "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", "cpu": [ "arm64" ], @@ -537,9 +537,9 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", - "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", "cpu": [ "ia32" ], @@ -554,9 +554,9 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", - "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", "cpu": [ "loong64" ], @@ -571,9 +571,9 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", - "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", "cpu": [ "mips64el" ], @@ -588,9 +588,9 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", - "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", "cpu": [ "ppc64" ], @@ -605,9 +605,9 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", - "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", "cpu": [ "riscv64" ], @@ -622,9 +622,9 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", - "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", "cpu": [ "s390x" ], @@ -639,9 +639,9 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", - "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", "cpu": [ "x64" ], @@ -656,9 +656,9 @@ } }, "node_modules/@esbuild/netbsd-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", - "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", "cpu": [ "arm64" ], @@ -673,9 +673,9 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", - "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", "cpu": [ "x64" ], @@ -690,9 +690,9 @@ } }, "node_modules/@esbuild/openbsd-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", - "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", "cpu": [ "arm64" ], @@ -707,9 +707,9 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", - "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", "cpu": [ "x64" ], @@ -724,9 +724,9 @@ } }, "node_modules/@esbuild/openharmony-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", - "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", "cpu": [ "arm64" ], @@ -741,9 +741,9 @@ } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", - "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", "cpu": [ "x64" ], @@ -758,9 +758,9 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", - "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", "cpu": [ "arm64" ], @@ -775,9 +775,9 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", - "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", "cpu": [ "ia32" ], @@ -792,9 +792,9 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", - "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", "cpu": [ "x64" ], @@ -2934,9 +2934,9 @@ ] }, "node_modules/esbuild": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", - "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -2947,32 +2947,32 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.27.7", - "@esbuild/android-arm": "0.27.7", - "@esbuild/android-arm64": "0.27.7", - "@esbuild/android-x64": "0.27.7", - "@esbuild/darwin-arm64": "0.27.7", - "@esbuild/darwin-x64": "0.27.7", - "@esbuild/freebsd-arm64": "0.27.7", - "@esbuild/freebsd-x64": "0.27.7", - "@esbuild/linux-arm": "0.27.7", - "@esbuild/linux-arm64": "0.27.7", - "@esbuild/linux-ia32": "0.27.7", - "@esbuild/linux-loong64": "0.27.7", - "@esbuild/linux-mips64el": "0.27.7", - "@esbuild/linux-ppc64": "0.27.7", - "@esbuild/linux-riscv64": "0.27.7", - "@esbuild/linux-s390x": "0.27.7", - "@esbuild/linux-x64": "0.27.7", - "@esbuild/netbsd-arm64": "0.27.7", - "@esbuild/netbsd-x64": "0.27.7", - "@esbuild/openbsd-arm64": "0.27.7", - "@esbuild/openbsd-x64": "0.27.7", - "@esbuild/openharmony-arm64": "0.27.7", - "@esbuild/sunos-x64": "0.27.7", - "@esbuild/win32-arm64": "0.27.7", - "@esbuild/win32-ia32": "0.27.7", - "@esbuild/win32-x64": "0.27.7" + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" } }, "node_modules/escalade": { @@ -5319,490 +5319,6 @@ "fsevents": "~2.3.3" } }, - "node_modules/tsx/node_modules/@esbuild/aix-ppc64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz", - "integrity": "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/android-arm": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.0.tgz", - "integrity": "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/android-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz", - "integrity": "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/android-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.0.tgz", - "integrity": "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/darwin-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz", - "integrity": "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/darwin-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz", - "integrity": "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/freebsd-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz", - "integrity": "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/freebsd-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz", - "integrity": "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-arm": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz", - "integrity": "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz", - "integrity": "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-ia32": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz", - "integrity": "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-loong64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz", - "integrity": "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-mips64el": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz", - "integrity": "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-ppc64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz", - "integrity": "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-riscv64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz", - "integrity": "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-s390x": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz", - "integrity": "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz", - "integrity": "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/netbsd-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz", - "integrity": "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/netbsd-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz", - "integrity": "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/openbsd-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz", - "integrity": "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/openbsd-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz", - "integrity": "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/openharmony-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz", - "integrity": "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/sunos-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz", - "integrity": "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/win32-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz", - "integrity": "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/win32-ia32": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz", - "integrity": "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/win32-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz", - "integrity": "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/esbuild": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.0.tgz", - "integrity": "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.28.0", - "@esbuild/android-arm": "0.28.0", - "@esbuild/android-arm64": "0.28.0", - "@esbuild/android-x64": "0.28.0", - "@esbuild/darwin-arm64": "0.28.0", - "@esbuild/darwin-x64": "0.28.0", - "@esbuild/freebsd-arm64": "0.28.0", - "@esbuild/freebsd-x64": "0.28.0", - "@esbuild/linux-arm": "0.28.0", - "@esbuild/linux-arm64": "0.28.0", - "@esbuild/linux-ia32": "0.28.0", - "@esbuild/linux-loong64": "0.28.0", - "@esbuild/linux-mips64el": "0.28.0", - "@esbuild/linux-ppc64": "0.28.0", - "@esbuild/linux-riscv64": "0.28.0", - "@esbuild/linux-s390x": "0.28.0", - "@esbuild/linux-x64": "0.28.0", - "@esbuild/netbsd-arm64": "0.28.0", - "@esbuild/netbsd-x64": "0.28.0", - "@esbuild/openbsd-arm64": "0.28.0", - "@esbuild/openbsd-x64": "0.28.0", - "@esbuild/openharmony-arm64": "0.28.0", - "@esbuild/sunos-x64": "0.28.0", - "@esbuild/win32-arm64": "0.28.0", - "@esbuild/win32-ia32": "0.28.0", - "@esbuild/win32-x64": "0.28.0" - } - }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", diff --git a/clients/tui/package.json b/clients/tui/package.json index 17839b57d..2078bdd0e 100644 --- a/clients/tui/package.json +++ b/clients/tui/package.json @@ -22,7 +22,7 @@ "validate": "npm run format:check && npm run lint && npm run typecheck && npm run build && npm run test", "test": "vitest run", "test:coverage": "vitest run --coverage", - "lint": "eslint .", + "lint": "eslint . --max-warnings 0", "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}\"" }, @@ -40,7 +40,8 @@ "zod": "^4.4.3" }, "overrides": { - "ink-select-input": "^6.2.0" + "ink-select-input": "^6.2.0", + "esbuild": "^0.28.2" }, "devDependencies": { "@eslint/js": "^10.0.1", diff --git a/clients/tui/src/App.tsx b/clients/tui/src/App.tsx index 240bbd82f..d8a8aee0b 100644 --- a/clients/tui/src/App.tsx +++ b/clients/tui/src/App.tsx @@ -31,7 +31,10 @@ import { FetchRequestLogState, StderrLogState, } from "@inspector/core/mcp/state/index.js"; -import { createTransportNode } from "@inspector/core/mcp/node/index.js"; +import { + createProxyFetch, + createTransportNode, +} from "@inspector/core/mcp/node/index.js"; import { useInspectorClient } from "@inspector/core/react/useInspectorClient.js"; import { useManagedTools } from "@inspector/core/react/useManagedTools.js"; import { useManagedResources } from "@inspector/core/react/useManagedResources.js"; @@ -290,14 +293,14 @@ function App({ const environment: InspectorClientEnvironment = { transport: createTransportNode, logger: getTuiLogger(), + // Bottom of the fetch stack, so InspectorClient's wrappers compose + // over it and OAuth requests are proxied too (#2067). Undefined when + // no proxy env var is set. + fetch: createProxyFetch(), }; - const defaultMetadata = savedSettings?.metadata - ? Object.fromEntries( - savedSettings.metadata - .filter((m) => m.key.trim() !== "") - .map((m) => [m.key, m.value]), - ) - : undefined; + // Per-server default `_meta` is already a JSON object (#1910) — no + // pair-array flattening left to do; `{}` means "no defaults". + const defaultMetadata = savedSettings?.metadata; const clientAuthOptions = buildRunnerClientAuthOptions( clientConfig, savedSettings, diff --git a/clients/tui/src/components/ToolsTab.tsx b/clients/tui/src/components/ToolsTab.tsx index 92eac0f5a..0ac47a5bc 100644 --- a/clients/tui/src/components/ToolsTab.tsx +++ b/clients/tui/src/components/ToolsTab.tsx @@ -1,9 +1,43 @@ -import React, { useState, useEffect, useRef } from "react"; +import React, { useState, useEffect, useMemo, useRef } from "react"; import { Box, Text, useInput, type Key } from "ink"; import { ScrollView, type ScrollViewRef } from "ink-scroll-view"; import type { Tool } from "@modelcontextprotocol/client"; +import { + describeSchemaPath, + lintToolSchemas, + type SchemaFinding, +} from "@inspector/core/json/schemaLint.js"; import { useSelectableList } from "../hooks/useSelectableList.js"; +/** + * How each severity renders in the terminal (#1005). One table, read by both + * the list row's flag and the detail block's heading, so the two can never + * disagree about what a severity looks like. + */ +const SEVERITY_MARKER = { + error: { glyph: "!", color: "red" }, + warning: { glyph: "?", color: "yellow" }, +} as const satisfies Record< + SchemaFinding["severity"], + { glyph: string; color: string } +>; + +/** + * The one-glyph flag a tool row carries when its schemas have portability + * findings: `!` in red when something is refused outright by a real client, + * `?` in yellow when it is merely handled unevenly. `undefined` — no glyph at + * all — for a clean tool, which is the overwhelming majority, so the list + * stays quiet unless there is something to say. + */ +export function schemaMarker( + findings: readonly SchemaFinding[] | undefined, +): { glyph: string; color: string } | undefined { + if (!findings || findings.length === 0) return undefined; + return findings.some((f) => f.severity === "error") + ? SEVERITY_MARKER.error + : SEVERITY_MARKER.warning; +} + interface ToolsTabProps { tools: Tool[]; isConnected: boolean; @@ -33,6 +67,13 @@ export function ToolsTab({ { resetWhen: tools }, ); const [error] = useState(null); + // Tool-schema portability findings, one entry per tool, same order (#1005). + // Pure walk over data already in memory, so it is recomputed only when the + // list itself changes rather than on every keypress. + const findingsByTool = useMemo( + () => tools.map((tool) => lintToolSchemas(tool)), + [tools], + ); const scrollViewRef = useRef(null); const listWidth = Math.floor(width * 0.4); const detailWidth = width - listWidth; @@ -90,6 +131,7 @@ export function ToolsTab({ }, [selectedIndex]); const selectedTool = tools[selectedIndex] || null; + const selectedFindings = findingsByTool[selectedIndex] ?? []; return ( @@ -133,11 +175,15 @@ export function ToolsTab({ .map((tool, i) => { const index = firstVisible + i; const isSelected = index === selectedIndex; + const marker = schemaMarker(findingsByTool[index]); return ( {isSelected ? "▶ " : " "} {tool.name || `Tool ${index + 1}`} + {marker && ( + {marker.glyph} + )} ); @@ -200,6 +246,36 @@ export function ToolsTab({ )} + {/* Schema portability findings (#1005) */} + {selectedFindings.length > 0 && ( + <> + + + Schema Portability ({selectedFindings.length}): + + + {selectedFindings.map((finding, idx) => { + const marker = SEVERITY_MARKER[finding.severity]; + return ( + + + {marker.glyph}{" "} + {describeSchemaPath(finding.schema, finding.path)} + + {finding.issue} + Fix: {finding.suggestion} + + ); + })} + + )} + {/* Input Schema */} {selectedTool.inputSchema && ( <> diff --git a/clients/tui/tsup.config.ts b/clients/tui/tsup.config.ts index 2c0197a9a..60fc98271 100644 --- a/clients/tui/tsup.config.ts +++ b/clients/tui/tsup.config.ts @@ -99,6 +99,14 @@ export default defineConfig({ // `__tests__/tsupConfig.test.ts` guards this list. noExternal: [/^@inspector\/core/, "ink-form", "ink-scroll-view"], external: [ + // `undici` MUST stay external. It is CommonJS, so inlining it rewrites + // `import("undici")` to a relative chunk whose `require("assert")` hits + // esbuild's ESM `__require` shim and throws "Dynamic require of \"assert\" + // is not supported" — and because the specifier was rewritten at build time, + // no user-side install can ever satisfy it (#2067). It is declared in the + // ROOT manifest only, so tsup cannot infer this from a nearest-manifest + // lookup; the entry has to be explicit. + "undici", // `react` is deliberately external — the single instance every inlined // package above resolves to, from this build directory. "react", @@ -119,7 +127,20 @@ export default defineConfig({ "pino", "@modelcontextprotocol/client", "@modelcontextprotocol/core", + // Root-declared and reached through `core/mcp/apps.ts`, so it must be + // external here like every other root runtime dependency — which client + // actually reaches it is a function of what `core/` imports, not of this + // client's own code, so all three lists carry it (AGENTS.md). The CLI was + // inlining it; the #2067 guard surfaced that. + "@modelcontextprotocol/ext-apps", "@napi-rs/keyring", + // Root-declared (see the repo's dependency-placement rule) and CJS, which + // is the combination that bites: tsup externalizes what the *client's* + // package.json declares, so a root-only dependency is bundled unless named + // here — and inlining a CJS module into an ESM bundle leaves esbuild's + // `Dynamic require of "path" is not supported` shim, which throws at + // import time and takes the whole binary down before it parses a flag. + "proper-lockfile", ], esbuildPlugins: [inkFormLabelPatch], esbuildOptions(options) { diff --git a/clients/web/.npmignore b/clients/web/.npmignore index 133cadc22..ca99459ae 100644 --- a/clients/web/.npmignore +++ b/clients/web/.npmignore @@ -8,11 +8,15 @@ # load. (The other clients don't hit this because none of them ship a nested # .gitignore.) # -# Crucially this file does NOT list `build` or `dist`, so both the prod runner -# (build/) and the SPA (dist/) are packed. The root "files" allowlist already -# restricts publishing to those two directories, so everything else in -# clients/web (src, configs, node_modules, coverage, storybook-static) stays out -# regardless — the entries below are just belt-and-suspenders. +# Crucially this file does NOT list `build`, `dist`, or `static`, so all three +# are packed: the prod runner (build/), the SPA (dist/), and the MCP Apps +# sandbox proxy page (static/sandbox_proxy.html — a committed source file, read +# from disk at runtime by server/sandbox-controller.ts as +# `/../static/sandbox_proxy.html`, so it must ship at exactly that +# path; #1859). The root "files" allowlist names those three directories and +# nothing else under clients/web, so the rest (src, configs, node_modules, +# coverage, storybook-static) stays out regardless — the entries below are just +# belt-and-suspenders. node_modules coverage storybook-static diff --git a/clients/web/.storybook/preview.tsx b/clients/web/.storybook/preview.tsx index 6b419a436..e1669ea0d 100644 --- a/clients/web/.storybook/preview.tsx +++ b/clients/web/.storybook/preview.tsx @@ -1,14 +1,11 @@ import type { Preview } from "@storybook/react-vite"; -import { - MantineProvider, - useMantineColorScheme, - type CSSVariablesResolver, -} from "@mantine/core"; +import { MantineProvider, useMantineColorScheme } from "@mantine/core"; import { Notifications } from "@mantine/notifications"; import "@mantine/core/styles.css"; import "@mantine/notifications/styles.css"; import "../src/App.css"; import { theme } from "../src/theme/theme"; +import { cssVariablesResolver } from "../src/theme/cssVariables"; import { useEffect } from "react"; // eslint-disable-next-line react-refresh/only-export-components @@ -28,14 +25,6 @@ function ColorSchemeWrapper({ return <>{children}; } -const resolver: CSSVariablesResolver = () => ({ - variables: {}, - light: {}, - dark: { - "--mantine-color-body": "var(--mantine-color-dark-9)", - }, -}); - const preview: Preview = { globalTypes: { colorScheme: { @@ -61,7 +50,7 @@ const preview: Preview = { &transport=http|sse&autoConnect= ``` -| Param | Meaning | -| --- | --- | -| `serverUrl` | The MCP server URL. Restricted to `http:` / `https:` (a crafted `javascript:` / `data:` / `file:` value is rejected). Canonicalized via `URL.href` so it matches the OAuth store's key form. | -| `transport` | `http` (streamable-HTTP, the default) or `sse`. Unknown values fall back to `http`. | +| Param | Meaning | +| ------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `serverUrl` | The MCP server URL. Restricted to `http:` / `https:` (a crafted `javascript:` / `data:` / `file:` value is rejected). Canonicalized via `URL.href` so it matches the OAuth store's key form. | +| `transport` | `http` (streamable-HTTP, the default) or `sse`. Unknown values fall back to `http`. | | `autoConnect` | **CSRF gate.** Must equal the per-launch `MCP_INSPECTOR_API_TOKEN`. The token is random per launch and only known to whatever started the server, so a third-party-minted link cannot satisfy it — this is the same exposure surface as the existing `?MCP_INSPECTOR_API_TOKEN=` param. Without a match the link is ignored. | The deep link upserts a stable `deep-link` catalog row (so a reload reconnects to the same row instead of accumulating duplicates) and connects. Connection-level outcomes are surfaced on the `AppShell.Header` as a machine-readable contract, so a driver can `waitForSelector` and read the failure reason without scraping a transient toast: -| Attribute | Where | Meaning | -| --- | --- | --- | -| `data-testid="connection-status"` | header | The element carrying the attributes below. | -| `data-status` | on `connection-status` | The live `ConnectionStatus` (`disconnected` → `connecting` → `connected` / `error`). Poll for `connected`. | -| `data-error-message` | on `connection-status` | Why the last connect failed (handshake error, OAuth-start failure, deep-link automation failure); absent when there is no error. | -| `data-deeplink` | on `connection-status` | `parsed` (a valid deep link drove this load), `rejected` (deep-link params present but the token/serverUrl gate failed), or `none`. Lets a driver distinguish "no deep link" from "rejected" — both otherwise leave `data-status` idle. | +| Attribute | Where | Meaning | +| --------------------------------- | ---------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `data-testid="connection-status"` | header | The element carrying the attributes below. | +| `data-status` | on `connection-status` | The live `ConnectionStatus` (`disconnected` → `connecting` → `connected` / `error`). Poll for `connected`. | +| `data-error-message` | on `connection-status` | Why the last connect failed (handshake error, OAuth-start failure, deep-link automation failure); absent when there is no error. | +| `data-deeplink` | on `connection-status` | `parsed` (a valid deep link drove this load), `rejected` (deep-link params present but the token/serverUrl gate failed), or `none`. Lets a driver distinguish "no deep link" from "rejected" — both otherwise leave `data-status` idle. | ### Landing on a rendered app @@ -118,27 +148,132 @@ Three further params extend the deep link to pre-select — and optionally auto- …&openApp=&appArgs=&autoOpen= ``` -| Param | Meaning | -| --- | --- | -| `openApp` | The app-tool name. Once the connection is up and the tool appears in the app list, the inspector switches to the Apps tab and pre-selects it. | -| `appArgs` | `base64url(JSON)` object of form values. Merged **over** the tool's schema defaults (`collectSchemaDefaults`) so a required-with-default field isn't left blank — which would otherwise disable "Open App". Malformed / non-object values fall back to `{}`. | -| `autoOpen` | **Same CSRF gate as `autoConnect`** — must equal the session token. When set, "Open App" fires automatically (a tool call from a URL), so the token gate is mandatory. Without a match the app is pre-selected but not opened. | +| Param | Meaning | +| ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `openApp` | The app-tool name. Once the connection is up and the tool appears in the app list, the inspector switches to the Apps tab and pre-selects it. | +| `appArgs` | `base64url(JSON)` object of form values. Merged **over** the tool's schema defaults (`collectSchemaDefaults`) so a required-with-default field isn't left blank — which would otherwise disable "Open App". Malformed / non-object values fall back to `{}`. | +| `autoOpen` | **Same CSRF gate as `autoConnect`** — must equal the session token. When set, "Open App" fires automatically (a tool call from a URL), so the token gate is mandatory. Without a match the app is pre-selected but not opened. | The app-side render lifecycle is observable through the [MCP Apps screen automation contract](#mcp-apps-screen-automation-contract) above (`data-app-status="ready"`), so a driver can `waitForSelector` the whole `connect → open → ready` chain deterministically. +## MCP App dedicated origins (`_meta.ui.domain`) + +By default an MCP App is rendered by handing its HTML to the sandbox proxy as +`srcdoc`, inside an iframe sandboxed **without** `allow-same-origin`. That is +the isolation model from #1565 and it stays the default — but it gives the app +document an *opaque* origin, so every request it makes carries `Origin: null`. +An app whose backend allowlists origins (CORS, an OAuth callback, an API-key +allowlist) can't work that way, which is what the spec's `_meta.ui.domain` +exists to solve: a server uses it to ask its host for a stable, dedicated +origin. + +**The Inspector's host-specific contract.** The spec makes `domain`'s format +host-dependent ("servers MUST consult host-specific documentation"), and the +Inspector owns no domain infrastructure — it cannot serve +`my-app.example.com`. So it treats the field as a **request, not an address**: + +- Any **non-empty** `domain` string opts the resource in. The value itself is + not parsed, matched, or reserved — declare whatever your production host + expects. +- The Inspector answers with a real HTTP origin on loopback: + `http://:`, default **`6278`** — in the same + `627x` family as the web port `6274` and the sandbox port `6275`, so the + three forward together. It is **not** `6276`: that is the fixed loopback + OAuth callback the CLI and TUI listen on, which OAuth apps pre-register and + which therefore cannot move. `6277` is skipped as v1's retired proxy port. +- The app document is served from there under an unguessable path, its + per-app CSP delivered as a real response **header** (stronger than the + `` the `srcdoc` path relies on) plus a `frame-ancestors` that admits + only the two origins in its real ancestor chain — the sandbox proxy that + frames it, **and** the Inspector page that frames the proxy. Both are + required, not belt-and-braces: `frame-ancestors` is checked against every + ancestor, so omitting the Inspector's own origin blocks the frame outright + (see `appDocumentEmbedders`). +- The inner iframe is granted `allow-same-origin` on this path **only** — + that is what makes the origin real rather than opaque. It is not a + weakening of #1565: the listener is on its own port, so the app is + cross-origin to both the sandbox proxy and the Inspector, and same-origin + policy blocks the reach either way. The proxy refuses the grant outright if + the URL's origin equals its own, and the grant is never reachable from the + server-supplied `sandbox` string, which is still stripped. + +**It is one shared origin, not one per app.** Every domain-declaring app is +served from the same port, keyed by path. That delivers the property the field +is *for* — a real, allowlistable origin — without minting a port or a DNS name +per app, at the cost of not being a per-app isolation boundary: two such apps +share `localStorage`, `sessionStorage`, and cookies for that origin. + +**And the separate port does not isolate cookies from the rest of loopback.** +A distinct port makes a distinct *origin*, so the origin-scoped surfaces — +DOM/scripting access, `localStorage`, `sessionStorage`, IndexedDB — are isolated +from the sandbox proxy and from the Inspector page. **Cookies are not +origin-scoped**: they are keyed by host and path, ignoring port. An app served +here therefore shares the `127.0.0.1` cookie jar with the Inspector on `6274` +and with anything else on loopback — it can read any non-`HttpOnly` cookie set +for that host, and set `Path=/` cookies those services will receive. + +This is not a hole in the Inspector's own auth: the API token travels in the +`x-mcp-remote-auth` header and lives in a `window` global and `sessionStorage`, +neither of which another origin can read. Closing the cookie gap properly needs +a distinct *host*, which this listener cannot mint (the Inspector owns no DNS, +and a second loopback address is not portable) — so it is stated rather than +papered over. Don't run the Inspector beside a loopback service whose session +cookie you would not hand to an app under test. + +**Every failure falls back rather than blanking the app.** No app-origin +listener, a port that never bound, an older backend with no +`POST /api/app-document` route, a network error — each renders the app the +default (opaque-origin) way and logs a console warning naming `_meta.ui.domain`. +Losing the real origin degrades what the app can reach; losing the app itself +would be worse. + +**One failure is outside that guarantee, deliberately: a published document the +browser cannot reach.** Every case above is one the *host* observes — publishing +returned nothing, so it falls back before choosing a render path. If publishing +succeeds and the browser then cannot load the URL (the port is not forwarded, or +a collision moved the listener to a dynamic port that is not), the frame stays +blank instead. There is no honest signal to fall back on: the navigation is +cross-origin, so `onerror` never fires for an HTTP error, `onload` fires for the +error page too, and nothing about the document is readable. The alternatives are +a timeout — which races an app that is merely slow, and "recovers" it by +re-running it at an opaque origin, executing its side effects twice — or a +reachability probe on every render, which proves the port answers rather than +that this fetch will. So the cause is removed instead of guessed at: every +documented remote workflow forwards the port (see below, the Docker section of +the root README, and the SSH recipe in `docs/mcp-app-review.md`). + +**Forward `6278` too** if you need this off loopback (a container, a tunnel). +As with the sandbox port, a taken port falls back to an OS-assigned one with a +loud warning — the app still renders, but the origin an app's backend was told +to allowlist is then wrong, which is what the warning tells you. + ## Theme (`src/theme/`) Each customized Mantine component has a `Theme.ts` file (`Button.ts`, `Text.ts`, …, ~21 total) exporting a `Theme` constant; the barrel `index.ts` re-exports them and `theme.ts` assembles the `MantineProvider` theme. Theme files hold app-wide defaults and **variants** (flat CSS-in-JS); only pseudo-selectors, nested child selectors, keyframes, and native-HTML styling belong in `App.css`. Element components import from `@mantine/core` (never from `theme/`) — the theme layer is applied transparently by the provider. +**`cssVariables.ts` is the third piece, beside the component files and `App.css`.** It holds overrides for the CSS variables `MantineProvider` injects at runtime, which `App.css` cannot reach: the provider appends its generated ` + + +

waiting for an elicitation…

+ + + +`; + +/** UI resource serving {@link APP_ELICITATION_HTML}. */ +export function createAppElicitationResource(): ResourceDefinition { + return { + name: "choose_option_app", + uri: APP_ELICITATION_URI, + description: "MCP App that renders and resolves a form elicitation", + mimeType: "text/html", + text: APP_ELICITATION_HTML, + _meta: { + ui: { + csp: { connectDomains: [], resourceDomains: [] }, + prefersBorder: true, + }, + }, + }; +} + +/** + * Tool that asks the client to render {@link createAppElicitationResource} for a + * one-field choice, by attaching `_meta.ui.resourceUri` to an otherwise + * completely ordinary form `elicitation/create` (#1854). + * + * Nothing else about the request is special, which is the contract being + * demonstrated: a client that did not negotiate app-rendered elicitation simply + * ignores the `_meta` and shows its own form, and either way the server gets + * back the same standard `ElicitResult` — echoed into the tool result here so + * the round-trip is visible without reading the Protocol tab. + */ +export function createAppElicitationTool(): ToolDefinition { + return { + name: "app_choose_option", + description: + "Ask the client to choose an option, offering an MCP App to render the form", + inputSchema: { + prompt: z.string().optional().describe("Message shown above the choice"), + }, + _meta: { ui: { visibility: ["model"] } }, + handler: async ( + params: Record, + context?: TestServerContext, + ): Promise => { + if (!context) { + throw new Error("Server context not available"); + } + const elicitationParams: ElicitRequestFormParams = { + message: + typeof params.prompt === "string" + ? params.prompt + : "Choose option A or B.", + requestedSchema: { + type: "object", + properties: { + choice: { + type: "string", + enum: ["option-a", "option-b"], + title: "Choice", + }, + }, + required: ["choice"], + }, + _meta: { ui: { resourceUri: APP_ELICITATION_URI } }, + }; + const result = await context.server.server.elicitInput(elicitationParams); + return toToolResult(`Elicitation response: ${JSON.stringify(result)}`); + }, + }; +} + +/** + * The modern (2026-07-28) counterpart of {@link createAppElicitationTool}: an + * MRTR tool whose EMBEDDED elicitation carries `_meta.ui.resourceUri` (#1854). + * + * The two paths reach the Inspector completely differently — a server→client + * `elicitation/create` request on the legacy leg, an `input_required` result + * the client unpacks and retries on the modern one — and the routing decision + * has to be identical on both. It is, because both funnel through the same + * `enqueuePendingElicitation`; this fixture is what lets a test prove that + * rather than assert it. + * + * Completes on the retry by echoing the `ElicitResult` the app produced, so a + * caller can see the app's answer round-trip through `inputResponses`. + */ +export function createMrtrAppElicitationTool(): ToolDefinition { + return { + name: "mrtr_app_choose_option", + description: + "Modern MRTR tool whose embedded elicitation offers an MCP App to render the form", + inputSchema: { + prompt: z.string().optional().describe("Message shown above the choice"), + }, + handler: async ( + params: Record, + _context?: TestServerContext, + extra?: HandlerExtra, + ) => { + const responses = extra?.inputResponses; + if (!responses || responses.choice === undefined) { + return inputRequired({ + inputRequests: { + choice: inputRequired.elicit({ + message: + typeof params.prompt === "string" + ? params.prompt + : "Choose option A or B.", + requestedSchema: { + type: "object", + properties: { + choice: { + type: "string", + enum: ["option-a", "option-b"], + title: "Choice", + }, + }, + required: ["choice"], + }, + _meta: { ui: { resourceUri: APP_ELICITATION_URI } }, + }), + }, + requestState: `mrtr-app:${++mrtrMintCount}`, + }); + } + return toToolResult( + `Elicitation response: ${JSON.stringify(responses.choice)}`, + ); + }, + }; +} + /** * Create an "mrtr_confirm" tool exercising the modern (2026-07-28) multi * round-trip request (MRTR) flow. On the first call it returns an @@ -1381,8 +1617,15 @@ export function createMcpAppDemoTool(): ToolDefinition { * `_meta.ui.csp` (no external connect/resource domains) and a sample * `permissions` block so `--app-info` and the host's CSP enforcement both have * something to read. - */ -export function createMcpAppDemoResource(): ResourceDefinition { + * + * `domain` is the spec field by which a server asks its host for a stable, + * dedicated origin (#2056). Omitted by default so the default opaque-origin + * render stays the thing this fixture exercises; pass one to drive the + * dedicated-origin path instead. Its *value* is not an address the Inspector + * serves — the spec makes the format host-dependent, and the Inspector reads + * any non-empty string as "give me a real origin". + */ +export function createMcpAppDemoResource(domain?: string): ResourceDefinition { return { name: "mcp_app_demo_widget", uri: MCP_APP_DEMO_URI, @@ -1394,6 +1637,7 @@ export function createMcpAppDemoResource(): ResourceDefinition { csp: { connectDomains: [], resourceDomains: [] }, permissions: { clipboard: false }, prefersBorder: true, + ...(domain ? { domain } : {}), }, }, }; @@ -2666,6 +2910,11 @@ export function createOAuthTestServerConfig(options: { supportCIMD?: boolean; tokenExpirationSeconds?: number; supportRefreshTokens?: boolean; + /** + * Move the RFC 9728 metadata document off the well-known path and advertise + * it via `WWW-Authenticate: Bearer resource_metadata="…"` (#2071). + */ + resourceMetadataPath?: string; }): Partial { return { oauth: { @@ -2673,6 +2922,12 @@ export function createOAuthTestServerConfig(options: { mode: "combined", requireAuth: options.requireAuth ?? false, scopesSupported: options.scopesSupported ?? ["mcp"], + // `!== undefined`, not truthiness: an explicitly supplied `""` is + // invalid, and dropping it here would silently fall back to the + // well-known route instead of reporting the bad fixture (Copilot). + ...(options.resourceMetadataPath !== undefined + ? { resourceMetadataPath: options.resourceMetadataPath } + : {}), staticClients: options.staticClients, supportDCR: options.supportDCR ?? false, supportCIMD: options.supportCIMD ?? false, diff --git a/test-servers/src/test-server-http.ts b/test-servers/src/test-server-http.ts index bbbb47cef..b8a5007f4 100644 --- a/test-servers/src/test-server-http.ts +++ b/test-servers/src/test-server-http.ts @@ -83,7 +83,18 @@ export interface RecordedRequest { method: string; params?: Record; headers?: Record; - metadata?: Record; + /** + * The request's `_meta`, recorded verbatim. Values are arbitrary JSON — + * objects, arrays, numbers, booleans, `null` — not just strings (#1910), so + * a test asserting on a structured value gets a contract that matches what + * the recorder actually holds. + * + * Typed as `unknown` values rather than `@inspector/core`'s + * `RequestMetadata`: this package compiles standalone (`tsc -p test-servers`, + * NodeNext, no `paths`), so it has no `@inspector/core` alias to import + * through. + */ + metadata?: Record; response: unknown; timestamp: number; } diff --git a/test-servers/src/test-server-oauth.ts b/test-servers/src/test-server-oauth.ts index 79200366b..3b7a28e88 100644 --- a/test-servers/src/test-server-oauth.ts +++ b/test-servers/src/test-server-oauth.ts @@ -27,6 +27,79 @@ export function getOAuthMode( return config.mode ?? "combined"; } +const PATH_VALIDATION_BASE = "http://config.invalid"; + +/** + * True for a path that resolves under its own origin — the only shape safe to + * use as both an Express route and a `resource_metadata` value. + * + * A leading-slash check is not enough: `//other-host/doc` and `/\other-host/doc` + * both re-point the origin when resolved against the request base (the URL + * parser folds a backslash into a slash for special schemes), while Express + * still registers the route locally — so the server would advertise a document + * it does not serve (Copilot). Comparing the resolved href against the literal + * also rejects anything the parser would rewrite (spaces, unescaped + * characters), which an Express route would not match either. + * + * A query or fragment is rejected for the same reason from the other + * direction: `href` preserves both, so `/doc?v=1` and `/doc#s` would pass the + * comparison above, yet Express matches on the path alone (and treats `?` as a + * pattern character) and a fragment is never sent on the wire at all — so the + * advertised URL could not reach the registered route (Copilot). + */ +export function isOriginRelativePath(value: unknown): value is string { + if (typeof value !== "string" || !value.startsWith("/")) { + return false; + } + try { + const resolved = new URL(value, PATH_VALIDATION_BASE); + return ( + resolved.origin === PATH_VALIDATION_BASE && + resolved.search === "" && + resolved.hash === "" && + resolved.href === `${PATH_VALIDATION_BASE}${value}` + ); + } catch { + return false; + } +} + +/** + * The configured metadata path, validated. Throws at server-setup time rather + * than serving a route that contradicts the challenge — the JSON-config path + * is validated earlier by `load-config`, so this covers a `ServerConfig` + * built programmatically. + */ +function resourceMetadataPath(config: OAuthConfig): string | undefined { + const path = config.resourceMetadataPath; + if (path === undefined) { + return undefined; + } + if (!isOriginRelativePath(path)) { + throw new Error( + `oauth.resourceMetadataPath must be an origin-relative path (got ${JSON.stringify(path)})`, + ); + } + return path; +} + +/** + * The `WWW-Authenticate` challenge sent with every 401. + * + * RFC 9728 §5.1: a resource server advertises where its protected-resource + * metadata lives via the `resource_metadata` parameter. Only emitted when the + * config moves that document off the well-known path — otherwise the bare + * `Bearer` challenge keeps the existing fixtures byte-identical. + */ +function bearerChallenge(config: OAuthConfig, req: Request): string { + const path = resourceMetadataPath(config); + if (!path) { + return "Bearer"; + } + const requestBaseUrl = `${req.protocol}://${req.get("host")}`; + return `Bearer resource_metadata="${new URL(path, requestBaseUrl).href}"`; +} + /** * Set up OAuth routes on an Express application * This adds all OAuth endpoints (authorization, token, metadata, etc.) @@ -76,7 +149,7 @@ export function createBearerTokenMiddleware( // For streamable-http, the SDK checks response status and throws StreamableHTTPError with code 401 res.status(401); res.setHeader("Content-Type", "application/json"); - res.setHeader("WWW-Authenticate", "Bearer"); + res.setHeader("WWW-Authenticate", bearerChallenge(config, req)); // Return a JSON-RPC error response format that the SDK will recognize res.json({ jsonrpc: "2.0", @@ -113,7 +186,7 @@ export function createBearerTokenMiddleware( // Return 401 - the SDK's transport should detect this and throw an error res.status(401); res.setHeader("Content-Type", "application/json"); - res.setHeader("WWW-Authenticate", "Bearer"); + res.setHeader("WWW-Authenticate", bearerChallenge(config, req)); // Return a JSON-RPC error response format that the SDK will recognize res.json({ jsonrpc: "2.0", @@ -184,9 +257,12 @@ function setupMetadataEndpoints( ); } - // OAuth Protected Resource Metadata + // OAuth Protected Resource Metadata. `resourceMetadataPath` moves the + // document off the well-known path entirely (rather than serving both), so + // a client that ignores the advertised `resource_metadata` URL gets a 404 + // — see the field's doc comment. app.get( - "/.well-known/oauth-protected-resource", + resourceMetadataPath(config) ?? "/.well-known/oauth-protected-resource", (req: Request, res: Response) => { const requestBaseUrl = `${req.protocol}://${req.get("host")}`; const resourceUrl = config.resource ?? new URL("/", requestBaseUrl).href; diff --git a/test-servers/tsconfig.json b/test-servers/tsconfig.json index 3f2a2079a..9c5d964c4 100644 --- a/test-servers/tsconfig.json +++ b/test-servers/tsconfig.json @@ -4,6 +4,15 @@ "module": "NodeNext", "moduleResolution": "NodeNext", "outDir": "./build", + // The script consumers rebuild this project on every run (#2111), so the + // up-to-date case has to be cheap. + "incremental": true, + // Pinned INSIDE the output dir. The default derives from `rootDir`, landing + // it at `test-servers/tsconfig.tsbuildinfo` — beside the config, outside + // `build/`. That would survive the `rm -rf test-servers/build` this project + // documents as the remedy for a deleted source file, leaving tsc convinced + // its outputs are up to date and emitting nothing. + "tsBuildInfoFile": "./build/tsconfig.tsbuildinfo", "rootDir": "./src", "declaration": false, "sourceMap": true, diff --git a/vitest.shared.mts b/vitest.shared.mts index 1ebbc96ae..8ad914b50 100644 --- a/vitest.shared.mts +++ b/vitest.shared.mts @@ -101,6 +101,17 @@ export function vitestSharedPaths(clientDir: string) { find: /^yaml$/, replacement: path.resolve(repoRoot, "node_modules/yaml"), }, + // Same reasoning, one layer in: `proper-lockfile` is reached only through + // `core/` (the secrets file's cross-process lock, #2082), which is the + // other root-owned tree with no manifest of its own. Resolution finds the + // root copy on its own today — nothing declares it in a client — and this + // pin is what keeps that from depending on nothing ever arriving as some + // client's transitive dependency, which would otherwise give a test two + // copies of a module whose whole job is a single registry of held locks. + { + find: /^proper-lockfile$/, + replacement: path.resolve(repoRoot, "node_modules/proper-lockfile"), + }, ]; const projectResolve = {