Merge remote-tracking branch 'origin/v2/main' into v2/fix/2071-oauth-resource-metadata
Signed-off-by: cliffhall <cliff@futurescale.com> # Conflicts: # core/mcp/node/transport.ts
This commit is contained in:
commit
baf4fe954f
140 changed files with 9048 additions and 1271 deletions
14
.github/copilot-instructions.md
vendored
14
.github/copilot-instructions.md
vendored
|
|
@ -78,9 +78,9 @@ Both exist and do different jobs. Theme files (`src/theme/<Component>.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 `<client>/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.
|
||||
- **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.
|
||||
|
||||
## Tests and the coverage gate
|
||||
|
||||
|
|
@ -95,20 +95,20 @@ Both exist and do different jobs. Theme files (`src/theme/<Component>.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 #<ISSUE_NUMBER>`.
|
||||
- **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~<n> --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~<n> --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
|
||||
|
|
|
|||
132
.github/workflows/claude.yml
vendored
132
.github/workflows/claude.yml
vendored
|
|
@ -1,132 +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)
|
||||
|
||||
# This lookup exists only to answer "is the head in a fork?". The action
|
||||
# below checks out the PR branch itself, so no `sha` is needed here.
|
||||
- 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 run against, 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('is_fork', String(isFork));
|
||||
|
||||
# A fork PR's head is untrusted code, and running a tool-enabled agent
|
||||
# holding ANTHROPIC_API_KEY against it is what this declines. The gate is
|
||||
# load-bearing even though we no longer check the head out ourselves:
|
||||
# claude-code-action fetches a cross-repository head via
|
||||
# `refs/pull/N/head` on its own (see `isCrossRepository` in its
|
||||
# src/github/operations/branch.ts), so skipping the action is the only
|
||||
# thing that keeps that code out of the workspace. Reviewing the base tree
|
||||
# instead would only trade the problem 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 Claude is not run against it."
|
||||
echo "Push the branch to this repository and re-trigger if a review is needed."
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
# Base ref only — no `ref:` input, so nothing a PR controls selects what
|
||||
# is checked out. On a PR, claude-code-action then fetches and checks out
|
||||
# the PR branch itself, which is why a second PR-head checkout here would
|
||||
# be redundant. It was also the one thing keeping CodeQL's
|
||||
# `actions/untrusted-checkout` alert open: the rule is syntactic and
|
||||
# cannot see the `is_fork` gate, the job-level `author_association` check,
|
||||
# or the repo's `collaborators_only` fork-PR policy. (#2069)
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Run Claude Code
|
||||
# `skipped` means the trigger was an issue or a non-PR comment. A
|
||||
# same-repo PR gives `is_fork == 'false'`. A fork PR reaches here with
|
||||
# `is_fork == 'true'` and both disjuncts false, so it is skipped — and
|
||||
# so the action never fetches its head. A *failed* lookup is excluded
|
||||
# too: this condition carries no status-check function, so GitHub
|
||||
# applies an implicit `success()` and skips the step after a failed
|
||||
# prior step regardless of what the comparisons say.
|
||||
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 <details> 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."
|
||||
11
.github/workflows/main.yml
vendored
11
.github/workflows/main.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
50
AGENTS.md
50
AGENTS.md
File diff suppressed because one or more lines are too long
|
|
@ -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`
|
||||
|
|
|
|||
29
README.md
29
README.md
|
|
@ -143,6 +143,7 @@ Each config below is a ready-made server for exercising one feature by hand. Loa
|
|||
| ----------------------------------------- | --------------------------------------------------- | ---------------------------------------------------------------------- |
|
||||
| `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) |
|
||||
|
|
@ -166,6 +167,16 @@ 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/<id>` 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.
|
||||
|
|
@ -385,11 +396,12 @@ Each client self-validates from its own folder; the root scripts chain them. The
|
|||
| `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 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 `// <path>` module banners, so it covers both shapes — a separate `<pkg>-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 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. 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. A third, `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. 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.
|
||||
|
|
@ -410,13 +422,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 `<runner dir>/../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.
|
||||
- **`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 `<runner dir>/../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 <tgz>` (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 <tgz>` (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
|
||||
|
||||
|
|
@ -483,7 +495,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:<container port>/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:<container port>/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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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,15 @@ Options that specify the MCP server (catalog/config file, ad-hoc command/URL, en
|
|||
| `--prompt-name <name>` | Prompt name (for `prompts/get`). |
|
||||
| `--prompt-args <key=value>` | Prompt arguments; repeat for multiple. |
|
||||
| `--log-level <level>` | Logging level for `logging/setLevel` (e.g. `debug`, `info`). |
|
||||
| `--metadata <key=value>` | General metadata (key=value); applied to all methods. |
|
||||
| `--tool-metadata <key=value>` | Tool-specific metadata for `tools/call`. |
|
||||
| `--metadata <key=value>` | 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 <key=value>` | Tool-specific `_meta` entries for `tools/call`. Same JSON-parsed value handling as `--metadata`. |
|
||||
| `--connect-timeout <ms>` | 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 <name>`: prints one JSON line (`hasApp`, `resourceUri`, `csp`, `permissions`, `domain`, …) and exits `0` if the tool has an app or `2` (`no_app`) if not. With `--method tools/list`: emits NDJSON — one app-info line per tool over a single connection. |
|
||||
| `--format <text\|json>` | 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`)
|
||||
|
||||
|
|
|
|||
|
|
@ -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<string, string | undefined> = {};
|
||||
|
||||
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<void>((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<void>((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
|
||||
|
|
|
|||
|
|
@ -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<string, MCPServerConfig>;
|
||||
mcpServers: Record<string, StoredMCPServer>;
|
||||
}): string {
|
||||
const tempDir = createTempDir("mcp-inspector-config-");
|
||||
const configPath = path.join(tempDir, "config.json");
|
||||
|
|
|
|||
|
|
@ -63,7 +63,7 @@ export function makeFakeServerSettings(
|
|||
): InspectorServerSettings {
|
||||
return {
|
||||
headers: [],
|
||||
metadata: [],
|
||||
metadata: {},
|
||||
env: [],
|
||||
connectionTimeout: 0,
|
||||
requestTimeout: 0,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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]");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ import {
|
|||
describe("withConnectTimeout", () => {
|
||||
const baseSettings: InspectorServerSettings = {
|
||||
headers: [{ key: "X-Test", value: "1" }],
|
||||
metadata: [],
|
||||
metadata: {},
|
||||
env: [],
|
||||
connectionTimeout: 42,
|
||||
requestTimeout: 0,
|
||||
|
|
|
|||
|
|
@ -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]" },
|
||||
|
|
|
|||
|
|
@ -554,6 +554,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 +575,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"),
|
||||
);
|
||||
|
|
|
|||
10
clients/cli/package-lock.json
generated
10
clients/cli/package-lock.json
generated
|
|
@ -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": {
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
@ -122,6 +124,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 +169,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).
|
||||
|
|
@ -445,6 +463,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 +485,7 @@ function buildHandoff(
|
|||
return {
|
||||
serverUrl: normalizedUrl,
|
||||
deepLink: `http://${linkHost}:${clientPort}/?${params.toString()}`,
|
||||
portForwardCmd: `coder port-forward <workspace> --tcp ${clientPort}:${clientPort} --tcp ${sandboxPort}:${sandboxPort}`,
|
||||
portForwardCmd: `coder port-forward <workspace> --tcp ${clientPort}:${clientPort} --tcp ${sandboxPort}:${sandboxPort} --tcp ${appOriginPort}:${appOriginPort}`,
|
||||
oauthStatePath: statePath,
|
||||
apiToken: apiToken ?? null,
|
||||
note:
|
||||
|
|
@ -474,8 +497,8 @@ function buildHandoff(
|
|||
|
||||
function parseKeyValuePair(
|
||||
value: string,
|
||||
previous: Record<string, JsonValue> = {},
|
||||
): Record<string, JsonValue> {
|
||||
previous: Record<string, StrictJsonValue> = {},
|
||||
): Record<string, StrictJsonValue> {
|
||||
const parts = value.split("=");
|
||||
const key = parts[0];
|
||||
const val = parts.slice(1).join("=");
|
||||
|
|
@ -486,13 +509,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 };
|
||||
}
|
||||
|
||||
|
|
@ -736,8 +777,8 @@ async function parseArgs(argv?: string[]): Promise<ParseResult> {
|
|||
promptName?: string;
|
||||
promptArgs?: Record<string, JsonValue>;
|
||||
logLevel?: LoggingLevel;
|
||||
metadata?: Record<string, JsonValue>;
|
||||
toolMetadata?: Record<string, JsonValue>;
|
||||
metadata?: Record<string, StrictJsonValue>;
|
||||
toolMetadata?: Record<string, StrictJsonValue>;
|
||||
cwd?: string;
|
||||
transport?: "sse" | "http" | "stdio";
|
||||
serverUrl?: string;
|
||||
|
|
@ -999,22 +1040,12 @@ async function parseArgs(argv?: string[]): Promise<ParseResult> {
|
|||
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,
|
||||
format: options.format,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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<InspectorClient, "readResource">,
|
||||
tool: Parameters<typeof extractAppInfo>[0],
|
||||
metadata: Record<string, string> | undefined,
|
||||
metadata: RequestMetadata | undefined,
|
||||
): Promise<CliAppInfo> {
|
||||
let base: AppInfo;
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ export function withConnectTimeout(
|
|||
if (settings) return { ...settings, connectionTimeout };
|
||||
return {
|
||||
headers: [],
|
||||
metadata: [],
|
||||
metadata: {},
|
||||
env: [],
|
||||
connectionTimeout,
|
||||
requestTimeout: 0,
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue