diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 000000000..1bcd18fa8 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,5 @@ +{ + "enabledPlugins": { + "playwright@claude-plugins-official": true + } +} diff --git a/.dockerignore b/.dockerignore index a3e960e3f..5f7a290f7 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,35 +1,38 @@ -# Version control +# Keep the build context small and force a clean, in-container install/build. +# Host node_modules and build outputs are excluded so nothing stale or +# wrong-architecture is copied in — the builder stage runs its own +# `npm install && npm pack`. + .git -.gitignore +.github -# Node.js +# Installed deps (root + every client) — reinstalled in the image node_modules -npm-debug.log +clients/*/node_modules +test-servers/node_modules -# Build artifacts -client/dist -client/build -server/dist -server/build +# Build outputs — rebuilt in the image +clients/*/build +clients/web/dist +test-servers/build +storybook-static +coverage -# Environment variables -.env -.env.local -.env.development -.env.test -.env.production +# Docs/specs aren't referenced by the build — trim them from the context. +# (test-servers/ stays: `tsc -b` typechecks the integration tests that import +# the `@modelcontextprotocol/inspector-test-server` alias.) +specification +docs -# Editor files +# Packed tarballs (the image makes its own) +*.tgz + +# Local / editor / env +.env +.env.* +.DS_Store .vscode .idea - -# Logs -logs +.claude +.playwright-mcp *.log - -# Testing -coverage - -# Docker -Dockerfile -.dockerignore \ No newline at end of file diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs deleted file mode 100644 index e69de29bb..000000000 diff --git a/.gitattributes b/.gitattributes deleted file mode 100644 index cf9687215..000000000 --- a/.gitattributes +++ /dev/null @@ -1 +0,0 @@ -package-lock.json linguist-generated=true diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md deleted file mode 100644 index 9c51db890..000000000 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ /dev/null @@ -1,40 +0,0 @@ ---- -name: Bug report -about: Create a report to help us improve -title: "" -labels: "" -assignees: "" ---- - -**Inspector Version** - -- [e.g. 0.16.5) - -**Describe the bug** -A clear and concise description of what the bug is. - -**To Reproduce** -Steps to reproduce the behavior: - -1. Go to '...' -2. Click on '....' -3. Scroll down to '....' -4. See error - -**Expected behavior** -A clear and concise description of what you expected to happen. - -**Screenshots** -If applicable, add screenshots to help explain your problem. - -**Environment (please complete the following information):** - -- OS: [e.g. iOS] -- Browser [e.g. chrome, safari] - -**Additional context** -Add any other context about the problem here. - -**Version Consideration** - -Inspector V2 is under development to address architectural and UX improvements. During this time, V1 contributions should focus on **bug fixes and MCP spec compliance**. See [CONTRIBUTING.md](../../CONTRIBUTING.md) for more details. diff --git a/.github/dependabot.yml b/.github/dependabot.yml deleted file mode 100644 index 5ace4600a..000000000 --- a/.github/dependabot.yml +++ /dev/null @@ -1,6 +0,0 @@ -version: 2 -updates: - - package-ecosystem: "github-actions" - directory: "/" - schedule: - interval: "weekly" diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 24a0ec922..fac9cfffd 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,57 +1,34 @@ -## Summary - - - -> **Note:** Inspector V2 is under development to address architectural and UX improvements. During this time, V1 contributions should focus on **bug fixes and MCP spec compliance**. See [CONTRIBUTING.md](../CONTRIBUTING.md) for more details. - -## Type of Change - - - -- [ ] Bug fix (non-breaking change that fixes an issue) -- [ ] New feature (non-breaking change that adds functionality) -- [ ] Documentation update -- [ ] Refactoring (no functional changes) -- [ ] Test updates -- [ ] Build/CI improvements - -## Changes Made - - - -## Related Issues - - - -## Testing - - - -- [ ] Tested in UI mode -- [ ] Tested in CLI mode -- [ ] Tested with STDIO transport -- [ ] Tested with SSE transport -- [ ] Tested with Streamable HTTP transport -- [ ] Added/updated automated tests -- [ ] Manual testing performed - -### Test Results and/or Instructions - - - -Screenshots are encouraged to share your testing results for this change. - -## Checklist - -- [ ] Code follows the style guidelines (ran `npm run prettier-fix`) -- [ ] Self-review completed -- [ ] Code is commented where necessary -- [ ] Documentation updated (README, comments, etc.) - -## Breaking Changes - - - -## Additional Context - - + + +> **Heads up:** this repository accepts **issues, not pull requests** from +> external contributors. Please read [`CONTRIBUTORS.md`](../blob/main/CONTRIBUTORS.md) +> before continuing. If you're an external contributor, open an issue (and +> share the prompt you used, if you've already built the change) rather than +> this PR. +> +> Want to work on the Inspector with us directly? Come say hello in +> **`#inspector-dev`** on the +> [MCP Contributor Discord](https://discord.gg/6CSzBmMkjX) — we're happy to +> scope work with you. diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml deleted file mode 100644 index 8aa4ba71a..000000000 --- a/.github/workflows/claude.yml +++ /dev/null @@ -1,93 +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: - - 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@v8 - 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 - }); - - core.setOutput('sha', pr.data.head.sha); - core.setOutput('repo', pr.data.head.repo.full_name); - - - name: Checkout PR branch - if: steps.pr.outcome == 'success' - uses: actions/checkout@v6 - with: - ref: ${{ steps.pr.outputs.sha }} - repository: ${{ steps.pr.outputs.repo }} - fetch-depth: 0 - - - name: Checkout repository - if: steps.pr.outcome != 'success' - uses: actions/checkout@v6 - with: - fetch-depth: 0 - - - name: Run Claude Code - id: claude - uses: anthropics/claude-code-action@v1 - 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/cli_tests.yml b/.github/workflows/cli_tests.yml deleted file mode 100644 index 106d98f13..000000000 --- a/.github/workflows/cli_tests.yml +++ /dev/null @@ -1,38 +0,0 @@ -name: CLI Tests - -on: - push: - paths: - - "cli/**" - pull_request: - paths: - - "cli/**" - -jobs: - test: - runs-on: ubuntu-latest - defaults: - run: - working-directory: ./cli - steps: - - uses: actions/checkout@v6 - - - name: Set up Node.js - uses: actions/setup-node@v6 - with: - node-version-file: package.json - cache: npm - - - name: Install dependencies - run: | - cd .. - npm ci --ignore-scripts - - - name: Build CLI - run: npm run build - - - name: Run tests - run: npm test - env: - NPM_CONFIG_YES: true - CI: true diff --git a/.github/workflows/e2e_tests.yml b/.github/workflows/e2e_tests.yml deleted file mode 100644 index 02fdbc226..000000000 --- a/.github/workflows/e2e_tests.yml +++ /dev/null @@ -1,78 +0,0 @@ -name: Playwright Tests - -on: - push: - branches: [main] - pull_request: - branches: [main] - -jobs: - test: - # Installing Playwright dependencies can take quite awhile, and also depends on GitHub CI load. - timeout-minutes: 15 - runs-on: ubuntu-latest - - steps: - - name: Install dependencies - run: | - sudo apt-get update - sudo apt-get install -y libwoff1 - - - uses: actions/checkout@v6 - - - uses: actions/setup-node@v6 - id: setup_node - with: - node-version-file: package.json - cache: npm - - # Cache Playwright browsers - - name: Cache Playwright browsers - id: cache-playwright - uses: actions/cache@v5 - with: - path: ~/.cache/ms-playwright # The default Playwright cache path - key: ${{ runner.os }}-playwright-${{ hashFiles('package-lock.json') }} # Cache key based on OS and package-lock.json - restore-keys: | - ${{ runner.os }}-playwright- - - - name: Install dependencies - run: npm ci - - - name: Install Playwright dependencies - run: npx playwright install-deps - - - name: Install Playwright and browsers unless cached - run: npx playwright install --with-deps - if: steps.cache-playwright.outputs.cache-hit != 'true' - - - name: Run Playwright tests - id: playwright-tests - run: npm run test:e2e - - - name: Upload Playwright Report and Screenshots - uses: actions/upload-artifact@v7 - if: steps.playwright-tests.conclusion != 'skipped' - with: - name: playwright-report - path: | - client/playwright-report/ - client/test-results/ - client/results.json - retention-days: 2 - - - name: Publish Playwright Test Summary - uses: daun/playwright-report-summary@v3 - if: steps.playwright-tests.conclusion != 'skipped' - with: - create-comment: ${{ github.event.pull_request.head.repo.full_name == github.repository }} - report-file: client/results.json - comment-title: "🎭 Playwright E2E Test Results" - job-summary: true - icon-style: "emojis" - custom-info: | - **Test Environment:** Ubuntu Latest, Node.js ${{ steps.setup_node.outputs.node-version }} - **Browsers:** Chromium, Firefox - - 📊 [View Detailed HTML Report](https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}) (download artifacts) - test-command: "npm run test:e2e" diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index c8d7405cc..5fdc748db 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -1,9 +1,10 @@ +name: Run install, format, lint, build, and test on every push + on: push: - branches: - - main - - pull_request: + # A published GitHub release triggers the `publish` job below. The release's + # target commit determines which workflow runs — so this only publishes when a + # release is cut from a commit that carries this (v2) workflow. release: types: [published] @@ -12,63 +13,161 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 + - name: Checkout code + uses: actions/checkout@v6 - - uses: actions/setup-node@v6 + - name: Setup Node.js + uses: actions/setup-node@v6 with: - node-version-file: package.json - cache: npm - - # Working around https://github.com/npm/cli/issues/4828 - # - run: npm ci - - run: npm install --no-package-lock - - - name: Check formatting - run: npm run prettier-check - - - name: Check version consistency - run: npm run check-version - - - name: Check linting - working-directory: ./client - run: npm run lint - - - name: Run client tests - working-directory: ./client - run: npm test - - - run: npm run build - + node-version: '22.x' + cache: 'npm' + + - name: Install dependencies (root + all clients) + # The root postinstall (scripts/install-clients.mjs) cascades + # `npm install` into clients/web, clients/cli, clients/tui, and + # clients/launcher, so this single step sets up every client. + run: npm install + + - name: Validate (coverage guards, format, lint, typecheck, build, fast tests) + # Runs the two coverage guards first (verify:format-coverage + + # verify:typecheck-coverage), then validate:core, then each client's + # self-validation: format:check + lint + typecheck + build + test (no + # coverage instrumentation — fast). This also builds every client bundle + # (web dist, cli/tui/launcher) that the smokes below need. The heavier + # per-file coverage gate runs in its own step below. Unit tests run in + # both steps (fast here, instrumented under coverage); the double unit + # run is an accepted trade-off for keeping this fast build step intact. + # A future optimization could split coverage into per-client parallel + # jobs, but that's a larger restructure and deliberately out of scope. + run: npm run validate + + - name: Enforce per-file coverage gate (≥90% on all four dimensions) + # CI-ENFORCED coverage gate (#1550): runs `npm run coverage`, which + # chains every client's `test:coverage` (v8-instrumented) and fails the + # job if ANY file drops below 90% on lines, statements, functions, or + # branches. This is the whole point of the step — a PR that regresses + # coverage below the threshold now blocks merge instead of relying on a + # contributor remembering to run the gate locally. + # + # This also covers the web integration project: web's `test:coverage` + # runs `--project=unit --project=integration --coverage`, so the former + # standalone `Run web integration tests` step was removed to avoid + # running the integration suite twice. + run: npm run coverage + + - name: Verify the browser-externalized-builtin build gate (#1769) + # Runs a real `vite build` with a Node built-in forced into the browser + # graph and asserts the build FAILS via the #1769 gate. The unit tests + # cover the detection logic against a captured message; only this real + # build catches the risk the issue calls out — that the Vite warning + # phrasing drifts across releases, silently disabling the message-keyed + # gate. It restores the mutated entry afterward (see the script). + run: npm run verify:build-gate + + # 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 + # smoke reuse the cache instead of downloading its own copy. The Storybook + # step below reuses the same install. + - name: Cache Playwright browsers + uses: actions/cache@v4 + with: + path: ~/.cache/ms-playwright + key: playwright-${{ runner.os }}-${{ hashFiles('clients/web/package-lock.json') }} + + - name: Install Playwright browsers + working-directory: ./clients/web + run: npx playwright install --with-deps chromium + + - name: Run cross-client smokes + # End-to-end smokes through the built launcher (--help dispatch + prod + # CLI/web). Not part of any client's `validate`: it needs the + # cli/tui/launcher bundles, which `validate` above already built + # (smoke:web builds clients/web/dist on demand — #1486). smoke:web:browser + # boots the prod web bundle in headless chromium (#1615). smoke:tui + # self-skips here — the Ink TUI needs a real TTY (raw mode) that headless + # CI lacks, so its boot/render check is local-only. + run: npm run smoke + + - name: Run Storybook play-function tests + working-directory: ./clients/web + run: npm run test:storybook + + # Publish the single `@modelcontextprotocol/inspector` package to npm on a + # published GitHub release. v2 is not an npm workspace, so there is no + # `publish-all` / `--workspaces` (v1) — just one `npm publish`, whose `prepack` + # (`npm run build`) builds every client bundle into the `files` allowlist. + # `pack:verify` runs first as the pre-publish gate: it builds, packs the real + # tarball, installs it into a clean throwaway consumer, and drives the + # installed `mcp-inspector` bin (web/cli/tui) end to end — so a broken package + # is caught before it reaches npm rather than after. publish: runs-on: ubuntu-latest if: github.event_name == 'release' environment: release needs: build - + # Serialize publishes so two releases cut in quick succession can't run + # overlapping `npm publish`es. Never cancel an in-flight publish. + concurrency: + group: publish-npm + cancel-in-progress: false permissions: contents: read + # Required for npm provenance (`--provenance` mints a signed attestation + # via GitHub's OIDC token). The repo is public, so provenance is available. id-token: write - steps: - - uses: actions/checkout@v6 - - uses: actions/setup-node@v6 - with: - node-version-file: package.json - cache: npm - registry-url: "https://registry.npmjs.org" + - name: Checkout code + uses: actions/checkout@v6 - # Working around https://github.com/npm/cli/issues/4828 - # - run: npm ci - - run: npm install --no-package-lock - - # OIDC trusted publishing requires npm >=11.5.1; Node 22's bundled npm is 10.x. - - name: Ensure npm CLI supports OIDC trusted publishing - run: npm install -g npm@^11.5.1 - - - run: npm run publish-all + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: '22.x' + cache: 'npm' + registry-url: 'https://registry.npmjs.org' + + - name: Assert release tag matches package version + # `npm publish` ships whatever `version` is in the root package.json, + # regardless of the release's tag. Fail fast (before the heavy install / + # pack:verify) if they disagree — e.g. a release drafted without running + # `npm version`, or cut from the wrong commit — since publishing is + # irreversible. Tolerates the conventional leading `v` (npm version tags + # as `vX.Y.Z`). The tag arrives via `env:` (not spliced into the script) + # to avoid the script-injection surface of interpolating `${{ }}` into a + # `run:` block. + env: + TAG: ${{ github.event.release.tag_name }} + run: | + PKG="$(node -p "require('./package.json').version")" + if [ "${TAG#v}" != "$PKG" ]; then + echo "Release tag '$TAG' does not match package.json version '$PKG'" + exit 1 + fi + + - name: Install dependencies (root + all clients) + run: npm install + + - name: Verify the publishable tarball end to end + # Builds, `npm pack`s, installs the tarball into a clean consumer, and + # drives the installed bin. Needs registry access to pull the tarball's + # runtime deps — available here. `smoke:tui` inside it self-skips on CI. + run: npm run pack:verify + + - name: Publish to npm (single package, with provenance) + # `prepack` (`npm run build`) rebuilds the client bundles into the tarball. + # The build runs three times on this path (build job → pack:verify → + # prepack); the redundancy is intentional — each is a clean-tree rebuild + # and the `prepack` one is what actually populates the published tarball, + # so don't "optimize" it away. + run: npm publish --access public --provenance env: - NPM_CONFIG_PROVENANCE: "true" + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + # Build and push the multi-arch container image to GHCR on a published + # release. The image installs the packed tarball (`Dockerfile`) so it ships + # the same artifact as npm. Independent of the npm `publish` job (both gated on + # the release event) — a container failure doesn't block the npm publish. publish-github-container-registry: runs-on: ubuntu-latest if: github.event_name == 'release' @@ -80,7 +179,8 @@ jobs: attestations: write id-token: write steps: - - uses: actions/checkout@v6 + - name: Checkout code + uses: actions/checkout@v6 - name: Log in to the Container registry uses: docker/login-action@v4 @@ -94,6 +194,14 @@ jobs: uses: docker/metadata-action@v6 with: images: ghcr.io/${{ github.repository }} + # Be explicit rather than relying on `flavor.latest=auto`: on a release + # cut both the version tags and `latest` land, so the README's bare + # `ghcr.io/…/inspector` (implicit `:latest`) never 404s. + tags: | + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + flavor: | + latest=true - name: Set up QEMU uses: docker/setup-qemu-action@v3 diff --git a/.gitignore b/.gitignore index 230d72d41..7e0989944 100644 --- a/.gitignore +++ b/.gitignore @@ -1,21 +1,27 @@ -.DS_Store -.vscode .idea -node_modules/ -*-workspace/ -server/build -client/dist -client/tsconfig.app.tsbuildinfo -client/tsconfig.node.tsbuildinfo -cli/build -test-output -tool-test-output -metadata-test-output -# symlinked by `npm run link:sdk`: -sdk -client/playwright-report/ -client/results.json -client/test-results/ -client/e2e/test-results/ -mcp.json -.claude/settings.local.json +.vscode +.DS_Store +.claude/ +node_modules +dist +clients/web/dist +clients/**/build +clients/**/coverage +test-servers/build +# TypeScript incremental build cache (written by `tsc -b`); web routes its own +# into node_modules/.tmp via tsBuildInfoFile, but a stray `tsc -b` elsewhere +# drops one next to the tsconfig — never source. +*.tsbuildinfo +.env +/.playwright-mcp/ + +# Local debugging artifacts dropped at the repo root (e.g. attached to +# Claude Code conversations): screenshots, exported Inspector history dumps, +# and ad-hoc registry/client config fixtures used for manual import testing. +# Should never be part of the source tree. +/*.png +/inspector-history-*.json +/inspector-network-*.json +/*.server.json +/configs/ +pr-screenshots diff --git a/.husky/pre-commit b/.husky/pre-commit deleted file mode 100644 index 0a3afdcb6..000000000 --- a/.husky/pre-commit +++ /dev/null @@ -1,2 +0,0 @@ -npx lint-staged -git update-index --again diff --git a/.mcp.json b/.mcp.json deleted file mode 100644 index 5f68642aa..000000000 --- a/.mcp.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "mcpServers": { - "mcp-docs": { - "type": "http", - "url": "https://modelcontextprotocol.io/mcp" - } - } -} diff --git a/.node-version b/.node-version deleted file mode 100644 index 980f29c2d..000000000 --- a/.node-version +++ /dev/null @@ -1 +0,0 @@ -22.x.x diff --git a/.npmignore b/.npmignore new file mode 100644 index 000000000..940a6a698 --- /dev/null +++ b/.npmignore @@ -0,0 +1,12 @@ +# npm pack uses .npmignore instead of .gitignore when this file exists. +# Built artifacts are whitelisted via package.json "files". + +node_modules +.env +.idea +.vscode +.DS_Store +.claude/ +*.log +coverage +storybook-static diff --git a/.npmrc b/.npmrc deleted file mode 100644 index 1a3d62095..000000000 --- a/.npmrc +++ /dev/null @@ -1,2 +0,0 @@ -registry="https://registry.npmjs.org/" -@modelcontextprotocol:registry="https://registry.npmjs.org/" diff --git a/.prettierignore b/.prettierignore deleted file mode 100644 index 167a7cb48..000000000 --- a/.prettierignore +++ /dev/null @@ -1,6 +0,0 @@ -packages -server/build -CODE_OF_CONDUCT.md -SECURITY.md -mcp.json -.claude/settings.local.json \ No newline at end of file diff --git a/.prettierrc b/.prettierrc deleted file mode 100644 index e69de29bb..000000000 diff --git a/AGENTS.md b/AGENTS.md index 27d28f72d..5861e60a3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,35 +1,351 @@ -# MCP Inspector Development Guide +# Inspector V2 -> **Note:** Inspector V2 is under development to address architectural and UX improvements. During this time, V1 contributions should focus on **bug fixes and MCP spec compliance**. See [CONTRIBUTING.md](CONTRIBUTING.md) for more details. +This is an application for inspecting MCP servers. Has three incarnations, Web, TUI, and CLI. -## Build Commands +## Project Structure -- Build all: `npm run build` -- Build client: `npm run build-client` -- Build server: `npm run build-server` -- Development mode: `npm run dev` (use `npm run dev:windows` on Windows) -- Format code: `npm run prettier-fix` -- Client lint: `cd client && npm run lint` +``` +inspector/ +├── clients/ +│ ├── web/ # Web client (Vite + React + Mantine) +│ │ ├── src/ # Browser source (React app, hooks, components) +│ │ ├── server/ # Node-only dev/prod backend wiring: +│ │ │ # vite-hono-plugin.ts (Hono middleware on the Vite dev server), +│ │ │ # server.ts (standalone Hono prod server), +│ │ │ # 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), +│ │ │ # 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: refuses an +│ │ │ # all-interfaces HOST unless DANGEROUSLY_BIND_ALL_INTERFACES; +│ │ │ # the all-interfaces DETECTION is core/node/hostUrl.isAllInterfacesHost. +│ │ │ # Used by both bind points — web-server-config.ts + vite.config.ts — #1795), +│ │ │ # browser-externalized-builtin-gate.ts (build-gate logic that fails +│ │ │ # `vite build` on a browser-externalized Node built-in — #1769) +│ │ └── static/ # sandbox_proxy.html (served by sandbox-controller for MCP Apps tab) +│ ├── cli/ # CLI client (tsup bundle, @inspector/core alias) +│ ├── tui/ # TUI client (Ink + React, tsup bundle) +│ ├── launcher/ # Shared launcher (relative imports into sibling build/ outputs) +├── core/ # Shared core code (no package.json — consumed via the `@inspector/core` vite alias) +│ ├── auth/ # OAuth: providers, discovery, OAuthStorage + persist backends; +│ │ # mid-session recovery (challenge.ts WWW-Authenticate +│ │ # parsing, 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 +│ │ # "lost authorization state" from a genuine +│ │ # cross-AS issuer mismatch — #1808) +│ │ ├── browser/ # Browser-side OAuth (sessionStorage, BrowserNavigation) +│ │ ├── node/ # Node-side OAuth (NodeOAuthStorage, OAuthCallbackServer, +│ │ │ # runner-interactive-oauth loopback callback flow) +│ │ └── remote/ # Remote OAuth storage (delegates to the remote server) +│ ├── json/ # JSON utilities and parameter/argument conversion +│ │ # (xMcpHeader.ts: SEP-2243 `x-mcp-header` +│ │ # annotation scan/validation + mirrored-param +│ │ # derivation, used by the Tools tab — #1632) +│ ├── logging/ # Silent pino logger singleton +│ ├── mcp/ # InspectorClient runtime + state stores +│ │ # (modernTaskSchemas.ts: SEP-2663 modern Tasks +│ │ # extension wire schemas + normalize/handle helpers, +│ │ # used by the raw-wire tasks/* channel — #1631) +│ │ ├── 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 +│ │ ├── 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/ +│ ├── node/ # Node-only shared helpers: version.ts (readInspectorVersion, +│ │ # walks to the root package.json), hostUrl.ts (shared host +│ │ # normalization + detection — formatHostForUrl brackets IPv6, +│ │ # canonicalUrlHost canonicalizes a bind host the way a browser +│ │ # builds `Origin`, isAllInterfacesHost is the wildcard-bind +│ │ # predicate the guard is built on, isLoopbackHost gates the OAuth +│ │ # callback listener; also stripBrackets. Used across +│ │ # clients/web/server, clients/cli, and core/auth/node — #1795) +│ ├── react/ # React hooks over the state stores +│ └── storage/ # File I/O helpers (store-io.ts) used by OAuth persist backends +├── test-servers/ # Composable MCP test servers + fixtures used by integration tests. +│ ├── src/ # TypeScript sources. (modern-tasks.ts: SEP-2663 modern +│ │ # Tasks extension runtime + tasks/* Express interceptor +│ │ # + modern_task/modern_input_task tools — #1631) +│ ├── build/ # Built JS (gitignored). Produced by `npm run test-servers:build` +│ │ # so integration tests can spawn the stdio server as a real +│ │ # subprocess via `node test-servers/build/test-server-stdio.js`. +│ └── tsconfig.json # tsc build config (NodeNext, outDir ./build). +│ # The Vite alias `@modelcontextprotocol/inspector-test-server` +│ # in clients/web/vite.config.ts points at build/index.js +│ # (not src/) so `getTestMcpServerPath()` returns a `.js` path. +│ # tsconfig.test.json keeps paths pointing at src for typecheck. +├── specification/ # Build specification +... +``` -## Code Style Guidelines +## Development setup -- Use TypeScript with proper type annotations -- Follow React functional component patterns with hooks -- Use ES modules (import/export) not CommonJS -- Use Prettier for formatting (auto-formatted on commit) -- Follow existing naming conventions: - - camelCase for variables and functions - - PascalCase for component names and types - - kebab-case for file names -- Use async/await for asynchronous operations -- Implement proper error handling with try/catch blocks -- Use Tailwind CSS for styling in the client -- Keep components small and focused on a single responsibility +v2 is **not** an npm workspace — each client under `clients/*` keeps its own `package.json` and `node_modules` (see the rationale in [specification/v2_cli_tui_launcher.md](specification/v2_cli_tui_launcher.md)). A single `npm install` at the repo root is still all you need: the root `postinstall` (`scripts/install-clients.mjs`) cascades `npm install` into `clients/web`, `clients/cli`, `clients/tui`, and `clients/launcher`. -## Project Organization +- **Fresh clone / first-time setup:** run `npm install` at the repo root. +- **After a pull that changes a client's dependencies:** re-run `npm install` at the root to re-sync every client (the `postinstall` cascade handles it). +- The cascade is dev-only: it exits early when the package is installed under `node_modules`, and the published tarball ships only each client's `build/`, so end users are unaffected. Set `INSPECTOR_SKIP_CLIENT_INSTALL=1` to skip it. -The project is organized as a monorepo with workspaces: +After installing, `npm run build` builds all clients. The launcher scripts (`npm run web` / `web:dev`) run the built launcher, so build first; for day-to-day web iteration use `cd clients/web && npm run dev`. -- `client/`: React frontend with Vite, TypeScript and Tailwind -- `server/`: Express backend with TypeScript -- `cli/`: Command-line interface for testing and invoking MCP server methods directly +## Repository & Project Board + +- **Repo**: https://github.com/modelcontextprotocol/inspector.git +- **Base Branches**: v2/main (active), main (v1). v1.5/main is merged into v2/main and no longer takes new work. +- **Project Boards**: + - v2 - https://github.com/orgs/modelcontextprotocol/projects/28 (active board — all current work goes here) + - v1 - https://github.com/orgs/modelcontextprotocol/projects/11 (existing inspector version, no new activity except security and bug fixes) + +## Contributing + +External contributions are accepted as **issues, not pull requests** — +maintainers handle design and implementation through a prompt-driven workflow. +If you've already built a change locally, share the **prompt** you used, not a +diff. See [`CONTRIBUTORS.md`](./CONTRIBUTORS.md) for the full policy. + +## Project Status and Direction +* The main branch currently contains the legacy version of the Inspector, which we are accepting bug fixes and minor improvement PRs for. + +* The v1.5/main branch was the intermediate version of the Inspector, where the shared logic between the three incarnations of the Inspector was extracted into a core subsystem with InspectorClient class as the common entry point. It also included the TUI, a refactored CLI, and streamlined launcher. The branch still exists but is **frozen** — it takes no new work. It is kept as a reference point (e.g. for tracking down a regression introduced by the merge into v2/main), so do not delete it. + +* The v2/main branch currently contains the new version of the web Inspector, composed of "dumb" components which accept data and callbacks as props and contain only display logic. + +The Launcher, TUI, CLI, and InspectorClient from v1.5/main have been merged into v2/main. InspectorClient is wired up to the new web Inspector. Eventually, we will replace main with v2/main, eliminating the legacy implementations. + +## Web backend auth token + +The dev/prod web backend protects every `/api/*` route with `x-mcp-remote-auth: Bearer `. The browser recovers that token from three sources, in priority order (see `App.tsx` `getAuthToken()`): + +1. `window.__INSPECTOR_API_TOKEN__` — injected into `index.html` on every page load by the backend (the dev Vite plugin via `transformIndexHtml`, the prod Hono server on the `/` route), both routed through `clients/web/server/inject-auth-token.ts`. This is what makes a bare-URL reload, a bookmark, or a cleared `sessionStorage` keep working. +2. `?MCP_INSPECTOR_API_TOKEN=…` query string — the URL the launcher banner prints; kept as a fallback for pasted full URLs. +3. `sessionStorage` — backstop for navigations that land without either of the above. + +Injection is a no-op when auth is disabled (`DANGEROUSLY_OMIT_AUTH`), and the global name is the shared `INSPECTOR_API_TOKEN_GLOBAL` constant in `core/mcp/remote/constants.ts`. + +## Maintenance Rules + +### Keep documentation files up to date +- When adding, removing, renaming, or changing the purpose of any file or folder, update the corresponding entry in the main README.md and/or the related clients/*/README.md +- When the structure of the project, the tech stack, or the developer setup changes, update appropriate README.md files with the details. +- When adding new commands, dependencies, or architectural patterns, update the relevant sections of appropriate README.md files as well. +- When rules for implementation and testing change, update this file AGENTS.md + +### Issue-driven Work Style + +All work should be driven by items on the project board. + +> **A v2 issue is not "created" until it is BOTH labeled `v2` AND on board #28 with a Status set.** Labeling alone is not enough — a label is a repo tag; the board is a separate org project. Applying `--label v2` does **not** add the item to the board, and adding it to the board does **not** set a Status. All three are distinct steps; do all three (see the recipes below). **Only issues go on the board — never PRs.** A PR still gets the `v2` label, but it is tracked through its linked issue's card (via `Closes #N`), not its own board item. + +- Before starting work, check the board for the relevant item. +- **Every board item is a real GitHub issue.** Do not create draft items (board cards with no issue number). If you find work that needs tracking, create an actual issue and add that to the board. Before creating a new issue, check the board for a matching item to avoid duplicates — **never create a duplicate**. +- **Assign the issue to its creator.** When you create an issue, assign it to the user it is created on behalf of (`gh issue create --assignee @me ...`, or `--assignee `). Board items should never be unassigned. +- **Label by version.** New issues and PRs must carry the label matching the target board / branch: + - `main` → `v1` + - `v2/main` → `v2` + + Set the label at create time (`gh issue create --label v2 ...`, `gh pr create --label v2 ...`) — don't rely on backfilling later, since unlabeled PRs are easy to miss when filtering by version. +- **Add the issue to the board and set Status.** After creating an issue, add it to board #28 and set its Status. (PRs are never added to the board — they're tracked through their linked issue's card.) This is the step most easily forgotten because it needs several IDs — copy the recipes below verbatim. +- When work begins, create a feature branch and set the item's Status to **In Progress** (or **V2 Go Live** for a card in the go-live phases, #1804). +- When work is complete: + - Run format, lint, typecheck, build, and test — ensure all checks pass + - Open a PR against the matching base branch (`main` for v1, `v2/main` for v2) and set the item's Status to **In Review** + - **Link the PR to its issue.** The PR body's **first line must be `Closes #`**. ⚠️ Note: closing keywords only auto-link/auto-close for PRs targeting the repo's **default branch** (`main`). Because v2 PRs target `v2/main` (a non-default branch), `Closes #N` there is only a cross-reference — it will **not** create a hard link or close the issue on merge. (There is no `gh` flag for manual linking — `gh pr edit` has no `--add-issue`; closing keywords are the only mechanism GitHub exposes, and they're gated to the default branch.) + - **On merge of a v2 PR, manually close its issue and move the board item to Done** (option id `248a3910`), since auto-close won't fire on `v2/main`. Keep the `Closes #N` line anyway so the issues close automatically if/when `v2/main` is eventually merged to `main`. +- If new tasks are discovered or requested during development, create issues and add them to the board. + +#### V2 board (#28) `gh` recipes + +The board is an **org project**, so all commands use `--owner modelcontextprotocol` and the numeric project `28`. The project node id and Status field id are stable. **The Status *option* ids are NOT stable — they are regenerated whenever the Status field's option list is edited** (see the ⚠️ hazard below). If any option id here is rejected, re-fetch the current set with: + +```sh +gh project field-list 28 --owner modelcontextprotocol --format json \ + | jq '.fields[] | select(.name=="Status") | .options' +``` + +| Thing | ID | +| --- | --- | +| Project node ID | `PVT_kwDOCt2Azc4BJVxt` | +| Status field ID | `PVTSSF_lADOCt2Azc4BJVxtzg5iI8c` | + +Status option IDs (`--single-select-option-id`) — **last verified 2026-07-27**. `V2 Go Live` was added via the web UI on 2026-07-27 for the go-live phases (#1804); the other four IDs were unchanged by that addition, confirming the web-UI path is safe (see the ⚠️ hazard below). Removed columns whose IDs are now rejected: `SDK V2 + New Spec` (`1bbb6f57`), `Building CLI / TUI / CORE` (`4ac261ee`), `Building Web` (`c28da89f`), `MCP Apps Extension` (`73d0b807`). + +| Status | Option ID | +| --- | --- | +| Todo | `fbdaf21e` | +| V2 Go Live | `b3a6966e` | +| In Progress | `195df262` | +| In Review | `159c8a02` | +| Done | `248a3910` | + +Use **Todo** for approved-but-not-started work, **In Progress** for general active work (regardless of surface), **V2 Go Live** for cards in the go-live phases (#1804), **In Review** once a PR is open, and **Done** on merge. + +> ⚠️ **Never add, rename, or remove a board column (Status option) with the `updateProjectV2Field` GraphQL mutation unless you pass every existing option's `id`.** That mutation does a **full replace** of the option list: if you resend options by name/color/description but omit their `id`s, GitHub **deletes all existing options and mints new ones**, which **orphans the Status of every card on the board** (all items go blank) *and* invalidates every option id in the table above. This has happened once (required reconstructing ~197 items' statuses by inference). Safe alternatives, in order of preference: +> 1. **Add/rename/remove a column in the GitHub web UI** (Project #28 → Status field settings). This preserves ids of untouched options and never orphans cards. +> 2. If you must script it, first `gh api graphql` the current options **with their `id`s**, then call `updateProjectV2Field` echoing back every existing option **including its `id`**, appending only the new one. Verify afterward that no card lost its Status. +> +> `gh project item-add` and `gh project item-edit` are always safe — they set a card's value and never touch the field schema. When option ids change for any reason, **re-verify and update the table above** (and the `248a3910` / `195df262` / `159c8a02` references in the recipes below and the merge step above). + +```sh +# 1. Add an issue to the board — prints the item id (PVTI_…); capture it. +gh project item-add 28 --owner modelcontextprotocol --url --format json + +# 2. Set its Status (here: In Progress). Use the option id from the table above. +gh project item-edit \ + --project-id PVT_kwDOCt2Azc4BJVxt \ + --id \ + --field-id PVTSSF_lADOCt2Azc4BJVxtzg5iI8c \ + --single-select-option-id 195df262 +``` + +The one-liner that does both, capturing the item id (use the option id for the status you want): + +```sh +ITEM_ID=$(gh project item-add 28 --owner modelcontextprotocol --url --format json --jq '.id') +gh project item-edit --project-id PVT_kwDOCt2Azc4BJVxt --id "$ITEM_ID" --field-id PVTSSF_lADOCt2Azc4BJVxtzg5iI8c --single-select-option-id 195df262 +``` + +For an issue **already on the board** (moving an existing card, e.g. to **In Review** when its PR opens), look its item id up by issue number instead of re-adding it. Keep `--limit` above the board's item count (~200 as of 2026-07-27) — past it `item-list` truncates silently, `select` matches nothing, and `item-edit --id ""` fails with an opaque node-resolution error rather than saying the limit was too low: + +```sh +# --limit must stay above the board's item count (~200 today) — past it the +# list truncates silently and item-edit fails with an opaque node-resolution error. +ITEM_ID=$(gh project item-list 28 --owner modelcontextprotocol --format json --limit 500 \ + --jq '.items[] | select(.content.number==) | .id') +gh project item-edit --project-id PVT_kwDOCt2Azc4BJVxt --id "$ITEM_ID" --field-id PVTSSF_lADOCt2Azc4BJVxtzg5iI8c --single-select-option-id 159c8a02 +``` + +### Always test new or modified code +- Ensure all code has corresponding tests +- Ensure test coverage for each file is at least 90% +- In unit tests that expect error output, suppress it from the console +- Run unit tests with `npm run test` (or `npm run test:watch` during development) from `clients/web/` +- Run CLI tests with `npm run test` from `clients/cli/` (builds test-servers + CLI bin first via `pretest`) +- Run TUI tests with `npm run test` from `clients/tui/` +- The repo root has no aggregate `test` script — each client self-validates, so run `npm run validate` from the root (all clients, fast) or `cd clients/ && npm run validate` (one client). Each client still exposes its own `test` / `test:coverage` for quick iteration. +- **`validate` is fast: it runs `test`, not `test:coverage`.** The coverage gate (slower — adds v8 instrumentation, and for web the integration project) is a **separate** top-level `npm run coverage` (and per-client `coverage:web` / `coverage:cli` / `coverage:tui` / `coverage:launcher`, each delegating to that client's `test:coverage`). Run `npm run coverage` when you want to reproduce the gate locally before pushing. **CI runs `coverage`** on every push (#1550): the per-file ≥90 gate is CI-enforced, so a PR that drops any file below 90 on lines/statements/functions/branches fails the job. CI runs `validate` (fast) for format/lint/build/unit tests, then `coverage` for the instrumented gate. Because web's `test:coverage` already runs the integration project, CI has no separate `test:integration` step — the integration paths are exercised inside the coverage gate. +- Each client's `test:coverage` enforces a **uniform per-file gate of ≥ 90 on all four dimensions** — lines, statements, functions, and branches — across `clients/web`, `clients/cli`, `clients/tui`, and `clients/launcher` (CI enforces this gate). This is the result of a codebase-wide audit: the branch floor was first lifted 50 → 70 for web (#1271), then the whole gate raised to 90 with real tests added for every outlier. Genuinely-unreachable branches are **not** waved through by lowering the gate — they are annotated at the source with a justified `/* v8 ignore … -- */` comment. Acceptable reasons are happy-dom-inherent paths (Mantine portal mount points, `useMediaQuery` fallbacks, `typeof window` SSR guards), React StrictMode effect-replay blocks, and provably-dead defensive guards (e.g. a `?? fallback` for a value the types guarantee non-null, or a `Select.onChange` receiving a value outside the allowed list). New code must clear 90 on every dimension; reach for a justified `v8 ignore` only when a branch is genuinely impossible to exercise. The web coverage `include` (in `clients/web/vite.config.ts`) covers the shared `core/` runtime consumed by the browser — `core/mcp`, `core/react`, `core/auth`, `core/storage`, `core/logging`, `core/node`, **`core/json`, and `core/client`** (the last two folded in by #1689). When adding a `core/json/*` or `core/client/*` module, its tests live under `clients/web/src/test/core/…` and are gated the same ≥90 way. +- The **same per-file gate** is enforced for the CLI and TUI (#1484), not just web: + - **CLI** (`clients/cli`): tests run **in-process** by importing `runCli()` (see `__tests__/helpers/cli-runner.ts`) so `clients/cli/src` is measured under v8 instrumentation. A thin out-of-process layer (`__tests__/e2e.test.ts` + `scripts/smoke-cli.mjs`) still spawns the built binary for the shebang/`process.exit` paths; `src/index.ts` (binary bootstrap) is the only coverage exclusion. `commander` uses `.exitOverride()` so a parse error throws instead of tearing down the test worker. + - **TUI** (`clients/tui`): the gate covers the **non-React logic** only — `logger.ts`, `components/tabsConfig.ts`, and `utils/*` (server resolution lives in `core/` and is measured by the web suite). The Ink components, `App.tsx`, and `hooks/` are an **interim exclusion** in `clients/tui/vitest.config.ts` pending the renderer-based follow-up (#1501). When adding new **non-React** logic under `clients/tui/src`, it falls under the gate automatically — add tests for it. +- Run `npm run test:integration` (also from `clients/web/`) for the InspectorClient + transport + auth integration suite. It runs under a separate `integration` vitest project in node env (no happy-dom) with 30s timeouts. The script builds `test-servers/` first via `tsc -p ../../test-servers --noCheck` so the stdio MCP test server can be spawned as a real subprocess. CI does not run `test:integration` as its own step — the integration project is covered by the CI `coverage` gate, whose web `test:coverage` runs `--project=unit --project=integration --coverage`. +- Test files live alongside the source as `.test.tsx` (or `.test.ts` for non-React modules). Integration tests live under `clients/web/src/test/integration/`, mirroring the `core/` source layout (`mcp/`, `mcp/node/`, `mcp/remote/`, `auth/`, `auth/node/`, `storage/`). Any test file under that folder is automatically picked up by the `integration` vitest project (node env, 30s timeouts) via the folder glob in `vite.config.ts` — placement is the manifest, there is no enumeration to keep in sync. Tests outside the folder run in the `unit` project (happy-dom). When adding a new test for, e.g., `core/mcp/remote/foo.ts`, put it at `src/test/integration/mcp/remote/foo.test.ts`. +- **Test placement: side-by-side by default, `src/test/` only for what can't be co-located.** These look like competing conventions but aren't — the split is: *tests live beside their source, **except** tests for the repo-root `core/` package (which lives outside `clients/web/`) and shared test scaffolding — both of which live under `src/test/`, with `core/` tests mirroring the `core/` layout and integration tests under `src/test/integration/`.* + - **Side-by-side (`.test.tsx` next to the source) — the default for web's own `src/` code.** Components, hooks, `lib/`, `utils/`. This is the overwhelming majority; a web-owned test living under `src/test/` instead of beside its source is a bug (fixed one such straggler, `downloadFile.test.ts`, in #1776). + - **`src/test/` — the three things that *cannot* be co-located:** (1) tests of the repo-root **`core/`** package (`src/test/core/…`, mirroring the `core/` folder layout — `core/` physically lives at `/core` outside `clients/web/`, is consumed via the `@inspector/core` alias, and has no test harness of its own, so co-locating would pollute the shared isomorphic package with web-only test infra); (2) the **`integration`** vitest project (`src/test/integration/…`, node env, 30s — placement *is* the manifest, see above); (3) **shared test infrastructure** (`renderWithMantine.tsx`, `setup.ts`, `fixtures/`, `scrollAreaStoryAssertions.ts`) — not tests *of* a source file, so nothing to sit beside. + - **The above is web only.** The Node clients (**cli, tui, launcher**) keep **all** their tests in a top-level **`__tests__/`** dir, not beside their source — their `tsconfig.json` excludes `**/*.test.*` and their `tsconfig.test.json` includes `__tests__/**/*` (plus, for launcher, its root `vitest.config.ts`), so a co-located `src/**/*.test.*` lands in **no** tsconfig project and fails `npm run verify:typecheck-coverage` (#1791). Put a new cli/tui/launcher test under `__tests__/`. +- Use `renderWithMantine` from `src/test/renderWithMantine.tsx` to render components — it wraps in `MantineProvider` with the project theme. It sets `env="test"` so Mantine renders transitions synchronously (no internal `setTimeout`); this prevents a `Transition`/`Modal` timer from firing after happy-dom tears down `window` at end-of-run and failing the whole run with an uncaught `ReferenceError: window is not defined` (#1760). **Always render through `renderWithMantine`; do not hand-roll a bare `MantineProvider` in a test** (that reintroduces the leak class). To exercise a **forced color scheme** (e.g. the `useComputedColorScheme` dark branch) pass the `colorScheme` option — `renderWithMantine(ui, { colorScheme: "dark" })` — instead of hand-rolling a `defaultColorScheme="dark"` provider (#1786). Only when a test must assert *mid-flight* transition state (e.g. a `data-anim="out"` cell during an exit crossfade) use `renderWithMantineTransitions` (real transitions). Such a test can leak the #1760 class because waiting for one cell to unmount does **not** settle a concurrent *enter* (a completed enter leaves no DOM signal to `waitFor`), so the helper **automatically drains the in-flight animation after the test**. The rule for using it: pass `settleMs` derived from the component's real animation duration — its `Transition` `duration`/`exitDuration` plus any `enterDelay`/`exitDelay` plus rAF slack — e.g. `renderWithMantineTransitions(ui, { settleMs: HEADER_ANIM_MS + 200 })` (so the window can't silently become insufficient when that duration changes); do **not** also use `vi.useFakeTimers()` in the same test (the auto-settle no-ops under fake timers — it warns, but anything the test left pending on the *real* clock is then unprotected, so the test depends on which clock was installed at teardown); and if the test unmounts the tree itself, use the `unmount()` the helper returns (it drops that tree from the settle's liveness check, while still draining — a bare mid-body `cleanup()` on a still-armed tree would trip the check). The mechanism behind all three — why the drain is `act`-wrapped, the fake-timer hazard, the `afterEach`-before-`cleanup()` ordering and its `container.isConnected` self-checks, and the exported `settleTransitions(ms)` for manual mid-body settling — is documented at length on the helper in `renderWithMantine.tsx`; read there before changing it. + +### Responding to Code Reviews +- When asked to respond to a code review of a PR, + - it is not necessary to implement all suggestions + - you are free to implement suggestions in a different way or to ignore if there is a good reason + - after making the changes, respond to each review comment with what was done (or why it was ignored) + +### 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). +- 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 **`test:scripts`** (the guard's 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. + - **`npm run coverage`** is the per-file ≥90 gate and is now part of `npm run ci` — never treat it as optional before a push. It supersedes the old standalone `test:integration` step: web's `test:coverage` runs the `unit` **and** `integration` projects under v8 instrumentation, so `coverage` both enforces the ≥90 gate and exercises the same web integration paths CI covers. +- **`smoke` is NOT part of `validate`** — it is included in `npm run ci`. It runs `smoke:launcher` (`--help` dispatch) plus the prod `smoke:cli` / `smoke:tui` / `smoke:web` / `smoke:web:browser`, and contains **no build commands** — it assumes the cli/tui/launcher bundles already exist (a full `validate` builds them; `smoke:web` builds `clients/web/dist` on demand). CI runs `validate`, then the `coverage` gate (which also covers the web integration project), then `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). +- `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`, so the two can't drift. +- `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). +- **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. + +### Typescript instructions +- Use TypeScript for all new code +- Follow TypeScript best practices and coding standards +- NEVER use 'any' as a type +- NEVER suppress error types (e.g., no-unused-vars, no-explicit-any) in the typescript or eslint configuration as a way of satisfying the linter or compiler. +- AVOID double casts (`as unknown as T`). They erase all type safety and usually signal that the real type is being worked around. Prefer a type guard, a narrower single `as` cast, or fixing the underlying type. When a double cast is genuinely unavoidable (e.g. a documented gap in a third-party type, or bridging a structurally-identical shape TS can't relate), it MUST carry an inline comment justifying why it is safe and why no better option exists — an unjustified `as unknown as` is not acceptable in review. +- Utilize type annotations and interfaces to improve code clarity and maintainability +- Leverage TypeScript's type inference and static analysis features for better code quality and refactoring +- Use type guards and type assertions to handle potential type mismatches and ensure type safety +- Take advantage of TypeScript's advanced features like generics, type aliases, and conditional types to write more expressive and reusable code +- Regularly review and refactor TypeScript code to ensure it remains well-structured and adheres to evolving best practices + +## Web source layout: `src/lib` vs `src/utils` + +The web client keeps two grab-bag directories under `clients/web/src`, split by a real (now codified) rule — **`utils` = functions that compute; `lib` = things that instantiate, adapt, or touch the environment.** If it does I/O or wraps a subsystem, it's `lib`; if it's a pure transform, it's `utils`. + +- **`src/utils/`** — pure, side-effect-free functions. Input → output, no DOM/browser/storage I/O, no subsystem ownership. Trivially unit-testable with no mocks. (Anchors: `jsonUtils`, `schemaUtils`, `toolUtils`, `maskSecrets`, `inspectorTabs`, `deepLink`, `mcpNetworkHeaders`.) Carve-outs that are still `utils`: + - _Domain types._ Pure **shared domain types plus their pure constructors/transforms** live here (`customHeaders` — `CustomHeader` + `headersToRecord`/`migrateFromLegacyAuth`, a shape staged for `ServerSettingsForm`, see `specification/v2_ux_interfaces_plan.md`, so it currently has no importer but its own test). There is no `types/` sub-bucket **inside** `lib`/`utils` — removing `lib/types/` is what the `customHeaders` move settles. + - _Diagnostic logging._ `console.warn`/`console.error` does **not** count as a side effect for this rule — a validator that warns on bad input is still "pure" here (`sandbox-csp`, `jsonUtils`, `schemaUtils` all warn). + - _Importing from `@inspector/core`._ Two forms are fine: a **type-only** import is not a subsystem dependency (`pendingReauth` is pure type declarations), and **re-exporting pure functions or constants** from core is not subsystem ownership either (`oauthUx`/`oauthFlow` re-export core copy/predicates). What makes a module `lib` is wrapping core's *stateful runtime*, not merely importing from it. +- **`src/lib/`** — infrastructure / integration / stateful adapters. Modules that instantiate or compose subsystems, wrap the `@inspector/core` **runtime** (not just its types), touch the DOM / `window` / `sessionStorage`, or otherwise produce side effects. (Anchors: `environmentFactory` composes `InspectorClientEnvironment`; `remoteOAuthStorage` is an adapter class over `core/auth`; `oauthResume` reads/writes `sessionStorage`; `browserTabVisibility` registers `visibilitychange` listeners; `clearServerOAuthState` drives the live `InspectorClient` / `OAuthStorage`; `downloadFile` triggers browser downloads.) + +The top-level **`src/types/`** is a sibling of both and is not the place for new domain types — it's now purely the home for ambient `.d.ts` module stubs (e.g. the `react-syntax-highlighter` shims wired through `tsconfig.app.json` `paths`). The last plain-`.ts` domain type there, the dead `navigation.ts` `InspectorTab`, was removed in #1785, so a pure domain type belongs in `utils/`, not `src/types/`. + +Cross-directory imports point **one way, `lib → utils`** (infra depends on pure helpers, never the reverse). Keep it that way: if a `utils/` module needs a type currently exported from a `lib/` module, declare the type in `utils/` and re-export it from `lib/` (as `pendingReauth` owns `OAuthResumeAuthKind` and `oauthResume` re-exports it), rather than importing "up" from `utils` into `lib`. + +Nothing **enforces** the boundary: no path alias keys off it, and the coverage `include` in `clients/web/vite.config.ts` lists **both** `src/lib/**` and `src/utils/**`, so a move between them is coverage-neutral (this is why the refactor was gate-safe). It's a human-legible signal at import time, valuable in a codebase this test-heavy (the ≥90% per-file gate). Note that `include` is a **whitelist** — it names `components`/`hooks`/`theme`/`lib`/`utils`/`server` (plus the `core/*` runtime; `hooks` and `theme` were added in #1787), so a module placed **outside** those directories (`types/`, `App.tsx`, or a brand-new grab-bag) falls out of the ≥90 gate entirely, silently. The **deliberate, documented** top-level-file exceptions are `src/App.tsx` — a ~4.5k-line composition root at ~42% branch coverage (gating it is a dedicated testing/decomposition effort, not a whitelist tweak) — and the `src/main.tsx` / `src/index.ts` bootstraps (browser `createRoot` render and the bin `runWeb` re-export, the analog of `clients/cli`'s excluded `src/index.ts`). All three are called out in a comment on the `include` array itself rather than left silent. When adding a module, place it by the rule and keep it inside a gated directory; when it genuinely mixes both (e.g. `downloadFile` bundles DOM-side-effect helpers with a couple of pure ones), keep it whole on its dominant side (`lib`) rather than splitting hairs. + +## React instructions +- UI Components + - We are using the Mantine component library for UI. + - Instructions are at https://mantine.dev/llms.txt + - Avoid using div and other basic HTML elements for layout purposes. + - Prefer Mantine's Box, Group, and Stack components for layout. + - Use Mantine's theme and styling utilities to ensure a consistent and responsive design. + - NEVER use inline styles on a component. + - NEVER use raw hex values (`#ddd`, `#94a3b8`, etc.) or `rgba()` literals for colors in component props or theme files. Use `--inspector-*` CSS custom properties defined in `App.css :root` (e.g., `c: 'var(--inspector-text-primary)'`). If no existing token fits, add one to `:root` first. + - NEVER add a CSS class to a Mantine component when the styles can instead be expressed as component props or a theme variant. CSS classes are a last resort. + - PREFER component props (via `.withProps()`) to CSS for behavioral and visual styles. + - PREFER defining styles as theme variants (via `Component.extend()` in `src/theme/.ts`) over CSS classes. Each Mantine component with custom variants has its own file in `src/theme/`, exporting a `Theme` constant. The barrel `src/theme/index.ts` re-exports them all and `theme.ts` imports from the barrel. Flat CSS properties (margin, padding, background, border, color, font-size, etc.) belong in the theme. Only pseudo-selectors, nested child selectors, keyframes, and native HTML element styles belong in App.css. + - App.css must contain ONLY styles that cannot be expressed in the Mantine theme: `@keyframes`, pseudo-selectors (`:hover`, `:focus`), cross-component hover relationships, nested child-element selectors for third-party HTML output (e.g. ReactMarkdown), and styles for native HTML elements (`img`, `iframe`). When refactoring a component, actively move any flat CSS properties out of App.css and into theme variants or `.withProps()` constants. + - NEVER use inline code; instead extract to functions in the same file, exported or located in a shared location if immediately reusable. + - In a component's file, for sub-components: + - ALWAYS use Mantine components for layout and content, configured with props for styling and behavior. + - ALWAYS declare a meaningfully named subcomponent as a constant using `.withProps()` if an inline Mantine element carries two or more **static** props. A *static* prop is one whose value is a literal that configures the element's **styling, layout, or behavior** (`size="sm"`, `c="dimmed"`, `fw={500}`, `gap="xs"`, `justify="space-between"`, `variant="light"`, `withBorder`, `readOnly`, `striped`, …); dynamic props (`value`, `onChange`/`on*`, `children`, `key`, `ref`, and anything whose value is a variable/expression) do **not** count toward the two and are passed at the call site, not baked into the constant. Purely per-instance **content/accessibility** literals — `label`, `description`, `placeholder`, `title`, `aria-label`, `role` — likewise do **not** count toward the two (a `` with no styling/layout/behavior props stays inline); they may be baked into a constant when it already qualifies and doing so aids reuse, but they never by themselves trigger extraction. This rule applies in **all** cases: "repeated pattern" is NOT the bar — a single-use element with two or more static styling/layout/behavior props must still be extracted. Bake the static props into the `.withProps()` constant and pass the dynamic ones where it's rendered. + - The following **cannot** be expressed via `.withProps()` and so stay inline (like `Box` below), each with a one-line comment saying why: **`Accordion`** (a compound, `multiple`-discriminated generic — `.withProps({ multiple: true, … })` loses its JSX call signature and fails to type); **headless, non-`factory()` Mantine components** such as **`Transition`** (plain function components with no Styles API — they have no `.withProps` static at all, e.g. `Transition.withProps` is a TS2339); and **`data-*` attributes** (not part of a component's typed props object, so excess-property-checked out of a `withProps` literal — pass them at the call site). The rule targets factory-based (Styles-API) Mantine components; anything that isn't one is out of scope entirely — a third-party element (a `react-icons` glyph, another library's component) **and** a first-party component that isn't a Mantine factory (a dumb `export function` like `ContentViewer`, which has no `.withProps` static of its own). + - NEVER use `Box` for subcomponent constants — `Box` does not support `.withProps()`. Use `Group`, `Stack`, `Flex`, `Text`, `Paper`, `UnstyledButton`, or `Image` instead. Pick the component that best matches the purpose: `Paper` for bordered/surfaced containers, `Text` for any text or content wrapper, `Stack`/`Group`/`Flex` for layout. A `Box` that genuinely needs a non-flex primitive it can't provide — `component="iframe"`, or `display="grid"` (no Mantine flex primitive is a CSS grid) — stays a `Box` inline, with a one-line comment saying why. + - NEVER use a CSS class on a subcomponent constant when the styles can be expressed as a Mantine theme variant instead. Define variants in `src/theme/.ts` using `Component.extend({ styles: (_theme, props) => { ... } })` and reference them with `variant="variantName"` on the component or in `.withProps()`. + - CSS classes are ONLY acceptable on subcomponents for styles that cannot be expressed as flat CSS-in-JS properties in the theme — specifically: pseudo-selectors (`:hover`, `:focus`), cross-component hover relationships (`.parent:hover .child`), nested child-element selectors (`.wrapper p`, `.wrapper code`), `@keyframes` definitions, and native HTML elements (`img`, `iframe`) that are not Mantine components. + - When a theme variant needs a CSS class for nested/pseudo selectors, use `classNames` in the theme extension to auto-assign it — never add `className` manually in JSX for theme-styled components. + - Example — subcomponent constant with `withProps`: + ```tsx + const CardContent = Group.withProps({ + flex: 1, + align: 'flex-start', + justify: 'space-between', + wrap: 'nowrap', + }); + return ... + ``` + - Example — theme variant with auto-assigned className for nested selectors: + ```tsx + // src/theme/Paper.ts + export const ThemePaper = Paper.extend({ + classNames: (_theme, props) => { + if (props.variant === 'message') return { root: 'message' }; + return {}; + }, + styles: (_theme, props) => { + if (props.variant === 'message') { + return { root: { padding: '1.5rem', borderRadius: 12 } }; + } + return { root: {} }; + }, + }), + + // Component.tsx + const MessageContainer = Paper.withProps({ variant: 'message' }); + ``` +- Theme files vs. Storybook element components + - **Theme files** (`src/theme/.ts`) and **element components** (`src/components/elements/`) serve different purposes and both are needed. + - Theme files customize every instance of a Mantine component app-wide — defaults (size, radius), custom variants, and global style overrides. They are applied automatically by `MantineProvider`. + - Element components add domain-specific semantics on top of Mantine primitives. For example, `AnnotationBadge` maps domain concepts (audience, destructive, longRun) to Mantine's styling primitives (color, variant). Storybook documents these domain components for designers and developers. + - Element components MUST import from `@mantine/core`, NOT from `src/theme/`. The theme layer is applied transparently by the provider — elements do not need to know about `Theme` constants. + - NEVER push domain-specific variant logic (e.g., annotation types, transport types) into theme files. Domain variants belong in the element component that owns those semantics. Theme files are for styling that applies to the Mantine primitive globally. diff --git a/CLAUDE.md b/CLAUDE.md index 285e0f5b3..1e49e1d0b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1 +1,2 @@ @./AGENTS.md +@./README.md diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md deleted file mode 100644 index 05c32c605..000000000 --- a/CODE_OF_CONDUCT.md +++ /dev/null @@ -1,128 +0,0 @@ -# Contributor Covenant Code of Conduct - -## Our Pledge - -We as members, contributors, and leaders pledge to make participation in our -community a harassment-free experience for everyone, regardless of age, body -size, visible or invisible disability, ethnicity, sex characteristics, gender -identity and expression, level of experience, education, socio-economic status, -nationality, personal appearance, race, religion, or sexual identity -and orientation. - -We pledge to act and interact in ways that contribute to an open, welcoming, -diverse, inclusive, and healthy community. - -## Our Standards - -Examples of behavior that contributes to a positive environment for our -community include: - -* Demonstrating empathy and kindness toward other people -* Being respectful of differing opinions, viewpoints, and experiences -* Giving and gracefully accepting constructive feedback -* Accepting responsibility and apologizing to those affected by our mistakes, - and learning from the experience -* Focusing on what is best not just for us as individuals, but for the - overall community - -Examples of unacceptable behavior include: - -* The use of sexualized language or imagery, and sexual attention or - advances of any kind -* Trolling, insulting or derogatory comments, and personal or political attacks -* Public or private harassment -* Publishing others' private information, such as a physical or email - address, without their explicit permission -* Other conduct which could reasonably be considered inappropriate in a - professional setting - -## Enforcement Responsibilities - -Community leaders are responsible for clarifying and enforcing our standards of -acceptable behavior and will take appropriate and fair corrective action in -response to any behavior that they deem inappropriate, threatening, offensive, -or harmful. - -Community leaders have the right and responsibility to remove, edit, or reject -comments, commits, code, wiki edits, issues, and other contributions that are -not aligned to this Code of Conduct, and will communicate reasons for moderation -decisions when appropriate. - -## Scope - -This Code of Conduct applies within all community spaces, and also applies when -an individual is officially representing the community in public spaces. -Examples of representing our community include using an official e-mail address, -posting via an official social media account, or acting as an appointed -representative at an online or offline event. - -## Enforcement - -Instances of abusive, harassing, or otherwise unacceptable behavior may be -reported to the community leaders responsible for enforcement at -mcp-coc@anthropic.com. -All complaints will be reviewed and investigated promptly and fairly. - -All community leaders are obligated to respect the privacy and security of the -reporter of any incident. - -## Enforcement Guidelines - -Community leaders will follow these Community Impact Guidelines in determining -the consequences for any action they deem in violation of this Code of Conduct: - -### 1. Correction - -**Community Impact**: Use of inappropriate language or other behavior deemed -unprofessional or unwelcome in the community. - -**Consequence**: A private, written warning from community leaders, providing -clarity around the nature of the violation and an explanation of why the -behavior was inappropriate. A public apology may be requested. - -### 2. Warning - -**Community Impact**: A violation through a single incident or series -of actions. - -**Consequence**: A warning with consequences for continued behavior. No -interaction with the people involved, including unsolicited interaction with -those enforcing the Code of Conduct, for a specified period of time. This -includes avoiding interactions in community spaces as well as external channels -like social media. Violating these terms may lead to a temporary or -permanent ban. - -### 3. Temporary Ban - -**Community Impact**: A serious violation of community standards, including -sustained inappropriate behavior. - -**Consequence**: A temporary ban from any sort of interaction or public -communication with the community for a specified period of time. No public or -private interaction with the people involved, including unsolicited interaction -with those enforcing the Code of Conduct, is allowed during this period. -Violating these terms may lead to a permanent ban. - -### 4. Permanent Ban - -**Community Impact**: Demonstrating a pattern of violation of community -standards, including sustained inappropriate behavior, harassment of an -individual, or aggression toward or disparagement of classes of individuals. - -**Consequence**: A permanent ban from any sort of public interaction within -the community. - -## Attribution - -This Code of Conduct is adapted from the [Contributor Covenant][homepage], -version 2.0, available at -https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. - -Community Impact Guidelines were inspired by [Mozilla's code of conduct -enforcement ladder](https://github.com/mozilla/diversity). - -[homepage]: https://www.contributor-covenant.org - -For answers to common questions about this code of conduct, see the FAQ at -https://www.contributor-covenant.org/faq. Translations are available at -https://www.contributor-covenant.org/translations. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md deleted file mode 100644 index daf1e2e0f..000000000 --- a/CONTRIBUTING.md +++ /dev/null @@ -1,47 +0,0 @@ -# Contributing to Model Context Protocol Inspector - -Thanks for your interest in contributing! This guide explains how to get involved. - -## Getting Started - -1. Fork the repository and clone it locally -2. Install dependencies with `npm install` -3. Run `npm run dev` to start both client and server in development mode -4. Use the web UI at http://localhost:6274 to interact with the inspector - -## Inspector V2 Development - -We're actively developing **Inspector V2** to address architectural and UX improvements. We invite you to follow progress and participate in the Inspector V2 Working Group in [Discord](https://modelcontextprotocol.io/community/communication), [weekly meetings](https://meet.modelcontextprotocol.io/tag/inspector-v2-wg), and [GitHub Discussions](https://github.com/modelcontextprotocol/modelcontextprotocol/discussions/categories/meeting-notes-other) (where notes are posted after meetings). - -**Current version (V1) contribution scope:** - -- Bug fixes and MCP spec compliance are actively maintained -- Documentation updates are always appreciated -- Major changes will be directed to V2 development - -## Development Process & Pull Requests - -1. Create a new branch for your changes -2. Make your changes following existing code style and conventions. You can run `npm run prettier-check` and `npm run prettier-fix` as applicable. -3. Test changes locally by running `npm test` and `npm run test:e2e` -4. Update documentation as needed -5. Use clear commit messages explaining your changes -6. Verify all changes work as expected -7. Submit a pull request -8. PRs will be reviewed by maintainers - -## Code of Conduct - -This project follows our [Code of Conduct](CODE_OF_CONDUCT.md). Please read it before contributing. - -## Security - -If you find a security vulnerability, please refer to our [Security Policy](SECURITY.md) for reporting instructions. - -## Questions? - -Feel free to [open an issue](https://github.com/modelcontextprotocol/inspector/issues) for questions or join the MCP Contributor [Discord server](https://modelcontextprotocol.io/community/communication). Also, please see notes above on Inspector V2 Development. - -## License - -By contributing, you agree that your contributions will be licensed under the MIT license. diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md new file mode 100644 index 000000000..595f2847a --- /dev/null +++ b/CONTRIBUTORS.md @@ -0,0 +1,116 @@ +# Contributing to MCP Inspector + +Thank you for your interest in improving the MCP Inspector. Contributors are +genuinely valued — the goal of this document is to channel your input into a +form we can act on quickly and consistently. + +## TL;DR + +**We accept issues, not pull requests.** Design and implementation are done by +the maintainers. If you've already built a fix or feature locally, share **the +prompt you used** to produce it — not the source code. + +## Why this policy exists + +The Inspector v2 is developed with an AI-assisted, prompt-driven workflow built +around a consistent architecture, a shared design system, and strict testing +gates (see [`AGENTS.md`](./AGENTS.md)). Every component follows the same +conventions: "dumb" components that take data and callbacks as props, Mantine +for all UI, theme variants instead of ad-hoc CSS, and a uniform per-file +coverage gate of ≥ 90% on lines, statements, functions, and branches. + +Accepting raw source PRs creates friction: a diff written outside this pipeline +has to be reverse-engineered to fit our component/theme conventions, coverage +gates, and review process — often it's faster to re-derive the change than to +adapt the patch. Capturing your **intent** (a well-formed issue) or the +**prompt** that generated your local change lets us reproduce the work inside +our own workflow and standards, with the quality bar already baked in. + +This policy is about efficiency, not gatekeeping. Your ideas, bug reports, and +prompts directly shape what gets built. And if you'd like to contribute more +than an issue, see +[Want to work on the Inspector with us?](#want-to-work-on-the-inspector-with-us) +below — we'd rather bring you into the workflow than turn you away. + +## How to contribute a bug report or feature request + +Open a well-formed issue describing the bug or the feature you have in mind. +A great issue gives us everything we need to act on it without a round-trip — +see [What makes a good issue or prompt submission](#what-makes-a-good-issue-or-prompt-submission) +below. That's the whole process: you describe the intent, we handle the design +and implementation. + +### Which version and label? + +The Inspector is maintained across two versions, each with its own base branch +and version label. File your issue against the version your report or request +targets: + +| Version | Base branch | Label | +| ------- | ----------- | ----- | +| v1 | `v1/main` | `v1` | +| v2 | `main` | `v2` | + +- **v1** (`v1/main`) is the legacy Inspector — it takes **security fixes + only**. +- **v2** (`main`) is where all current work happens — when in doubt, target v2. + +**Label by version.** Every issue (and the PRs maintainers open for it) must +carry the label matching the target branch — `v1` for `v1/main` and `v2` for +`main`. This mirrors the "Label by version" convention documented in +[`AGENTS.md`](./AGENTS.md). + +## If you've already fixed it locally + +Please don't send a diff or open a pull request. Instead, open an issue that +includes: + +- **The prompt(s) you used** to generate the change — the exact text, so we can + reproduce it through our own workflow. +- **A description of the behavior before and after** your change. +- **How you verified it** (steps you ran, tests you added, what you observed). + +We'll reproduce the change through our pipeline so it lands with the right +conventions, tests, and coverage. + +## What makes a good issue or prompt submission + +A great submission gives us everything we need to act without a round-trip: + +- **Clear reproduction or use case** — exact steps to reproduce a bug, or a + concrete description of the feature and the problem it solves. +- **Expected vs. actual behavior** — what you saw, and what you expected + instead. +- **Affected client** — which incarnation is involved: **Web**, **TUI**, or + **CLI** (or "all" / "core" if it's shared logic). +- **Environment details** when relevant — OS, Node version, the MCP server you + were inspecting, and any relevant config. +- **The exact prompt text**, if you generated a local change and want us to + reproduce it. + +## Want to work on the Inspector with us? + +The issues-only policy is about how **unsolicited patches** are handled — it is +not a closed door. If you want to contribute at a deeper level, either in +general or in a specific area, we'd genuinely like to hear from you: + +- **Join the [MCP Contributor Discord](https://discord.gg/6CSzBmMkjX)** and say + hello in **`#inspector-dev`**. That's where day-to-day Inspector development + is discussed and the fastest way to reach the maintainers. +- **Attend the community calls** at + [meet.modelcontextprotocol.io](https://meet.modelcontextprotocol.io/) — see + [Contributor Communication](https://modelcontextprotocol.io/community/communication) + for the full set of channels and how they're used. + +From there we can scope a piece of work with you and supervise it through our +workflow — which is also the path toward becoming a maintainer. What we want to +avoid is drive-by diffs that have to be reverse-engineered into our pipeline; +what we want to encourage is sustained, coordinated contribution. + +All participation is governed by the MCP +[Code of Conduct](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/CODE_OF_CONDUCT.md). + +## Questions + +If you're unsure how to scope something, open the issue anyway and say so — +we'll help shape it. Thanks for helping make the Inspector better. diff --git a/Dockerfile b/Dockerfile index d66091d16..986231a09 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,52 +1,54 @@ -# Build stage -FROM node:current-alpine3.22 AS builder - -# Set working directory -WORKDIR /app - -# Copy package files for installation -COPY package*.json ./ -COPY .npmrc ./ -COPY client/package*.json ./client/ -COPY server/package*.json ./server/ -COPY cli/package*.json ./cli/ - -# Install dependencies -RUN npm ci --ignore-scripts - -# Copy source files +# syntax=docker/dockerfile:1 + +# --------------------------------------------------------------------------- +# Build stage — install everything, build all clients, and pack the exact +# tarball npm would publish. `npm install` runs the root postinstall cascade +# (per-client installs, since v2 is not a workspace); `npm pack` runs `prepack` +# (`npm run build`) and emits `modelcontextprotocol-inspector-.tgz`. +# --------------------------------------------------------------------------- +FROM node:22-slim AS builder +WORKDIR /build COPY . . - -# Build the application -RUN npm run build - -# Production stage -FROM node:24-slim - -WORKDIR /app - -# Copy package files for production -COPY package*.json ./ -COPY .npmrc ./ -COPY client/package*.json ./client/ -COPY server/package*.json ./server/ -COPY cli/package*.json ./cli/ - -# Install only production dependencies -RUN npm ci --omit=dev --ignore-scripts - -# Copy built files from builder stage -COPY --from=builder /app/client/dist ./client/dist -COPY --from=builder /app/client/bin ./client/bin -COPY --from=builder /app/server/build ./server/build -COPY --from=builder /app/cli/build ./cli/build - -# Set default port values as environment variables -ENV CLIENT_PORT=6274 -ENV SERVER_PORT=6277 - -# Document which ports the application uses internally -EXPOSE ${CLIENT_PORT} ${SERVER_PORT} - -# Use ENTRYPOINT with CMD for arguments -ENTRYPOINT ["npm", "start"] +RUN npm install && npm pack + +# --------------------------------------------------------------------------- +# Runtime stage — install just the packed tarball (production deps only), +# yielding a clean global `mcp-inspector` bin: the same artifact a user gets +# from `npm i -g @modelcontextprotocol/inspector`. The package's postinstall +# early-exits under node_modules, and the tarball ships only each client's +# build/ output, so there is nothing to build here. +# --------------------------------------------------------------------------- +FROM node:22-slim AS runner +ENV NODE_ENV=production + +COPY --from=builder /build/modelcontextprotocol-inspector-*.tgz /tmp/inspector.tgz +RUN npm install -g /tmp/inspector.tgz && rm /tmp/inspector.tgz + +# Serve on all interfaces so the UI is reachable from outside the container, and +# never try to open a browser from inside it. Binding 0.0.0.0 is refused by +# default (it exposes the process-spawning backend to the network); a container +# is the sanctioned exception, so opt in explicitly via DANGEROUSLY_BIND_ALL_INTERFACES. +ENV HOST=0.0.0.0 \ + DANGEROUSLY_BIND_ALL_INTERFACES=true \ + CLIENT_PORT=6274 \ + MCP_AUTO_OPEN_ENABLED=false +EXPOSE 6274 + +# 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` +# (core/storage/store-io.ts), so set it explicitly to the node user's writable +# home and work from there. +ENV HOME=/home/node +USER node +WORKDIR /home/node + +# Report readiness by probing the served SPA (`/` needs no auth). Uses Node's +# global fetch — no curl/wget in the slim image. Assumes the default `--web` +# mode; running `--cli`/`--tui` has no web server, so add `--no-healthcheck` to +# `docker run` for those. +HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \ + CMD node -e "fetch('http://127.0.0.1:'+(process.env.CLIENT_PORT||6274)+'/').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))" + +# Default to the web UI; override the args to run --cli / --tui. +ENTRYPOINT ["mcp-inspector"] +CMD ["--web"] diff --git a/LICENSE b/LICENSE deleted file mode 100644 index 4a9398576..000000000 --- a/LICENSE +++ /dev/null @@ -1,216 +0,0 @@ -The MCP project is undergoing a licensing transition from the MIT License to the Apache License, Version 2.0 ("Apache-2.0"). All new code and specification contributions to the project are licensed under Apache-2.0. Documentation contributions (excluding specifications) are licensed under CC-BY-4.0. - -Contributions for which relicensing consent has been obtained are licensed under Apache-2.0. Contributions made by authors who originally licensed their work under the MIT License and who have not yet granted explicit permission to relicense remain licensed under the MIT License. - -No rights beyond those granted by the applicable original license are conveyed for such contributions. - ---- - - Apache License - Version 2.0, January 2004 - http://www.apache.org/licenses/ - - TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION - - 1. Definitions. - - "License" shall mean the terms and conditions for use, reproduction, - and distribution as defined by Sections 1 through 9 of this document. - - "Licensor" shall mean the copyright owner or entity authorized by - the copyright owner that is granting the License. - - "Legal Entity" shall mean the union of the acting entity and all - other entities that control, are controlled by, or are under common - control with that entity. For the purposes of this definition, - "control" means (i) the power, direct or indirect, to cause the - direction or management of such entity, whether by contract or - otherwise, or (ii) ownership of fifty percent (50%) or more of the - outstanding shares, or (iii) beneficial ownership of such entity. - - "You" (or "Your") shall mean an individual or Legal Entity - exercising permissions granted by this License. - - "Source" form shall mean the preferred form for making modifications, - including but not limited to software source code, documentation - source, and configuration files. - - "Object" form shall mean any form resulting from mechanical - transformation or translation of a Source form, including but - not limited to compiled object code, generated documentation, - and conversions to other media types. - - "Work" shall mean the work of authorship, whether in Source or - Object form, made available under the License, as indicated by a - copyright notice that is included in or attached to the work - (an example is provided in the Appendix below). - - "Derivative Works" shall mean any work, whether in Source or Object - form, that is based on (or derived from) the Work and for which the - editorial revisions, annotations, elaborations, or other modifications - represent, as a whole, an original work of authorship. For the purposes - of this License, Derivative Works shall not include works that remain - separable from, or merely link (or bind by name) to the interfaces of, - the Work and Derivative Works thereof. - - "Contribution" shall mean any work of authorship, including - the original version of the Work and any modifications or additions - to that Work or Derivative Works thereof, that is intentionally - submitted to the Licensor for inclusion in the Work by the copyright - owner or by an individual or Legal Entity authorized to submit on behalf - of the copyright owner. For the purposes of this definition, "submitted" - means any form of electronic, verbal, or written communication sent - to the Licensor or its representatives, including but not limited to - communication on electronic mailing lists, source code control systems, - and issue tracking systems that are managed by, or on behalf of, the - Licensor for the purpose of discussing and improving the Work, but - excluding communication that is conspicuously marked or otherwise - designated in writing by the copyright owner as "Not a Contribution." - - "Contributor" shall mean Licensor and any individual or Legal Entity - on behalf of whom a Contribution has been received by Licensor and - subsequently incorporated within the Work. - - 2. Grant of Copyright License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - copyright license to reproduce, prepare Derivative Works of, - publicly display, publicly perform, sublicense, and distribute the - Work and such Derivative Works in Source or Object form. - - 3. Grant of Patent License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - (except as stated in this section) patent license to make, have made, - use, offer to sell, sell, import, and otherwise transfer the Work, - where such license applies only to those patent claims licensable - by such Contributor that are necessarily infringed by their - Contribution(s) alone or by combination of their Contribution(s) - with the Work to which such Contribution(s) was submitted. If You - institute patent litigation against any entity (including a - cross-claim or counterclaim in a lawsuit) alleging that the Work - or a Contribution incorporated within the Work constitutes direct - or contributory patent infringement, then any patent licenses - granted to You under this License for that Work shall terminate - as of the date such litigation is filed. - - 4. Redistribution. You may reproduce and distribute copies of the - Work or Derivative Works thereof in any medium, with or without - modifications, and in Source or Object form, provided that You - meet the following conditions: - - (a) You must give any other recipients of the Work or - Derivative Works a copy of this License; and - - (b) You must cause any modified files to carry prominent notices - stating that You changed the files; and - - (c) You must retain, in the Source form of any Derivative Works - that You distribute, all copyright, patent, trademark, and - attribution notices from the Source form of the Work, - excluding those notices that do not pertain to any part of - the Derivative Works; and - - (d) If the Work includes a "NOTICE" text file as part of its - distribution, then any Derivative Works that You distribute must - include a readable copy of the attribution notices contained - within such NOTICE file, excluding those notices that do not - pertain to any part of the Derivative Works, in at least one - of the following places: within a NOTICE text file distributed - as part of the Derivative Works; within the Source form or - documentation, if provided along with the Derivative Works; or, - within a display generated by the Derivative Works, if and - wherever such third-party notices normally appear. The contents - of the NOTICE file are for informational purposes only and - do not modify the License. You may add Your own attribution - notices within Derivative Works that You distribute, alongside - or as an addendum to the NOTICE text from the Work, provided - that such additional attribution notices cannot be construed - as modifying the License. - - You may add Your own copyright statement to Your modifications and - may provide additional or different license terms and conditions - for use, reproduction, or distribution of Your modifications, or - for any such Derivative Works as a whole, provided Your use, - reproduction, and distribution of the Work otherwise complies with - the conditions stated in this License. - - 5. Submission of Contributions. Unless You explicitly state otherwise, - any Contribution intentionally submitted for inclusion in the Work - by You to the Licensor shall be under the terms and conditions of - this License, without any additional terms or conditions. - Notwithstanding the above, nothing herein shall supersede or modify - the terms of any separate license agreement you may have executed - with Licensor regarding such Contributions. - - 6. Trademarks. This License does not grant permission to use the trade - names, trademarks, service marks, or product names of the Licensor, - except as required for reasonable and customary use in describing the - origin of the Work and reproducing the content of the NOTICE file. - - 7. Disclaimer of Warranty. Unless required by applicable law or - agreed to in writing, Licensor provides the Work (and each - Contributor provides its Contributions) on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or - implied, including, without limitation, any warranties or conditions - of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A - PARTICULAR PURPOSE. You are solely responsible for determining the - appropriateness of using or redistributing the Work and assume any - risks associated with Your exercise of permissions under this License. - - 8. Limitation of Liability. In no event and under no legal theory, - whether in tort (including negligence), contract, or otherwise, - unless required by applicable law (such as deliberate and grossly - negligent acts) or agreed to in writing, shall any Contributor be - liable to You for damages, including any direct, indirect, special, - incidental, or consequential damages of any character arising as a - result of this License or out of the use or inability to use the - Work (including but not limited to damages for loss of goodwill, - work stoppage, computer failure or malfunction, or any and all - other commercial damages or losses), even if such Contributor - has been advised of the possibility of such damages. - - 9. Accepting Warranty or Additional Liability. While redistributing - the Work or Derivative Works thereof, You may choose to offer, - and charge a fee for, acceptance of support, warranty, indemnity, - or other liability obligations and/or rights consistent with this - License. However, in accepting such obligations, You may act only - on Your own behalf and on Your sole responsibility, not on behalf - of any other Contributor, and only if You agree to indemnify, - defend, and hold each Contributor harmless for any liability - incurred by, or claims asserted against, such Contributor by reason - of your accepting any such warranty or additional liability. - - END OF TERMS AND CONDITIONS - ---- - -MIT License - -Copyright (c) 2024-2025 Model Context Protocol a Series of LF Projects, LLC. - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. - ---- - -Creative Commons Attribution 4.0 International (CC-BY-4.0) - -Documentation in this project (excluding specifications) is licensed under -CC-BY-4.0. See https://creativecommons.org/licenses/by/4.0/legalcode for -the full license text. diff --git a/README.md b/README.md index 36e9f3dee..c61006021 100644 --- a/README.md +++ b/README.md @@ -1,479 +1,220 @@ # MCP Inspector -The MCP inspector is a developer tool for testing and debugging MCP servers. +A developer tool for inspecting [Model Context Protocol](https://modelcontextprotocol.io) (MCP) servers. It ships as a single package, `@modelcontextprotocol/inspector`, that provides three ways to inspect a server: -![MCP Inspector Screenshot](https://raw.githubusercontent.com/modelcontextprotocol/inspector/main/mcp-inspector.png) +- **Web** — a Vite + React + [Mantine](https://mantine.dev) single-page app with a Node backend. +- **CLI** — a scriptable command-line client for automation, CI, and fast agent feedback loops. +- **TUI** — an interactive terminal UI built with [Ink](https://github.com/vadimdemedes/ink). -## Architecture Overview - -The MCP Inspector consists of two main components that work together: - -- **MCP Inspector Client (MCPI)**: A React-based web UI that provides an interactive interface for testing and debugging MCP servers -- **MCP Proxy (MCPP)**: A Node.js server that acts as a protocol bridge, connecting the web UI to MCP servers via various transport methods (stdio, SSE, streamable-http) - -Note that the proxy is not a network proxy for intercepting traffic. Instead, it functions as both an MCP client (connecting to your MCP server) and an HTTP server (serving the web UI), enabling browser-based interaction with MCP servers that use different transport protocols. - -## Running the Inspector - -### Requirements - -- Node.js: ^22.7.5 - -### Quick Start (UI mode) - -To get up and running right away with the UI, just execute the following: +All three run through one global `mcp-inspector` binary: ```bash -npx @modelcontextprotocol/inspector +npx @modelcontextprotocol/inspector # web UI (default) +npx @modelcontextprotocol/inspector --cli # CLI +npx @modelcontextprotocol/inspector --tui # TUI ``` -The server will start up and the UI will be accessible at `http://localhost:6274`. +> **Repo status.** This is the **v2** line of the Inspector (branch `v2/main`). The `main` branch is the legacy v1 implementation (bug fixes only). v2 will eventually replace `main`. See [`AGENTS.md`](./AGENTS.md) for branch/board conventions. -### Docker Container +## Project layout -You can also start it in a Docker container with the following command: +v2 is **not** an npm workspace. Each client under `clients/*` keeps its own `package.json` and `node_modules`; shared code lives in `core/` and is consumed via a `@inspector/core` build-time alias (no `package.json` of its own). A single `npm install` at the root cascades installs into every client (see [Setup](#setup)). -```bash -docker run --rm \ - -p 127.0.0.1:6274:6274 \ - -p 127.0.0.1:6277:6277 \ - -e HOST=0.0.0.0 \ - -e MCP_AUTO_OPEN_ENABLED=false \ - ghcr.io/modelcontextprotocol/inspector:latest ``` - -### From an MCP server repository - -To inspect an MCP server implementation, there's no need to clone this repo. Instead, use `npx`. For example, if your server is built at `build/index.js`: - -```bash -npx @modelcontextprotocol/inspector node build/index.js +inspector/ +├── clients/ +│ ├── web/ # Web client (Vite + React + Mantine). src/ = browser app; server/ = Node dev/prod backend +│ ├── cli/ # CLI client (tsup bundle, @inspector/core alias) +│ ├── 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, mid-session recovery (browser/node/remote backends) +│ ├── json/ # JSON + parameter/argument conversion utilities +│ ├── logging/ # Silent pino logger singleton +│ ├── mcp/ # InspectorClient runtime, state stores, transports, config import +│ ├── node/ # Node-only shared helpers: version reader, hostUrl (host normalize/canonicalize + all-interfaces/loopback detection) +│ ├── react/ # React hooks over the state stores +│ └── storage/ # File I/O helpers for the OAuth persist backends +├── test-servers/ # Composable MCP test servers + fixtures used by integration tests +├── scripts/ # Root build/verify tooling (install cascade, smokes, verify-build-gate, verify-format-coverage, pack:verify) +├── specification/ # Design/build specifications +├── AGENTS.md # Contribution rules for agents AND humans (see below) +└── README.md # You are here ``` -You can pass both arguments and environment variables to your MCP server. Arguments are passed directly to your server, while environment variables can be set using the `-e` flag: +Each client has its own README with client-specific detail: +[web](./clients/web/README.md) · [cli](./clients/cli/README.md) · [tui](./clients/tui/README.md) · [launcher](./clients/launcher/README.md). -```bash -# Pass arguments only -npx @modelcontextprotocol/inspector node build/index.js arg1 arg2 +Task-oriented guides live under [`docs/`](./docs): -# Pass environment variables only -npx @modelcontextprotocol/inspector -e key=value -e key2=$VALUE2 node build/index.js +- [MCP server configuration](./docs/mcp-server-configuration.md) — which server(s) the Inspector connects to: `--catalog` vs. `--config`, ad-hoc targets, the `--` separator, the file format and its Inspector-specific per-server fields. Shared by all three clients; the cli and tui READMEs delegate their server-options sections to it. +- [Reviewing an MCP App](./docs/mcp-app-review.md) — the CLI-first → one-shot-web recipe for automated App-tool review: `--app-info` probe → deep-link navigate → rendered widget, plus OAuth handoff and proxy support. +- [Launcher and config consolidation](./docs/launcher-config-consolidation-plan.md) — why the launcher runs a client in-process rather than spawning it, and how the shared config processor fits in. -# Pass both environment variables and arguments -npx @modelcontextprotocol/inspector -e key=value -e key2=$VALUE2 node build/index.js arg1 arg2 - -# Use -- to separate inspector flags from server arguments -npx @modelcontextprotocol/inspector -e key=$VALUE -- node build/index.js -e server-flag -``` +## Setup -The inspector runs both an MCP Inspector (MCPI) client UI (default port 6274) and an MCP Proxy (MCPP) server (default port 6277). Open the MCPI client UI in your browser to use the inspector. (These ports are derived from the T9 dialpad mapping of MCPI and MCPP respectively, as a mnemonic). You can customize the ports if needed: +Requires Node `>=22.19.0`. ```bash -CLIENT_PORT=8080 SERVER_PORT=9000 npx @modelcontextprotocol/inspector node build/index.js -``` - -For more details on ways to use the inspector, see the [Inspector section of the MCP docs site](https://modelcontextprotocol.io/docs/tools/inspector). For help with debugging, see the [Debugging guide](https://modelcontextprotocol.io/docs/tools/debugging). - -### Servers File Export - -The MCP Inspector provides convenient buttons to export server launch configurations for use in clients such as Cursor, Claude Code, or the Inspector's CLI. The file is usually called `mcp.json`. - -- **Server Entry** - Copies a single server configuration entry to your clipboard. This can be added to your `mcp.json` file inside the `mcpServers` object with your preferred server name. - - **STDIO transport example:** - - ```json - { - "command": "node", - "args": ["build/index.js", "--debug"], - "env": { - "API_KEY": "your-api-key", - "DEBUG": "true" - } - } - ``` - - **SSE transport example:** - - ```json - { - "type": "sse", - "url": "http://localhost:3000/events", - "note": "For SSE connections, add this URL directly in Client" - } - ``` - - **Streamable HTTP transport example:** - - ```json - { - "type": "streamable-http", - "url": "http://localhost:3000/mcp", - "note": "For Streamable HTTP connections, add this URL directly in your MCP Client" - } - ``` - -- **Servers File** - Copies a complete MCP configuration file structure to your clipboard, with your current server configuration added as `default-server`. This can be saved directly as `mcp.json`. - - **STDIO transport example:** - - ```json - { - "mcpServers": { - "default-server": { - "command": "node", - "args": ["build/index.js", "--debug"], - "env": { - "API_KEY": "your-api-key", - "DEBUG": "true" - } - } - } - } - ``` - - **SSE transport example:** - - ```json - { - "mcpServers": { - "default-server": { - "type": "sse", - "url": "http://localhost:3000/events", - "note": "For SSE connections, add this URL directly in Client" - } - } - } - ``` - - **Streamable HTTP transport example:** - - ```json - { - "mcpServers": { - "default-server": { - "type": "streamable-http", - "url": "http://localhost:3000/mcp", - "note": "For Streamable HTTP connections, add this URL directly in your MCP Client" - } - } - } - ``` - -These buttons appear in the Inspector UI after you've configured your server settings, making it easy to save and reuse your configurations. - -For SSE and Streamable HTTP transport connections, the Inspector provides similar functionality for both buttons. The "Server Entry" button copies the configuration that can be added to your existing configuration file, while the "Servers File" button creates a complete configuration file containing the URL for direct use in clients. - -You can paste the Server Entry into your existing `mcp.json` file under your chosen server name, or use the complete Servers File payload to create a new configuration file. - -### Authentication - -The inspector supports bearer token authentication for SSE connections. Enter your token in the UI when connecting to an MCP server, and it will be sent in the Authorization header. You can override the header name using the input field in the sidebar. - -### Security Considerations - -The MCP Inspector includes a proxy server that can run and communicate with local MCP processes. The proxy server should not be exposed to untrusted networks as it has permissions to spawn local processes and can connect to any specified MCP server. - -#### Authentication - -The MCP Inspector proxy server requires authentication by default. When starting the server, a random session token is generated and printed to the console: - +npm install # root install; postinstall cascades into every client ``` -🔑 Session token: 3a1c267fad21f7150b7d624c160b7f09b0b8c4f623c7107bbf13378f051538d4 - -🔗 Open inspector with token pre-filled: - http://localhost:6274/?MCP_PROXY_AUTH_TOKEN=3a1c267fad21f7150b7d624c160b7f09b0b8c4f623c7107bbf13378f051538d4 -``` - -This token must be included as a Bearer token in the Authorization header for all requests to the server. The inspector will automatically open your browser with the token pre-filled in the URL. -**Automatic browser opening** - The inspector now automatically opens your browser with the token pre-filled in the URL when authentication is enabled. +- **Fresh clone:** run `npm install` at the repo root. +- **After a pull that changes a client's dependencies:** re-run `npm install` at the root to re-sync every client. -**Alternative: Manual configuration** - If you already have the inspector open: +The cascade (`scripts/install-clients.mjs`) is dev-only — it exits early when the package is installed as a dependency, and the published tarball ships only each client's `build/`, so end users are unaffected. Set `INSPECTOR_SKIP_CLIENT_INSTALL=1` to skip it. -1. Click the "Configuration" button in the sidebar -2. Find "Proxy Session Token" and enter the token displayed in the proxy console -3. Click "Save" to apply the configuration +## Running during development -The token will be saved in your browser's local storage for future use. - -If you need to disable authentication (NOT RECOMMENDED), you can set the `DANGEROUSLY_OMIT_AUTH` environment variable: +For day-to-day web iteration, run Vite directly from the web client (fast HMR, no launcher build needed): ```bash -DANGEROUSLY_OMIT_AUTH=true npm start +cd clients/web && npm run dev ``` ---- - -**🚨 WARNING 🚨** - -Disabling authentication with `DANGEROUSLY_OMIT_AUTH` is incredibly dangerous! Disabling auth leaves your machine open to attack not just when exposed to the public internet, but also **via your web browser**. Meaning, visiting a malicious website OR viewing a malicious advertizement could allow an attacker to remotely compromise your computer. Do not disable this feature unless you truly understand the risks. - -Read more about the risks of this vulnerability on Oligo's blog: [Critical RCE Vulnerability in Anthropic MCP Inspector - CVE-2025-49596](https://www.oligo.security/blog/critical-rce-vulnerability-in-anthropic-mcp-inspector-cve-2025-49596) - ---- - -You can also set the token via the `MCP_PROXY_AUTH_TOKEN` environment variable when starting the server: +The launcher-driven scripts below run the **built** launcher, so build first (`npm run build`): ```bash -MCP_PROXY_AUTH_TOKEN=$(openssl rand -hex 32) npm start +npm run web # prod web launcher against clients/web/dist +npm run web:dev # web launcher in --dev mode (Vite) ``` -#### Local-only Binding +## The `@inspector/core` shared package -By default, both the MCP Inspector proxy server and client bind only to `localhost` to prevent network access. This ensures they are not accessible from other devices on the network. If you need to bind to all interfaces for development purposes, you can override this with the `HOST` environment variable: +![Shared code architecture: the four clients over the @inspector/core shared package](specification/diagrams/shared-code-architecture.png) -```bash -HOST=0.0.0.0 npm start -``` +`core/` holds the logic shared by all three clients so that web, CLI, and TUI behave identically. Its entry point is the **`InspectorClient`** class (`core/mcp/`), which owns the connection to an MCP server, the request/response lifecycle, and a set of state stores; `core/react/` exposes React hooks over those stores that both the web and TUI (Ink) React trees consume. OAuth (`core/auth/`) is factored into isomorphic logic plus browser/node/remote backends so the same flows work in the browser, in Node, and against a remote backend. -**Warning:** Only bind to all interfaces in trusted network environments, as this exposes the proxy server's ability to execute local processes and both services to network access. +`core/` intentionally has **no `package.json`** — it is not published on its own. Each client bundles it in via a `@inspector/core` alias: -#### DNS Rebinding Protection +- **CLI / TUI:** `esbuildOptions.alias` in their `tsup.config.ts` maps `@inspector/core` → the repo `core/` directory, and `noExternal: [/^@inspector\/core/]` inlines it into the bundle. +- **Web:** the same alias in `clients/web/vite.config.ts` for the browser app and the Node backend runner. -To prevent DNS rebinding attacks, the MCP Inspector validates the `Origin` header on incoming requests. By default, only requests from the client origin are allowed (respects `CLIENT_PORT` if set, defaulting to port 6274). You can configure additional allowed origins by setting the `ALLOWED_ORIGINS` environment variable (comma-separated list): +Publishing `core/` as its own package (e.g. for third parties to build on) is deliberately deferred — see issue [#1636](https://github.com/modelcontextprotocol/inspector/issues/1636). -```bash -ALLOWED_ORIGINS=http://localhost:6274,http://localhost:8000 npm start -``` +## Web client: "dumb components" + Storybook -### Configuration +The v2 web client is built from **presentational ("dumb") components** — they accept data and callbacks as props and contain only display logic, with no direct data fetching or client state. State comes from the `@inspector/core` hooks, wired in near the top of the tree. This keeps components isolated, testable, and documentable. -The MCP Inspector supports the following configuration settings. To change them, click on the `Configuration` button in the MCP Inspector UI: +That approach is what makes **Storybook** first-class here: every screen and element component has a `*.stories.tsx` file (96+ stories) that renders it against fixture props. Storybook **play functions** double as interaction tests, run headless in CI (`npm run ci:storybook`, Chromium via Playwright). -| Setting | Description | Default | -| --------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | ------- | -| `MCP_SERVER_REQUEST_TIMEOUT` | Client-side timeout (ms) - Inspector will cancel the request if no response is received within this time. Note: servers may have their own timeouts | 300000 | -| `MCP_REQUEST_TIMEOUT_RESET_ON_PROGRESS` | Reset timeout on progress notifications | true | -| `MCP_REQUEST_MAX_TOTAL_TIMEOUT` | Maximum total timeout for requests sent to the MCP server (ms) (Use with progress notifications) | 60000 | -| `MCP_PROXY_FULL_ADDRESS` | Set this if you are running the MCP Inspector Proxy on a non-default address. Example: http://10.1.1.22:5577 | "" | -| `MCP_AUTO_OPEN_ENABLED` | Enable automatic browser opening when inspector starts (works with authentication enabled). Only as environment var, not configurable in browser. | true | +Styling follows a strict Mantine-first convention (theme variants and component props over CSS classes, `--inspector-*` CSS custom properties over raw color literals). The full rules live in [`AGENTS.md`](./AGENTS.md) under **React instructions** — read them before touching web UI. Element components live in `clients/web/src/components/elements/`; theme variants in `clients/web/src/theme/`. -**Note on Timeouts:** The timeout settings above control when the Inspector (as an MCP client) will cancel requests. These are independent of any server-side timeouts. For example, if a server tool has a 10-minute timeout but the Inspector's timeout is set to 30 seconds, the Inspector will cancel the request after 30 seconds. Conversely, if the Inspector's timeout is 10 minutes but the server times out after 30 seconds, you'll receive the server's timeout error. For tools that require user interaction (like elicitation) or long-running operations, ensure the Inspector's timeout is set appropriately. +## Test servers -These settings can be adjusted in real-time through the UI and will persist across sessions. +`test-servers/` provides **composable MCP servers** used by the integration and smoke suites, so tests exercise a real server over a real transport instead of mocks. A server is assembled from **presets** (fixture factories in `test-servers/src/preset-registry.ts` — tools, resources, prompts, tasks, elicitation, sampling, OAuth, …) and can be driven two ways: -The inspector also supports configuration files to store settings for different MCP servers. This is useful when working with multiple servers or complex configurations: +- **In-process** — import the factories (`createTestServerHttp`, `createEchoTool`, …) and run the server inside the test's event loop (used by the HTTP integration paths). +- **As a subprocess** — `test-servers/build/test-server-stdio.js` is spawned as a real stdio child (used by the CLI smoke and stdio integration tests). + +Configure a server declaratively with a JSON config (see `test-servers/configs/*.json`) selecting presets, then load it via `--config`. Because the servers are spawned as real subprocesses, the build output must exist first: ```bash -npx @modelcontextprotocol/inspector --config path/to/config.json --server everything +npm run test-servers:build # (from clients/web) → tsc -p test-servers, emits test-servers/build/ ``` -Example server configuration file: - -```json -{ - "mcpServers": { - "everything": { - "command": "npx", - "args": ["@modelcontextprotocol/server-everything"], - "env": { - "hello": "Hello MCP!" - } - }, - "my-server": { - "command": "node", - "args": ["build/index.js", "arg1", "arg2"], - "env": { - "key": "value", - "key2": "value2" - } - } - } -} -``` +The Vite alias `@modelcontextprotocol/inspector-test-server` (in `clients/web/vite.config.ts`) points at `test-servers/build/index.js` so `getTestMcpServerPath()` resolves to a real `.js` path. -#### Transport Types in Config Files +A streamable-HTTP server can also serve the **modern (2026-07-28) protocol era** via the SDK's `createMcpHandler` — set `transport.modern` in the JSON config (`true` for dual-era stateless serving, or `{ "legacy": "reject" }` for modern-only strict), or pass `modern` on the `ServerConfig` for an in-process `createTestServerHttp`. This is what lets an Inspector connection negotiating `protocolEra: "auto" | "modern"` reach the modern leg (populated `server/discover`, sessionless). See `test-servers/configs/modern-http.json`. `test-servers/configs/modern-mrtr-http.json` additionally 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 MRTR round-trip (`input_required` → the client fulfils the embedded elicitation and retries with a new id → `complete`). The Inspector drives MRTR manually (`inputRequired: { autoFulfill: false }`), so the embedded elicitation pauses at the pending-request modal (tagged "input_required") for you to answer, then the retry completes — useful for eyeballing both that pending-request UX and the Protocol view's MRTR conversation grouping. `test-servers/configs/mrtr-showcase-http.json` bundles every MRTR preset in one modern server for manual testing: `mrtr_confirm` (single round), `mrtr_two_step` (two elicitation rounds via `requestState`), `mrtr_sample` (embedded sampling → the Sampling panel), `mrtr_roots` (embedded `roots/list`, auto-answered silently from configured roots — no modal), `mrtr_edge` (an `inputRequests`-only round then a `requestState`-only round), and `mrtr_loop` (never completes → the `MRTR_MAX_ROUNDS` bound trips). (The legacy `collect_elicitation` preset calls `server.elicitInput`, which errors on the 2026-07-28 leg — server→client requests aren't allowed there; MRTR is the modern replacement.) -The inspector automatically detects the transport type from your config file. You can specify different transport types: +`test-servers/configs/modern-network-http.json` is the **Network-tab showcase** for the standardized HTTP headers and new error taxonomy (SEP-2243 / SEP-2575). It serves a `get_weather` tool whose `city` argument carries an `x-mcp-header: "City"` annotation (so a modern client mirrors it to `Mcp-Param-City`), plus four `trigger_*` tools that the modern leg's spec-error injector (`transport.modern.injectSpecErrors: true`) answers with a real HTTP status + JSON-RPC error body: `trigger_header_mismatch` → `400 / -32020`, `trigger_missing_capability` → `400 / -32021`, `trigger_unsupported_version` → `400 / -32022` (with `data.supported`), `trigger_method_not_found` → `404 / -32601`. Connect to it with **Protocol Era = Modern** and open the Network tab to see the mirrored `Mcp-*` headers highlighted, sentinel values decoded, and each error rendered distinctly. Note: `Mcp-Param-*` mirroring is **skipped by the SDK in the browser** (`detectProbeEnvironment() !== "browser"`), so calling `get_weather` from the **web** client omits `Mcp-Param-City` and the strict server answers `-32020` — the same tool is callable from the Node CLI/TUI, where mirroring is active. -**STDIO (default):** +`test-servers/configs/xmcpheader-modern-http.json` is the **`x-mcp-header` Tools-tab showcase** (#1632). It serves `echo`, a `get_weather` tool with a **valid** `x-mcp-header: "City"` annotation on its `city` argument, an `invalid_header_tool` whose annotation uses the header name `"Bad Header"` (a space makes it an invalid RFC 9110 token, so the whole tool definition is invalid), and a `trigger_invalid_params` tool that the modern leg's spec-error injector (`transport.modern.injectSpecErrors: true`) answers with a real `-32602 Invalid params` JSON-RPC error whose message is not about a missing tool. Connect with **Protocol Era = Modern** and open the Tools tab: `get_weather`'s detail panel shows a **"Mirrored request headers (SEP-2243)"** section (`city → Mcp-Param-City`), and `invalid_header_tool` appears struck-through under an **"Excluded (SEP-2243)"** divider in the sidebar with the reason on hover (a conforming Streamable HTTP client MUST drop it from `tools/list`; the Inspector surfaces _why_). Under SDK v2 a `tools/call` that rejects with **`-32602`** now renders as a distinct error panel rather than an `isError` result — headed **"Unknown Tool"** when the message names a missing tool (reproduce by calling a tool the server dropped from its list), or **"Invalid Parameters"** for any other `-32602` (run `trigger_invalid_params`). -```json -{ - "mcpServers": { - "my-stdio-server": { - "type": "stdio", - "command": "npx", - "args": ["@modelcontextprotocol/server-everything"] - } - } -} -``` +`test-servers/configs/pagination-http.json` is the **page-by-page fetch showcase** (#1721). It serves 12 tools, 12 resources, and 12 prompts (presets `numbered_tools` / `numbered_resources` / `numbered_prompts`, `count: 12`) with `maxPageSize` of 4 for each, so every list paginates into three pages. Turn on **"Fetch Lists One Page at a Time"** (Server Settings — the `paginatedLists` setting, or the **Paginated** switch in a list sidebar) and the Tools/Resources/Prompts lists load page 1 only (4 items) with a **Load next page** control and an _N pages loaded_ status; each click fetches the next 4 and appends them, and Refresh resets to page 1. With the switch off (the default), the same lists auto-aggregate all three pages on connect. -**SSE (Server-Sent Events):** - -```json -{ - "mcpServers": { - "my-sse-server": { - "type": "sse", - "url": "http://localhost:3000/sse" - } - } -} -``` +`test-servers/configs/advertised-extensions-http.json` is the **advertised-extensions showcase** (#1739). It serves `echo` (always) and a `get_weather` tool that is **gated on the `io.modelcontextprotocol/tasks` extension** (`extensionGatedTools`): the tool is registered but starts disabled, and the server enables it on `notifications/initialized` only when the connected client declared that extension in its `capabilities.extensions`. Connect (the Inspector advertises the Tasks extension by default) and the Tools list shows both `echo` and `get_weather`. Open **Server Settings → Advertised Extensions**, uncheck **Tasks (io.modelcontextprotocol/tasks)**, and reconnect: the client now advertises no extensions, the server never enables `get_weather`, and the Tools list shows only `echo`. This demonstrates the debugging knob — a server legitimately changing tool registration on what the client advertises. (Legacy stateful leg only; the modern per-request leg has no persistent `oninitialized`.) -**Streamable HTTP:** - -```json -{ - "mcpServers": { - "my-http-server": { - "type": "streamable-http", - "url": "http://localhost:3000/mcp" - } - } -} -``` +`test-servers/configs/logging-legacy-http.json` and `test-servers/configs/logging-modern-http.json` are the **logging era-fork showcase** (#1629). 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 (`logging/setLevel` era) and the modern one sets `transport.modern: true`. Connect to the legacy server and open the **Logs** tab to get the session-scoped **Set Active Level** selector + **Set** button; calling `send_notification` streams the log into the panel. Connect to the modern one with **Protocol Era = Modern** and the same tab instead shows the **Log Level per Request** control — pick a level to opt in and the client stamps `_meta["io.modelcontextprotocol/logLevel"]` on every subsequent request (verify in the Network tab's request body); calling `send_notification` then streams the log into the panel over the request's SSE response. Set the control back to **Off** and the same call is silently gated — the request omits the `logLevel` key, so the log never arrives. That gating is faithful to the spec ("a server MUST NOT emit `notifications/message` for a request that didn't opt in") because `send_notification` emits through the SDK's request-scoped, threshold-aware `extra.log` (`ctx.mcpReq.log`): on the modern leg it reads the per-request `logLevel` opt-in from the request envelope and drops the message when the client didn't opt in or the level is below the requested severity; on legacy it honors the session level from `logging/setLevel`. Because it emits through the request's `notify`, the modern response upgrades to SSE and the log rides the originating request's stream. -#### Default Server Selection +`test-servers/configs/subscriptions-legacy-http.json` and `test-servers/configs/subscriptions-modern-http.json` are the **resource-subscription era-fork showcase** (#1630). Both serve three `numbered_resources` with `subscriptions: true`; the legacy one also serves an `update_resource` tool, and the modern one sets `transport.modern: true`. Connect to the **legacy** server, open a resource in the **Resources** tab and click **Subscribe** — the client sends `resources/subscribe` (Network/Protocol view) and the Subscriptions section lists the URI with no stream chrome; call `update_resource` with that URI and the server updates the content and emits `notifications/resources/updated`, stamping the subscribed tile's last-updated time. Connect to the **modern** server with **Protocol Era = Modern** and the same Subscribe instead sends **`subscriptions/listen`** (its filter carries `resourceSubscriptions` + the `resourcesListChanged` opt-in) and resolves on `notifications/subscriptions/acknowledged`; the Subscriptions section then shows the stream-status badge (`Connecting…` → `Listening`) in its header, and if the long-lived stream drops it reconnects by re-listing. The modern config deliberately **omits** `update_resource`: the SDK's modern leg is stateless/per-request (`createMcpHandler(() => createMcpServer(config))`), so the tool would run against a throwaway server instance — the content change wouldn't persist for the next `resources/read`, and its `resources/updated` wouldn't reach the (separate) listen stream — which is more confusing than useful. The live update-notification round-trip is therefore demonstrated on the legacy (stateful-session) server; the modern server is for the subscribe/listen/badge behavior. (The Inspector's _receive_ path is era-transparent, so a real stateful modern server that routes `resources/updated` onto the listen stream drives the subscribed tile the same way.) -You can launch the inspector without specifying a server name if your config has: +`test-servers/configs/tasks-legacy-http.json` and `test-servers/configs/tasks-modern-http.json` are the **Tasks era-fork showcase** (#1631). The legacy server advertises `capabilities.tasks` (`tasks: { list, cancel }`) with the `simple_task` / `progress_task` / `elicitation_task` presets — connect to it, run one of those tools with **Run as task** on, and the **Tasks** tab lists it (populated via `tasks/list`), polls `tasks/get`, fetches the payload with the blocking `tasks/result`, and cancels with `tasks/cancel`. The modern server sets `transport.modern: true` and `tasksExtension: true`, advertising the `io.modelcontextprotocol/tasks` extension (SEP-2663) and serving the `modern_task` / `modern_input_task` tools. Connect with **Protocol Era = Modern**: the **Tasks** tab is now gated on the negotiated extension (not `capabilities.tasks`). Run `modern_task` as a task — the `tools/call` returns a `CreateTaskResult` (`resultType: "task"`, visible in the Protocol/Network tabs), the client polls **`tasks/get`** (no `tasks/list`), and the completed task inlines its result (no blocking `tasks/result`). Run `modern_input_task` and the task moves to `input_required`, surfacing an embedded elicitation through the pending-request modal; answering it sends **`tasks/update`** with the `inputResponses`, and the next poll completes. SDK v2 removed all tasks support **and** era-gates the `tasks/*` spec methods out of the modern era on both sides — so the Inspector drives the extension itself (the `resultType: "task"` frame is rewritten at the transport into a `CallToolResult` carrying the handle; `tasks/get`/`update`/`cancel` ride a raw-wire request channel with the full modern envelope), and the test server serves `tasks/*` from an Express interceptor ahead of the SDK handler (the SDK's modern leg would answer them `-32601`). The Tasks tab's **Refresh** re-polls the handles already known to the client (modern has no server-side task list). -1. **A single server** - automatically selected: +## Building ```bash -# Automatically uses "my-server" if it's the only one -npx @modelcontextprotocol/inspector --config mcp.json +npm run build # builds all clients: web → cli → tui → launcher ``` -2. **A server named "default-server"** - automatically selected: - -```json -{ - "mcpServers": { - "default-server": { - "command": "npx", - "args": ["@modelcontextprotocol/server-everything"] - }, - "other-server": { - "command": "node", - "args": ["other.js"] - } - } -} -``` +Individual clients: `build:web`, `build:cli`, `build:tui`, `build:launcher`. The web build produces both the browser SPA (`clients/web/dist`, Vite) and the Node prod-server runner (`clients/web/build`, tsup). -> **Tip:** You can easily generate this configuration format using the **Server Entry** and **Servers File** buttons in the Inspector UI, as described in the Servers File Export section above. +## Testing & the quality gate -You can also set the initial `transport` type, `serverUrl`, `serverCommand`, and `serverArgs` via query params, for example: +Each client self-validates from its own folder; the root scripts chain them. There is **no** aggregate root `test` script — use `validate` (fast) or `coverage` (the gate). -``` -http://localhost:6274/?transport=sse&serverUrl=http://localhost:8787/sse -http://localhost:6274/?transport=streamable-http&serverUrl=http://localhost:8787/mcp -http://localhost:6274/?transport=stdio&serverCommand=npx&serverArgs=arg1%20arg2 -``` +| Script | What it does | +| --------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `npm run validate` | Runs `verify:format-coverage` (asserts every tracked source file is format-gated) first, then `validate:core` (the shared `core/` `format:check` + `lint` gate), then per client: `format:check` + `lint` + **`typecheck`** (cli/tui only) + `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 a headless-Chromium 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). | +| `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: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` + the exported helpers of `verify-typecheck-coverage.mjs`), one case per rule they encode. 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 ci` | **Mandatory pre-push command.** `validate` → `coverage` → `verify:build-gate` → `smoke` → Storybook. A true superset of GitHub CI. | +| `npm run pack:verify` | Publish smoke — see [Publishing](#publishing). | -You can also set initial config settings via query params, for example: +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. -``` -http://localhost:6274/?MCP_SERVER_REQUEST_TIMEOUT=60000&MCP_REQUEST_TIMEOUT_RESET_ON_PROGRESS=false&MCP_PROXY_FULL_ADDRESS=http://10.1.1.22:5577 -``` +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). -Note that if both the query param and the corresponding localStorage item are set, the query param will take precedence. +## Publishing -### From this repository +The root `@modelcontextprotocol/inspector` package ships as **one tarball with a single version number** — no separate `-web` / `-cli` / `-tui` / `-core` packages. `npm run build` builds every client, then `prepack` runs before `npm publish`. Runtime dependencies are declared on the root `package.json`; client builds bundle `@inspector/core` and externalize npm packages resolved from the root install. -If you're working on the inspector itself: +### What ships, and the packaging invariants -Development mode: +The root `package.json` `"files"` allowlist is the source of truth for the tarball. A few non-obvious entries exist because they are read **at runtime** or were silently dropped by npm's packlist — do not remove them without re-running `npm run pack:verify`: -```bash -npm run dev +- **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`. +- **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)). -# To co-develop with the typescript-sdk package (assuming it's cloned in ../typescript-sdk; set MCP_SDK otherwise): -npm run dev:sdk "cd sdk && npm run examples:simple-server:w" -# then open http://localhost:3000/mcp as SHTTP in the inspector. -# To go back to the deployed SDK version: -# npm run unlink:sdk && npm i -``` +### `npm run pack:verify` — publish smoke against the real tarball -> **Note for Windows users:** -> On Windows, use the following command instead: -> -> ```bash -> npm run dev:windows -> ``` +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. -Production mode: +### Cutting a release -```bash -npm run build -npm start -``` +Publishing is automated by two release-gated jobs in [`.github/workflows/main.yml`](.github/workflows/main.yml) (`github.event_name == 'release'`, both `needs: build`): -### CLI Mode +- **`publish`** — the npm package. Runs `npm run pack:verify` as the pre-publish gate, asserts the release tag matches the root `package.json` version, then `npm publish --access public --provenance` — a single `npm publish` (v2 is not an npm workspace, so there is no v1-style `publish-all`/`--workspaces`), with a signed provenance attestation via GitHub OIDC (`id-token: write`, `environment: release`, `NPM_TOKEN`). +- **`publish-github-container-registry`** — the container image (see [Docker](#docker)). -CLI mode enables programmatic interaction with MCP servers from the command line, ideal for scripting, automation, and integration with coding assistants. This creates an efficient feedback loop for MCP server development. +Because there is **one version number** (only the root `package.json` has one — the clients carry none, so there is nothing to keep in sync and no `check-version` step), the release flow is just: ```bash -npx @modelcontextprotocol/inspector --cli node build/index.js +npm version # bumps the root package.json + tags +git push --follow-tags +# then draft & publish a GitHub Release for that tag → triggers `publish` ``` -The CLI mode supports most operations across tools, resources, and prompts. A few examples: - -```bash -# Basic usage -npx @modelcontextprotocol/inspector --cli node build/index.js - -# With config file -npx @modelcontextprotocol/inspector --cli --config path/to/config.json --server myserver - -# List available tools -npx @modelcontextprotocol/inspector --cli node build/index.js --method tools/list - -# Call a specific tool -npx @modelcontextprotocol/inspector --cli node build/index.js --method tools/call --tool-name mytool --tool-arg key=value --tool-arg another=value2 - -# Call a tool with JSON arguments -npx @modelcontextprotocol/inspector --cli node build/index.js --method tools/call --tool-name mytool --tool-arg 'options={"format": "json", "max_tokens": 100}' +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. -# List available resources -npx @modelcontextprotocol/inspector --cli node build/index.js --method resources/list +### Docker -# List available prompts -npx @modelcontextprotocol/inspector --cli node build/index.js --method prompts/list +A container image is published to GHCR (`ghcr.io/modelcontextprotocol/inspector`, `linux/amd64` + `linux/arm64`) by the release workflow. The [`Dockerfile`](Dockerfile) is a two-stage build: the first stage installs and `npm pack`s the publishable tarball; the second stage `npm install -g`s that tarball, so the image ships the exact same artifact as npm, with a clean `mcp-inspector` bin. -# Connect to a remote MCP server (default is SSE transport) -npx @modelcontextprotocol/inspector --cli https://my-mcp-server.example.com - -# Connect to a remote MCP server (with Streamable HTTP transport) -npx @modelcontextprotocol/inspector --cli https://my-mcp-server.example.com --transport http --method tools/list - -# Connect to a remote MCP server (with custom headers) -npx @modelcontextprotocol/inspector --cli https://my-mcp-server.example.com --transport http --method tools/list --header "X-API-Key: your-api-key" - -# Call a tool on a remote server -npx @modelcontextprotocol/inspector --cli https://my-mcp-server.example.com --method tools/call --tool-name remotetool --tool-arg param=value +```bash +# run the web UI (reads the auth token from the container logs) +docker run --rm -p 6274:6274 ghcr.io/modelcontextprotocol/inspector -# List resources from a remote server -npx @modelcontextprotocol/inspector --cli https://my-mcp-server.example.com --method resources/list +# or build the image locally +docker build -t mcp-inspector . +docker run --rm -p 6274:6274 mcp-inspector ``` -### UI Mode vs CLI Mode: When to Use Each - -| Use Case | UI Mode | CLI Mode | -| ------------------------ | ------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | -| **Server Development** | Visual interface for interactive testing and debugging during development | Scriptable commands for quick testing and continuous integration; creates feedback loops with AI coding assistants like Cursor for rapid development | -| **Resource Exploration** | Interactive browser with hierarchical navigation and JSON visualization | Programmatic listing and reading for automation and scripting | -| **Tool Testing** | Form-based parameter input with real-time response visualization | Command-line tool execution with JSON output for scripting | -| **Prompt Engineering** | Interactive sampling with streaming responses and visual comparison | Batch processing of prompts with machine-readable output | -| **Debugging** | Request history, visualized errors, and real-time notifications | Direct JSON output for log analysis and integration with other tools | -| **Automation** | N/A | Ideal for CI/CD pipelines, batch processing, and integration with coding assistants | -| **Learning MCP** | Rich visual interface helps new users understand server capabilities | Simplified commands for focused learning of specific endpoints | +The image defaults to `--web` bound to `0.0.0.0:6274` with browser auto-open disabled; override the args to run another mode (`docker run --rm ghcr.io/modelcontextprotocol/inspector --cli …`). Pass `-e MCP_INSPECTOR_API_TOKEN=…` to set a known token (otherwise one is generated and printed in the logs), or `-e DANGEROUSLY_OMIT_AUTH=true` to disable auth. Binding `0.0.0.0` (all network interfaces) is refused by default outside a container — it exposes the process-spawning backend to the local network — so the image opts in explicitly with `DANGEROUSLY_BIND_ALL_INTERFACES=true` (already set in the `Dockerfile`); a bare `HOST=0.0.0.0` without that flag exits with an error. If you **remap the published port** (`-p 8080:6274`), the browser's origin (`http://localhost:8080`) no longer matches the in-container port, so set `-e ALLOWED_ORIGINS=http://localhost:8080,http://127.0.0.1:8080` (or run `-e CLIENT_PORT=8080 -p 8080:8080`) or connects will 403. `ALLOWED_ORIGINS` **replaces** the default list rather than merging, so list every loopback form you'll browse from (see the [web README](./clients/web/README.md#host-binding--the-origin-allow-list)). The image runs as the non-root `node` user and has a `HEALTHCHECK` that probes the web UI — it assumes the default `--web` mode, so add `--no-healthcheck` when running `--cli`/`--tui` (which have no web server). -## Tool Input Validation Guidelines +## Contributing — `AGENTS.md` and `CLAUDE.md` -When implementing or modifying tool input parameter handling in the Inspector: +**[`AGENTS.md`](./AGENTS.md) is the contract for changing this codebase, and it applies to humans and AI agents alike.** It is not agent-only boilerplate — it holds the project's real conventions: the issue-and-board workflow, branch/label rules, the TypeScript and Mantine/React standards, the testing and coverage requirements, and the mandatory pre-push gate. Read it before making changes, and keep it up to date when you change structure, tooling, or rules. -- **Omit optional fields with empty values** - When processing form inputs, omit empty strings or null values for optional parameters, UNLESS the field has an explicit default value in the schema that matches the current value -- **Preserve explicit default values** - If a field schema contains an explicit default (e.g., `default: null`), and the current value matches that default, include it in the request. This is a meaningful value the tool expects -- **Always include required fields** - Preserve required field values even when empty, allowing the MCP server to validate and return appropriate error messages -- **Defer deep validation to the server** - Implement basic field presence checking in the Inspector client, but rely on the MCP server for parameter validation according to its schema +`CLAUDE.md` is the entry point the [Claude Code](https://claude.com/claude-code) agent loads automatically; it simply includes `AGENTS.md` and this README, so both agents and humans work from the same source of truth. If you use a different agent that reads `AGENTS.md`, you get the same rules. -These guidelines maintain clean parameter passing and proper separation of concerns between the Inspector client and MCP servers. +A key rule worth surfacing here: **all work is issue-driven.** Before starting, find or create a tracking issue on the v2 project board; open PRs against `v2/main` with `Closes #`. The exact recipes (labels, board IDs, statuses) are in `AGENTS.md`. ## License -This project is licensed under the MIT License—see the [LICENSE](LICENSE) file for details. +MIT. diff --git a/SECURITY.md b/SECURITY.md deleted file mode 100644 index 502924200..000000000 --- a/SECURITY.md +++ /dev/null @@ -1,21 +0,0 @@ -# Security Policy - -Thank you for helping keep the Model Context Protocol and its ecosystem secure. - -## Reporting Security Issues - -If you discover a security vulnerability in this repository, please report it through -the [GitHub Security Advisory process](https://docs.github.com/en/code-security/security-advisories/guidance-on-reporting-and-writing-information-about-vulnerabilities/privately-reporting-a-security-vulnerability) -for this repository. - -Please **do not** report security vulnerabilities through public GitHub issues, discussions, -or pull requests. - -## What to Include - -To help us triage and respond quickly, please include: - -- A description of the vulnerability -- Steps to reproduce the issue -- The potential impact -- Any suggested fixes (optional) diff --git a/cli/LICENSE b/cli/LICENSE deleted file mode 100644 index 4a9398576..000000000 --- a/cli/LICENSE +++ /dev/null @@ -1,216 +0,0 @@ -The MCP project is undergoing a licensing transition from the MIT License to the Apache License, Version 2.0 ("Apache-2.0"). All new code and specification contributions to the project are licensed under Apache-2.0. Documentation contributions (excluding specifications) are licensed under CC-BY-4.0. - -Contributions for which relicensing consent has been obtained are licensed under Apache-2.0. Contributions made by authors who originally licensed their work under the MIT License and who have not yet granted explicit permission to relicense remain licensed under the MIT License. - -No rights beyond those granted by the applicable original license are conveyed for such contributions. - ---- - - Apache License - Version 2.0, January 2004 - http://www.apache.org/licenses/ - - TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION - - 1. Definitions. - - "License" shall mean the terms and conditions for use, reproduction, - and distribution as defined by Sections 1 through 9 of this document. - - "Licensor" shall mean the copyright owner or entity authorized by - the copyright owner that is granting the License. - - "Legal Entity" shall mean the union of the acting entity and all - other entities that control, are controlled by, or are under common - control with that entity. For the purposes of this definition, - "control" means (i) the power, direct or indirect, to cause the - direction or management of such entity, whether by contract or - otherwise, or (ii) ownership of fifty percent (50%) or more of the - outstanding shares, or (iii) beneficial ownership of such entity. - - "You" (or "Your") shall mean an individual or Legal Entity - exercising permissions granted by this License. - - "Source" form shall mean the preferred form for making modifications, - including but not limited to software source code, documentation - source, and configuration files. - - "Object" form shall mean any form resulting from mechanical - transformation or translation of a Source form, including but - not limited to compiled object code, generated documentation, - and conversions to other media types. - - "Work" shall mean the work of authorship, whether in Source or - Object form, made available under the License, as indicated by a - copyright notice that is included in or attached to the work - (an example is provided in the Appendix below). - - "Derivative Works" shall mean any work, whether in Source or Object - form, that is based on (or derived from) the Work and for which the - editorial revisions, annotations, elaborations, or other modifications - represent, as a whole, an original work of authorship. For the purposes - of this License, Derivative Works shall not include works that remain - separable from, or merely link (or bind by name) to the interfaces of, - the Work and Derivative Works thereof. - - "Contribution" shall mean any work of authorship, including - the original version of the Work and any modifications or additions - to that Work or Derivative Works thereof, that is intentionally - submitted to the Licensor for inclusion in the Work by the copyright - owner or by an individual or Legal Entity authorized to submit on behalf - of the copyright owner. For the purposes of this definition, "submitted" - means any form of electronic, verbal, or written communication sent - to the Licensor or its representatives, including but not limited to - communication on electronic mailing lists, source code control systems, - and issue tracking systems that are managed by, or on behalf of, the - Licensor for the purpose of discussing and improving the Work, but - excluding communication that is conspicuously marked or otherwise - designated in writing by the copyright owner as "Not a Contribution." - - "Contributor" shall mean Licensor and any individual or Legal Entity - on behalf of whom a Contribution has been received by Licensor and - subsequently incorporated within the Work. - - 2. Grant of Copyright License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - copyright license to reproduce, prepare Derivative Works of, - publicly display, publicly perform, sublicense, and distribute the - Work and such Derivative Works in Source or Object form. - - 3. Grant of Patent License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - (except as stated in this section) patent license to make, have made, - use, offer to sell, sell, import, and otherwise transfer the Work, - where such license applies only to those patent claims licensable - by such Contributor that are necessarily infringed by their - Contribution(s) alone or by combination of their Contribution(s) - with the Work to which such Contribution(s) was submitted. If You - institute patent litigation against any entity (including a - cross-claim or counterclaim in a lawsuit) alleging that the Work - or a Contribution incorporated within the Work constitutes direct - or contributory patent infringement, then any patent licenses - granted to You under this License for that Work shall terminate - as of the date such litigation is filed. - - 4. Redistribution. You may reproduce and distribute copies of the - Work or Derivative Works thereof in any medium, with or without - modifications, and in Source or Object form, provided that You - meet the following conditions: - - (a) You must give any other recipients of the Work or - Derivative Works a copy of this License; and - - (b) You must cause any modified files to carry prominent notices - stating that You changed the files; and - - (c) You must retain, in the Source form of any Derivative Works - that You distribute, all copyright, patent, trademark, and - attribution notices from the Source form of the Work, - excluding those notices that do not pertain to any part of - the Derivative Works; and - - (d) If the Work includes a "NOTICE" text file as part of its - distribution, then any Derivative Works that You distribute must - include a readable copy of the attribution notices contained - within such NOTICE file, excluding those notices that do not - pertain to any part of the Derivative Works, in at least one - of the following places: within a NOTICE text file distributed - as part of the Derivative Works; within the Source form or - documentation, if provided along with the Derivative Works; or, - within a display generated by the Derivative Works, if and - wherever such third-party notices normally appear. The contents - of the NOTICE file are for informational purposes only and - do not modify the License. You may add Your own attribution - notices within Derivative Works that You distribute, alongside - or as an addendum to the NOTICE text from the Work, provided - that such additional attribution notices cannot be construed - as modifying the License. - - You may add Your own copyright statement to Your modifications and - may provide additional or different license terms and conditions - for use, reproduction, or distribution of Your modifications, or - for any such Derivative Works as a whole, provided Your use, - reproduction, and distribution of the Work otherwise complies with - the conditions stated in this License. - - 5. Submission of Contributions. Unless You explicitly state otherwise, - any Contribution intentionally submitted for inclusion in the Work - by You to the Licensor shall be under the terms and conditions of - this License, without any additional terms or conditions. - Notwithstanding the above, nothing herein shall supersede or modify - the terms of any separate license agreement you may have executed - with Licensor regarding such Contributions. - - 6. Trademarks. This License does not grant permission to use the trade - names, trademarks, service marks, or product names of the Licensor, - except as required for reasonable and customary use in describing the - origin of the Work and reproducing the content of the NOTICE file. - - 7. Disclaimer of Warranty. Unless required by applicable law or - agreed to in writing, Licensor provides the Work (and each - Contributor provides its Contributions) on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or - implied, including, without limitation, any warranties or conditions - of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A - PARTICULAR PURPOSE. You are solely responsible for determining the - appropriateness of using or redistributing the Work and assume any - risks associated with Your exercise of permissions under this License. - - 8. Limitation of Liability. In no event and under no legal theory, - whether in tort (including negligence), contract, or otherwise, - unless required by applicable law (such as deliberate and grossly - negligent acts) or agreed to in writing, shall any Contributor be - liable to You for damages, including any direct, indirect, special, - incidental, or consequential damages of any character arising as a - result of this License or out of the use or inability to use the - Work (including but not limited to damages for loss of goodwill, - work stoppage, computer failure or malfunction, or any and all - other commercial damages or losses), even if such Contributor - has been advised of the possibility of such damages. - - 9. Accepting Warranty or Additional Liability. While redistributing - the Work or Derivative Works thereof, You may choose to offer, - and charge a fee for, acceptance of support, warranty, indemnity, - or other liability obligations and/or rights consistent with this - License. However, in accepting such obligations, You may act only - on Your own behalf and on Your sole responsibility, not on behalf - of any other Contributor, and only if You agree to indemnify, - defend, and hold each Contributor harmless for any liability - incurred by, or claims asserted against, such Contributor by reason - of your accepting any such warranty or additional liability. - - END OF TERMS AND CONDITIONS - ---- - -MIT License - -Copyright (c) 2024-2025 Model Context Protocol a Series of LF Projects, LLC. - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. - ---- - -Creative Commons Attribution 4.0 International (CC-BY-4.0) - -Documentation in this project (excluding specifications) is licensed under -CC-BY-4.0. See https://creativecommons.org/licenses/by/4.0/legalcode for -the full license text. diff --git a/cli/__tests__/README.md b/cli/__tests__/README.md deleted file mode 100644 index dd3f5ccca..000000000 --- a/cli/__tests__/README.md +++ /dev/null @@ -1,44 +0,0 @@ -# CLI Tests - -## Running Tests - -```bash -# Run all tests -npm test - -# Run in watch mode (useful for test file changes; won't work on CLI source changes without rebuild) -npm run test:watch - -# Run specific test file -npm run test:cli # cli.test.ts -npm run test:cli-tools # tools.test.ts -npm run test:cli-headers # headers.test.ts -npm run test:cli-metadata # metadata.test.ts -``` - -## Test Files - -- `cli.test.ts` - Basic CLI functionality: CLI mode, environment variables, config files, resources, prompts, logging, transport types -- `tools.test.ts` - Tool-related tests: Tool discovery, JSON argument parsing, error handling, prompts -- `headers.test.ts` - Header parsing and validation -- `metadata.test.ts` - Metadata functionality: General metadata, tool-specific metadata, parsing, merging, validation - -## Helpers - -The `helpers/` directory contains shared utilities: - -- `cli-runner.ts` - Spawns CLI as subprocess and captures output -- `test-mcp-server.ts` - Standalone stdio MCP server script for stdio transport testing -- `instrumented-server.ts` - In-process MCP test server for HTTP/SSE transports with request recording -- `assertions.ts` - Custom assertion helpers for CLI output validation -- `fixtures.ts` - Test config file generators and temporary directory management - -## Notes - -- Tests run in parallel across files (Vitest default) -- Tests within a file run sequentially (we have isolated config files and ports, so we could get more aggressive if desired) -- Config files use `crypto.randomUUID()` for uniqueness in parallel execution -- HTTP/SSE servers use dynamic port allocation to avoid conflicts -- Coverage is not used because much of the code that we want to measure is run by a spawned process, so it can't be tracked by Vitest -- /sample-config.json is no longer used by tests - not clear if this file serves some other purpose so leaving it for now -- All tests now use built-in MCP test servers, there are no external dependencies on servers from a registry diff --git a/cli/__tests__/cli.test.ts b/cli/__tests__/cli.test.ts deleted file mode 100644 index b263f618c..000000000 --- a/cli/__tests__/cli.test.ts +++ /dev/null @@ -1,871 +0,0 @@ -import { describe, it, beforeAll, afterAll, expect } from "vitest"; -import { runCli } from "./helpers/cli-runner.js"; -import { - expectCliSuccess, - expectCliFailure, - expectValidJson, -} from "./helpers/assertions.js"; -import { - NO_SERVER_SENTINEL, - createSampleTestConfig, - createTestConfig, - createInvalidConfig, - deleteConfigFile, -} from "./helpers/fixtures.js"; -import { getTestMcpServerCommand } from "./helpers/test-server-stdio.js"; -import { createTestServerHttp } from "./helpers/test-server-http.js"; -import { - createEchoTool, - createTestServerInfo, -} from "./helpers/test-fixtures.js"; - -describe("CLI Tests", () => { - describe("Basic CLI Mode", () => { - it("should execute tools/list successfully", async () => { - const { command, args } = getTestMcpServerCommand(); - const result = await runCli([ - command, - ...args, - "--cli", - "--method", - "tools/list", - ]); - - expectCliSuccess(result); - const json = expectValidJson(result); - expect(json).toHaveProperty("tools"); - expect(Array.isArray(json.tools)).toBe(true); - - // Validate expected tools from test-mcp-server - const toolNames = json.tools.map((tool: any) => tool.name); - expect(toolNames).toContain("echo"); - expect(toolNames).toContain("get-sum"); - expect(toolNames).toContain("get-annotated-message"); - }); - - it("should fail with nonexistent method", async () => { - const result = await runCli([ - NO_SERVER_SENTINEL, - "--cli", - "--method", - "nonexistent/method", - ]); - - expectCliFailure(result); - }); - - it("should fail without method", async () => { - const result = await runCli([NO_SERVER_SENTINEL, "--cli"]); - - expectCliFailure(result); - }); - }); - - describe("Environment Variables", () => { - it("should accept environment variables", async () => { - const { command, args } = getTestMcpServerCommand(); - const result = await runCli([ - command, - ...args, - "-e", - "KEY1=value1", - "-e", - "KEY2=value2", - "--cli", - "--method", - "resources/read", - "--uri", - "test://env", - ]); - - expectCliSuccess(result); - const json = expectValidJson(result); - expect(json).toHaveProperty("contents"); - expect(Array.isArray(json.contents)).toBe(true); - expect(json.contents.length).toBeGreaterThan(0); - - // Parse the env vars from the resource - const envVars = JSON.parse(json.contents[0].text); - expect(envVars.KEY1).toBe("value1"); - expect(envVars.KEY2).toBe("value2"); - }); - - it("should reject invalid environment variable format", async () => { - const result = await runCli([ - NO_SERVER_SENTINEL, - "-e", - "INVALID_FORMAT", - "--cli", - "--method", - "tools/list", - ]); - - expectCliFailure(result); - }); - - it("should handle environment variable with equals sign in value", async () => { - const { command, args } = getTestMcpServerCommand(); - const result = await runCli([ - command, - ...args, - "-e", - "API_KEY=abc123=xyz789==", - "--cli", - "--method", - "resources/read", - "--uri", - "test://env", - ]); - - expectCliSuccess(result); - const json = expectValidJson(result); - const envVars = JSON.parse(json.contents[0].text); - expect(envVars.API_KEY).toBe("abc123=xyz789=="); - }); - - it("should handle environment variable with base64-encoded value", async () => { - const { command, args } = getTestMcpServerCommand(); - const result = await runCli([ - command, - ...args, - "-e", - "JWT_TOKEN=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0=", - "--cli", - "--method", - "resources/read", - "--uri", - "test://env", - ]); - - expectCliSuccess(result); - const json = expectValidJson(result); - const envVars = JSON.parse(json.contents[0].text); - expect(envVars.JWT_TOKEN).toBe( - "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0=", - ); - }); - }); - - describe("Config File", () => { - it("should use config file with CLI mode", async () => { - const configPath = createSampleTestConfig(); - try { - const result = await runCli([ - "--config", - configPath, - "--server", - "test-stdio", - "--cli", - "--method", - "tools/list", - ]); - - expectCliSuccess(result); - const json = expectValidJson(result); - expect(json).toHaveProperty("tools"); - expect(Array.isArray(json.tools)).toBe(true); - expect(json.tools.length).toBeGreaterThan(0); - } finally { - deleteConfigFile(configPath); - } - }); - - it("should fail when using config file without server name", async () => { - const configPath = createSampleTestConfig(); - try { - const result = await runCli([ - "--config", - configPath, - "--cli", - "--method", - "tools/list", - ]); - - expectCliFailure(result); - } finally { - deleteConfigFile(configPath); - } - }); - - it("should fail when using server name without config file", async () => { - const result = await runCli([ - "--server", - "test-stdio", - "--cli", - "--method", - "tools/list", - ]); - - expectCliFailure(result); - }); - - it("should fail with nonexistent config file", async () => { - const result = await runCli([ - "--config", - "./nonexistent-config.json", - "--server", - "test-stdio", - "--cli", - "--method", - "tools/list", - ]); - - expectCliFailure(result); - }); - - it("should fail with invalid config file format", async () => { - // Create invalid config temporarily - const invalidConfigPath = createInvalidConfig(); - try { - const result = await runCli([ - "--config", - invalidConfigPath, - "--server", - "test-stdio", - "--cli", - "--method", - "tools/list", - ]); - - expectCliFailure(result); - } finally { - deleteConfigFile(invalidConfigPath); - } - }); - - it("should fail with nonexistent server in config", async () => { - const configPath = createSampleTestConfig(); - try { - const result = await runCli([ - "--config", - configPath, - "--server", - "nonexistent", - "--cli", - "--method", - "tools/list", - ]); - - expectCliFailure(result); - } finally { - deleteConfigFile(configPath); - } - }); - }); - - describe("Resource Options", () => { - it("should read resource with URI", async () => { - const { command, args } = getTestMcpServerCommand(); - const result = await runCli([ - command, - ...args, - "--cli", - "--method", - "resources/read", - "--uri", - "demo://resource/static/document/architecture.md", - ]); - - expectCliSuccess(result); - const json = expectValidJson(result); - expect(json).toHaveProperty("contents"); - expect(Array.isArray(json.contents)).toBe(true); - expect(json.contents.length).toBeGreaterThan(0); - expect(json.contents[0]).toHaveProperty( - "uri", - "demo://resource/static/document/architecture.md", - ); - expect(json.contents[0]).toHaveProperty("mimeType", "text/markdown"); - expect(json.contents[0]).toHaveProperty("text"); - expect(json.contents[0].text).toContain("Architecture Documentation"); - }); - - it("should fail when reading resource without URI", async () => { - const { command, args } = getTestMcpServerCommand(); - const result = await runCli([ - command, - ...args, - "--cli", - "--method", - "resources/read", - ]); - - expectCliFailure(result); - }); - }); - - describe("Prompt Options", () => { - it("should get prompt by name", async () => { - const { command, args } = getTestMcpServerCommand(); - const result = await runCli([ - command, - ...args, - "--cli", - "--method", - "prompts/get", - "--prompt-name", - "simple-prompt", - ]); - - expectCliSuccess(result); - const json = expectValidJson(result); - expect(json).toHaveProperty("messages"); - expect(Array.isArray(json.messages)).toBe(true); - expect(json.messages.length).toBeGreaterThan(0); - expect(json.messages[0]).toHaveProperty("role", "user"); - expect(json.messages[0]).toHaveProperty("content"); - expect(json.messages[0].content).toHaveProperty("type", "text"); - expect(json.messages[0].content.text).toBe( - "This is a simple prompt for testing purposes.", - ); - }); - - it("should get prompt with arguments", async () => { - const { command, args } = getTestMcpServerCommand(); - const result = await runCli([ - command, - ...args, - "--cli", - "--method", - "prompts/get", - "--prompt-name", - "args-prompt", - "--prompt-args", - "city=New York", - "state=NY", - ]); - - expectCliSuccess(result); - const json = expectValidJson(result); - expect(json).toHaveProperty("messages"); - expect(Array.isArray(json.messages)).toBe(true); - expect(json.messages.length).toBeGreaterThan(0); - expect(json.messages[0]).toHaveProperty("role", "user"); - expect(json.messages[0]).toHaveProperty("content"); - expect(json.messages[0].content).toHaveProperty("type", "text"); - // Verify that the arguments were actually used in the response - expect(json.messages[0].content.text).toContain("city=New York"); - expect(json.messages[0].content.text).toContain("state=NY"); - }); - - it("should fail when getting prompt without name", async () => { - const { command, args } = getTestMcpServerCommand(); - const result = await runCli([ - command, - ...args, - "--cli", - "--method", - "prompts/get", - ]); - - expectCliFailure(result); - }); - }); - - describe("Logging Options", () => { - it("should set log level", async () => { - const server = createTestServerHttp({ - serverInfo: createTestServerInfo(), - logging: true, - }); - - try { - const port = await server.start("http"); - const serverUrl = `${server.getUrl()}/mcp`; - - const result = await runCli([ - serverUrl, - "--cli", - "--method", - "logging/setLevel", - "--log-level", - "debug", - "--transport", - "http", - ]); - - expectCliSuccess(result); - // Validate the response - logging/setLevel should return an empty result - const json = expectValidJson(result); - expect(json).toEqual({}); - - // Validate that the server actually received and recorded the log level - expect(server.getCurrentLogLevel()).toBe("debug"); - } finally { - await server.stop(); - } - }); - - it("should reject invalid log level", async () => { - const { command, args } = getTestMcpServerCommand(); - const result = await runCli([ - command, - ...args, - "--cli", - "--method", - "logging/setLevel", - "--log-level", - "invalid", - ]); - - expectCliFailure(result); - }); - }); - - describe("Combined Options", () => { - it("should handle config file with environment variables", async () => { - const configPath = createSampleTestConfig(); - try { - const result = await runCli([ - "--config", - configPath, - "--server", - "test-stdio", - "-e", - "CLI_ENV_VAR=cli_value", - "--cli", - "--method", - "resources/read", - "--uri", - "test://env", - ]); - - expectCliSuccess(result); - const json = expectValidJson(result); - expect(json).toHaveProperty("contents"); - expect(Array.isArray(json.contents)).toBe(true); - expect(json.contents.length).toBeGreaterThan(0); - - // Parse the env vars from the resource - const envVars = JSON.parse(json.contents[0].text); - expect(envVars).toHaveProperty("CLI_ENV_VAR"); - expect(envVars.CLI_ENV_VAR).toBe("cli_value"); - } finally { - deleteConfigFile(configPath); - } - }); - - it("should handle all options together", async () => { - const configPath = createSampleTestConfig(); - try { - const result = await runCli([ - "--config", - configPath, - "--server", - "test-stdio", - "-e", - "CLI_ENV_VAR=cli_value", - "--cli", - "--method", - "tools/call", - "--tool-name", - "echo", - "--tool-arg", - "message=Hello", - "--log-level", - "debug", - ]); - - expectCliSuccess(result); - const json = expectValidJson(result); - expect(json).toHaveProperty("content"); - expect(Array.isArray(json.content)).toBe(true); - expect(json.content.length).toBeGreaterThan(0); - expect(json.content[0]).toHaveProperty("type", "text"); - expect(json.content[0].text).toBe("Echo: Hello"); - } finally { - deleteConfigFile(configPath); - } - }); - }); - - describe("Config Transport Types", () => { - it("should work with stdio transport type", async () => { - const { command, args } = getTestMcpServerCommand(); - const configPath = createTestConfig({ - mcpServers: { - "test-stdio": { - type: "stdio", - command, - args, - env: { - TEST_ENV: "test-value", - }, - }, - }, - }); - try { - // First validate tools/list works - const toolsResult = await runCli([ - "--config", - configPath, - "--server", - "test-stdio", - "--cli", - "--method", - "tools/list", - ]); - - expectCliSuccess(toolsResult); - const toolsJson = expectValidJson(toolsResult); - expect(toolsJson).toHaveProperty("tools"); - expect(Array.isArray(toolsJson.tools)).toBe(true); - expect(toolsJson.tools.length).toBeGreaterThan(0); - - // Then validate env vars from config are passed to server - const envResult = await runCli([ - "--config", - configPath, - "--server", - "test-stdio", - "--cli", - "--method", - "resources/read", - "--uri", - "test://env", - ]); - - expectCliSuccess(envResult); - const envJson = expectValidJson(envResult); - const envVars = JSON.parse(envJson.contents[0].text); - expect(envVars).toHaveProperty("TEST_ENV"); - expect(envVars.TEST_ENV).toBe("test-value"); - } finally { - deleteConfigFile(configPath); - } - }); - - it("should fail with SSE transport type in CLI mode (connection error)", async () => { - const configPath = createTestConfig({ - mcpServers: { - "test-sse": { - type: "sse", - url: "http://localhost:3000/sse", - note: "Test SSE server", - }, - }, - }); - try { - const result = await runCli([ - "--config", - configPath, - "--server", - "test-sse", - "--cli", - "--method", - "tools/list", - ]); - - expectCliFailure(result); - } finally { - deleteConfigFile(configPath); - } - }); - - it("should fail with HTTP transport type in CLI mode (connection error)", async () => { - const configPath = createTestConfig({ - mcpServers: { - "test-http": { - type: "streamable-http", - url: "http://localhost:3001/mcp", - note: "Test HTTP server", - }, - }, - }); - try { - const result = await runCli([ - "--config", - configPath, - "--server", - "test-http", - "--cli", - "--method", - "tools/list", - ]); - - expectCliFailure(result); - } finally { - deleteConfigFile(configPath); - } - }); - - it("should work with legacy config without type field", async () => { - const { command, args } = getTestMcpServerCommand(); - const configPath = createTestConfig({ - mcpServers: { - "test-legacy": { - command, - args, - env: { - LEGACY_ENV: "legacy-value", - }, - }, - }, - }); - try { - // First validate tools/list works - const toolsResult = await runCli([ - "--config", - configPath, - "--server", - "test-legacy", - "--cli", - "--method", - "tools/list", - ]); - - expectCliSuccess(toolsResult); - const toolsJson = expectValidJson(toolsResult); - expect(toolsJson).toHaveProperty("tools"); - expect(Array.isArray(toolsJson.tools)).toBe(true); - expect(toolsJson.tools.length).toBeGreaterThan(0); - - // Then validate env vars from config are passed to server - const envResult = await runCli([ - "--config", - configPath, - "--server", - "test-legacy", - "--cli", - "--method", - "resources/read", - "--uri", - "test://env", - ]); - - expectCliSuccess(envResult); - const envJson = expectValidJson(envResult); - const envVars = JSON.parse(envJson.contents[0].text); - expect(envVars).toHaveProperty("LEGACY_ENV"); - expect(envVars.LEGACY_ENV).toBe("legacy-value"); - } finally { - deleteConfigFile(configPath); - } - }); - }); - - describe("Default Server Selection", () => { - it("should auto-select single server", async () => { - const { command, args } = getTestMcpServerCommand(); - const configPath = createTestConfig({ - mcpServers: { - "only-server": { - command, - args, - }, - }, - }); - try { - const result = await runCli([ - "--config", - configPath, - "--cli", - "--method", - "tools/list", - ]); - - expectCliSuccess(result); - const json = expectValidJson(result); - expect(json).toHaveProperty("tools"); - expect(Array.isArray(json.tools)).toBe(true); - expect(json.tools.length).toBeGreaterThan(0); - } finally { - deleteConfigFile(configPath); - } - }); - - it("should require explicit server selection even with default-server key (multiple servers)", async () => { - const { command, args } = getTestMcpServerCommand(); - const configPath = createTestConfig({ - mcpServers: { - "default-server": { - command, - args, - }, - "other-server": { - command: "node", - args: ["other.js"], - }, - }, - }); - try { - const result = await runCli([ - "--config", - configPath, - "--cli", - "--method", - "tools/list", - ]); - - expectCliFailure(result); - } finally { - deleteConfigFile(configPath); - } - }); - - it("should require explicit server selection with multiple servers", async () => { - const { command, args } = getTestMcpServerCommand(); - const configPath = createTestConfig({ - mcpServers: { - server1: { - command, - args, - }, - server2: { - command: "node", - args: ["other.js"], - }, - }, - }); - try { - const result = await runCli([ - "--config", - configPath, - "--cli", - "--method", - "tools/list", - ]); - - expectCliFailure(result); - } finally { - deleteConfigFile(configPath); - } - }); - }); - - describe("HTTP Transport", () => { - it("should infer HTTP transport from URL ending with /mcp", async () => { - const server = createTestServerHttp({ - serverInfo: createTestServerInfo(), - tools: [createEchoTool()], - }); - - try { - await server.start("http"); - const serverUrl = `${server.getUrl()}/mcp`; - - const result = await runCli([ - serverUrl, - "--cli", - "--method", - "tools/list", - ]); - - expectCliSuccess(result); - const json = expectValidJson(result); - expect(json).toHaveProperty("tools"); - expect(Array.isArray(json.tools)).toBe(true); - expect(json.tools.length).toBeGreaterThan(0); - } finally { - await server.stop(); - } - }); - - it("should work with explicit --transport http flag", async () => { - const server = createTestServerHttp({ - serverInfo: createTestServerInfo(), - tools: [createEchoTool()], - }); - - try { - await server.start("http"); - const serverUrl = `${server.getUrl()}/mcp`; - - const result = await runCli([ - serverUrl, - "--transport", - "http", - "--cli", - "--method", - "tools/list", - ]); - - expectCliSuccess(result); - const json = expectValidJson(result); - expect(json).toHaveProperty("tools"); - expect(Array.isArray(json.tools)).toBe(true); - expect(json.tools.length).toBeGreaterThan(0); - } finally { - await server.stop(); - } - }); - - it("should work with explicit transport flag and URL suffix", async () => { - const server = createTestServerHttp({ - serverInfo: createTestServerInfo(), - tools: [createEchoTool()], - }); - - try { - await server.start("http"); - const serverUrl = `${server.getUrl()}/mcp`; - - const result = await runCli([ - serverUrl, - "--transport", - "http", - "--cli", - "--method", - "tools/list", - ]); - - expectCliSuccess(result); - const json = expectValidJson(result); - expect(json).toHaveProperty("tools"); - expect(Array.isArray(json.tools)).toBe(true); - expect(json.tools.length).toBeGreaterThan(0); - } finally { - await server.stop(); - } - }); - - it("should fail when SSE transport is given to HTTP server", async () => { - const server = createTestServerHttp({ - serverInfo: createTestServerInfo(), - tools: [createEchoTool()], - }); - - try { - await server.start("http"); - const serverUrl = `${server.getUrl()}/mcp`; - - const result = await runCli([ - serverUrl, - "--transport", - "sse", - "--cli", - "--method", - "tools/list", - ]); - - expectCliFailure(result); - } finally { - await server.stop(); - } - }); - - it("should fail when HTTP transport is specified without URL", async () => { - const result = await runCli([ - "--transport", - "http", - "--cli", - "--method", - "tools/list", - ]); - - expectCliFailure(result); - }); - - it("should fail when SSE transport is specified without URL", async () => { - const result = await runCli([ - "--transport", - "sse", - "--cli", - "--method", - "tools/list", - ]); - - expectCliFailure(result); - }); - }); -}); diff --git a/cli/__tests__/helpers/cli-runner.ts b/cli/__tests__/helpers/cli-runner.ts deleted file mode 100644 index 073aa9ae4..000000000 --- a/cli/__tests__/helpers/cli-runner.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { spawn } from "child_process"; -import { resolve } from "path"; -import { fileURLToPath } from "url"; -import { dirname } from "path"; - -const __dirname = dirname(fileURLToPath(import.meta.url)); -const CLI_PATH = resolve(__dirname, "../../build/cli.js"); - -export interface CliResult { - exitCode: number | null; - stdout: string; - stderr: string; - output: string; // Combined stdout + stderr -} - -export interface CliOptions { - timeout?: number; - cwd?: string; - env?: Record; - signal?: AbortSignal; -} - -/** - * Run the CLI with given arguments and capture output - */ -export async function runCli( - args: string[], - options: CliOptions = {}, -): Promise { - return new Promise((resolve, reject) => { - const child = spawn("node", [CLI_PATH, ...args], { - stdio: ["pipe", "pipe", "pipe"], - cwd: options.cwd, - env: { ...process.env, ...options.env }, - signal: options.signal, - // Kill child process tree on exit - detached: false, - }); - - let stdout = ""; - let stderr = ""; - let resolved = false; - - // Default timeout of 10 seconds (less than vitest's 15s) - const timeoutMs = options.timeout ?? 10000; - const timeout = setTimeout(() => { - if (!resolved) { - resolved = true; - // Kill the process and all its children - try { - if (process.platform === "win32") { - child.kill("SIGTERM"); - } else { - // On Unix, kill the process group - process.kill(-child.pid!, "SIGTERM"); - } - } catch (e) { - // Process might already be dead, try direct kill - try { - child.kill("SIGKILL"); - } catch (e2) { - // Process is definitely dead - } - } - reject(new Error(`CLI command timed out after ${timeoutMs}ms`)); - } - }, timeoutMs); - - child.stdout.on("data", (data) => { - stdout += data.toString(); - }); - - child.stderr.on("data", (data) => { - stderr += data.toString(); - }); - - child.on("close", (code) => { - if (!resolved) { - resolved = true; - clearTimeout(timeout); - resolve({ - exitCode: code, - stdout, - stderr, - output: stdout + stderr, - }); - } - }); - - child.on("error", (error) => { - if (!resolved) { - resolved = true; - clearTimeout(timeout); - reject(error); - } - }); - }); -} diff --git a/cli/__tests__/helpers/test-fixtures.ts b/cli/__tests__/helpers/test-fixtures.ts deleted file mode 100644 index d92d79ae0..000000000 --- a/cli/__tests__/helpers/test-fixtures.ts +++ /dev/null @@ -1,267 +0,0 @@ -/** - * Shared types and test fixtures for composable MCP test servers - */ - -import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import type { Implementation } from "@modelcontextprotocol/sdk/types.js"; -import * as z from "zod/v4"; -import { ZodRawShapeCompat } from "@modelcontextprotocol/sdk/server/zod-compat.js"; - -type ToolInputSchema = ZodRawShapeCompat; - -export interface ToolDefinition { - name: string; - description: string; - inputSchema?: ToolInputSchema; - handler: (params: Record) => Promise; -} - -export interface ResourceDefinition { - uri: string; - name: string; - description?: string; - mimeType?: string; - text?: string; -} - -type PromptArgsSchema = ZodRawShapeCompat; - -export interface PromptDefinition { - name: string; - description?: string; - argsSchema?: PromptArgsSchema; -} - -// This allows us to compose tests servers using the metadata and features we want in a given scenario -export interface ServerConfig { - serverInfo: Implementation; // Server metadata (name, version, etc.) - required - tools?: ToolDefinition[]; // Tools to register (optional, empty array means no tools, but tools capability is still advertised) - resources?: ResourceDefinition[]; // Resources to register (optional, empty array means no resources, but resources capability is still advertised) - prompts?: PromptDefinition[]; // Prompts to register (optional, empty array means no prompts, but prompts capability is still advertised) - logging?: boolean; // Whether to advertise logging capability (default: false) -} - -/** - * Create an "echo" tool that echoes back the input message - */ -export function createEchoTool(): ToolDefinition { - return { - name: "echo", - description: "Echo back the input message", - inputSchema: { - message: z.string().describe("Message to echo back"), - }, - handler: async (params: Record) => { - return { message: `Echo: ${params.message as string}` }; - }, - }; -} - -/** - * Create an "add" tool that adds two numbers together - */ -export function createAddTool(): ToolDefinition { - return { - name: "add", - description: "Add two numbers together", - inputSchema: { - a: z.number().describe("First number"), - b: z.number().describe("Second number"), - }, - handler: async (params: Record) => { - const a = params.a as number; - const b = params.b as number; - return { result: a + b }; - }, - }; -} - -/** - * Create a "get-sum" tool that returns the sum of two numbers (alias for add) - */ -export function createGetSumTool(): ToolDefinition { - return { - name: "get-sum", - description: "Get the sum of two numbers", - inputSchema: { - a: z.number().describe("First number"), - b: z.number().describe("Second number"), - }, - handler: async (params: Record) => { - const a = params.a as number; - const b = params.b as number; - return { result: a + b }; - }, - }; -} - -/** - * Create a "get-annotated-message" tool that returns a message with optional image - */ -export function createGetAnnotatedMessageTool(): ToolDefinition { - return { - name: "get-annotated-message", - description: "Get an annotated message", - inputSchema: { - messageType: z - .enum(["success", "error", "warning", "info"]) - .describe("Type of message"), - includeImage: z - .boolean() - .optional() - .describe("Whether to include an image"), - }, - handler: async (params: Record) => { - const messageType = params.messageType as string; - const includeImage = params.includeImage as boolean | undefined; - const message = `This is a ${messageType} message`; - const content: Array< - | { type: "text"; text: string } - | { type: "image"; data: string; mimeType: string } - > = [ - { - type: "text", - text: message, - }, - ]; - - if (includeImage) { - content.push({ - type: "image", - data: "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==", // 1x1 transparent PNG - mimeType: "image/png", - }); - } - - return { content }; - }, - }; -} - -/** - * Create a "simple-prompt" prompt definition - */ -export function createSimplePrompt(): PromptDefinition { - return { - name: "simple-prompt", - description: "A simple prompt for testing", - }; -} - -/** - * Create an "args-prompt" prompt that accepts arguments - */ -export function createArgsPrompt(): PromptDefinition { - return { - name: "args-prompt", - description: "A prompt that accepts arguments for testing", - argsSchema: { - city: z.string().describe("City name"), - state: z.string().describe("State name"), - }, - }; -} - -/** - * Create an "architecture" resource definition - */ -export function createArchitectureResource(): ResourceDefinition { - return { - name: "architecture", - uri: "demo://resource/static/document/architecture.md", - description: "Architecture documentation", - mimeType: "text/markdown", - text: `# Architecture Documentation - -This is a test resource for the MCP test server. - -## Overview - -This resource is used for testing resource reading functionality in the CLI. - -## Sections - -- Introduction -- Design -- Implementation -- Testing - -## Notes - -This is a static resource provided by the test MCP server. -`, - }; -} - -/** - * Create a "test-cwd" resource that exposes the current working directory (generally useful when testing with the stdio test server) - */ -export function createTestCwdResource(): ResourceDefinition { - return { - name: "test-cwd", - uri: "test://cwd", - description: "Current working directory of the test server", - mimeType: "text/plain", - text: process.cwd(), - }; -} - -/** - * Create a "test-env" resource that exposes environment variables (generally useful when testing with the stdio test server) - */ -export function createTestEnvResource(): ResourceDefinition { - return { - name: "test-env", - uri: "test://env", - description: "Environment variables available to the test server", - mimeType: "application/json", - text: JSON.stringify(process.env, null, 2), - }; -} - -/** - * Create a "test-argv" resource that exposes command-line arguments (generally useful when testing with the stdio test server) - */ -export function createTestArgvResource(): ResourceDefinition { - return { - name: "test-argv", - uri: "test://argv", - description: "Command-line arguments the test server was started with", - mimeType: "application/json", - text: JSON.stringify(process.argv, null, 2), - }; -} - -/** - * Create minimal server info for test servers - */ -export function createTestServerInfo( - name: string = "test-server", - version: string = "1.0.0", -): Implementation { - return { - name, - version, - }; -} - -/** - * Get default server config with common test tools, prompts, and resources - */ -export function getDefaultServerConfig(): ServerConfig { - return { - serverInfo: createTestServerInfo("test-mcp-server", "1.0.0"), - tools: [ - createEchoTool(), - createGetSumTool(), - createGetAnnotatedMessageTool(), - ], - prompts: [createSimplePrompt(), createArgsPrompt()], - resources: [ - createArchitectureResource(), - createTestCwdResource(), - createTestEnvResource(), - createTestArgvResource(), - ], - }; -} diff --git a/cli/__tests__/helpers/test-server-http.ts b/cli/__tests__/helpers/test-server-http.ts deleted file mode 100644 index d5eadc3ff..000000000 --- a/cli/__tests__/helpers/test-server-http.ts +++ /dev/null @@ -1,452 +0,0 @@ -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; -import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js"; -import { SetLevelRequestSchema } from "@modelcontextprotocol/sdk/types.js"; -import type { Request, Response } from "express"; -import express from "express"; -import { createServer as createHttpServer, Server as HttpServer } from "http"; -import { createServer as createNetServer } from "net"; -import { randomUUID } from "crypto"; -import * as z from "zod/v4"; -import type { ServerConfig } from "./test-fixtures.js"; - -export interface RecordedRequest { - method: string; - params?: any; - headers?: Record; - metadata?: Record; - response: any; - timestamp: number; -} - -/** - * Find an available port starting from the given port - */ -async function findAvailablePort(startPort: number): Promise { - return new Promise((resolve, reject) => { - const server = createNetServer(); - server.listen(startPort, () => { - const port = (server.address() as { port: number })?.port; - server.close(() => resolve(port || startPort)); - }); - server.on("error", (err: NodeJS.ErrnoException) => { - if (err.code === "EADDRINUSE") { - // Try next port - findAvailablePort(startPort + 1) - .then(resolve) - .catch(reject); - } else { - reject(err); - } - }); - }); -} - -/** - * Extract headers from Express request - */ -function extractHeaders(req: Request): Record { - const headers: Record = {}; - for (const [key, value] of Object.entries(req.headers)) { - if (typeof value === "string") { - headers[key] = value; - } else if (Array.isArray(value) && value.length > 0) { - headers[key] = value[value.length - 1]; - } - } - return headers; -} - -// With this test server, your test can hold an instance and you can get the server's recorded message history at any time. -// -export class TestServerHttp { - private mcpServer: McpServer; - private config: ServerConfig; - private recordedRequests: RecordedRequest[] = []; - private httpServer?: HttpServer; - private transport?: StreamableHTTPServerTransport | SSEServerTransport; - private url?: string; - private currentRequestHeaders?: Record; - private currentLogLevel: string | null = null; - - constructor(config: ServerConfig) { - this.config = config; - const capabilities: { - tools?: {}; - resources?: {}; - prompts?: {}; - logging?: {}; - } = {}; - - // Only include capabilities for features that are present in config - if (config.tools !== undefined) { - capabilities.tools = {}; - } - if (config.resources !== undefined) { - capabilities.resources = {}; - } - if (config.prompts !== undefined) { - capabilities.prompts = {}; - } - if (config.logging === true) { - capabilities.logging = {}; - } - - this.mcpServer = new McpServer(config.serverInfo, { - capabilities, - }); - - this.setupHandlers(); - if (config.logging === true) { - this.setupLoggingHandler(); - } - } - - private setupHandlers() { - // Set up tools - if (this.config.tools && this.config.tools.length > 0) { - for (const tool of this.config.tools) { - this.mcpServer.registerTool( - tool.name, - { - description: tool.description, - inputSchema: tool.inputSchema, - }, - async (args) => { - const result = await tool.handler(args as Record); - return { - content: [{ type: "text", text: JSON.stringify(result) }], - }; - }, - ); - } - } - - // Set up resources - if (this.config.resources && this.config.resources.length > 0) { - for (const resource of this.config.resources) { - this.mcpServer.registerResource( - resource.name, - resource.uri, - { - description: resource.description, - mimeType: resource.mimeType, - }, - async () => { - return { - contents: [ - { - uri: resource.uri, - mimeType: resource.mimeType || "text/plain", - text: resource.text || "", - }, - ], - }; - }, - ); - } - } - - // Set up prompts - if (this.config.prompts && this.config.prompts.length > 0) { - for (const prompt of this.config.prompts) { - this.mcpServer.registerPrompt( - prompt.name, - { - description: prompt.description, - argsSchema: prompt.argsSchema, - }, - async (args) => { - // Return a simple prompt response - return { - messages: [ - { - role: "user", - content: { - type: "text", - text: `Prompt: ${prompt.name}${args ? ` with args: ${JSON.stringify(args)}` : ""}`, - }, - }, - ], - }; - }, - ); - } - } - } - - private setupLoggingHandler() { - // Intercept logging/setLevel requests to track the level - this.mcpServer.server.setRequestHandler( - SetLevelRequestSchema, - async (request) => { - this.currentLogLevel = request.params.level; - // Return empty result as per MCP spec - return {}; - }, - ); - } - - /** - * Start the server with the specified transport. - * When requestedPort is omitted, uses port 0 so the OS assigns a unique port (avoids EADDRINUSE when tests run in parallel). - */ - async start( - transport: "http" | "sse", - requestedPort?: number, - ): Promise { - const port = - requestedPort !== undefined ? await findAvailablePort(requestedPort) : 0; - - if (transport === "http") { - const actualPort = await this.startHttp(port); - this.url = `http://localhost:${actualPort}`; - return actualPort; - } else { - const actualPort = await this.startSse(port); - this.url = `http://localhost:${actualPort}`; - return actualPort; - } - } - - private async startHttp(port: number): Promise { - const app = express(); - app.use(express.json()); - - // Create HTTP server - this.httpServer = createHttpServer(app); - - // Create StreamableHTTP transport (stateful so it can handle multiple requests per session) - this.transport = new StreamableHTTPServerTransport({ - sessionIdGenerator: () => randomUUID(), - }); - - // Set up Express route to handle MCP requests - app.post("/mcp", async (req: Request, res: Response) => { - // Capture headers for this request - this.currentRequestHeaders = extractHeaders(req); - - try { - await (this.transport as StreamableHTTPServerTransport).handleRequest( - req, - res, - req.body, - ); - } catch (error) { - res.status(500).json({ - error: error instanceof Error ? error.message : String(error), - }); - } - }); - - // Intercept messages to record them - const originalOnMessage = this.transport.onmessage; - this.transport.onmessage = async (message) => { - const timestamp = Date.now(); - const method = - "method" in message && typeof message.method === "string" - ? message.method - : "unknown"; - const params = "params" in message ? message.params : undefined; - - try { - // Extract metadata from params if present - const metadata = - params && typeof params === "object" && "_meta" in params - ? ((params as any)._meta as Record) - : undefined; - - // Let the server handle the message - if (originalOnMessage) { - await originalOnMessage.call(this.transport, message); - } - - // Record successful request (response will be sent by transport) - // Note: We can't easily capture the response here, so we'll record - // that the request was processed - this.recordedRequests.push({ - method, - params, - headers: { ...this.currentRequestHeaders }, - metadata: metadata ? { ...metadata } : undefined, - response: { processed: true }, - timestamp, - }); - } catch (error) { - // Extract metadata from params if present - const metadata = - params && typeof params === "object" && "_meta" in params - ? ((params as any)._meta as Record) - : undefined; - - // Record error - this.recordedRequests.push({ - method, - params, - headers: { ...this.currentRequestHeaders }, - metadata: metadata ? { ...metadata } : undefined, - response: { - error: error instanceof Error ? error.message : String(error), - }, - timestamp, - }); - throw error; - } - }; - - // Connect transport to server - await this.mcpServer.connect(this.transport); - - // Start listening (port 0 = OS assigns a unique port) - return new Promise((resolve, reject) => { - this.httpServer!.listen(port, () => { - const assignedPort = (this.httpServer!.address() as { port: number }) - ?.port; - resolve(assignedPort ?? port); - }); - this.httpServer!.on("error", reject); - }); - } - - private async startSse(port: number): Promise { - const app = express(); - app.use(express.json()); - - // Create HTTP server - this.httpServer = createHttpServer(app); - - // For SSE, we need to set up an Express route that creates the transport per request - // This is a simplified version - SSE transport is created per connection - app.get("/mcp", async (req: Request, res: Response) => { - this.currentRequestHeaders = extractHeaders(req); - const sseTransport = new SSEServerTransport("/mcp", res); - - // Intercept messages - const originalOnMessage = sseTransport.onmessage; - sseTransport.onmessage = async (message) => { - const timestamp = Date.now(); - const method = - "method" in message && typeof message.method === "string" - ? message.method - : "unknown"; - const params = "params" in message ? message.params : undefined; - - try { - // Extract metadata from params if present - const metadata = - params && typeof params === "object" && "_meta" in params - ? ((params as any)._meta as Record) - : undefined; - - if (originalOnMessage) { - await originalOnMessage.call(sseTransport, message); - } - - this.recordedRequests.push({ - method, - params, - headers: { ...this.currentRequestHeaders }, - metadata: metadata ? { ...metadata } : undefined, - response: { processed: true }, - timestamp, - }); - } catch (error) { - // Extract metadata from params if present - const metadata = - params && typeof params === "object" && "_meta" in params - ? ((params as any)._meta as Record) - : undefined; - - this.recordedRequests.push({ - method, - params, - headers: { ...this.currentRequestHeaders }, - metadata: metadata ? { ...metadata } : undefined, - response: { - error: error instanceof Error ? error.message : String(error), - }, - timestamp, - }); - throw error; - } - }; - - await this.mcpServer.connect(sseTransport); - await sseTransport.start(); - }); - - // Note: SSE transport is created per request, so we don't store a single instance - this.transport = undefined; - - // Start listening (port 0 = OS assigns a unique port) - return new Promise((resolve, reject) => { - this.httpServer!.listen(port, () => { - const assignedPort = (this.httpServer!.address() as { port: number }) - ?.port; - resolve(assignedPort ?? port); - }); - this.httpServer!.on("error", reject); - }); - } - - /** - * Stop the server - */ - async stop(): Promise { - await this.mcpServer.close(); - - if (this.transport) { - await this.transport.close(); - this.transport = undefined; - } - - if (this.httpServer) { - return new Promise((resolve) => { - // Force close all connections - this.httpServer!.closeAllConnections?.(); - this.httpServer!.close(() => { - this.httpServer = undefined; - resolve(); - }); - }); - } - } - - /** - * Get all recorded requests - */ - getRecordedRequests(): RecordedRequest[] { - return [...this.recordedRequests]; - } - - /** - * Clear recorded requests - */ - clearRecordings(): void { - this.recordedRequests = []; - } - - /** - * Get the server URL - */ - getUrl(): string { - if (!this.url) { - throw new Error("Server not started"); - } - return this.url; - } - - /** - * Get the most recent log level that was set - */ - getCurrentLogLevel(): string | null { - return this.currentLogLevel; - } -} - -/** - * Create an HTTP/SSE MCP test server - */ -export function createTestServerHttp(config: ServerConfig): TestServerHttp { - return new TestServerHttp(config); -} diff --git a/cli/__tests__/helpers/test-server-stdio.ts b/cli/__tests__/helpers/test-server-stdio.ts deleted file mode 100644 index 7fe6a1c47..000000000 --- a/cli/__tests__/helpers/test-server-stdio.ts +++ /dev/null @@ -1,241 +0,0 @@ -#!/usr/bin/env node - -/** - * Test MCP server for stdio transport testing - * Can be used programmatically or run as a standalone executable - */ - -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; -import * as z from "zod/v4"; -import path from "path"; -import { fileURLToPath } from "url"; -import { dirname } from "path"; -import type { - ServerConfig, - ToolDefinition, - PromptDefinition, - ResourceDefinition, -} from "./test-fixtures.js"; -import { getDefaultServerConfig } from "./test-fixtures.js"; - -const __dirname = dirname(fileURLToPath(import.meta.url)); - -export class TestServerStdio { - private mcpServer: McpServer; - private config: ServerConfig; - private transport?: StdioServerTransport; - - constructor(config: ServerConfig) { - this.config = config; - const capabilities: { - tools?: {}; - resources?: {}; - prompts?: {}; - logging?: {}; - } = {}; - - // Only include capabilities for features that are present in config - if (config.tools !== undefined) { - capabilities.tools = {}; - } - if (config.resources !== undefined) { - capabilities.resources = {}; - } - if (config.prompts !== undefined) { - capabilities.prompts = {}; - } - if (config.logging === true) { - capabilities.logging = {}; - } - - this.mcpServer = new McpServer(config.serverInfo, { - capabilities, - }); - - this.setupHandlers(); - } - - private setupHandlers() { - // Set up tools - if (this.config.tools && this.config.tools.length > 0) { - for (const tool of this.config.tools) { - this.mcpServer.registerTool( - tool.name, - { - description: tool.description, - inputSchema: tool.inputSchema, - }, - async (args) => { - const result = await tool.handler(args as Record); - // If handler returns content array directly (like get-annotated-message), use it - if (result && Array.isArray(result.content)) { - return { content: result.content }; - } - // If handler returns message (like echo), format it - if (result && typeof result.message === "string") { - return { - content: [ - { - type: "text", - text: result.message, - }, - ], - }; - } - // Otherwise, stringify the result - return { - content: [ - { - type: "text", - text: JSON.stringify(result), - }, - ], - }; - }, - ); - } - } - - // Set up resources - if (this.config.resources && this.config.resources.length > 0) { - for (const resource of this.config.resources) { - this.mcpServer.registerResource( - resource.name, - resource.uri, - { - description: resource.description, - mimeType: resource.mimeType, - }, - async () => { - // For dynamic resources, get fresh text - let text = resource.text; - if (resource.name === "test-cwd") { - text = process.cwd(); - } else if (resource.name === "test-env") { - text = JSON.stringify(process.env, null, 2); - } else if (resource.name === "test-argv") { - text = JSON.stringify(process.argv, null, 2); - } - - return { - contents: [ - { - uri: resource.uri, - mimeType: resource.mimeType || "text/plain", - text: text || "", - }, - ], - }; - }, - ); - } - } - - // Set up prompts - if (this.config.prompts && this.config.prompts.length > 0) { - for (const prompt of this.config.prompts) { - this.mcpServer.registerPrompt( - prompt.name, - { - description: prompt.description, - argsSchema: prompt.argsSchema, - }, - async (args) => { - if (prompt.name === "args-prompt" && args) { - const city = (args as any).city as string; - const state = (args as any).state as string; - return { - messages: [ - { - role: "user", - content: { - type: "text", - text: `This is a prompt with arguments: city=${city}, state=${state}`, - }, - }, - ], - }; - } else { - return { - messages: [ - { - role: "user", - content: { - type: "text", - text: "This is a simple prompt for testing purposes.", - }, - }, - ], - }; - } - }, - ); - } - } - } - - /** - * Start the server with stdio transport - */ - async start(): Promise { - this.transport = new StdioServerTransport(); - await this.mcpServer.connect(this.transport); - } - - /** - * Stop the server - */ - async stop(): Promise { - await this.mcpServer.close(); - if (this.transport) { - await this.transport.close(); - this.transport = undefined; - } - } -} - -/** - * Create a stdio MCP test server - */ -export function createTestServerStdio(config: ServerConfig): TestServerStdio { - return new TestServerStdio(config); -} - -/** - * Get the path to the test MCP server script - */ -export function getTestMcpServerPath(): string { - return path.resolve(__dirname, "test-server-stdio.ts"); -} - -/** - * Get the command and args to run the test MCP server - */ -export function getTestMcpServerCommand(): { command: string; args: string[] } { - return { - command: "tsx", - args: [getTestMcpServerPath()], - }; -} - -// If run as a standalone script, start with default config -// Check if this file is being executed directly (not imported) -const isMainModule = - import.meta.url.endsWith(process.argv[1]) || - process.argv[1]?.endsWith("test-server-stdio.ts") || - process.argv[1]?.endsWith("test-server-stdio.js"); - -if (isMainModule) { - const server = new TestServerStdio(getDefaultServerConfig()); - server - .start() - .then(() => { - // Server is now running and listening on stdio - // Keep the process alive - }) - .catch((error) => { - console.error("Failed to start test MCP server:", error); - process.exit(1); - }); -} diff --git a/cli/package.json b/cli/package.json deleted file mode 100644 index 81f0ac68f..000000000 --- a/cli/package.json +++ /dev/null @@ -1,44 +0,0 @@ -{ - "name": "@modelcontextprotocol/inspector-cli", - "version": "1.0.0", - "description": "CLI for the Model Context Protocol inspector", - "license": "SEE LICENSE IN LICENSE", - "author": "Model Context Protocol a Series of LF Projects, LLC.", - "homepage": "https://modelcontextprotocol.io", - "bugs": "https://github.com/modelcontextprotocol/inspector/issues", - "repository": { - "type": "git", - "url": "https://github.com/modelcontextprotocol/inspector", - "directory": "cli" - }, - "main": "build/cli.js", - "type": "module", - "bin": { - "mcp-inspector-cli": "build/cli.js" - }, - "files": [ - "build", - "LICENSE" - ], - "scripts": { - "build": "tsc", - "postbuild": "node scripts/make-executable.js", - "test": "vitest run", - "test:watch": "vitest", - "test:cli": "vitest run cli.test.ts", - "test:cli-tools": "vitest run tools.test.ts", - "test:cli-headers": "vitest run headers.test.ts", - "test:cli-metadata": "vitest run metadata.test.ts" - }, - "devDependencies": { - "@types/express": "^5.0.0", - "tsx": "^4.7.0", - "vitest": "^4.1.0" - }, - "dependencies": { - "@modelcontextprotocol/sdk": "^1.25.2", - "commander": "^13.1.0", - "express": "^5.2.1", - "spawn-rx": "^5.1.2" - } -} diff --git a/cli/scripts/make-executable.js b/cli/scripts/make-executable.js deleted file mode 100755 index f3b8c9024..000000000 --- a/cli/scripts/make-executable.js +++ /dev/null @@ -1,29 +0,0 @@ -/** - * Cross-platform script to make a file executable - */ -import { promises as fs } from "fs"; -import { platform } from "os"; -import { execSync } from "child_process"; -import path from "path"; - -const TARGET_FILE = path.resolve("build/cli.js"); - -async function makeExecutable() { - try { - // On Unix-like systems (Linux, macOS), use chmod - if (platform() !== "win32") { - execSync(`chmod +x "${TARGET_FILE}"`); - console.log("Made file executable with chmod"); - } else { - // On Windows, no need to make files "executable" in the Unix sense - // Just ensure the file exists - await fs.access(TARGET_FILE); - console.log("File exists and is accessible on Windows"); - } - } catch (error) { - console.error("Error making file executable:", error); - process.exit(1); - } -} - -makeExecutable(); diff --git a/cli/src/cli.ts b/cli/src/cli.ts deleted file mode 100644 index f4187e02d..000000000 --- a/cli/src/cli.ts +++ /dev/null @@ -1,394 +0,0 @@ -#!/usr/bin/env node - -import { Command } from "commander"; -import fs from "node:fs"; -import path from "node:path"; -import { dirname, resolve } from "path"; -import { spawnPromise } from "spawn-rx"; -import { fileURLToPath } from "url"; - -const __dirname = dirname(fileURLToPath(import.meta.url)); - -type Args = { - command: string; - args: string[]; - envArgs: Record; - cli: boolean; - transport?: "stdio" | "sse" | "streamable-http"; - serverUrl?: string; - headers?: Record; -}; - -type CliOptions = { - e?: Record; - config?: string; - server?: string; - cli?: boolean; - transport?: string; - serverUrl?: string; - header?: Record; -}; - -type ServerConfig = - | { - type: "stdio"; - command: string; - args?: string[]; - env?: Record; - } - | { - type: "sse" | "streamable-http"; - url: string; - note?: string; - }; - -function handleError(error: unknown): never { - let message: string; - - if (error instanceof Error) { - message = error.message; - } else if (typeof error === "string") { - message = error; - } else { - message = "Unknown error"; - } - - console.error(message); - - process.exit(1); -} - -function delay(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms, true)); -} - -async function runWebClient(args: Args): Promise { - // Path to the client entry point - const inspectorClientPath = resolve( - __dirname, - "../../", - "client", - "bin", - "start.js", - ); - - const abort = new AbortController(); - let cancelled: boolean = false; - process.on("SIGINT", () => { - cancelled = true; - abort.abort(); - }); - - // Build arguments to pass to start.js - const startArgs: string[] = []; - - // Pass environment variables - for (const [key, value] of Object.entries(args.envArgs)) { - startArgs.push("-e", `${key}=${value}`); - } - - // Pass transport type if specified - if (args.transport) { - startArgs.push("--transport", args.transport); - } - - // Pass server URL if specified - if (args.serverUrl) { - startArgs.push("--server-url", args.serverUrl); - } - - // Pass command and args (using -- to separate them) - if (args.command) { - startArgs.push("--", args.command, ...args.args); - } - - try { - await spawnPromise("node", [inspectorClientPath, ...startArgs], { - signal: abort.signal, - echoOutput: true, - // pipe the stdout through here, prevents issues with buffering and - // dropping the end of console.out after 8192 chars due to node - // closing the stdout pipe before the output has finished flushing - stdio: "inherit", - }); - } catch (e) { - if (!cancelled || process.env.DEBUG) throw e; - } -} - -async function runCli(args: Args): Promise { - const projectRoot = resolve(__dirname, ".."); - const cliPath = resolve(projectRoot, "build", "index.js"); - - const abort = new AbortController(); - - let cancelled = false; - - process.on("SIGINT", () => { - cancelled = true; - abort.abort(); - }); - - try { - // Build CLI arguments - const cliArgs = [cliPath]; - - // Add target URL/command first - cliArgs.push(args.command, ...args.args); - - // Add transport flag if specified - if (args.transport && args.transport !== "stdio") { - // Convert streamable-http back to http for CLI mode - const cliTransport = - args.transport === "streamable-http" ? "http" : args.transport; - cliArgs.push("--transport", cliTransport); - } - - // Add headers if specified - if (args.headers) { - for (const [key, value] of Object.entries(args.headers)) { - cliArgs.push("--header", `${key}: ${value}`); - } - } - - await spawnPromise("node", cliArgs, { - env: { ...process.env, ...args.envArgs }, - signal: abort.signal, - echoOutput: true, - // pipe the stdout through here, prevents issues with buffering and - // dropping the end of console.out after 8192 chars due to node - // closing the stdout pipe before the output has finished flushing - stdio: "inherit", - }); - } catch (e) { - if (!cancelled || process.env.DEBUG) { - throw e; - } - } -} - -function loadConfigFile(configPath: string, serverName: string): ServerConfig { - try { - const resolvedConfigPath = path.isAbsolute(configPath) - ? configPath - : path.resolve(process.cwd(), configPath); - - if (!fs.existsSync(resolvedConfigPath)) { - throw new Error(`Config file not found: ${resolvedConfigPath}`); - } - - const configContent = fs.readFileSync(resolvedConfigPath, "utf8"); - const parsedConfig = JSON.parse(configContent); - - if (!parsedConfig.mcpServers || !parsedConfig.mcpServers[serverName]) { - const availableServers = Object.keys(parsedConfig.mcpServers || {}).join( - ", ", - ); - throw new Error( - `Server '${serverName}' not found in config file. Available servers: ${availableServers}`, - ); - } - - const serverConfig = parsedConfig.mcpServers[serverName]; - - return serverConfig; - } catch (err: unknown) { - if (err instanceof SyntaxError) { - throw new Error(`Invalid JSON in config file: ${err.message}`); - } - - throw err; - } -} - -function parseKeyValuePair( - value: string, - previous: Record = {}, -): Record { - const parts = value.split("="); - const key = parts[0]; - const val = parts.slice(1).join("="); - - if (val === undefined || val === "") { - throw new Error( - `Invalid parameter format: ${value}. Use key=value format.`, - ); - } - - return { ...previous, [key as string]: val }; -} - -function parseHeaderPair( - value: string, - previous: Record = {}, -): Record { - const colonIndex = value.indexOf(":"); - - if (colonIndex === -1) { - throw new Error( - `Invalid header format: ${value}. Use "HeaderName: Value" format.`, - ); - } - - const key = value.slice(0, colonIndex).trim(); - const val = value.slice(colonIndex + 1).trim(); - - if (key === "" || val === "") { - throw new Error( - `Invalid header format: ${value}. Use "HeaderName: Value" format.`, - ); - } - - return { ...previous, [key]: val }; -} - -function parseArgs(): Args { - const program = new Command(); - - const argSeparatorIndex = process.argv.indexOf("--"); - let preArgs = process.argv; - let postArgs: string[] = []; - - if (argSeparatorIndex !== -1) { - preArgs = process.argv.slice(0, argSeparatorIndex); - postArgs = process.argv.slice(argSeparatorIndex + 1); - } - - program - .name("inspector-bin") - .allowExcessArguments() - .allowUnknownOption() - .option( - "-e ", - "environment variables in KEY=VALUE format", - parseKeyValuePair, - {}, - ) - .option("--config ", "config file path") - .option("--server ", "server name from config file") - .option("--cli", "enable CLI mode") - .option("--transport ", "transport type (stdio, sse, http)") - .option("--server-url ", "server URL for SSE/HTTP transport") - .option( - "--header ", - 'HTTP headers as "HeaderName: Value" pairs (for HTTP/SSE transports)', - parseHeaderPair, - {}, - ); - - // Parse only the arguments before -- - program.parse(preArgs); - - const options = program.opts() as CliOptions; - const remainingArgs = program.args; - - // Add back any arguments that came after -- - const finalArgs = [...remainingArgs, ...postArgs]; - - // Validate config and server options - if (!options.config && options.server) { - throw new Error("--server requires --config to be specified"); - } - - // If config is provided without server, try to auto-select - if (options.config && !options.server) { - const configContent = fs.readFileSync( - path.isAbsolute(options.config) - ? options.config - : path.resolve(process.cwd(), options.config), - "utf8", - ); - const parsedConfig = JSON.parse(configContent); - const servers = Object.keys(parsedConfig.mcpServers || {}); - - if (servers.length === 1) { - // Use the only server if there's just one - options.server = servers[0]; - } else if (servers.length === 0) { - throw new Error("No servers found in config file"); - } else { - // Multiple servers, require explicit selection - throw new Error( - `Multiple servers found in config file. Please specify one with --server.\nAvailable servers: ${servers.join(", ")}`, - ); - } - } - - // If config file is specified, load and use the options from the file. We must merge the args - // from the command line and the file together, or we will miss the method options (--method, - // etc.) - if (options.config && options.server) { - const config = loadConfigFile(options.config, options.server); - - if (config.type === "stdio") { - return { - command: config.command, - args: [...(config.args || []), ...finalArgs], - envArgs: { ...(config.env || {}), ...(options.e || {}) }, - cli: options.cli || false, - transport: "stdio", - headers: options.header, - }; - } else if (config.type === "sse" || config.type === "streamable-http") { - return { - command: config.url, - args: finalArgs, - envArgs: options.e || {}, - cli: options.cli || false, - transport: config.type, - serverUrl: config.url, - headers: options.header, - }; - } else { - // Backwards compatibility: if no type field, assume stdio - return { - command: (config as any).command || "", - args: [...((config as any).args || []), ...finalArgs], - envArgs: { ...((config as any).env || {}), ...(options.e || {}) }, - cli: options.cli || false, - transport: "stdio", - headers: options.header, - }; - } - } - - // Otherwise use command line arguments - const command = finalArgs[0] || ""; - const args = finalArgs.slice(1); - - // Map "http" shorthand to "streamable-http" - let transport = options.transport; - if (transport === "http") { - transport = "streamable-http"; - } - - return { - command, - args, - envArgs: options.e || {}, - cli: options.cli || false, - transport: transport as "stdio" | "sse" | "streamable-http" | undefined, - serverUrl: options.serverUrl, - headers: options.header, - }; -} - -async function main(): Promise { - process.on("uncaughtException", (error) => { - handleError(error); - }); - - try { - const args = parseArgs(); - - if (args.cli) { - await runCli(args); - } else { - await runWebClient(args); - } - } catch (error) { - handleError(error); - } -} - -main(); diff --git a/cli/src/client/connection.ts b/cli/src/client/connection.ts deleted file mode 100644 index dcbe8e518..000000000 --- a/cli/src/client/connection.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { Client } from "@modelcontextprotocol/sdk/client/index.js"; -import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js"; -import { McpResponse } from "./types.js"; - -export const validLogLevels = [ - "trace", - "debug", - "info", - "warn", - "error", -] as const; - -export type LogLevel = (typeof validLogLevels)[number]; - -export async function connect( - client: Client, - transport: Transport, -): Promise { - try { - await client.connect(transport); - - if (client.getServerCapabilities()?.logging) { - // default logging level is undefined in the spec, but the user of the - // inspector most likely wants debug. - await client.setLoggingLevel("debug"); - } - } catch (error) { - throw new Error( - `Failed to connect to MCP server: ${error instanceof Error ? error.message : String(error)}`, - ); - } -} - -export async function disconnect(transport: Transport): Promise { - try { - await transport.close(); - } catch (error) { - throw new Error( - `Failed to disconnect from MCP server: ${error instanceof Error ? error.message : String(error)}`, - ); - } -} - -// Set logging level -export async function setLoggingLevel( - client: Client, - level: LogLevel, -): Promise { - try { - const response = await client.setLoggingLevel(level as any); - return response; - } catch (error) { - throw new Error( - `Failed to set logging level: ${error instanceof Error ? error.message : String(error)}`, - ); - } -} diff --git a/cli/src/client/index.ts b/cli/src/client/index.ts deleted file mode 100644 index 095d716b2..000000000 --- a/cli/src/client/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -// Re-export everything from the client modules -export * from "./connection.js"; -export * from "./prompts.js"; -export * from "./resources.js"; -export * from "./tools.js"; -export * from "./types.js"; diff --git a/cli/src/client/prompts.ts b/cli/src/client/prompts.ts deleted file mode 100644 index e7a1cf2f2..000000000 --- a/cli/src/client/prompts.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { Client } from "@modelcontextprotocol/sdk/client/index.js"; -import { McpResponse } from "./types.js"; - -// JSON value type matching the client utils -type JsonValue = - | string - | number - | boolean - | null - | undefined - | JsonValue[] - | { [key: string]: JsonValue }; - -// List available prompts -export async function listPrompts( - client: Client, - metadata?: Record, -): Promise { - try { - const params = - metadata && Object.keys(metadata).length > 0 ? { _meta: metadata } : {}; - const response = await client.listPrompts(params); - return response; - } catch (error) { - throw new Error( - `Failed to list prompts: ${error instanceof Error ? error.message : String(error)}`, - ); - } -} - -// Get a prompt -export async function getPrompt( - client: Client, - name: string, - args?: Record, - metadata?: Record, -): Promise { - try { - // Convert all arguments to strings for prompt arguments - const stringArgs: Record = {}; - if (args) { - for (const [key, value] of Object.entries(args)) { - if (typeof value === "string") { - stringArgs[key] = value; - } else if (value === null || value === undefined) { - stringArgs[key] = String(value); - } else { - stringArgs[key] = JSON.stringify(value); - } - } - } - - const params: any = { - name, - arguments: stringArgs, - }; - - if (metadata && Object.keys(metadata).length > 0) { - params._meta = metadata; - } - - const response = await client.getPrompt(params); - - return response; - } catch (error) { - throw new Error( - `Failed to get prompt: ${error instanceof Error ? error.message : String(error)}`, - ); - } -} diff --git a/cli/src/client/resources.ts b/cli/src/client/resources.ts deleted file mode 100644 index 3e44820ca..000000000 --- a/cli/src/client/resources.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { Client } from "@modelcontextprotocol/sdk/client/index.js"; -import { McpResponse } from "./types.js"; - -// List available resources -export async function listResources( - client: Client, - metadata?: Record, -): Promise { - try { - const params = - metadata && Object.keys(metadata).length > 0 ? { _meta: metadata } : {}; - const response = await client.listResources(params); - return response; - } catch (error) { - throw new Error( - `Failed to list resources: ${error instanceof Error ? error.message : String(error)}`, - ); - } -} - -// Read a resource -export async function readResource( - client: Client, - uri: string, - metadata?: Record, -): Promise { - try { - const params: any = { uri }; - if (metadata && Object.keys(metadata).length > 0) { - params._meta = metadata; - } - const response = await client.readResource(params); - return response; - } catch (error) { - throw new Error( - `Failed to read resource ${uri}: ${error instanceof Error ? error.message : String(error)}`, - ); - } -} - -// List resource templates -export async function listResourceTemplates( - client: Client, - metadata?: Record, -): Promise { - try { - const params = - metadata && Object.keys(metadata).length > 0 ? { _meta: metadata } : {}; - const response = await client.listResourceTemplates(params); - return response; - } catch (error) { - throw new Error( - `Failed to list resource templates: ${error instanceof Error ? error.message : String(error)}`, - ); - } -} diff --git a/cli/src/client/tools.ts b/cli/src/client/tools.ts deleted file mode 100644 index 516814115..000000000 --- a/cli/src/client/tools.ts +++ /dev/null @@ -1,140 +0,0 @@ -import { Client } from "@modelcontextprotocol/sdk/client/index.js"; -import { Tool } from "@modelcontextprotocol/sdk/types.js"; -import { McpResponse } from "./types.js"; - -// JSON value type matching the client utils -type JsonValue = - | string - | number - | boolean - | null - | undefined - | JsonValue[] - | { [key: string]: JsonValue }; - -type JsonSchemaType = { - type: "string" | "number" | "integer" | "boolean" | "array" | "object"; - description?: string; - properties?: Record; - items?: JsonSchemaType; -}; - -export async function listTools( - client: Client, - metadata?: Record, -): Promise { - try { - const params = - metadata && Object.keys(metadata).length > 0 ? { _meta: metadata } : {}; - const response = await client.listTools(params); - return response; - } catch (error) { - throw new Error( - `Failed to list tools: ${error instanceof Error ? error.message : String(error)}`, - ); - } -} - -function convertParameterValue( - value: string, - schema: JsonSchemaType, -): JsonValue { - if (!value) { - return value; - } - - if (schema.type === "number" || schema.type === "integer") { - return Number(value); - } - - if (schema.type === "boolean") { - return value.toLowerCase() === "true"; - } - - if (schema.type === "object" || schema.type === "array") { - try { - return JSON.parse(value) as JsonValue; - } catch (error) { - return value; - } - } - - return value; -} - -function convertParameters( - tool: Tool, - params: Record, -): Record { - const result: Record = {}; - const properties = tool.inputSchema.properties || {}; - - for (const [key, value] of Object.entries(params)) { - const paramSchema = properties[key] as JsonSchemaType | undefined; - - if (paramSchema) { - result[key] = convertParameterValue(value, paramSchema); - } else { - // If no schema is found for this parameter, keep it as string - result[key] = value; - } - } - - return result; -} - -export async function callTool( - client: Client, - name: string, - args: Record, - generalMetadata?: Record, - toolSpecificMetadata?: Record, -): Promise { - try { - const toolsResponse = await listTools(client, generalMetadata); - const tools = toolsResponse.tools as Tool[]; - const tool = tools.find((t) => t.name === name); - - let convertedArgs: Record = args; - - if (tool) { - // Convert parameters based on the tool's schema, but only for string values - // since we now accept pre-parsed values from the CLI - const stringArgs: Record = {}; - for (const [key, value] of Object.entries(args)) { - if (typeof value === "string") { - stringArgs[key] = value; - } - } - - if (Object.keys(stringArgs).length > 0) { - const convertedStringArgs = convertParameters(tool, stringArgs); - convertedArgs = { ...args, ...convertedStringArgs }; - } - } - - // Merge general metadata with tool-specific metadata - // Tool-specific metadata takes precedence over general metadata - let mergedMetadata: Record | undefined; - if (generalMetadata || toolSpecificMetadata) { - mergedMetadata = { - ...(generalMetadata || {}), - ...(toolSpecificMetadata || {}), - }; - } - - const response = await client.callTool({ - name: name, - arguments: convertedArgs, - _meta: - mergedMetadata && Object.keys(mergedMetadata).length > 0 - ? mergedMetadata - : undefined, - }); - return response; - } catch (error) { - throw new Error( - `Failed to call tool ${name}: ${error instanceof Error ? error.message : String(error)}`, - ); - } -} diff --git a/cli/src/client/types.ts b/cli/src/client/types.ts deleted file mode 100644 index bbbe1bf4f..000000000 --- a/cli/src/client/types.ts +++ /dev/null @@ -1 +0,0 @@ -export type McpResponse = Record; diff --git a/cli/src/error-handler.ts b/cli/src/error-handler.ts deleted file mode 100644 index 972577453..000000000 --- a/cli/src/error-handler.ts +++ /dev/null @@ -1,20 +0,0 @@ -function formatError(error: unknown): string { - let message: string; - - if (error instanceof Error) { - message = error.message; - } else if (typeof error === "string") { - message = error; - } else { - message = "Unknown error"; - } - - return message; -} - -export function handleError(error: unknown): never { - const errorMessage = formatError(error); - console.error(errorMessage); - - process.exit(1); -} diff --git a/cli/src/index.ts b/cli/src/index.ts deleted file mode 100644 index 45a71a052..000000000 --- a/cli/src/index.ts +++ /dev/null @@ -1,420 +0,0 @@ -#!/usr/bin/env node - -import * as fs from "fs"; -import { Client } from "@modelcontextprotocol/sdk/client/index.js"; -import { Command } from "commander"; -import { - callTool, - connect, - disconnect, - getPrompt, - listPrompts, - listResources, - listResourceTemplates, - listTools, - LogLevel, - McpResponse, - readResource, - setLoggingLevel, - validLogLevels, -} from "./client/index.js"; -import { handleError } from "./error-handler.js"; -import { createTransport, TransportOptions } from "./transport.js"; -import { awaitableLog } from "./utils/awaitable-log.js"; - -// JSON value type for CLI arguments -type JsonValue = - | string - | number - | boolean - | null - | undefined - | JsonValue[] - | { [key: string]: JsonValue }; - -type Args = { - target: string[]; - method?: string; - promptName?: string; - promptArgs?: Record; - uri?: string; - logLevel?: LogLevel; - toolName?: string; - toolArg?: Record; - toolMeta?: Record; - transport?: "sse" | "stdio" | "http"; - headers?: Record; - metadata?: Record; -}; - -function createTransportOptions( - target: string[], - transport?: "sse" | "stdio" | "http", - headers?: Record, -): TransportOptions { - if (target.length === 0) { - throw new Error( - "Target is required. Specify a URL or a command to execute.", - ); - } - - const [command, ...commandArgs] = target; - - if (!command) { - throw new Error("Command is required."); - } - - const isUrl = command.startsWith("http://") || command.startsWith("https://"); - - if (isUrl && commandArgs.length > 0) { - throw new Error("Arguments cannot be passed to a URL-based MCP server."); - } - - let transportType: "sse" | "stdio" | "http"; - if (transport) { - if (!isUrl && transport !== "stdio") { - throw new Error("Only stdio transport can be used with local commands."); - } - if (isUrl && transport === "stdio") { - throw new Error("stdio transport cannot be used with URLs."); - } - transportType = transport; - } else if (isUrl) { - const url = new URL(command); - if (url.pathname.endsWith("/mcp")) { - transportType = "http"; - } else if (url.pathname.endsWith("/sse")) { - transportType = "sse"; - } else { - transportType = "sse"; - } - } else { - transportType = "stdio"; - } - - return { - transportType, - command: isUrl ? undefined : command, - args: isUrl ? undefined : commandArgs, - url: isUrl ? command : undefined, - headers, - }; -} - -async function callMethod(args: Args): Promise { - // Read package.json to get name and version for client identity - const pathA = "../package.json"; // We're in package @modelcontextprotocol/inspector-cli - const pathB = "../../package.json"; // We're in package @modelcontextprotocol/inspector - let packageJson: { name: string; version: string }; - let packageJsonData = await import(fs.existsSync(pathA) ? pathA : pathB, { - with: { type: "json" }, - }); - packageJson = packageJsonData.default; - - const transportOptions = createTransportOptions( - args.target, - args.transport, - args.headers, - ); - const transport = createTransport(transportOptions); - - const [, name = packageJson.name] = packageJson.name.split("/"); - const version = packageJson.version; - const clientIdentity = { name, version }; - - const client = new Client(clientIdentity); - - try { - await connect(client, transport); - - let result: McpResponse; - - // Tools methods - if (args.method === "tools/list") { - result = await listTools(client, args.metadata); - } else if (args.method === "tools/call") { - if (!args.toolName) { - throw new Error( - "Tool name is required for tools/call method. Use --tool-name to specify the tool name.", - ); - } - - result = await callTool( - client, - args.toolName, - args.toolArg || {}, - args.metadata, - args.toolMeta, - ); - } - // Resources methods - else if (args.method === "resources/list") { - result = await listResources(client, args.metadata); - } else if (args.method === "resources/read") { - if (!args.uri) { - throw new Error( - "URI is required for resources/read method. Use --uri to specify the resource URI.", - ); - } - - result = await readResource(client, args.uri, args.metadata); - } else if (args.method === "resources/templates/list") { - result = await listResourceTemplates(client, args.metadata); - } - // Prompts methods - else if (args.method === "prompts/list") { - result = await listPrompts(client, args.metadata); - } else if (args.method === "prompts/get") { - if (!args.promptName) { - throw new Error( - "Prompt name is required for prompts/get method. Use --prompt-name to specify the prompt name.", - ); - } - - result = await getPrompt( - client, - args.promptName, - args.promptArgs || {}, - args.metadata, - ); - } - // Logging methods - else if (args.method === "logging/setLevel") { - if (!args.logLevel) { - throw new Error( - "Log level is required for logging/setLevel method. Use --log-level to specify the log level.", - ); - } - - result = await setLoggingLevel(client, args.logLevel); - } else { - throw new Error( - `Unsupported method: ${args.method}. Supported methods include: tools/list, tools/call, resources/list, resources/read, resources/templates/list, prompts/list, prompts/get, logging/setLevel`, - ); - } - - await awaitableLog(JSON.stringify(result, null, 2)); - } finally { - try { - await disconnect(transport); - } catch (disconnectError) { - throw disconnectError; - } - } -} - -function parseKeyValuePair( - value: string, - previous: Record = {}, -): Record { - const parts = value.split("="); - const key = parts[0]; - const val = parts.slice(1).join("="); - - if (val === undefined || val === "") { - throw new Error( - `Invalid parameter format: ${value}. Use key=value format.`, - ); - } - - // Try to parse as JSON first - let parsedValue: JsonValue; - try { - parsedValue = JSON.parse(val) as JsonValue; - } catch { - // If JSON parsing fails, keep as string - parsedValue = val; - } - - return { ...previous, [key as string]: parsedValue }; -} - -function parseHeaderPair( - value: string, - previous: Record = {}, -): Record { - const colonIndex = value.indexOf(":"); - - if (colonIndex === -1) { - throw new Error( - `Invalid header format: ${value}. Use "HeaderName: Value" format.`, - ); - } - - const key = value.slice(0, colonIndex).trim(); - const val = value.slice(colonIndex + 1).trim(); - - if (key === "" || val === "") { - throw new Error( - `Invalid header format: ${value}. Use "HeaderName: Value" format.`, - ); - } - - return { ...previous, [key]: val }; -} - -function parseArgs(): Args { - const program = new Command(); - - // Find if there's a -- in the arguments and split them - const argSeparatorIndex = process.argv.indexOf("--"); - let preArgs = process.argv; - let postArgs: string[] = []; - - if (argSeparatorIndex !== -1) { - preArgs = process.argv.slice(0, argSeparatorIndex); - postArgs = process.argv.slice(argSeparatorIndex + 1); - } - - program - .name("inspector-cli") - .allowUnknownOption() - .argument("", "Command and arguments or URL of the MCP server") - // - // Method selection - // - .option("--method ", "Method to invoke") - // - // Tool-related options - // - .option("--tool-name ", "Tool name (for tools/call method)") - .option( - "--tool-arg ", - "Tool argument as key=value pair", - parseKeyValuePair, - {}, - ) - // - // Resource-related options - // - .option("--uri ", "URI of the resource (for resources/read method)") - // - // Prompt-related options - // - .option( - "--prompt-name ", - "Name of the prompt (for prompts/get method)", - ) - .option( - "--prompt-args ", - "Prompt arguments as key=value pairs", - parseKeyValuePair, - {}, - ) - // - // Logging options - // - .option( - "--log-level ", - "Logging level (for logging/setLevel method)", - (value: string) => { - if (!validLogLevels.includes(value as any)) { - throw new Error( - `Invalid log level: ${value}. Valid levels are: ${validLogLevels.join(", ")}`, - ); - } - - return value as LogLevel; - }, - ) - // - // Transport options - // - .option( - "--transport ", - "Transport type (sse, http, or stdio). Auto-detected from URL: /mcp → http, /sse → sse, commands → stdio", - (value: string) => { - const validTransports = ["sse", "http", "stdio"]; - if (!validTransports.includes(value)) { - throw new Error( - `Invalid transport type: ${value}. Valid types are: ${validTransports.join(", ")}`, - ); - } - return value as "sse" | "http" | "stdio"; - }, - ) - // - // HTTP headers - // - .option( - "--header ", - 'HTTP headers as "HeaderName: Value" pairs (for HTTP/SSE transports)', - parseHeaderPair, - {}, - ) - // - // Metadata options - // - .option( - "--metadata ", - "General metadata as key=value pairs (applied to all methods)", - parseKeyValuePair, - {}, - ) - .option( - "--tool-metadata ", - "Tool-specific metadata as key=value pairs (for tools/call method only)", - parseKeyValuePair, - {}, - ); - - // Parse only the arguments before -- - program.parse(preArgs); - - const options = program.opts() as Omit & { - header?: Record; - metadata?: Record; - toolMetadata?: Record; - }; - - let remainingArgs = program.args; - - // Add back any arguments that came after -- - const finalArgs = [...remainingArgs, ...postArgs]; - - if (!options.method) { - throw new Error( - "Method is required. Use --method to specify the method to invoke.", - ); - } - - return { - target: finalArgs, - ...options, - headers: options.header, // commander.js uses 'header' field, map to 'headers' - metadata: options.metadata - ? Object.fromEntries( - Object.entries(options.metadata).map(([key, value]) => [ - key, - String(value), - ]), - ) - : undefined, - toolMeta: options.toolMetadata - ? Object.fromEntries( - Object.entries(options.toolMetadata).map(([key, value]) => [ - key, - String(value), - ]), - ) - : undefined, - }; -} - -async function main(): Promise { - process.on("uncaughtException", (error) => { - handleError(error); - }); - - try { - const args = parseArgs(); - await callMethod(args); - - // Explicitly exit to ensure process terminates in CI - process.exit(0); - } catch (error) { - handleError(error); - } -} - -main(); diff --git a/cli/src/transport.ts b/cli/src/transport.ts deleted file mode 100644 index 84af393b9..000000000 --- a/cli/src/transport.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js"; -import { - getDefaultEnvironment, - StdioClientTransport, -} from "@modelcontextprotocol/sdk/client/stdio.js"; -import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; -import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js"; -import { findActualExecutable } from "spawn-rx"; - -export type TransportOptions = { - transportType: "sse" | "stdio" | "http"; - command?: string; - args?: string[]; - url?: string; - headers?: Record; -}; - -function createStdioTransport(options: TransportOptions): Transport { - let args: string[] = []; - - if (options.args !== undefined) { - args = options.args; - } - - const processEnv: Record = {}; - - for (const [key, value] of Object.entries(process.env)) { - if (value !== undefined) { - processEnv[key] = value; - } - } - - const defaultEnv = getDefaultEnvironment(); - - const env: Record = { - ...defaultEnv, - ...processEnv, - }; - - const { cmd: actualCommand, args: actualArgs } = findActualExecutable( - options.command ?? "", - args, - ); - - return new StdioClientTransport({ - command: actualCommand, - args: actualArgs, - env, - stderr: "pipe", - }); -} - -export function createTransport(options: TransportOptions): Transport { - const { transportType } = options; - - try { - if (transportType === "stdio") { - return createStdioTransport(options); - } - - // If not STDIO, then it must be either SSE or HTTP. - if (!options.url) { - throw new Error("URL must be provided for SSE or HTTP transport types."); - } - const url = new URL(options.url); - - if (transportType === "sse") { - const transportOptions = options.headers - ? { - requestInit: { - headers: options.headers, - }, - } - : undefined; - return new SSEClientTransport(url, transportOptions); - } - - if (transportType === "http") { - const transportOptions = options.headers - ? { - requestInit: { - headers: options.headers, - }, - } - : undefined; - return new StreamableHTTPClientTransport(url, transportOptions); - } - - throw new Error(`Unsupported transport type: ${transportType}`); - } catch (error) { - throw new Error( - `Failed to create transport: ${error instanceof Error ? error.message : String(error)}`, - ); - } -} diff --git a/cli/tsconfig.json b/cli/tsconfig.json deleted file mode 100644 index effa34f2b..000000000 --- a/cli/tsconfig.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2022", - "module": "NodeNext", - "moduleResolution": "NodeNext", - "outDir": "./build", - "rootDir": "./src", - "strict": true, - "esModuleInterop": true, - "skipLibCheck": true, - "forceConsistentCasingInFileNames": true, - "resolveJsonModule": true, - "noUncheckedIndexedAccess": true - }, - "include": ["src/**/*"], - "exclude": ["node_modules", "packages", "**/*.spec.ts", "build"] -} diff --git a/cli/vitest.config.ts b/cli/vitest.config.ts deleted file mode 100644 index 9984fb11a..000000000 --- a/cli/vitest.config.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { defineConfig } from "vitest/config"; - -export default defineConfig({ - test: { - globals: true, - environment: "node", - include: ["**/__tests__/**/*.test.ts"], - testTimeout: 15000, // 15 seconds - CLI tests spawn subprocesses that need time - }, -}); diff --git a/client/LICENSE b/client/LICENSE deleted file mode 100644 index 4a9398576..000000000 --- a/client/LICENSE +++ /dev/null @@ -1,216 +0,0 @@ -The MCP project is undergoing a licensing transition from the MIT License to the Apache License, Version 2.0 ("Apache-2.0"). All new code and specification contributions to the project are licensed under Apache-2.0. Documentation contributions (excluding specifications) are licensed under CC-BY-4.0. - -Contributions for which relicensing consent has been obtained are licensed under Apache-2.0. Contributions made by authors who originally licensed their work under the MIT License and who have not yet granted explicit permission to relicense remain licensed under the MIT License. - -No rights beyond those granted by the applicable original license are conveyed for such contributions. - ---- - - Apache License - Version 2.0, January 2004 - http://www.apache.org/licenses/ - - TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION - - 1. Definitions. - - "License" shall mean the terms and conditions for use, reproduction, - and distribution as defined by Sections 1 through 9 of this document. - - "Licensor" shall mean the copyright owner or entity authorized by - the copyright owner that is granting the License. - - "Legal Entity" shall mean the union of the acting entity and all - other entities that control, are controlled by, or are under common - control with that entity. For the purposes of this definition, - "control" means (i) the power, direct or indirect, to cause the - direction or management of such entity, whether by contract or - otherwise, or (ii) ownership of fifty percent (50%) or more of the - outstanding shares, or (iii) beneficial ownership of such entity. - - "You" (or "Your") shall mean an individual or Legal Entity - exercising permissions granted by this License. - - "Source" form shall mean the preferred form for making modifications, - including but not limited to software source code, documentation - source, and configuration files. - - "Object" form shall mean any form resulting from mechanical - transformation or translation of a Source form, including but - not limited to compiled object code, generated documentation, - and conversions to other media types. - - "Work" shall mean the work of authorship, whether in Source or - Object form, made available under the License, as indicated by a - copyright notice that is included in or attached to the work - (an example is provided in the Appendix below). - - "Derivative Works" shall mean any work, whether in Source or Object - form, that is based on (or derived from) the Work and for which the - editorial revisions, annotations, elaborations, or other modifications - represent, as a whole, an original work of authorship. For the purposes - of this License, Derivative Works shall not include works that remain - separable from, or merely link (or bind by name) to the interfaces of, - the Work and Derivative Works thereof. - - "Contribution" shall mean any work of authorship, including - the original version of the Work and any modifications or additions - to that Work or Derivative Works thereof, that is intentionally - submitted to the Licensor for inclusion in the Work by the copyright - owner or by an individual or Legal Entity authorized to submit on behalf - of the copyright owner. For the purposes of this definition, "submitted" - means any form of electronic, verbal, or written communication sent - to the Licensor or its representatives, including but not limited to - communication on electronic mailing lists, source code control systems, - and issue tracking systems that are managed by, or on behalf of, the - Licensor for the purpose of discussing and improving the Work, but - excluding communication that is conspicuously marked or otherwise - designated in writing by the copyright owner as "Not a Contribution." - - "Contributor" shall mean Licensor and any individual or Legal Entity - on behalf of whom a Contribution has been received by Licensor and - subsequently incorporated within the Work. - - 2. Grant of Copyright License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - copyright license to reproduce, prepare Derivative Works of, - publicly display, publicly perform, sublicense, and distribute the - Work and such Derivative Works in Source or Object form. - - 3. Grant of Patent License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - (except as stated in this section) patent license to make, have made, - use, offer to sell, sell, import, and otherwise transfer the Work, - where such license applies only to those patent claims licensable - by such Contributor that are necessarily infringed by their - Contribution(s) alone or by combination of their Contribution(s) - with the Work to which such Contribution(s) was submitted. If You - institute patent litigation against any entity (including a - cross-claim or counterclaim in a lawsuit) alleging that the Work - or a Contribution incorporated within the Work constitutes direct - or contributory patent infringement, then any patent licenses - granted to You under this License for that Work shall terminate - as of the date such litigation is filed. - - 4. Redistribution. You may reproduce and distribute copies of the - Work or Derivative Works thereof in any medium, with or without - modifications, and in Source or Object form, provided that You - meet the following conditions: - - (a) You must give any other recipients of the Work or - Derivative Works a copy of this License; and - - (b) You must cause any modified files to carry prominent notices - stating that You changed the files; and - - (c) You must retain, in the Source form of any Derivative Works - that You distribute, all copyright, patent, trademark, and - attribution notices from the Source form of the Work, - excluding those notices that do not pertain to any part of - the Derivative Works; and - - (d) If the Work includes a "NOTICE" text file as part of its - distribution, then any Derivative Works that You distribute must - include a readable copy of the attribution notices contained - within such NOTICE file, excluding those notices that do not - pertain to any part of the Derivative Works, in at least one - of the following places: within a NOTICE text file distributed - as part of the Derivative Works; within the Source form or - documentation, if provided along with the Derivative Works; or, - within a display generated by the Derivative Works, if and - wherever such third-party notices normally appear. The contents - of the NOTICE file are for informational purposes only and - do not modify the License. You may add Your own attribution - notices within Derivative Works that You distribute, alongside - or as an addendum to the NOTICE text from the Work, provided - that such additional attribution notices cannot be construed - as modifying the License. - - You may add Your own copyright statement to Your modifications and - may provide additional or different license terms and conditions - for use, reproduction, or distribution of Your modifications, or - for any such Derivative Works as a whole, provided Your use, - reproduction, and distribution of the Work otherwise complies with - the conditions stated in this License. - - 5. Submission of Contributions. Unless You explicitly state otherwise, - any Contribution intentionally submitted for inclusion in the Work - by You to the Licensor shall be under the terms and conditions of - this License, without any additional terms or conditions. - Notwithstanding the above, nothing herein shall supersede or modify - the terms of any separate license agreement you may have executed - with Licensor regarding such Contributions. - - 6. Trademarks. This License does not grant permission to use the trade - names, trademarks, service marks, or product names of the Licensor, - except as required for reasonable and customary use in describing the - origin of the Work and reproducing the content of the NOTICE file. - - 7. Disclaimer of Warranty. Unless required by applicable law or - agreed to in writing, Licensor provides the Work (and each - Contributor provides its Contributions) on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or - implied, including, without limitation, any warranties or conditions - of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A - PARTICULAR PURPOSE. You are solely responsible for determining the - appropriateness of using or redistributing the Work and assume any - risks associated with Your exercise of permissions under this License. - - 8. Limitation of Liability. In no event and under no legal theory, - whether in tort (including negligence), contract, or otherwise, - unless required by applicable law (such as deliberate and grossly - negligent acts) or agreed to in writing, shall any Contributor be - liable to You for damages, including any direct, indirect, special, - incidental, or consequential damages of any character arising as a - result of this License or out of the use or inability to use the - Work (including but not limited to damages for loss of goodwill, - work stoppage, computer failure or malfunction, or any and all - other commercial damages or losses), even if such Contributor - has been advised of the possibility of such damages. - - 9. Accepting Warranty or Additional Liability. While redistributing - the Work or Derivative Works thereof, You may choose to offer, - and charge a fee for, acceptance of support, warranty, indemnity, - or other liability obligations and/or rights consistent with this - License. However, in accepting such obligations, You may act only - on Your own behalf and on Your sole responsibility, not on behalf - of any other Contributor, and only if You agree to indemnify, - defend, and hold each Contributor harmless for any liability - incurred by, or claims asserted against, such Contributor by reason - of your accepting any such warranty or additional liability. - - END OF TERMS AND CONDITIONS - ---- - -MIT License - -Copyright (c) 2024-2025 Model Context Protocol a Series of LF Projects, LLC. - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. - ---- - -Creative Commons Attribution 4.0 International (CC-BY-4.0) - -Documentation in this project (excluding specifications) is licensed under -CC-BY-4.0. See https://creativecommons.org/licenses/by/4.0/legalcode for -the full license text. diff --git a/client/README.md b/client/README.md deleted file mode 100644 index 780c92d8b..000000000 --- a/client/README.md +++ /dev/null @@ -1,50 +0,0 @@ -# React + TypeScript + Vite - -This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. - -Currently, two official plugins are available: - -- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh -- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh - -## Expanding the ESLint configuration - -If you are developing a production application, we recommend updating the configuration to enable type aware lint rules: - -- Configure the top-level `parserOptions` property like this: - -```js -export default tseslint.config({ - languageOptions: { - // other options... - parserOptions: { - project: ["./tsconfig.node.json", "./tsconfig.app.json"], - tsconfigRootDir: import.meta.dirname, - }, - }, -}); -``` - -- Replace `tseslint.configs.recommended` to `tseslint.configs.recommendedTypeChecked` or `tseslint.configs.strictTypeChecked` -- Optionally add `...tseslint.configs.stylisticTypeChecked` -- Install [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) and update the config: - -```js -// eslint.config.js -import react from "eslint-plugin-react"; - -export default tseslint.config({ - // Set the react version - settings: { react: { version: "18.3" } }, - plugins: { - // Add the react plugin - react, - }, - rules: { - // other rules... - // Enable its recommended rules - ...react.configs.recommended.rules, - ...react.configs["jsx-runtime"].rules, - }, -}); -``` diff --git a/client/bin/client.js b/client/bin/client.js deleted file mode 100755 index 2a7419e66..000000000 --- a/client/bin/client.js +++ /dev/null @@ -1,62 +0,0 @@ -#!/usr/bin/env node - -import open from "open"; -import { join, dirname } from "path"; -import { fileURLToPath } from "url"; -import handler from "serve-handler"; -import http from "http"; - -const __dirname = dirname(fileURLToPath(import.meta.url)); -const distPath = join(__dirname, "../dist"); - -const server = http.createServer((request, response) => { - const handlerOptions = { - public: distPath, - rewrites: [{ source: "/**", destination: "/index.html" }], - headers: [ - { - // Ensure index.html is never cached - source: "index.html", - headers: [ - { - key: "Cache-Control", - value: "no-cache, no-store, max-age=0", - }, - ], - }, - { - // Allow long-term caching for hashed assets - source: "assets/**", - headers: [ - { - key: "Cache-Control", - value: "public, max-age=31536000, immutable", - }, - ], - }, - ], - }; - - return handler(request, response, handlerOptions); -}); - -const port = parseInt(process.env.CLIENT_PORT || "6274", 10); -const host = process.env.HOST || "localhost"; -server.on("listening", () => { - const url = process.env.INSPECTOR_URL || `http://${host}:${port}`; - console.log(`\n🚀 MCP Inspector is up and running at:\n ${url}\n`); - if (process.env.MCP_AUTO_OPEN_ENABLED !== "false") { - console.log(`🌐 Opening browser...`); - open(url); - } -}); -server.on("error", (err) => { - if (err.message.includes(`EADDRINUSE`)) { - console.error( - `❌ MCP Inspector PORT IS IN USE at http://${host}:${port} ❌ `, - ); - } else { - throw err; - } -}); -server.listen(port, host); diff --git a/client/bin/start.js b/client/bin/start.js deleted file mode 100755 index 7e7e013af..000000000 --- a/client/bin/start.js +++ /dev/null @@ -1,350 +0,0 @@ -#!/usr/bin/env node - -import open from "open"; -import { resolve, dirname } from "path"; -import { spawnPromise, spawn } from "spawn-rx"; -import { fileURLToPath } from "url"; -import { randomBytes } from "crypto"; - -const __dirname = dirname(fileURLToPath(import.meta.url)); -const DEFAULT_MCP_PROXY_LISTEN_PORT = "6277"; - -function delay(ms) { - return new Promise((resolve) => setTimeout(resolve, ms, true)); -} - -function getClientUrl(port, authDisabled, sessionToken, serverPort) { - const host = process.env.HOST || "localhost"; - const baseUrl = `http://${host}:${port}`; - - const params = new URLSearchParams(); - if (serverPort && serverPort !== DEFAULT_MCP_PROXY_LISTEN_PORT) { - params.set("MCP_PROXY_PORT", serverPort); - } - if (!authDisabled) { - params.set("MCP_PROXY_AUTH_TOKEN", sessionToken); - } - return params.size > 0 ? `${baseUrl}/?${params.toString()}` : baseUrl; -} - -async function startDevServer(serverOptions) { - const { - SERVER_PORT, - CLIENT_PORT, - sessionToken, - envVars, - abort, - transport, - serverUrl, - } = serverOptions; - const serverCommand = "npx"; - const serverArgs = ["tsx", "watch", "--clear-screen=false", "src/index.ts"]; - const isWindows = process.platform === "win32"; - - const spawnOptions = { - cwd: resolve(__dirname, "../..", "server"), - env: { - ...process.env, - SERVER_PORT, - CLIENT_PORT, - MCP_PROXY_AUTH_TOKEN: sessionToken, - MCP_ENV_VARS: JSON.stringify(envVars), - ...(transport ? { MCP_TRANSPORT: transport } : {}), - ...(serverUrl ? { MCP_SERVER_URL: serverUrl } : {}), - }, - signal: abort.signal, - echoOutput: true, - }; - - // For Windows, we need to ignore stdin to simulate < NUL - // spawn-rx's 'stdin' option expects an Observable, not 'ignore' - // Use Node's stdio option instead - if (isWindows) { - spawnOptions.stdio = ["ignore", "pipe", "pipe"]; - } - - const server = spawn(serverCommand, serverArgs, spawnOptions); - - // Give server time to start - const serverOk = await Promise.race([ - new Promise((resolve) => { - server.subscribe({ - complete: () => resolve(false), - error: () => resolve(false), - next: () => {}, // We're using echoOutput - }); - }), - delay(3000).then(() => true), - ]); - - return { server, serverOk }; -} - -async function startProdServer(serverOptions) { - const { - SERVER_PORT, - CLIENT_PORT, - sessionToken, - envVars, - abort, - command, - mcpServerArgs, - transport, - serverUrl, - } = serverOptions; - const inspectorServerPath = resolve( - __dirname, - "../..", - "server", - "build", - "index.js", - ); - - const server = spawnPromise( - "node", - [ - inspectorServerPath, - ...(command ? [`--command=${command}`] : []), - ...(mcpServerArgs && mcpServerArgs.length > 0 - ? [`--args=${mcpServerArgs.join(" ")}`] - : []), - ...(transport ? [`--transport=${transport}`] : []), - ...(serverUrl ? [`--server-url=${serverUrl}`] : []), - ], - { - env: { - ...process.env, - SERVER_PORT, - CLIENT_PORT, - MCP_PROXY_AUTH_TOKEN: sessionToken, - MCP_ENV_VARS: JSON.stringify(envVars), - }, - signal: abort.signal, - echoOutput: true, - }, - ); - - // Make sure server started before starting client - const serverOk = await Promise.race([server, delay(2 * 1000)]); - - return { server, serverOk }; -} - -async function startDevClient(clientOptions) { - const { - CLIENT_PORT, - SERVER_PORT, - authDisabled, - sessionToken, - abort, - cancelled, - } = clientOptions; - const clientCommand = "npx"; - const host = process.env.HOST || "localhost"; - const clientArgs = ["vite", "--port", CLIENT_PORT, "--host", host]; - const isWindows = process.platform === "win32"; - - const spawnOptions = { - cwd: resolve(__dirname, ".."), - env: { ...process.env, CLIENT_PORT }, - signal: abort.signal, - echoOutput: true, - }; - - // For Windows, we need to ignore stdin to prevent hanging - if (isWindows) { - spawnOptions.stdio = ["ignore", "pipe", "pipe"]; - } - - const client = spawn(clientCommand, clientArgs, spawnOptions); - - const url = getClientUrl( - CLIENT_PORT, - authDisabled, - sessionToken, - SERVER_PORT, - ); - - // Give vite time to start before opening or logging the URL - setTimeout(() => { - console.log(`\n🚀 MCP Inspector is up and running at:\n ${url}\n`); - if (process.env.MCP_AUTO_OPEN_ENABLED !== "false") { - console.log("🌐 Opening browser..."); - open(url); - } - }, 3000); - - await new Promise((resolve) => { - client.subscribe({ - complete: resolve, - error: (err) => { - if (!cancelled || process.env.DEBUG) { - console.error("Client error:", err); - } - resolve(null); - }, - next: () => {}, // We're using echoOutput - }); - }); -} - -async function startProdClient(clientOptions) { - const { - CLIENT_PORT, - SERVER_PORT, - authDisabled, - sessionToken, - abort, - cancelled, - } = clientOptions; - const inspectorClientPath = resolve( - __dirname, - "../..", - "client", - "bin", - "client.js", - ); - - const url = getClientUrl( - CLIENT_PORT, - authDisabled, - sessionToken, - SERVER_PORT, - ); - - await spawnPromise("node", [inspectorClientPath], { - env: { - ...process.env, - CLIENT_PORT, - INSPECTOR_URL: url, - }, - signal: abort.signal, - echoOutput: true, - }); -} - -async function main() { - // Parse command line arguments - const args = process.argv.slice(2); - const envVars = {}; - const mcpServerArgs = []; - let command = null; - let parsingFlags = true; - let isDev = false; - let transport = null; - let serverUrl = null; - - for (let i = 0; i < args.length; i++) { - const arg = args[i]; - - if (parsingFlags && arg === "--") { - parsingFlags = false; - continue; - } - - if (parsingFlags && arg === "--dev") { - isDev = true; - continue; - } - - if (parsingFlags && arg === "--transport" && i + 1 < args.length) { - transport = args[++i]; - continue; - } - - if (parsingFlags && arg === "--server-url" && i + 1 < args.length) { - serverUrl = args[++i]; - continue; - } - - if (parsingFlags && arg === "-e" && i + 1 < args.length) { - const envVar = args[++i]; - const equalsIndex = envVar.indexOf("="); - - if (equalsIndex !== -1) { - const key = envVar.substring(0, equalsIndex); - const value = envVar.substring(equalsIndex + 1); - envVars[key] = value; - } else { - envVars[envVar] = ""; - } - } else if (!command && !isDev) { - command = arg; - } else if (!isDev) { - mcpServerArgs.push(arg); - } - } - - const CLIENT_PORT = process.env.CLIENT_PORT ?? "6274"; - const SERVER_PORT = process.env.SERVER_PORT ?? DEFAULT_MCP_PROXY_LISTEN_PORT; - - console.log( - isDev - ? "Starting MCP inspector in development mode..." - : "Starting MCP inspector...", - ); - - // Use provided token from environment or generate a new one - const sessionToken = - process.env.MCP_PROXY_AUTH_TOKEN || randomBytes(32).toString("hex"); - const authDisabled = !!process.env.DANGEROUSLY_OMIT_AUTH; - - const abort = new AbortController(); - - let cancelled = false; - process.on("SIGINT", () => { - cancelled = true; - abort.abort(); - }); - - let server, serverOk; - - try { - const serverOptions = { - SERVER_PORT, - CLIENT_PORT, - sessionToken, - envVars, - abort, - command, - mcpServerArgs, - transport, - serverUrl, - }; - - const result = isDev - ? await startDevServer(serverOptions) - : await startProdServer(serverOptions); - - server = result.server; - serverOk = result.serverOk; - } catch (error) {} - - if (serverOk) { - try { - const clientOptions = { - CLIENT_PORT, - SERVER_PORT, - authDisabled, - sessionToken, - abort, - cancelled, - }; - - await (isDev - ? startDevClient(clientOptions) - : startProdClient(clientOptions)); - } catch (e) { - if (!cancelled || process.env.DEBUG) throw e; - } - } - - return 0; -} - -main() - .then((_) => process.exit(0)) - .catch((e) => { - console.error(e); - process.exit(1); - }); diff --git a/client/components.json b/client/components.json deleted file mode 100644 index d296ddc68..000000000 --- a/client/components.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "$schema": "https://ui.shadcn.com/schema.json", - "style": "new-york", - "rsc": false, - "tsx": true, - "tailwind": { - "config": "tailwind.config.js", - "css": "src/index.css", - "baseColor": "slate", - "cssVariables": true, - "prefix": "" - }, - "aliases": { - "components": "@/components", - "utils": "@/lib/utils", - "ui": "@/components/ui", - "lib": "@/lib", - "hooks": "@/hooks" - } -} diff --git a/client/e2e/cli-arguments.spec.ts b/client/e2e/cli-arguments.spec.ts deleted file mode 100644 index a4dcdcce2..000000000 --- a/client/e2e/cli-arguments.spec.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { test, expect } from "@playwright/test"; - -// These tests verify that CLI arguments correctly set URL parameters -// The CLI should parse config files and pass transport/serverUrl as URL params -test.describe("CLI Arguments @cli", () => { - test("should pass transport parameter from command line", async ({ - page, - }) => { - // Simulate: npx . --transport sse --server-url http://localhost:3000/sse - await page.goto( - "http://localhost:6274/?transport=sse&serverUrl=http://localhost:3000/sse", - ); - - // Wait for the Transport Type dropdown to be visible - const selectTrigger = page.getByLabel("Transport Type"); - await expect(selectTrigger).toBeVisible(); - - // Verify transport dropdown shows SSE - await expect(selectTrigger).toContainText("SSE"); - - // Verify URL field is visible and populated - const urlInput = page.locator("#sse-url-input"); - await expect(urlInput).toBeVisible(); - await expect(urlInput).toHaveValue("http://localhost:3000/sse"); - }); - - test("should pass transport parameter for streamable-http", async ({ - page, - }) => { - // Simulate config with streamable-http transport - await page.goto( - "http://localhost:6274/?transport=streamable-http&serverUrl=http://localhost:3000/mcp", - ); - - // Wait for the Transport Type dropdown to be visible - const selectTrigger = page.getByLabel("Transport Type"); - await expect(selectTrigger).toBeVisible(); - - // Verify transport dropdown shows Streamable HTTP - await expect(selectTrigger).toContainText("Streamable HTTP"); - - // Verify URL field is visible and populated - const urlInput = page.locator("#sse-url-input"); - await expect(urlInput).toBeVisible(); - await expect(urlInput).toHaveValue("http://localhost:3000/mcp"); - }); - - test("should not pass transport parameter for stdio config", async ({ - page, - }) => { - // Simulate stdio config (no transport param needed) - await page.goto("http://localhost:6274/"); - - // Wait for the Transport Type dropdown to be visible - const selectTrigger = page.getByLabel("Transport Type"); - await expect(selectTrigger).toBeVisible(); - - // Verify transport dropdown defaults to STDIO - await expect(selectTrigger).toContainText("STDIO"); - - // Verify command/args fields are visible - await expect(page.locator("#command-input")).toBeVisible(); - await expect(page.locator("#arguments-input")).toBeVisible(); - }); -}); diff --git a/client/e2e/global-teardown.js b/client/e2e/global-teardown.js deleted file mode 100644 index 9308d940f..000000000 --- a/client/e2e/global-teardown.js +++ /dev/null @@ -1,18 +0,0 @@ -import { rimraf } from "rimraf"; - -async function globalTeardown() { - if (!process.env.CI) { - console.log("Cleaning up test-results directory..."); - // Add a small delay to ensure all Playwright files are written - await new Promise((resolve) => setTimeout(resolve, 100)); - await rimraf("./e2e/test-results"); - console.log("Test-results directory cleaned up."); - } -} - -export default globalTeardown; - -// Call the function when this script is run directly -if (import.meta.url === `file://${process.argv[1]}`) { - globalTeardown().catch(console.error); -} diff --git a/client/e2e/startup-state.spec.ts b/client/e2e/startup-state.spec.ts deleted file mode 100644 index 414ebd3e6..000000000 --- a/client/e2e/startup-state.spec.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { test, expect } from "@playwright/test"; - -// Adjust the URL if your dev server runs on a different port -const APP_URL = "http://localhost:6274/"; - -test.describe("Startup State", () => { - test("should not navigate to a tab when Inspector first opens", async ({ - page, - }) => { - await page.goto(APP_URL); - - // Check that there is no hash fragment in the URL - const url = page.url(); - expect(url).not.toContain("#"); - }); -}); diff --git a/client/e2e/transport-type-dropdown.spec.ts b/client/e2e/transport-type-dropdown.spec.ts deleted file mode 100644 index e4ac8fbb4..000000000 --- a/client/e2e/transport-type-dropdown.spec.ts +++ /dev/null @@ -1,113 +0,0 @@ -import { test, expect } from "@playwright/test"; - -// Adjust the URL if your dev server runs on a different port -const APP_URL = "http://localhost:6274/"; - -test.describe("Transport Type Dropdown", () => { - test("should have options for STDIO, SSE, and Streamable HTTP", async ({ - page, - }) => { - await page.goto(APP_URL); - - // Wait for the Transport Type dropdown to be visible - const selectTrigger = page.getByLabel("Transport Type"); - await expect(selectTrigger).toBeVisible(); - - // Open the dropdown - await selectTrigger.click(); - - // Check for the three options - await expect(page.getByRole("option", { name: "STDIO" })).toBeVisible(); - await expect(page.getByRole("option", { name: "SSE" })).toBeVisible(); - await expect( - page.getByRole("option", { name: "Streamable HTTP" }), - ).toBeVisible(); - }); - - test("should show Command and Arguments fields and hide URL field when Transport Type is STDIO", async ({ - page, - }) => { - await page.goto(APP_URL); - - // Wait for the Transport Type dropdown to be visible - const selectTrigger = page.getByLabel("Transport Type"); - await expect(selectTrigger).toBeVisible(); - - // Open the dropdown and select STDIO - await selectTrigger.click(); - await page.getByRole("option", { name: "STDIO" }).click(); - - // Wait for the form to update - await page.waitForTimeout(100); - - // Check that Command and Arguments fields are visible - await expect(page.locator("#command-input")).toBeVisible(); - await expect(page.locator("#arguments-input")).toBeVisible(); - - // Check that URL field is not visible - await expect(page.locator("#sse-url-input")).not.toBeVisible(); - - // Also verify the labels are present - await expect(page.getByText("Command")).toBeVisible(); - await expect(page.getByText("Arguments")).toBeVisible(); - await expect(page.getByText("URL")).not.toBeVisible(); - }); - - test("should show URL field and hide Command and Arguments fields when Transport Type is SSE", async ({ - page, - }) => { - await page.goto(APP_URL); - - // Wait for the Transport Type dropdown to be visible - const selectTrigger = page.getByLabel("Transport Type"); - await expect(selectTrigger).toBeVisible(); - - // Open the dropdown and select SSE - await selectTrigger.click(); - await page.getByRole("option", { name: "SSE" }).click(); - - // Wait for the form to update - await page.waitForTimeout(100); - - // Check that URL field is visible - await expect(page.locator("#sse-url-input")).toBeVisible(); - - // Check that Command and Arguments fields are not visible - await expect(page.locator("#command-input")).not.toBeVisible(); - await expect(page.locator("#arguments-input")).not.toBeVisible(); - - // Also verify the labels are present/absent - await expect(page.getByText("URL")).toBeVisible(); - await expect(page.getByText("Command")).not.toBeVisible(); - await expect(page.getByText("Arguments")).not.toBeVisible(); - }); - - test("should show URL field and hide Command and Arguments fields when Transport Type is Streamable HTTP", async ({ - page, - }) => { - await page.goto(APP_URL); - - // Wait for the Transport Type dropdown to be visible - const selectTrigger = page.getByLabel("Transport Type"); - await expect(selectTrigger).toBeVisible(); - - // Open the dropdown and select Streamable HTTP - await selectTrigger.click(); - await page.getByRole("option", { name: "Streamable HTTP" }).click(); - - // Wait for the form to update - await page.waitForTimeout(100); - - // Check that URL field is visible - await expect(page.locator("#sse-url-input")).toBeVisible(); - - // Check that Command and Arguments fields are not visible - await expect(page.locator("#command-input")).not.toBeVisible(); - await expect(page.locator("#arguments-input")).not.toBeVisible(); - - // Also verify the labels are present/absent - await expect(page.getByText("URL")).toBeVisible(); - await expect(page.getByText("Command")).not.toBeVisible(); - await expect(page.getByText("Arguments")).not.toBeVisible(); - }); -}); diff --git a/client/eslint.config.js b/client/eslint.config.js deleted file mode 100644 index 79a552ea9..000000000 --- a/client/eslint.config.js +++ /dev/null @@ -1,28 +0,0 @@ -import js from "@eslint/js"; -import globals from "globals"; -import reactHooks from "eslint-plugin-react-hooks"; -import reactRefresh from "eslint-plugin-react-refresh"; -import tseslint from "typescript-eslint"; - -export default tseslint.config( - { ignores: ["dist"] }, - { - extends: [js.configs.recommended, ...tseslint.configs.recommended], - files: ["**/*.{ts,tsx}"], - languageOptions: { - ecmaVersion: 2020, - globals: globals.browser, - }, - plugins: { - "react-hooks": reactHooks, - "react-refresh": reactRefresh, - }, - rules: { - ...reactHooks.configs.recommended.rules, - "react-refresh/only-export-components": [ - "warn", - { allowConstantExport: true }, - ], - }, - }, -); diff --git a/client/index.html b/client/index.html deleted file mode 100644 index b3736a822..000000000 --- a/client/index.html +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - MCP Inspector - - -
- - - diff --git a/client/jest.config.cjs b/client/jest.config.cjs deleted file mode 100644 index 704852961..000000000 --- a/client/jest.config.cjs +++ /dev/null @@ -1,43 +0,0 @@ -module.exports = { - preset: "ts-jest", - testEnvironment: "jest-fixed-jsdom", - moduleNameMapper: { - "^@/(.*)$": "/src/$1", - "\\.css$": "/src/__mocks__/styleMock.js", - }, - transform: { - "^.+\\.tsx?$": [ - "ts-jest", - { - jsx: "react-jsx", - tsconfig: "tsconfig.jest.json", - }, - ], - "^.+\\.m?js$": [ - "ts-jest", - { - tsconfig: "tsconfig.jest.json", - }, - ], - }, - extensionsToTreatAsEsm: [".ts", ".tsx"], - transformIgnorePatterns: ["node_modules/(?!(@modelcontextprotocol)/)"], - testRegex: "(/__tests__/.*|(\\.|/)(test|spec))\\.(jsx?|tsx?)$", - // Exclude directories and files that don't need to be tested - testPathIgnorePatterns: [ - "/node_modules/", - "/dist/", - "/bin/", - "/e2e/", - "\\.config\\.(js|ts|cjs|mjs)$", - ], - // Exclude the same patterns from coverage reports - coveragePathIgnorePatterns: [ - "/node_modules/", - "/dist/", - "/bin/", - "/e2e/", - "\\.config\\.(js|ts|cjs|mjs)$", - ], - randomize: true, -}; diff --git a/client/package.json b/client/package.json deleted file mode 100644 index fc0d9194d..000000000 --- a/client/package.json +++ /dev/null @@ -1,90 +0,0 @@ -{ - "name": "@modelcontextprotocol/inspector-client", - "version": "1.0.0", - "description": "Client-side application for the Model Context Protocol inspector", - "license": "SEE LICENSE IN LICENSE", - "author": "Model Context Protocol a Series of LF Projects, LLC.", - "homepage": "https://modelcontextprotocol.io", - "bugs": "https://github.com/modelcontextprotocol/inspector/issues", - "repository": { - "type": "git", - "url": "https://github.com/modelcontextprotocol/inspector", - "directory": "client" - }, - "type": "module", - "bin": { - "mcp-inspector-client": "./bin/start.js" - }, - "files": [ - "bin", - "dist", - "LICENSE" - ], - "scripts": { - "dev": "vite --port 6274", - "build": "tsc -b && vite build", - "lint": "eslint .", - "preview": "vite preview --port 6274", - "test": "jest --config jest.config.cjs", - "test:watch": "jest --config jest.config.cjs --watch", - "test:e2e": "playwright test e2e && npm run cleanup:e2e", - "cleanup:e2e": "node e2e/global-teardown.js" - }, - "dependencies": { - "@mcp-ui/client": "^6.0.0", - "@modelcontextprotocol/ext-apps": "^1.0.0", - "@modelcontextprotocol/sdk": "^1.25.2", - "@radix-ui/react-checkbox": "^1.1.4", - "@radix-ui/react-dialog": "^1.1.3", - "@radix-ui/react-icons": "^1.3.0", - "@radix-ui/react-label": "^2.1.0", - "@radix-ui/react-popover": "^1.1.3", - "@radix-ui/react-select": "^2.1.2", - "@radix-ui/react-slot": "^1.1.0", - "@radix-ui/react-switch": "^1.2.6", - "@radix-ui/react-tabs": "^1.1.1", - "@radix-ui/react-toast": "^1.2.6", - "@radix-ui/react-tooltip": "^1.1.8", - "ajv": "^6.12.6", - "class-variance-authority": "^0.7.0", - "clsx": "^2.1.1", - "cmdk": "^1.0.4", - "lucide-react": "^0.523.0", - "pkce-challenge": "^4.1.0", - "prismjs": "^1.30.0", - "react": "^18.3.1", - "react-dom": "^18.3.1", - "react-simple-code-editor": "^0.14.1", - "serve-handler": "^6.1.6", - "tailwind-merge": "^2.5.3", - "zod": "^3.25.76" - }, - "devDependencies": { - "@eslint/js": "^9.11.1", - "@testing-library/jest-dom": "^6.6.3", - "@testing-library/react": "^16.2.0", - "@types/jest": "^29.5.14", - "@types/node": "^22.17.0", - "@types/prismjs": "^1.26.5", - "@types/react": "^18.3.23", - "@types/react-dom": "^18.3.0", - "@types/serve-handler": "^6.1.4", - "@vitejs/plugin-react": "^5.0.4", - "autoprefixer": "^10.4.20", - "co": "^4.6.0", - "eslint": "^9.11.1", - "eslint-plugin-react-hooks": "^5.1.0-rc.0", - "eslint-plugin-react-refresh": "^0.4.12", - "globals": "^15.9.0", - "jest": "^29.7.0", - "jest-environment-jsdom": "^29.7.0", - "jest-fixed-jsdom": "^0.0.9", - "postcss": "^8.5.6", - "tailwindcss": "^3.4.13", - "tailwindcss-animate": "^1.0.7", - "ts-jest": "^29.4.0", - "typescript": "^5.5.3", - "typescript-eslint": "^8.38.0", - "vite": "^7.3.5" - } -} diff --git a/client/playwright.config.ts b/client/playwright.config.ts deleted file mode 100644 index 570dd054e..000000000 --- a/client/playwright.config.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { defineConfig, devices } from "@playwright/test"; - -/** - * @see https://playwright.dev/docs/test-configuration - */ -export default defineConfig({ - /* Run your local dev server before starting the tests */ - webServer: { - cwd: "..", - command: "npm run dev", - url: "http://localhost:6274", - reuseExistingServer: !process.env.CI, - }, - - testDir: "./e2e", - outputDir: "./e2e/test-results", - /* Run tests in files in parallel */ - fullyParallel: true, - /* Fail the build on CI if you accidentally left test.only in the source code. */ - forbidOnly: !!process.env.CI, - /* Retry on CI only */ - retries: process.env.CI ? 2 : 0, - /* Opt out of parallel tests on CI. */ - workers: process.env.CI ? 1 : undefined, - /* Reporter to use. See https://playwright.dev/docs/test-reporters */ - reporter: process.env.CI - ? [ - ["html", { outputFolder: "playwright-report" }], - ["json", { outputFile: "results.json" }], - ["line"], - ] - : [["line"]], - /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ - use: { - /* Base URL to use in actions like `await page.goto('/')`. */ - baseURL: "http://localhost:6274", - - /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ - trace: "on-first-retry", - - /* Take screenshots on failure */ - screenshot: "only-on-failure", - - /* Record video on failure */ - video: "retain-on-failure", - }, - - /* Configure projects for major browsers */ - projects: [ - { - name: "chromium", - use: { ...devices["Desktop Chrome"] }, - }, - - { - name: "firefox", - use: { ...devices["Desktop Firefox"] }, - }, - - // Skip WebKit on macOS due to compatibility issues - ...(process.platform !== "darwin" - ? [ - { - name: "webkit", - use: { ...devices["Desktop Safari"] }, - }, - ] - : []), - ], -}); diff --git a/client/postcss.config.js b/client/postcss.config.js deleted file mode 100644 index 2aa7205d4..000000000 --- a/client/postcss.config.js +++ /dev/null @@ -1,6 +0,0 @@ -export default { - plugins: { - tailwindcss: {}, - autoprefixer: {}, - }, -}; diff --git a/client/src/App.css b/client/src/App.css deleted file mode 100644 index 0d669ffa5..000000000 --- a/client/src/App.css +++ /dev/null @@ -1,35 +0,0 @@ -.logo { - height: 6em; - padding: 1.5em; - will-change: filter; - transition: filter 300ms; -} -.logo:hover { - filter: drop-shadow(0 0 2em #646cffaa); -} -.logo.react:hover { - filter: drop-shadow(0 0 2em #61dafbaa); -} - -@keyframes logo-spin { - from { - transform: rotate(0deg); - } - to { - transform: rotate(360deg); - } -} - -@media (prefers-reduced-motion: no-preference) { - a:nth-of-type(2) .logo { - animation: logo-spin infinite 20s linear; - } -} - -.card { - padding: 2em; -} - -.read-the-docs { - color: #888; -} diff --git a/client/src/App.tsx b/client/src/App.tsx deleted file mode 100644 index 7cf6d751a..000000000 --- a/client/src/App.tsx +++ /dev/null @@ -1,1800 +0,0 @@ -import { - ClientRequest, - CompatibilityCallToolResult, - CompatibilityCallToolResultSchema, - CreateMessageResult, - EmptyResultSchema, - GetPromptResultSchema, - ListPromptsResultSchema, - ListResourcesResultSchema, - ListResourceTemplatesResultSchema, - ListToolsResultSchema, - ReadResourceResultSchema, - Resource, - ResourceTemplate, - Root, - ServerNotification, - Tool, - LoggingLevel, - Task, - GetTaskResultSchema, -} from "@modelcontextprotocol/sdk/types.js"; -import { OAuthTokensSchema } from "@modelcontextprotocol/sdk/shared/auth.js"; -import type { - AnySchema, - SchemaOutput, -} from "@modelcontextprotocol/sdk/server/zod-compat.js"; -import { SESSION_KEYS, getServerSpecificKey } from "./lib/constants"; -import { - hasValidMetaName, - hasValidMetaPrefix, - isReservedMetaKey, -} from "@/utils/metaUtils"; -import { getToolUiResourceUri } from "@modelcontextprotocol/ext-apps/app-bridge"; -import { AuthDebuggerState, EMPTY_DEBUGGER_STATE } from "./lib/auth-types"; -import { OAuthStateMachine } from "./lib/oauth-state-machine"; -import { createProxyFetch } from "./lib/proxyFetch"; -import { cacheToolOutputSchemas } from "./utils/schemaUtils"; -import { cleanParams } from "./utils/paramUtils"; -import type { JsonSchemaType } from "./utils/jsonUtils"; -import React, { - Suspense, - useCallback, - useEffect, - useRef, - useState, -} from "react"; -import { useConnection } from "./lib/hooks/useConnection"; -import { - useDraggablePane, - useDraggableSidebar, -} from "./lib/hooks/useDraggablePane"; - -import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import { Button } from "@/components/ui/button"; -import { - AppWindow, - Bell, - Files, - FolderTree, - Hammer, - Hash, - Key, - ListTodo, - MessageSquare, - Settings, -} from "lucide-react"; - -import { z } from "zod"; -import "./App.css"; -import AuthDebugger from "./components/AuthDebugger"; -import ConsoleTab from "./components/ConsoleTab"; -import HistoryAndNotifications from "./components/HistoryAndNotifications"; -import PingTab from "./components/PingTab"; -import PromptsTab, { Prompt } from "./components/PromptsTab"; -import ResourcesTab from "./components/ResourcesTab"; -import RootsTab from "./components/RootsTab"; -import SamplingTab, { PendingRequest } from "./components/SamplingTab"; -import Sidebar from "./components/Sidebar"; -import ToolsTab from "./components/ToolsTab"; -import TasksTab from "./components/TasksTab"; -import AppsTab from "./components/AppsTab"; -import { InspectorConfig } from "./lib/configurationTypes"; -import { - getMCPProxyAddress, - getMCPProxyAuthToken, - getInitialSseUrl, - getInitialTransportType, - getInitialCommand, - getInitialArgs, - initializeInspectorConfig, - saveInspectorConfig, - getMCPTaskTtl, -} from "./utils/configUtils"; -import ElicitationTab, { - PendingElicitationRequest, - ElicitationResponse, -} from "./components/ElicitationTab"; -import { - CustomHeaders, - migrateFromLegacyAuth, -} from "./lib/types/customHeaders"; -import MetadataTab from "./components/MetadataTab"; - -const CONFIG_LOCAL_STORAGE_KEY = "inspectorConfig_v1"; - -type PrefilledAppsToolCall = { - id: number; - toolName: string; - params: Record; - result: CompatibilityCallToolResult; -}; - -const hasAppResourceUri = (tool: Tool): boolean => { - return Boolean(getToolUiResourceUri(tool)); -}; - -const cloneToolParams = ( - source: Record, -): Record => { - try { - return structuredClone(source); - } catch { - return { ...source }; - } -}; - -const filterReservedMetadata = ( - metadata: Record, -): Record => { - return Object.entries(metadata).reduce>( - (acc, [key, value]) => { - if ( - !isReservedMetaKey(key) && - hasValidMetaPrefix(key) && - hasValidMetaName(key) - ) { - acc[key] = value; - } - return acc; - }, - {}, - ); -}; - -const App = () => { - const [resources, setResources] = useState([]); - const [resourceTemplates, setResourceTemplates] = useState< - ResourceTemplate[] - >([]); - const [resourceContent, setResourceContent] = useState(""); - const [resourceContentMap, setResourceContentMap] = useState< - Record - >({}); - const [resourceErrorMap, setResourceErrorMap] = useState< - Record - >({}); - const [fetchingResources, setFetchingResources] = useState>( - new Set(), - ); - const [prompts, setPrompts] = useState([]); - const [promptContent, setPromptContent] = useState(""); - const [tools, setTools] = useState([]); - const [tasks, setTasks] = useState([]); - const [toolResult, setToolResult] = - useState(null); - const [prefilledAppsToolCall, setPrefilledAppsToolCall] = - useState(null); - const [errors, setErrors] = useState>({ - resources: null, - prompts: null, - tools: null, - tasks: null, - }); - const [command, setCommand] = useState(getInitialCommand); - const [args, setArgs] = useState(getInitialArgs); - - const [sseUrl, setSseUrl] = useState(getInitialSseUrl); - const [transportType, setTransportType] = useState< - "stdio" | "sse" | "streamable-http" - >(getInitialTransportType); - const [connectionType, setConnectionType] = useState<"direct" | "proxy">( - () => { - return ( - (localStorage.getItem("lastConnectionType") as "direct" | "proxy") || - "proxy" - ); - }, - ); - const [logLevel, setLogLevel] = useState("debug"); - const [notifications, setNotifications] = useState([]); - const [roots, setRoots] = useState([]); - const [env, setEnv] = useState>({}); - - const [config, setConfig] = useState(() => - initializeInspectorConfig(CONFIG_LOCAL_STORAGE_KEY), - ); - const [bearerToken, setBearerToken] = useState(() => { - return localStorage.getItem("lastBearerToken") || ""; - }); - - const [headerName, setHeaderName] = useState(() => { - return localStorage.getItem("lastHeaderName") || ""; - }); - - const [oauthClientId, setOauthClientId] = useState(() => { - return localStorage.getItem("lastOauthClientId") || ""; - }); - - const [oauthScope, setOauthScope] = useState(() => { - return localStorage.getItem("lastOauthScope") || ""; - }); - - const [oauthClientSecret, setOauthClientSecret] = useState(() => { - return localStorage.getItem("lastOauthClientSecret") || ""; - }); - - // Custom headers state with migration from legacy auth - const [customHeaders, setCustomHeaders] = useState(() => { - const savedHeaders = localStorage.getItem("lastCustomHeaders"); - if (savedHeaders) { - try { - return JSON.parse(savedHeaders); - } catch (error) { - console.warn( - `Failed to parse custom headers: "${savedHeaders}", will try legacy migration`, - error, - ); - // Fall back to migration if JSON parsing fails - } - } - - // Migrate from legacy auth if available - const legacyToken = localStorage.getItem("lastBearerToken") || ""; - const legacyHeaderName = localStorage.getItem("lastHeaderName") || ""; - - if (legacyToken) { - return migrateFromLegacyAuth(legacyToken, legacyHeaderName); - } - - // Default to empty array - return [ - { - name: "Authorization", - value: "Bearer ", - enabled: false, - }, - ]; - }); - - const [pendingSampleRequests, setPendingSampleRequests] = useState< - Array< - PendingRequest & { - resolve: (result: CreateMessageResult) => void; - reject: (error: Error) => void; - } - > - >([]); - const [pendingElicitationRequests, setPendingElicitationRequests] = useState< - Array< - PendingElicitationRequest & { - resolve: (response: ElicitationResponse) => void; - decline: (error: Error) => void; - } - > - >([]); - const [isAuthDebuggerVisible, setIsAuthDebuggerVisible] = useState(false); - - const [authState, setAuthState] = - useState(EMPTY_DEBUGGER_STATE); - - // Metadata state - persisted in localStorage - const [metadata, setMetadata] = useState>(() => { - const savedMetadata = localStorage.getItem("lastMetadata"); - if (savedMetadata) { - try { - const parsed = JSON.parse(savedMetadata); - if (parsed && typeof parsed === "object") { - return filterReservedMetadata(parsed); - } - } catch (error) { - console.warn("Failed to parse saved metadata:", error); - } - } - return {}; - }); - - const updateAuthState = (updates: Partial) => { - setAuthState((prev) => ({ ...prev, ...updates })); - }; - - const handleMetadataChange = (newMetadata: Record) => { - const sanitizedMetadata = filterReservedMetadata(newMetadata); - setMetadata(sanitizedMetadata); - localStorage.setItem("lastMetadata", JSON.stringify(sanitizedMetadata)); - }; - const nextRequestId = useRef(0); - const rootsRef = useRef([]); - - const [selectedResource, setSelectedResource] = useState( - null, - ); - const [resourceSubscriptions, setResourceSubscriptions] = useState< - Set - >(new Set()); - - const [selectedPrompt, setSelectedPrompt] = useState(null); - const [selectedTool, setSelectedTool] = useState(null); - const [selectedTask, setSelectedTask] = useState(null); - const [isPollingTask, setIsPollingTask] = useState(false); - const [nextResourceCursor, setNextResourceCursor] = useState< - string | undefined - >(); - const [nextResourceTemplateCursor, setNextResourceTemplateCursor] = useState< - string | undefined - >(); - const [nextPromptCursor, setNextPromptCursor] = useState< - string | undefined - >(); - const [nextToolCursor, setNextToolCursor] = useState(); - const [nextTaskCursor, setNextTaskCursor] = useState(); - const progressTokenRef = useRef(0); - const prefilledAppsToolCallIdRef = useRef(0); - - const [activeTab, setActiveTab] = useState(() => { - const hash = window.location.hash.slice(1); - const initialTab = hash || "resources"; - return initialTab; - }); - - const currentTabRef = useRef(activeTab); - const lastToolCallOriginTabRef = useRef(activeTab); - - useEffect(() => { - currentTabRef.current = activeTab; - }, [activeTab]); - - const navigateToOriginatingTab = (originatingTab?: string) => { - if (!originatingTab) return; - - const validTabs = [ - ...(serverCapabilities?.resources ? ["resources"] : []), - ...(serverCapabilities?.prompts ? ["prompts"] : []), - ...(serverCapabilities?.tools ? ["tools"] : []), - ...(serverCapabilities?.tasks ? ["tasks"] : []), - "apps", - "ping", - "sampling", - "elicitations", - "roots", - "auth", - "metadata", - ]; - - if (!validTabs.includes(originatingTab)) return; - - setActiveTab(originatingTab); - window.location.hash = originatingTab; - - setTimeout(() => { - setActiveTab(originatingTab); - window.location.hash = originatingTab; - }, 100); - }; - - const { height: historyPaneHeight, handleDragStart } = useDraggablePane(300); - const { - width: sidebarWidth, - isDragging: isSidebarDragging, - handleDragStart: handleSidebarDragStart, - } = useDraggableSidebar(320); - - const selectedTaskRef = useRef(null); - useEffect(() => { - selectedTaskRef.current = selectedTask; - }, [selectedTask]); - - const { - connectionStatus, - serverCapabilities, - serverImplementation, - mcpClient, - requestHistory, - clearRequestHistory, - makeRequest, - cancelTask: cancelMcpTask, - listTasks: listMcpTasks, - sendNotification, - handleCompletion, - completionsSupported, - connect: connectMcpServer, - disconnect: disconnectMcpServer, - } = useConnection({ - transportType, - command, - args, - sseUrl, - env, - customHeaders, - oauthClientId, - oauthClientSecret, - oauthScope, - config, - connectionType, - onNotification: (notification) => { - setNotifications((prev) => [...prev, notification as ServerNotification]); - - if (notification.method === "notifications/tasks/list_changed") { - void listTasks(); - } - - if (notification.method === "notifications/tasks/status") { - const task = notification.params as unknown as Task; - setTasks((prev) => { - const exists = prev.some((t) => t.taskId === task.taskId); - if (exists) { - return prev.map((t) => (t.taskId === task.taskId ? task : t)); - } else { - return [task, ...prev]; - } - }); - if (selectedTaskRef.current?.taskId === task.taskId) { - setSelectedTask(task); - } - } - }, - onPendingRequest: (request, resolve, reject) => { - const currentTab = lastToolCallOriginTabRef.current; - setPendingSampleRequests((prev) => [ - ...prev, - { - id: nextRequestId.current++, - request, - originatingTab: currentTab, - resolve, - reject, - }, - ]); - - setActiveTab("sampling"); - window.location.hash = "sampling"; - }, - onElicitationRequest: (request, resolve) => { - const currentTab = lastToolCallOriginTabRef.current; - - const requestId = nextRequestId.current++; - const requestData = - request.params.mode === "url" - ? { - id: requestId, - mode: "url" as const, - message: request.params.message, - url: request.params.url, - elicitationId: request.params.elicitationId, - } - : { - id: requestId, - message: request.params.message, - requestedSchema: request.params.requestedSchema, - }; - - setPendingElicitationRequests((prev) => [ - ...prev, - { - id: requestId, - request: requestData, - originatingTab: currentTab, - resolve, - decline: (error: Error) => { - console.error("Elicitation request rejected:", error); - }, - }, - ]); - - setActiveTab("elicitations"); - window.location.hash = "elicitations"; - }, - getRoots: () => rootsRef.current, - defaultLoggingLevel: logLevel, - metadata, - }); - - useEffect(() => { - if (serverCapabilities) { - const hash = window.location.hash.slice(1); - - const validTabs = [ - ...(serverCapabilities?.resources ? ["resources"] : []), - ...(serverCapabilities?.prompts ? ["prompts"] : []), - ...(serverCapabilities?.tools ? ["tools"] : []), - ...(serverCapabilities?.tasks ? ["tasks"] : []), - "apps", - "ping", - "sampling", - "elicitations", - "roots", - "auth", - "metadata", - ]; - - const isValidTab = validTabs.includes(hash); - - if (!isValidTab) { - const defaultTab = serverCapabilities?.resources - ? "resources" - : serverCapabilities?.prompts - ? "prompts" - : serverCapabilities?.tools - ? "tools" - : serverCapabilities?.tasks - ? "tasks" - : "ping"; - - setActiveTab(defaultTab); - window.location.hash = defaultTab; - } - } - }, [serverCapabilities]); - - useEffect(() => { - if (mcpClient && activeTab === "tasks") { - void listTasks(); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [mcpClient, activeTab]); - - useEffect(() => { - if (mcpClient && activeTab === "apps" && serverCapabilities?.tools) { - void listTools(); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [mcpClient, activeTab, serverCapabilities?.tools]); - - useEffect(() => { - localStorage.setItem("lastCommand", command); - }, [command]); - - useEffect(() => { - localStorage.setItem("lastArgs", args); - }, [args]); - - useEffect(() => { - localStorage.setItem("lastSseUrl", sseUrl); - }, [sseUrl]); - - useEffect(() => { - localStorage.setItem("lastTransportType", transportType); - }, [transportType]); - - useEffect(() => { - localStorage.setItem("lastConnectionType", connectionType); - }, [connectionType]); - - useEffect(() => { - if (bearerToken) { - localStorage.setItem("lastBearerToken", bearerToken); - } else { - localStorage.removeItem("lastBearerToken"); - } - }, [bearerToken]); - - useEffect(() => { - if (headerName) { - localStorage.setItem("lastHeaderName", headerName); - } else { - localStorage.removeItem("lastHeaderName"); - } - }, [headerName]); - - useEffect(() => { - localStorage.setItem("lastCustomHeaders", JSON.stringify(customHeaders)); - }, [customHeaders]); - - // Auto-migrate from legacy auth when custom headers are empty but legacy auth exists - useEffect(() => { - if (customHeaders.length === 0 && (bearerToken || headerName)) { - const migratedHeaders = migrateFromLegacyAuth(bearerToken, headerName); - if (migratedHeaders.length > 0) { - setCustomHeaders(migratedHeaders); - // Clear legacy auth after migration - setBearerToken(""); - setHeaderName(""); - } - } - }, [bearerToken, headerName, customHeaders, setCustomHeaders]); - - useEffect(() => { - localStorage.setItem("lastOauthClientId", oauthClientId); - }, [oauthClientId]); - - useEffect(() => { - localStorage.setItem("lastOauthScope", oauthScope); - }, [oauthScope]); - - useEffect(() => { - localStorage.setItem("lastOauthClientSecret", oauthClientSecret); - }, [oauthClientSecret]); - - useEffect(() => { - saveInspectorConfig(CONFIG_LOCAL_STORAGE_KEY, config); - }, [config]); - - const onOAuthConnect = useCallback( - (serverUrl: string) => { - setSseUrl(serverUrl); - setIsAuthDebuggerVisible(false); - void connectMcpServer(); - }, - [connectMcpServer], - ); - - const onOAuthDebugConnect = useCallback( - async ({ - authorizationCode, - errorMsg, - restoredState, - }: { - authorizationCode?: string; - errorMsg?: string; - restoredState?: AuthDebuggerState; - }) => { - setIsAuthDebuggerVisible(true); - - if (errorMsg) { - updateAuthState({ - latestError: new Error(errorMsg), - }); - return; - } - - if (restoredState && authorizationCode) { - let currentState: AuthDebuggerState = { - ...restoredState, - authorizationCode, - oauthStep: "token_request", - isInitiatingAuth: true, - statusMessage: null, - latestError: null, - }; - - try { - const fetchFn = - connectionType === "proxy" && config - ? createProxyFetch(config) - : undefined; - const stateMachine = new OAuthStateMachine( - sseUrl, - (updates) => { - currentState = { ...currentState, ...updates }; - }, - fetchFn, - ); - - while ( - currentState.oauthStep !== "complete" && - currentState.oauthStep !== "authorization_code" - ) { - await stateMachine.executeStep(currentState); - } - - if (currentState.oauthStep === "complete") { - updateAuthState({ - ...currentState, - statusMessage: { - type: "success", - message: "Authentication completed successfully", - }, - isInitiatingAuth: false, - }); - } - } catch (error) { - console.error("OAuth continuation error:", error); - updateAuthState({ - latestError: - error instanceof Error ? error : new Error(String(error)), - statusMessage: { - type: "error", - message: `Failed to complete OAuth flow: ${error instanceof Error ? error.message : String(error)}`, - }, - isInitiatingAuth: false, - }); - } - } else if (authorizationCode) { - updateAuthState({ - authorizationCode, - oauthStep: "token_request", - }); - } - }, - [sseUrl, connectionType, config], - ); - - useEffect(() => { - const loadOAuthTokens = async () => { - try { - if (sseUrl) { - const key = getServerSpecificKey(SESSION_KEYS.TOKENS, sseUrl); - const tokens = sessionStorage.getItem(key); - if (tokens) { - const parsedTokens = await OAuthTokensSchema.parseAsync( - JSON.parse(tokens), - ); - updateAuthState({ - oauthTokens: parsedTokens, - oauthStep: "complete", - }); - } - } - } catch (error) { - console.error("Error loading OAuth tokens:", error); - } - }; - - loadOAuthTokens(); - }, [sseUrl]); - - useEffect(() => { - const headers: HeadersInit = {}; - const { token: proxyAuthToken, header: proxyAuthTokenHeader } = - getMCPProxyAuthToken(config); - if (proxyAuthToken) { - headers[proxyAuthTokenHeader] = `Bearer ${proxyAuthToken}`; - } - - fetch(`${getMCPProxyAddress(config)}/config`, { headers }) - .then((response) => response.json()) - .then((data) => { - setEnv(data.defaultEnvironment); - if (data.defaultCommand) { - setCommand(data.defaultCommand); - } - if (data.defaultArgs) { - setArgs(data.defaultArgs); - } - if (data.defaultTransport) { - setTransportType( - data.defaultTransport as "stdio" | "sse" | "streamable-http", - ); - } - if (data.defaultServerUrl) { - setSseUrl(data.defaultServerUrl); - } - }) - .catch((error) => - console.error("Error fetching default environment:", error), - ); - }, [config]); - - useEffect(() => { - rootsRef.current = roots; - }, [roots]); - - useEffect(() => { - if (mcpClient && !window.location.hash) { - const defaultTab = serverCapabilities?.resources - ? "resources" - : serverCapabilities?.prompts - ? "prompts" - : serverCapabilities?.tools - ? "tools" - : serverCapabilities?.tasks - ? "tasks" - : "ping"; - window.location.hash = defaultTab; - } else if (!mcpClient && window.location.hash) { - // Clear hash when disconnected - completely remove the fragment - window.history.replaceState( - null, - "", - window.location.pathname + window.location.search, - ); - } - }, [mcpClient, serverCapabilities]); - - useEffect(() => { - const handleHashChange = () => { - const hash = window.location.hash.slice(1); - if (hash && hash !== activeTab) { - setActiveTab(hash); - } - }; - - window.addEventListener("hashchange", handleHashChange); - return () => window.removeEventListener("hashchange", handleHashChange); - }, [activeTab]); - - const handleApproveSampling = (id: number, result: CreateMessageResult) => { - setPendingSampleRequests((prev) => { - const request = prev.find((r) => r.id === id); - request?.resolve(result); - - navigateToOriginatingTab(request?.originatingTab); - - return prev.filter((r) => r.id !== id); - }); - }; - - const handleRejectSampling = (id: number) => { - setPendingSampleRequests((prev) => { - const request = prev.find((r) => r.id === id); - request?.reject(new Error("Sampling request rejected")); - - navigateToOriginatingTab(request?.originatingTab); - - return prev.filter((r) => r.id !== id); - }); - }; - - const handleResolveElicitation = ( - id: number, - response: ElicitationResponse, - ) => { - setPendingElicitationRequests((prev) => { - const request = prev.find((r) => r.id === id); - if (request) { - request.resolve(response); - - if (request.originatingTab) { - const originatingTab = request.originatingTab; - - const validTabs = [ - ...(serverCapabilities?.resources ? ["resources"] : []), - ...(serverCapabilities?.prompts ? ["prompts"] : []), - ...(serverCapabilities?.tools ? ["tools"] : []), - ...(serverCapabilities?.tasks ? ["tasks"] : []), - "apps", - "ping", - "sampling", - "elicitations", - "roots", - "auth", - "metadata", - ]; - - if (validTabs.includes(originatingTab)) { - setActiveTab(originatingTab); - window.location.hash = originatingTab; - - setTimeout(() => { - setActiveTab(originatingTab); - window.location.hash = originatingTab; - }, 100); - } - } - } - return prev.filter((r) => r.id !== id); - }); - }; - - const clearError = (tabKey: keyof typeof errors) => { - setErrors((prev) => ({ ...prev, [tabKey]: null })); - }; - - const sendMCPRequest = async ( - request: ClientRequest, - schema: T, - tabKey?: keyof typeof errors, - ): Promise> => { - try { - const response = await makeRequest(request, schema); - if (tabKey !== undefined) { - clearError(tabKey); - } - return response; - } catch (e) { - const errorString = (e as Error).message ?? String(e); - if (tabKey !== undefined) { - setErrors((prev) => ({ - ...prev, - [tabKey]: errorString, - })); - } - throw e; - } - }; - - const listResources = async () => { - const response = await sendMCPRequest( - { - method: "resources/list" as const, - params: nextResourceCursor ? { cursor: nextResourceCursor } : {}, - }, - ListResourcesResultSchema, - "resources", - ); - setResources(resources.concat(response.resources ?? [])); - setNextResourceCursor(response.nextCursor); - }; - - const listResourceTemplates = async () => { - const response = await sendMCPRequest( - { - method: "resources/templates/list" as const, - params: nextResourceTemplateCursor - ? { cursor: nextResourceTemplateCursor } - : {}, - }, - ListResourceTemplatesResultSchema, - "resources", - ); - setResourceTemplates( - resourceTemplates.concat(response.resourceTemplates ?? []), - ); - setNextResourceTemplateCursor(response.nextCursor); - }; - - const getPrompt = async (name: string, args: Record = {}) => { - lastToolCallOriginTabRef.current = currentTabRef.current; - - const response = await sendMCPRequest( - { - method: "prompts/get" as const, - params: { name, arguments: args }, - }, - GetPromptResultSchema, - "prompts", - ); - setPromptContent(JSON.stringify(response, null, 2)); - }; - - const readResource = async ( - uri: string, - { bypassCache = false }: { bypassCache?: boolean } = {}, - ) => { - if (fetchingResources.has(uri)) { - return; - } - - const hasOwn = Object.prototype.hasOwnProperty; - if ( - !bypassCache && - hasOwn.call(resourceContentMap, uri) && - !hasOwn.call(resourceErrorMap, uri) - ) { - if (currentTabRef.current === "resources") { - setResourceContent(resourceContentMap[uri]); - } - return; - } - - console.log("[App] Reading resource:", uri); - setFetchingResources((prev) => new Set(prev).add(uri)); - setResourceErrorMap((prev) => { - if (!hasOwn.call(prev, uri)) return prev; - const next = { ...prev }; - delete next[uri]; - return next; - }); - lastToolCallOriginTabRef.current = currentTabRef.current; - - try { - const response = await sendMCPRequest( - { - method: "resources/read" as const, - params: { uri }, - }, - ReadResourceResultSchema, - "resources", - ); - console.log("[App] Resource read response:", { - uri, - responseLength: JSON.stringify(response).length, - hasContents: !!(response as { contents?: unknown[] }).contents, - }); - const content = JSON.stringify(response, null, 2); - setResourceContent(content); - setResourceContentMap((prev) => ({ - ...prev, - [uri]: content, - })); - } catch (error) { - console.error(`[App] Failed to read resource ${uri}:`, error); - const errorString = (error as Error).message || String(error); - setResourceErrorMap((prev) => ({ - ...prev, - [uri]: errorString, - })); - } finally { - setFetchingResources((prev) => { - const next = new Set(prev); - next.delete(uri); - return next; - }); - } - }; - - const subscribeToResource = async (uri: string) => { - if (!resourceSubscriptions.has(uri)) { - await sendMCPRequest( - { - method: "resources/subscribe" as const, - params: { uri }, - }, - z.object({}), - "resources", - ); - const clone = new Set(resourceSubscriptions); - clone.add(uri); - setResourceSubscriptions(clone); - } - }; - - const unsubscribeFromResource = async (uri: string) => { - if (resourceSubscriptions.has(uri)) { - await sendMCPRequest( - { - method: "resources/unsubscribe" as const, - params: { uri }, - }, - z.object({}), - "resources", - ); - const clone = new Set(resourceSubscriptions); - clone.delete(uri); - setResourceSubscriptions(clone); - } - }; - - const listPrompts = async () => { - const response = await sendMCPRequest( - { - method: "prompts/list" as const, - params: nextPromptCursor ? { cursor: nextPromptCursor } : {}, - }, - ListPromptsResultSchema, - "prompts", - ); - setPrompts(response.prompts); - setNextPromptCursor(response.nextCursor); - }; - - const listTools = async () => { - const response = await sendMCPRequest( - { - method: "tools/list" as const, - params: nextToolCursor ? { cursor: nextToolCursor } : {}, - }, - ListToolsResultSchema, - "tools", - ); - setTools(response.tools); - setNextToolCursor(response.nextCursor); - cacheToolOutputSchemas(response.tools); - }; - - const callTool = async ( - name: string, - params: Record, - toolMetadata?: Record, - runAsTask?: boolean, - ): Promise => { - lastToolCallOriginTabRef.current = currentTabRef.current; - - try { - // Find the tool schema to clean parameters properly - const tool = tools.find((t) => t.name === name); - const cleanedParams = tool?.inputSchema - ? cleanParams(params, tool.inputSchema as JsonSchemaType) - : params; - - // Merge general metadata with tool-specific metadata - // Tool-specific metadata takes precedence over general metadata - const mergedMetadata = { - ...metadata, // General metadata - progressToken: progressTokenRef.current++, - ...toolMetadata, // Tool-specific metadata - }; - - const request: ClientRequest = { - method: "tools/call" as const, - params: { - name, - arguments: cleanedParams, - _meta: mergedMetadata, - }, - }; - - if (runAsTask) { - request.params = { - ...request.params, - task: { - ttl: getMCPTaskTtl(config), - }, - }; - } - - const response = await sendMCPRequest( - request, - CompatibilityCallToolResultSchema, - "tools", - ); - - // Check if this was a task-augmented request that returned a task reference - // The server returns { task: { taskId, status, ... } } when a task is created - const isTaskResult = ( - res: unknown, - ): res is { - task: { taskId: string; status: string; pollInterval: number }; - } => - !!res && - typeof res === "object" && - "task" in res && - !!res.task && - typeof res.task === "object" && - "taskId" in res.task; - - if (runAsTask && isTaskResult(response)) { - const taskId = response.task.taskId; - const pollInterval = response.task.pollInterval; - // Set polling state BEFORE setting tool result for proper UI update - setIsPollingTask(true); - // Safely extract any _meta from the original response (if present) - const initialResponseMeta = - response && - typeof response === "object" && - "_meta" in (response as Record) - ? ((response as { _meta?: Record })._meta ?? {}) - : undefined; - let latestToolResult: CompatibilityCallToolResult = { - content: [ - { - type: "text", - text: `Task created: ${taskId}. Polling for status...`, - }, - ], - _meta: { - ...(initialResponseMeta || {}), - "io.modelcontextprotocol/related-task": { taskId }, - }, - }; - setToolResult(latestToolResult); - - // Polling loop - let taskCompleted = false; - while (!taskCompleted) { - try { - // Wait for 1 second before polling - await new Promise((resolve) => setTimeout(resolve, pollInterval)); - - const taskStatus = await sendMCPRequest( - { - method: "tasks/get", - params: { taskId }, - }, - GetTaskResultSchema, - ); - - if ( - taskStatus.status === "completed" || - taskStatus.status === "failed" || - taskStatus.status === "cancelled" - ) { - taskCompleted = true; - console.log( - `Polling complete for task ${taskId}: ${taskStatus.status}`, - ); - - if (taskStatus.status === "completed") { - console.log(`Fetching result for task ${taskId}`); - const result = await sendMCPRequest( - { - method: "tasks/result", - params: { taskId }, - }, - CompatibilityCallToolResultSchema, - ); - console.log(`Result received for task ${taskId}:`, result); - latestToolResult = result as CompatibilityCallToolResult; - setToolResult(latestToolResult); - - // Refresh tasks list to show completed state - void listTasks(); - } else { - latestToolResult = { - content: [ - { - type: "text", - text: `Task ${taskStatus.status}: ${taskStatus.statusMessage || "No additional information"}`, - }, - ], - isError: true, - }; - setToolResult(latestToolResult); - // Refresh tasks list to show failed/cancelled state - void listTasks(); - } - } else { - // Update status message while polling - // Safely extract any _meta from the original response (if present) - const pollingResponseMeta = - response && - typeof response === "object" && - "_meta" in (response as Record) - ? ((response as { _meta?: Record })._meta ?? - {}) - : undefined; - - if (taskStatus.status === "input_required") { - // Per MCP spec: when input_required, call tasks/result to give - // the server a chance to deliver queued elicitation/sampling - // requests. After elicitation is handled, the task transitions - // back to "working" — do NOT set taskCompleted here. - latestToolResult = { - content: [ - { - type: "text", - text: `Task status: input_required${taskStatus.statusMessage ? ` - ${taskStatus.statusMessage}` : ""}. Awaiting input...`, - }, - ], - _meta: { - ...(pollingResponseMeta || {}), - "io.modelcontextprotocol/related-task": { taskId }, - }, - }; - setToolResult(latestToolResult); - await sendMCPRequest( - { - method: "tasks/result", - params: { taskId }, - }, - CompatibilityCallToolResultSchema, - ); - void listTasks(); - } else { - latestToolResult = { - content: [ - { - type: "text", - text: `Task status: ${taskStatus.status}${taskStatus.statusMessage ? ` - ${taskStatus.statusMessage}` : ""}. Polling...`, - }, - ], - _meta: { - ...(pollingResponseMeta || {}), - "io.modelcontextprotocol/related-task": { taskId }, - }, - }; - setToolResult(latestToolResult); - // Refresh tasks list to show progress - void listTasks(); - } - } - } catch (pollingError) { - console.error("Error polling task status:", pollingError); - latestToolResult = { - content: [ - { - type: "text", - text: `Error polling task status: ${pollingError instanceof Error ? pollingError.message : String(pollingError)}`, - }, - ], - isError: true, - }; - setToolResult(latestToolResult); - taskCompleted = true; - } - } - setIsPollingTask(false); - // Clear any validation errors since tool execution completed - setErrors((prev) => ({ ...prev, tools: null })); - return latestToolResult; - } else { - const directResult = response as CompatibilityCallToolResult; - setToolResult(directResult); - // Clear any validation errors since tool execution completed - setErrors((prev) => ({ ...prev, tools: null })); - return directResult; - } - } catch (e) { - const toolResult: CompatibilityCallToolResult = { - content: [ - { - type: "text", - text: (e as Error).message ?? String(e), - }, - ], - isError: true, - }; - setToolResult(toolResult); - // Clear validation errors - tool execution errors are shown in ToolResults - setErrors((prev) => ({ ...prev, tools: null })); - return toolResult; - } - }; - - const listTasks = useCallback(async () => { - try { - const response = await listMcpTasks(nextTaskCursor); - setTasks(response.tasks); - setNextTaskCursor(response.nextCursor); - // Inline error clear to avoid extra dependency on clearError - setErrors((prev) => ({ ...prev, tasks: null })); - } catch (e) { - setErrors((prev) => ({ - ...prev, - tasks: (e as Error).message ?? String(e), - })); - } - }, [listMcpTasks, nextTaskCursor]); - - const cancelTask = async (taskId: string) => { - try { - const response = await cancelMcpTask(taskId); - setTasks((prev) => prev.map((t) => (t.taskId === taskId ? response : t))); - if (selectedTask?.taskId === taskId) { - setSelectedTask(response); - } - clearError("tasks"); - } catch (e) { - setErrors((prev) => ({ - ...prev, - tasks: (e as Error).message ?? String(e), - })); - } - }; - - const handleRootsChange = async () => { - await sendNotification({ method: "notifications/roots/list_changed" }); - }; - - const handleClearNotifications = () => { - setNotifications([]); - }; - - const sendLogLevelRequest = async (level: LoggingLevel) => { - await sendMCPRequest( - { - method: "logging/setLevel" as const, - params: { level }, - }, - z.object({}), - ); - setLogLevel(level); - }; - - const AuthDebuggerWrapper = () => ( - - setIsAuthDebuggerVisible(false)} - authState={authState} - updateAuthState={updateAuthState} - config={config} - connectionType={connectionType} - /> - - ); - - if (window.location.pathname === "/oauth/callback") { - const OAuthCallback = React.lazy( - () => import("./components/OAuthCallback"), - ); - return ( - Loading...}> - - - ); - } - - if (window.location.pathname === "/oauth/callback/debug") { - const OAuthDebugCallback = React.lazy( - () => import("./components/OAuthDebugCallback"), - ); - return ( - Loading...}> - - - ); - } - - return ( -
-
- -
-
-
-
- {mcpClient ? ( - { - setActiveTab(value); - window.location.hash = value; - }} - > - - - - Resources - - - - Prompts - - - - Tools - - - - Tasks - - - - Apps - - - - Ping - - - - Sampling - {pendingSampleRequests.length > 0 && ( - - {pendingSampleRequests.length} - - )} - - - - Elicitations - {pendingElicitationRequests.length > 0 && ( - - {pendingElicitationRequests.length} - - )} - - - - Roots - - - - Auth - - - - Metadata - - - -
- {!serverCapabilities?.resources && - !serverCapabilities?.prompts && - !serverCapabilities?.tools ? ( - <> -
-

- The connected server does not support any MCP - capabilities -

-
- { - void sendMCPRequest( - { - method: "ping" as const, - }, - EmptyResultSchema, - ); - }} - /> - - ) : ( - <> - { - clearError("resources"); - listResources(); - }} - clearResources={() => { - setResources([]); - setNextResourceCursor(undefined); - }} - listResourceTemplates={() => { - clearError("resources"); - listResourceTemplates(); - }} - clearResourceTemplates={() => { - setResourceTemplates([]); - setNextResourceTemplateCursor(undefined); - }} - readResource={(uri, options) => { - clearError("resources"); - readResource(uri, options); - }} - selectedResource={selectedResource} - setSelectedResource={(resource) => { - clearError("resources"); - setSelectedResource(resource); - }} - resourceSubscriptionsSupported={ - serverCapabilities?.resources?.subscribe || false - } - resourceSubscriptions={resourceSubscriptions} - subscribeToResource={(uri) => { - clearError("resources"); - subscribeToResource(uri); - }} - unsubscribeFromResource={(uri) => { - clearError("resources"); - unsubscribeFromResource(uri); - }} - handleCompletion={handleCompletion} - completionsSupported={completionsSupported} - resourceContent={resourceContent} - nextCursor={nextResourceCursor} - nextTemplateCursor={nextResourceTemplateCursor} - error={errors.resources} - /> - { - clearError("prompts"); - listPrompts(); - }} - clearPrompts={() => { - setPrompts([]); - setNextPromptCursor(undefined); - }} - getPrompt={(name, args) => { - clearError("prompts"); - getPrompt(name, args); - }} - selectedPrompt={selectedPrompt} - setSelectedPrompt={(prompt) => { - clearError("prompts"); - setSelectedPrompt(prompt); - setPromptContent(""); - }} - handleCompletion={handleCompletion} - completionsSupported={completionsSupported} - promptContent={promptContent} - nextCursor={nextPromptCursor} - error={errors.prompts} - /> - { - clearError("tools"); - listTools(); - }} - clearTools={() => { - setTools([]); - setNextToolCursor(undefined); - cacheToolOutputSchemas([]); - }} - callTool={async ( - name: string, - params: Record, - metadata?: Record, - runAsTask?: boolean, - ) => { - clearError("tools"); - setToolResult(null); - const result = await callTool( - name, - params, - metadata, - runAsTask, - ); - const calledTool = tools.find( - (tool) => tool.name === name, - ); - if (calledTool && hasAppResourceUri(calledTool)) { - setPrefilledAppsToolCall({ - id: ++prefilledAppsToolCallIdRef.current, - toolName: name, - params: cloneToolParams(params), - result, - }); - } else { - setPrefilledAppsToolCall(null); - } - return result; - }} - selectedTool={selectedTool} - setSelectedTool={(tool) => { - clearError("tools"); - setSelectedTool(tool); - setToolResult(null); - }} - toolResult={toolResult} - isPollingTask={isPollingTask} - nextCursor={nextToolCursor} - error={errors.tools} - resourceContent={resourceContentMap} - resourceError={resourceErrorMap} - onReadResource={(uri: string) => { - clearError("resources"); - readResource(uri); - }} - /> - { - clearError("tasks"); - listTasks(); - }} - clearTasks={() => { - setTasks([]); - setNextTaskCursor(undefined); - }} - cancelTask={cancelTask} - selectedTask={selectedTask} - setSelectedTask={(task) => { - clearError("tasks"); - setSelectedTask(task); - }} - error={errors.tasks} - nextCursor={nextTaskCursor} - /> - { - clearError("tools"); - listTools(); - }} - callTool={async ( - name: string, - params: Record, - metadata?: Record, - runAsTask?: boolean, - ) => { - clearError("tools"); - setToolResult(null); - return callTool(name, params, metadata, runAsTask); - }} - prefilledToolCall={prefilledAppsToolCall} - onPrefilledToolCallConsumed={(callId) => { - setPrefilledAppsToolCall((prev) => - prev?.id === callId ? null : prev, - ); - }} - error={errors.tools} - mcpClient={mcpClient} - onNotification={(notification) => { - setNotifications((prev) => [...prev, notification]); - }} - /> - - { - void sendMCPRequest( - { - method: "ping" as const, - }, - EmptyResultSchema, - ); - }} - /> - - - - - - - )} -
-
- ) : isAuthDebuggerVisible ? ( - (window.location.hash = value)} - > - - - ) : ( -
-

- Connect to an MCP server to start inspecting -

-
-

- Need to configure authentication? -

- -
-
- )} -
-
-
-
-
-
- -
-
-
-
- ); -}; - -export default App; diff --git a/client/src/__mocks__/styleMock.js b/client/src/__mocks__/styleMock.js deleted file mode 100644 index f053ebf79..000000000 --- a/client/src/__mocks__/styleMock.js +++ /dev/null @@ -1 +0,0 @@ -module.exports = {}; diff --git a/client/src/__tests__/App.config.test.tsx b/client/src/__tests__/App.config.test.tsx deleted file mode 100644 index 7458c2055..000000000 --- a/client/src/__tests__/App.config.test.tsx +++ /dev/null @@ -1,241 +0,0 @@ -import { render, waitFor } from "@testing-library/react"; -import App from "../App"; -import { DEFAULT_INSPECTOR_CONFIG } from "../lib/constants"; -import { InspectorConfig } from "../lib/configurationTypes"; -import * as configUtils from "../utils/configUtils"; - -// Mock auth dependencies first -jest.mock("@modelcontextprotocol/sdk/client/auth.js", () => ({ - auth: jest.fn(), -})); - -jest.mock("../lib/oauth-state-machine", () => ({ - OAuthStateMachine: jest.fn(), -})); - -jest.mock("../lib/auth", () => ({ - InspectorOAuthClientProvider: jest.fn().mockImplementation(() => ({ - tokens: jest.fn().mockResolvedValue(null), - clear: jest.fn(), - })), - DebugInspectorOAuthClientProvider: jest.fn(), -})); - -// Mock the config utils -jest.mock("../utils/configUtils", () => ({ - ...jest.requireActual("../utils/configUtils"), - getMCPProxyAddress: jest.fn(() => "http://localhost:6277"), - getMCPProxyAuthToken: jest.fn((config: InspectorConfig) => ({ - token: config.MCP_PROXY_AUTH_TOKEN.value, - header: "X-MCP-Proxy-Auth", - })), - getInitialTransportType: jest.fn(() => "stdio"), - getInitialSseUrl: jest.fn(() => "http://localhost:3001/sse"), - getInitialCommand: jest.fn(() => "mcp-server-everything"), - getInitialArgs: jest.fn(() => ""), - initializeInspectorConfig: jest.fn(() => DEFAULT_INSPECTOR_CONFIG), - saveInspectorConfig: jest.fn(), -})); - -// Get references to the mocked functions -const mockGetMCPProxyAuthToken = configUtils.getMCPProxyAuthToken as jest.Mock; -const mockInitializeInspectorConfig = - configUtils.initializeInspectorConfig as jest.Mock; - -// Mock other dependencies -jest.mock("../lib/hooks/useConnection", () => ({ - useConnection: () => ({ - connectionStatus: "disconnected", - serverCapabilities: null, - mcpClient: null, - requestHistory: [], - clearRequestHistory: jest.fn(), - makeRequest: jest.fn(), - sendNotification: jest.fn(), - handleCompletion: jest.fn(), - completionsSupported: false, - connect: jest.fn(), - disconnect: jest.fn(), - }), -})); - -jest.mock("../lib/hooks/useDraggablePane", () => ({ - useDraggablePane: () => ({ - height: 300, - handleDragStart: jest.fn(), - }), - useDraggableSidebar: () => ({ - width: 320, - isDragging: false, - handleDragStart: jest.fn(), - }), -})); - -jest.mock("../components/Sidebar", () => ({ - __esModule: true, - default: () =>
Sidebar
, -})); - -// Mock fetch -global.fetch = jest.fn(); - -describe("App - Config Endpoint", () => { - beforeEach(() => { - jest.clearAllMocks(); - (global.fetch as jest.Mock).mockResolvedValue({ - json: () => - Promise.resolve({ - defaultEnvironment: { TEST_ENV: "test" }, - defaultCommand: "test-command", - defaultArgs: "test-args", - }), - }); - }); - - afterEach(() => { - jest.clearAllMocks(); - - // Reset getMCPProxyAuthToken to default behavior - mockGetMCPProxyAuthToken.mockImplementation((config: InspectorConfig) => ({ - token: config.MCP_PROXY_AUTH_TOKEN.value, - header: "X-MCP-Proxy-Auth", - })); - }); - - test("sends X-MCP-Proxy-Auth header when fetching config with proxy auth token", async () => { - const mockConfig = { - ...DEFAULT_INSPECTOR_CONFIG, - MCP_PROXY_AUTH_TOKEN: { - ...DEFAULT_INSPECTOR_CONFIG.MCP_PROXY_AUTH_TOKEN, - value: "test-proxy-token", - }, - }; - - // Mock initializeInspectorConfig to return our test config - mockInitializeInspectorConfig.mockReturnValue(mockConfig); - - render(); - - await waitFor(() => { - expect(global.fetch).toHaveBeenCalledWith( - "http://localhost:6277/config", - { - headers: { - "X-MCP-Proxy-Auth": "Bearer test-proxy-token", - }, - }, - ); - }); - }); - - test("does not send auth header when proxy auth token is empty", async () => { - const mockConfig = { - ...DEFAULT_INSPECTOR_CONFIG, - MCP_PROXY_AUTH_TOKEN: { - ...DEFAULT_INSPECTOR_CONFIG.MCP_PROXY_AUTH_TOKEN, - value: "", - }, - }; - - // Mock initializeInspectorConfig to return our test config - mockInitializeInspectorConfig.mockReturnValue(mockConfig); - - render(); - - await waitFor(() => { - expect(global.fetch).toHaveBeenCalledWith( - "http://localhost:6277/config", - { - headers: {}, - }, - ); - }); - }); - - test("uses custom header name if getMCPProxyAuthToken returns different header", async () => { - const mockConfig = { - ...DEFAULT_INSPECTOR_CONFIG, - MCP_PROXY_AUTH_TOKEN: { - ...DEFAULT_INSPECTOR_CONFIG.MCP_PROXY_AUTH_TOKEN, - value: "test-proxy-token", - }, - }; - - // Mock to return a custom header name - mockGetMCPProxyAuthToken.mockReturnValue({ - token: "test-proxy-token", - header: "X-Custom-Auth", - }); - mockInitializeInspectorConfig.mockReturnValue(mockConfig); - - render(); - - await waitFor(() => { - expect(global.fetch).toHaveBeenCalledWith( - "http://localhost:6277/config", - { - headers: { - "X-Custom-Auth": "Bearer test-proxy-token", - }, - }, - ); - }); - }); - - test("config endpoint response updates app state", async () => { - const mockConfig = { - ...DEFAULT_INSPECTOR_CONFIG, - MCP_PROXY_AUTH_TOKEN: { - ...DEFAULT_INSPECTOR_CONFIG.MCP_PROXY_AUTH_TOKEN, - value: "test-proxy-token", - }, - }; - - mockInitializeInspectorConfig.mockReturnValue(mockConfig); - - render(); - - await waitFor(() => { - expect(global.fetch).toHaveBeenCalledTimes(1); - }); - - // Verify the fetch was called with correct parameters - expect(global.fetch).toHaveBeenCalledWith( - "http://localhost:6277/config", - expect.objectContaining({ - headers: expect.objectContaining({ - "X-MCP-Proxy-Auth": "Bearer test-proxy-token", - }), - }), - ); - }); - - test("handles config endpoint errors gracefully", async () => { - const mockConfig = { - ...DEFAULT_INSPECTOR_CONFIG, - MCP_PROXY_AUTH_TOKEN: { - ...DEFAULT_INSPECTOR_CONFIG.MCP_PROXY_AUTH_TOKEN, - value: "test-proxy-token", - }, - }; - - mockInitializeInspectorConfig.mockReturnValue(mockConfig); - - // Mock fetch to reject - (global.fetch as jest.Mock).mockRejectedValue(new Error("Network error")); - - // Spy on console.error - const consoleErrorSpy = jest.spyOn(console, "error").mockImplementation(); - - render(); - - await waitFor(() => { - expect(consoleErrorSpy).toHaveBeenCalledWith( - "Error fetching default environment:", - expect.any(Error), - ); - }); - - consoleErrorSpy.mockRestore(); - }); -}); diff --git a/client/src/__tests__/App.routing.test.tsx b/client/src/__tests__/App.routing.test.tsx deleted file mode 100644 index 4713bef9a..000000000 --- a/client/src/__tests__/App.routing.test.tsx +++ /dev/null @@ -1,161 +0,0 @@ -import { render, waitFor } from "@testing-library/react"; -import App from "../App"; -import { useConnection } from "../lib/hooks/useConnection"; -import { Client } from "@modelcontextprotocol/sdk/client/index.js"; - -// Mock auth dependencies first -jest.mock("@modelcontextprotocol/sdk/client/auth.js", () => ({ - auth: jest.fn(), -})); - -jest.mock("../lib/oauth-state-machine", () => ({ - OAuthStateMachine: jest.fn(), -})); - -jest.mock("../lib/auth", () => ({ - InspectorOAuthClientProvider: jest.fn().mockImplementation(() => ({ - tokens: jest.fn().mockResolvedValue(null), - clear: jest.fn(), - })), - DebugInspectorOAuthClientProvider: jest.fn(), -})); - -// Mock the config utils -jest.mock("../utils/configUtils", () => ({ - ...jest.requireActual("../utils/configUtils"), - getMCPProxyAddress: jest.fn(() => "http://localhost:6277"), - getMCPProxyAuthToken: jest.fn(() => ({ - token: "", - header: "X-MCP-Proxy-Auth", - })), - getInitialTransportType: jest.fn(() => "stdio"), - getInitialSseUrl: jest.fn(() => "http://localhost:3001/sse"), - getInitialCommand: jest.fn(() => "mcp-server-everything"), - getInitialArgs: jest.fn(() => ""), - initializeInspectorConfig: jest.fn(() => ({})), - saveInspectorConfig: jest.fn(), -})); - -// Default connection state is disconnected -const disconnectedConnectionState = { - connectionStatus: "disconnected" as const, - serverCapabilities: null, - mcpClient: null, - requestHistory: [], - clearRequestHistory: jest.fn(), - makeRequest: jest.fn(), - sendNotification: jest.fn(), - handleCompletion: jest.fn(), - completionsSupported: false, - connect: jest.fn(), - disconnect: jest.fn(), - serverImplementation: null, -}; - -// Connected state for tests that need an active connection -const connectedConnectionState = { - ...disconnectedConnectionState, - connectionStatus: "connected" as const, - serverCapabilities: {}, - mcpClient: { - request: jest.fn(), - notification: jest.fn(), - close: jest.fn(), - } as unknown as Client, -}; - -// Mock required dependencies, but unrelated to routing. -jest.mock("../lib/hooks/useDraggablePane", () => ({ - useDraggablePane: () => ({ - height: 300, - handleDragStart: jest.fn(), - }), - useDraggableSidebar: () => ({ - width: 320, - isDragging: false, - handleDragStart: jest.fn(), - }), -})); - -jest.mock("../components/Sidebar", () => ({ - __esModule: true, - default: () =>
Sidebar
, -})); - -// Mock fetch -global.fetch = jest.fn().mockResolvedValue({ json: () => Promise.resolve({}) }); - -// Use an empty module mock, so that mock state can be reset between tests. -jest.mock("../lib/hooks/useConnection", () => ({ - useConnection: jest.fn(), -})); - -describe("App - URL Fragment Routing", () => { - const mockUseConnection = jest.mocked(useConnection); - - beforeEach(() => { - jest.restoreAllMocks(); - - // Inspector starts disconnected. - mockUseConnection.mockReturnValue(disconnectedConnectionState); - }); - - test("does not set hash when starting disconnected", async () => { - render(); - - await waitFor(() => { - expect(window.location.hash).toBe(""); - }); - }); - - test("sets default hash based on server capabilities priority", async () => { - // Tab priority follows UI order: Resources | Prompts | Tools | Ping | Sampling | Roots | Auth - // - // Server capabilities determine the first three tabs; if none are present, falls back to Ping. - - const testCases = [ - { - capabilities: { resources: { listChanged: true, subscribe: true } }, - expected: "#resources", - }, - { - capabilities: { prompts: { listChanged: true, subscribe: true } }, - expected: "#prompts", - }, - { - capabilities: { tools: { listChanged: true, subscribe: true } }, - expected: "#tools", - }, - { capabilities: {}, expected: "#ping" }, - ]; - - const { rerender } = render(); - - for (const { capabilities, expected } of testCases) { - window.location.hash = ""; - mockUseConnection.mockReturnValue({ - ...connectedConnectionState, - serverCapabilities: capabilities, - }); - - rerender(); - - await waitFor(() => { - expect(window.location.hash).toBe(expected); - }); - } - }); - - test("clears hash when disconnected", async () => { - // Start with a hash set (simulating a connection) - window.location.hash = "#resources"; - - // App starts disconnected (default mock) - render(); - - // Should clear the hash when disconnected - await waitFor(() => { - expect(window.location.hash).toBe(""); - }); - }); -}); diff --git a/client/src/__tests__/App.samplingNavigation.test.tsx b/client/src/__tests__/App.samplingNavigation.test.tsx deleted file mode 100644 index 70a42a92f..000000000 --- a/client/src/__tests__/App.samplingNavigation.test.tsx +++ /dev/null @@ -1,239 +0,0 @@ -import { - act, - fireEvent, - render, - screen, - waitFor, -} from "@testing-library/react"; -import App from "../App"; -import { useConnection } from "../lib/hooks/useConnection"; -import type { Client } from "@modelcontextprotocol/sdk/client/index.js"; -import type { - CreateMessageRequest, - CreateMessageResult, -} from "@modelcontextprotocol/sdk/types.js"; - -type OnPendingRequestHandler = ( - request: CreateMessageRequest, - resolve: (result: CreateMessageResult) => void, - reject: (error: Error) => void, -) => void; - -type SamplingRequestMockProps = { - request: { id: number }; - onApprove: (id: number, result: CreateMessageResult) => void; - onReject: (id: number) => void; -}; - -type UseConnectionReturn = ReturnType; - -// Mock auth dependencies first -jest.mock("@modelcontextprotocol/sdk/client/auth.js", () => ({ - auth: jest.fn(), -})); - -jest.mock("../lib/oauth-state-machine", () => ({ - OAuthStateMachine: jest.fn(), -})); - -jest.mock("../lib/auth", () => ({ - InspectorOAuthClientProvider: jest.fn().mockImplementation(() => ({ - tokens: jest.fn().mockResolvedValue(null), - clear: jest.fn(), - })), - DebugInspectorOAuthClientProvider: jest.fn(), -})); - -jest.mock("../utils/configUtils", () => ({ - ...jest.requireActual("../utils/configUtils"), - getMCPProxyAddress: jest.fn(() => "http://localhost:6277"), - getMCPProxyAuthToken: jest.fn(() => ({ - token: "", - header: "X-MCP-Proxy-Auth", - })), - getInitialTransportType: jest.fn(() => "stdio"), - getInitialSseUrl: jest.fn(() => "http://localhost:3001/sse"), - getInitialCommand: jest.fn(() => "mcp-server-everything"), - getInitialArgs: jest.fn(() => ""), - initializeInspectorConfig: jest.fn(() => ({})), - saveInspectorConfig: jest.fn(), -})); - -jest.mock("../lib/hooks/useDraggablePane", () => ({ - useDraggablePane: () => ({ - height: 300, - handleDragStart: jest.fn(), - }), - useDraggableSidebar: () => ({ - width: 320, - isDragging: false, - handleDragStart: jest.fn(), - }), -})); - -jest.mock("../components/Sidebar", () => ({ - __esModule: true, - default: () =>
Sidebar
, -})); - -jest.mock("../lib/hooks/useToast", () => ({ - useToast: () => ({ toast: jest.fn() }), -})); - -// Keep the test focused on navigation; avoid DynamicJsonForm/schema complexity. -jest.mock("../components/SamplingRequest", () => ({ - __esModule: true, - default: ({ request, onApprove, onReject }: SamplingRequestMockProps) => ( -
-
sampling-request-{request.id}
- - -
- ), -})); - -// Mock fetch -global.fetch = jest.fn().mockResolvedValue({ json: () => Promise.resolve({}) }); - -jest.mock("../lib/hooks/useConnection", () => ({ - useConnection: jest.fn(), -})); - -describe("App - Sampling auto-navigation", () => { - const mockUseConnection = jest.mocked(useConnection); - - const baseConnectionState = { - connectionStatus: "connected" as const, - serverCapabilities: { tools: { listChanged: true, subscribe: true } }, - mcpClient: { - request: jest.fn(), - notification: jest.fn(), - close: jest.fn(), - } as unknown as Client, - requestHistory: [], - clearRequestHistory: jest.fn(), - makeRequest: jest.fn(), - sendNotification: jest.fn(), - handleCompletion: jest.fn(), - completionsSupported: false, - connect: jest.fn(), - disconnect: jest.fn(), - serverImplementation: null, - cancelTask: jest.fn(), - listTasks: jest.fn(), - }; - - beforeEach(() => { - jest.restoreAllMocks(); - window.location.hash = "#tools"; - }); - - test("switches to #sampling when a sampling request arrives and switches back to #tools after approve", async () => { - let capturedOnPendingRequest: OnPendingRequestHandler | undefined; - - mockUseConnection.mockImplementation((options) => { - capturedOnPendingRequest = ( - options as { onPendingRequest?: OnPendingRequestHandler } - ).onPendingRequest; - return baseConnectionState as unknown as UseConnectionReturn; - }); - - render(); - - // Ensure we start on tools. - await waitFor(() => { - expect(window.location.hash).toBe("#tools"); - }); - - const resolve = jest.fn(); - const reject = jest.fn(); - - act(() => { - if (!capturedOnPendingRequest) { - throw new Error("Expected onPendingRequest to be provided"); - } - - capturedOnPendingRequest( - { - method: "sampling/createMessage", - params: { messages: [], maxTokens: 1 }, - }, - resolve, - reject, - ); - }); - - await waitFor(() => { - expect(window.location.hash).toBe("#sampling"); - expect(screen.getByTestId("sampling-request")).toBeTruthy(); - }); - - fireEvent.click(screen.getByText("Approve")); - - await waitFor(() => { - expect(resolve).toHaveBeenCalled(); - expect(window.location.hash).toBe("#tools"); - }); - }); - - test("switches back to #tools after reject", async () => { - let capturedOnPendingRequest: OnPendingRequestHandler | undefined; - - mockUseConnection.mockImplementation((options) => { - capturedOnPendingRequest = ( - options as { onPendingRequest?: OnPendingRequestHandler } - ).onPendingRequest; - return baseConnectionState as unknown as UseConnectionReturn; - }); - - render(); - - await waitFor(() => { - expect(window.location.hash).toBe("#tools"); - }); - - const resolve = jest.fn(); - const reject = jest.fn(); - - act(() => { - if (!capturedOnPendingRequest) { - throw new Error("Expected onPendingRequest to be provided"); - } - - capturedOnPendingRequest( - { - method: "sampling/createMessage", - params: { messages: [], maxTokens: 1 }, - }, - resolve, - reject, - ); - }); - - await waitFor(() => { - expect(window.location.hash).toBe("#sampling"); - expect(screen.getByTestId("sampling-request")).toBeTruthy(); - }); - - fireEvent.click(screen.getByRole("button", { name: /Reject/i })); - - await waitFor(() => { - expect(reject).toHaveBeenCalled(); - expect(window.location.hash).toBe("#tools"); - }); - }); -}); diff --git a/client/src/__tests__/App.taskPolling.test.tsx b/client/src/__tests__/App.taskPolling.test.tsx deleted file mode 100644 index 1eff4951c..000000000 --- a/client/src/__tests__/App.taskPolling.test.tsx +++ /dev/null @@ -1,336 +0,0 @@ -import { fireEvent, render, screen, waitFor } from "@testing-library/react"; -import "@testing-library/jest-dom"; -import App from "../App"; -import { useConnection } from "../lib/hooks/useConnection"; -import type { Client } from "@modelcontextprotocol/sdk/client/index.js"; - -// Mock auth dependencies -jest.mock("@modelcontextprotocol/sdk/client/auth.js", () => ({ - auth: jest.fn(), -})); - -jest.mock("../lib/oauth-state-machine", () => ({ - OAuthStateMachine: jest.fn(), -})); - -jest.mock("../lib/auth", () => ({ - InspectorOAuthClientProvider: jest.fn().mockImplementation(() => ({ - tokens: jest.fn().mockResolvedValue(null), - clear: jest.fn(), - })), - DebugInspectorOAuthClientProvider: jest.fn(), -})); - -jest.mock("../utils/configUtils", () => ({ - ...jest.requireActual("../utils/configUtils"), - getMCPProxyAddress: jest.fn(() => "http://localhost:6277"), - getMCPProxyAuthToken: jest.fn(() => ({ - token: "", - header: "X-MCP-Proxy-Auth", - })), - getMCPTaskTtl: jest.fn(() => 30000), - getInitialTransportType: jest.fn(() => "stdio"), - getInitialSseUrl: jest.fn(() => "http://localhost:3001/sse"), - getInitialCommand: jest.fn(() => "mcp-server-everything"), - getInitialArgs: jest.fn(() => ""), - initializeInspectorConfig: jest.fn(() => ({})), - saveInspectorConfig: jest.fn(), -})); - -jest.mock("../lib/hooks/useDraggablePane", () => ({ - useDraggablePane: () => ({ - height: 300, - handleDragStart: jest.fn(), - }), - useDraggableSidebar: () => ({ - width: 320, - isDragging: false, - handleDragStart: jest.fn(), - }), -})); - -jest.mock("../components/Sidebar", () => ({ - __esModule: true, - default: () =>
Sidebar
, -})); - -jest.mock("../components/ResourcesTab", () => ({ - __esModule: true, - default: () =>
ResourcesTab
, -})); - -jest.mock("../components/PromptsTab", () => ({ - __esModule: true, - default: () =>
PromptsTab
, -})); - -jest.mock("../components/TasksTab", () => ({ - __esModule: true, - default: () =>
TasksTab
, -})); - -jest.mock("../components/ConsoleTab", () => ({ - __esModule: true, - default: () =>
ConsoleTab
, -})); - -jest.mock("../components/PingTab", () => ({ - __esModule: true, - default: () =>
PingTab
, -})); - -jest.mock("../components/SamplingTab", () => ({ - __esModule: true, - default: () =>
SamplingTab
, -})); - -jest.mock("../components/RootsTab", () => ({ - __esModule: true, - default: () =>
RootsTab
, -})); - -jest.mock("../components/ElicitationTab", () => ({ - __esModule: true, - default: () =>
ElicitationTab
, -})); - -jest.mock("../components/MetadataTab", () => ({ - __esModule: true, - default: () =>
MetadataTab
, -})); - -jest.mock("../components/AuthDebugger", () => ({ - __esModule: true, - default: () =>
AuthDebugger
, -})); - -jest.mock("../components/HistoryAndNotifications", () => ({ - __esModule: true, - default: () =>
HistoryAndNotifications
, -})); - -jest.mock("../components/AppsTab", () => ({ - __esModule: true, - default: () =>
AppsTab
, -})); - -jest.mock("../components/ToolsTab", () => ({ - __esModule: true, - default: ({ - callTool, - toolResult, - }: { - callTool: ( - name: string, - params: Record, - metadata?: Record, - runAsTask?: boolean, - ) => Promise; - toolResult: { content: Array<{ type: string; text: string }> } | null; - }) => ( -
- - {toolResult && ( -
- {toolResult.content.map((c, i) => ( - {c.text} - ))} -
- )} -
- ), -})); - -global.fetch = jest.fn().mockResolvedValue({ json: () => Promise.resolve({}) }); - -jest.mock("../lib/hooks/useConnection", () => ({ - useConnection: jest.fn(), -})); - -describe("App - task polling with input_required status", () => { - const mockUseConnection = jest.mocked(useConnection); - - beforeEach(() => { - jest.clearAllMocks(); - window.location.hash = "#tools"; - }); - - it("calls tasks/result when polling sees input_required, then continues until completed", async () => { - const taskId = "task-abc-123"; - let tasksGetCallCount = 0; - - const makeRequest = jest.fn(async (request: { method: string }) => { - if (request.method === "tools/list") { - return { - tools: [ - { - name: "myTool", - inputSchema: { type: "object", properties: {} }, - }, - ], - nextCursor: undefined, - }; - } - - if (request.method === "tools/call") { - return { - task: { taskId, status: "input_required", pollInterval: 10 }, - }; - } - - if (request.method === "tasks/get") { - tasksGetCallCount++; - // First poll: still input_required; second poll: completed - if (tasksGetCallCount === 1) { - return { - taskId, - status: "input_required", - statusMessage: "Needs input", - }; - } - return { taskId, status: "completed" }; - } - - if (request.method === "tasks/result") { - return { - content: [{ type: "text", text: "final task result" }], - }; - } - - if (request.method === "tasks/list") { - return { tasks: [] }; - } - - throw new Error(`Unexpected method: ${request.method}`); - }); - - mockUseConnection.mockReturnValue({ - connectionStatus: "connected", - serverCapabilities: { tools: { listChanged: true } }, - serverImplementation: null, - mcpClient: { - request: jest.fn(), - notification: jest.fn(), - close: jest.fn(), - } as unknown as Client, - requestHistory: [], - clearRequestHistory: jest.fn(), - makeRequest, - cancelTask: jest.fn(), - listTasks: jest.fn(), - sendNotification: jest.fn(), - handleCompletion: jest.fn(), - completionsSupported: false, - connect: jest.fn(), - disconnect: jest.fn(), - } as ReturnType); - - render(); - - fireEvent.click(screen.getByRole("button", { name: /run task tool/i })); - - // tasks/result should be called for input_required, then again for completed - await waitFor(() => { - const resultCalls = makeRequest.mock.calls.filter( - ([req]) => (req as { method: string }).method === "tasks/result", - ); - expect(resultCalls.length).toBeGreaterThanOrEqual(1); - }); - - // Final result should be displayed after completed - await waitFor(() => { - expect(screen.getByTestId("tool-result")).toHaveTextContent( - "final task result", - ); - }); - }); - - it("does not call tasks/result while status is working (non-input_required non-terminal)", async () => { - const taskId = "task-working-456"; - let tasksGetCallCount = 0; - - const makeRequest = jest.fn(async (request: { method: string }) => { - if (request.method === "tools/list") { - return { - tools: [ - { - name: "myTool", - inputSchema: { type: "object", properties: {} }, - }, - ], - nextCursor: undefined, - }; - } - - if (request.method === "tools/call") { - return { - task: { taskId, status: "working", pollInterval: 10 }, - }; - } - - if (request.method === "tasks/get") { - tasksGetCallCount++; - if (tasksGetCallCount < 3) { - return { taskId, status: "working" }; - } - return { taskId, status: "completed" }; - } - - if (request.method === "tasks/result") { - return { - content: [{ type: "text", text: "working tool result" }], - }; - } - - if (request.method === "tasks/list") { - return { tasks: [] }; - } - - throw new Error(`Unexpected method: ${request.method}`); - }); - - mockUseConnection.mockReturnValue({ - connectionStatus: "connected", - serverCapabilities: { tools: { listChanged: true } }, - serverImplementation: null, - mcpClient: { - request: jest.fn(), - notification: jest.fn(), - close: jest.fn(), - } as unknown as Client, - requestHistory: [], - clearRequestHistory: jest.fn(), - makeRequest, - cancelTask: jest.fn(), - listTasks: jest.fn(), - sendNotification: jest.fn(), - handleCompletion: jest.fn(), - completionsSupported: false, - connect: jest.fn(), - disconnect: jest.fn(), - } as ReturnType); - - render(); - - fireEvent.click(screen.getByRole("button", { name: /run task tool/i })); - - await waitFor(() => { - expect(screen.getByTestId("tool-result")).toHaveTextContent( - "working tool result", - ); - }); - - // tasks/result should only have been called once — for the completed status, not for working - const resultCalls = makeRequest.mock.calls.filter( - ([req]) => (req as { method: string }).method === "tasks/result", - ); - expect(resultCalls).toHaveLength(1); - }); -}); diff --git a/client/src/__tests__/App.toolsAppsPrefill.test.tsx b/client/src/__tests__/App.toolsAppsPrefill.test.tsx deleted file mode 100644 index 9239f29db..000000000 --- a/client/src/__tests__/App.toolsAppsPrefill.test.tsx +++ /dev/null @@ -1,284 +0,0 @@ -import { fireEvent, render, screen, waitFor } from "@testing-library/react"; -import "@testing-library/jest-dom"; -import App from "../App"; -import { useConnection } from "../lib/hooks/useConnection"; -import type { Client } from "@modelcontextprotocol/sdk/client/index.js"; - -type ToolListEntry = { - name: string; - inputSchema: { - type: "object"; - properties: Record; - }; - _meta: Record; -}; - -type AppsTabProps = { - tools: ToolListEntry[]; - prefilledToolCall?: { - id: number; - toolName: string; - params: Record; - result: { - content: Array<{ type: string; text: string }>; - }; - } | null; - onPrefilledToolCallConsumed?: (callId: number) => void; -}; - -// Mock auth dependencies first -jest.mock("@modelcontextprotocol/sdk/client/auth.js", () => ({ - auth: jest.fn(), -})); - -jest.mock("../lib/oauth-state-machine", () => ({ - OAuthStateMachine: jest.fn(), -})); - -jest.mock("../lib/auth", () => ({ - InspectorOAuthClientProvider: jest.fn().mockImplementation(() => ({ - tokens: jest.fn().mockResolvedValue(null), - clear: jest.fn(), - })), - DebugInspectorOAuthClientProvider: jest.fn(), -})); - -jest.mock("../utils/configUtils", () => ({ - ...jest.requireActual("../utils/configUtils"), - getMCPProxyAddress: jest.fn(() => "http://localhost:6277"), - getMCPProxyAuthToken: jest.fn(() => ({ - token: "", - header: "X-MCP-Proxy-Auth", - })), - getInitialTransportType: jest.fn(() => "stdio"), - getInitialSseUrl: jest.fn(() => "http://localhost:3001/sse"), - getInitialCommand: jest.fn(() => "mcp-server-everything"), - getInitialArgs: jest.fn(() => ""), - initializeInspectorConfig: jest.fn(() => ({})), - saveInspectorConfig: jest.fn(), -})); - -jest.mock("../lib/hooks/useDraggablePane", () => ({ - useDraggablePane: () => ({ - height: 300, - handleDragStart: jest.fn(), - }), - useDraggableSidebar: () => ({ - width: 320, - isDragging: false, - handleDragStart: jest.fn(), - }), -})); - -jest.mock("../components/Sidebar", () => ({ - __esModule: true, - default: () =>
Sidebar
, -})); - -jest.mock("../components/ResourcesTab", () => ({ - __esModule: true, - default: () =>
ResourcesTab
, -})); - -jest.mock("../components/PromptsTab", () => ({ - __esModule: true, - default: () =>
PromptsTab
, -})); - -jest.mock("../components/TasksTab", () => ({ - __esModule: true, - default: () =>
TasksTab
, -})); - -jest.mock("../components/ConsoleTab", () => ({ - __esModule: true, - default: () =>
ConsoleTab
, -})); - -jest.mock("../components/PingTab", () => ({ - __esModule: true, - default: () =>
PingTab
, -})); - -jest.mock("../components/SamplingTab", () => ({ - __esModule: true, - default: () =>
SamplingTab
, -})); - -jest.mock("../components/RootsTab", () => ({ - __esModule: true, - default: () =>
RootsTab
, -})); - -jest.mock("../components/ElicitationTab", () => ({ - __esModule: true, - default: () =>
ElicitationTab
, -})); - -jest.mock("../components/MetadataTab", () => ({ - __esModule: true, - default: () =>
MetadataTab
, -})); - -jest.mock("../components/AuthDebugger", () => ({ - __esModule: true, - default: () =>
AuthDebugger
, -})); - -jest.mock("../components/HistoryAndNotifications", () => ({ - __esModule: true, - default: () =>
HistoryAndNotifications
, -})); - -jest.mock("../components/ToolsTab", () => ({ - __esModule: true, - default: ({ - listTools, - callTool, - }: { - listTools: () => void; - callTool: ( - name: string, - params: Record, - metadata?: Record, - runAsTask?: boolean, - ) => Promise; - }) => ( -
- - -
- ), -})); - -jest.mock("../components/AppsTab", () => ({ - __esModule: true, - default: (props: AppsTabProps) => { - const prefilled = - props && "prefilledToolCall" in props ? props.prefilledToolCall : null; - const tools = props && "tools" in props ? props.tools : []; - - return ( -
-
{JSON.stringify(tools)}
-
- {JSON.stringify(prefilled ?? null)} -
- {prefilled && ( - - )} -
- ); - }, -})); - -global.fetch = jest.fn().mockResolvedValue({ json: () => Promise.resolve({}) }); - -jest.mock("../lib/hooks/useConnection", () => ({ - useConnection: jest.fn(), -})); - -describe("App - Tools to Apps prefilled handoff", () => { - const mockUseConnection = jest.mocked(useConnection); - - beforeEach(() => { - jest.clearAllMocks(); - window.location.hash = "#tools"; - }); - - it("passes prefilled call data to AppsTab for tools using _meta['ui/resourceUri']", async () => { - const makeRequest = jest.fn(async (request: { method: string }) => { - if (request.method === "tools/list") { - return { - tools: [ - { - name: "weatherApp", - inputSchema: { - type: "object", - properties: { - city: { type: "string" }, - }, - }, - _meta: { - "ui/resourceUri": "ui://weather-app", - }, - }, - ], - nextCursor: undefined, - }; - } - - if (request.method === "tools/call") { - return { - content: [{ type: "text", text: "weather result" }], - }; - } - - throw new Error(`Unexpected method: ${request.method}`); - }); - - mockUseConnection.mockReturnValue({ - connectionStatus: "connected", - serverCapabilities: { tools: { listChanged: true } }, - serverImplementation: null, - mcpClient: { - request: jest.fn(), - notification: jest.fn(), - close: jest.fn(), - } as unknown as Client, - requestHistory: [], - clearRequestHistory: jest.fn(), - makeRequest, - cancelTask: jest.fn(), - listTasks: jest.fn(), - sendNotification: jest.fn(), - handleCompletion: jest.fn(), - completionsSupported: false, - connect: jest.fn(), - disconnect: jest.fn(), - } as ReturnType); - - render(); - - fireEvent.click(screen.getByRole("button", { name: /mock list tools/i })); - - await waitFor(() => { - const tools = JSON.parse( - screen.getByTestId("apps-tools").textContent || "[]", - ); - expect(tools).toHaveLength(1); - expect(tools[0].name).toBe("weatherApp"); - }); - - fireEvent.click(screen.getByRole("button", { name: /mock run app tool/i })); - - await waitFor(() => { - const prefilled = JSON.parse( - screen.getByTestId("apps-prefilled").textContent || "null", - ); - expect(prefilled.toolName).toBe("weatherApp"); - expect(prefilled.params).toEqual({ city: "Lisbon" }); - expect(prefilled.result.content[0].text).toBe("weather result"); - }); - - fireEvent.click(screen.getByRole("button", { name: /consume prefilled/i })); - - await waitFor(() => { - expect(screen.getByTestId("apps-prefilled")).toHaveTextContent("null"); - }); - }); -}); diff --git a/client/src/__tests__/proxyFetchEndpoint.test.ts b/client/src/__tests__/proxyFetchEndpoint.test.ts deleted file mode 100644 index 7084cb6e2..000000000 --- a/client/src/__tests__/proxyFetchEndpoint.test.ts +++ /dev/null @@ -1,327 +0,0 @@ -/** - * Tests for the proxy server's POST /fetch endpoint. - * Spawns the server and hits it like any other HTTP client would. - */ -import { spawn, type ChildProcess } from "child_process"; -import { - createServer, - type IncomingMessage, - type Server, - type ServerResponse, -} from "http"; -import { resolve } from "path"; - -const TEST_PORT = 16321; -const TEST_TOKEN = "test-proxy-token-12345"; -const SERVER_PATH = resolve(__dirname, "../../../server/build/index.js"); - -/** Placeholder URL for tests where auth fails before the proxy fetches (no network). */ -const UNUSED_UPSTREAM_URL = "http://127.0.0.1:1/unused"; - -async function waitForServer(baseUrl: string, maxWaitMs = 5000): Promise { - const start = Date.now(); - while (Date.now() - start < maxWaitMs) { - try { - const res = await fetch(`${baseUrl}/health`); - if (res.ok) return; - } catch { - await new Promise((r) => setTimeout(r, 50)); - } - } - throw new Error("Server did not become ready"); -} - -/** - * Runs `fn` with a local HTTP server on 127.0.0.1:ephemeral-port. - * `origin` is `http://127.0.0.1:` (no trailing path). - */ -async function withLocalUpstream( - onRequest: (req: IncomingMessage, res: ServerResponse) => void, - fn: (origin: string) => Promise, -): Promise { - const upstream: Server = createServer(onRequest); - - await new Promise((resolve, reject) => { - upstream.once("error", reject); - upstream.listen(0, "127.0.0.1", () => resolve()); - }); - - const addr = upstream.address(); - if (!addr || typeof addr === "string") { - upstream.close(); - throw new Error("Expected TCP listen address"); - } - - const origin = `http://127.0.0.1:${addr.port}`; - - try { - await fn(origin); - } finally { - await new Promise((r) => upstream.close(() => r())); - } -} - -describe("POST /fetch endpoint", () => { - let server: ChildProcess; - const baseUrl = `http://localhost:${TEST_PORT}`; - - beforeAll(async () => { - server = spawn("node", [SERVER_PATH], { - env: { - ...process.env, - SERVER_PORT: String(TEST_PORT), - MCP_PROXY_AUTH_TOKEN: TEST_TOKEN, - }, - stdio: "ignore", - }); - await waitForServer(baseUrl); - }, 10000); - - afterAll(() => { - server.kill(); - }); - - it("returns 401 when no auth header", async () => { - const res = await fetch(`${baseUrl}/fetch`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - url: UNUSED_UPSTREAM_URL, - init: { method: "GET" }, - }), - }); - expect(res.status).toBe(401); - const body = await res.json(); - expect(body.error).toBe("Unauthorized"); - }); - - it("returns 401 when auth token is invalid", async () => { - const res = await fetch(`${baseUrl}/fetch`, { - method: "POST", - headers: { - "Content-Type": "application/json", - "X-MCP-Proxy-Auth": "Bearer wrong-token", - }, - body: JSON.stringify({ - url: UNUSED_UPSTREAM_URL, - init: { method: "GET" }, - }), - }); - expect(res.status).toBe(401); - }); - - it("returns 400 for non-http(s) URL when auth token is valid", async () => { - const res = await fetch(`${baseUrl}/fetch`, { - method: "POST", - headers: { - "Content-Type": "application/json", - "X-MCP-Proxy-Auth": `Bearer ${TEST_TOKEN}`, - }, - body: JSON.stringify({ - url: "file:///etc/passwd", - init: { method: "GET" }, - }), - }); - expect(res.status).toBe(400); - const body = (await res.json()) as { error: string }; - expect(body.error).toBe("Only http/https URLs are allowed"); - }); - - it("returns 400 for invalid URL string when auth token is valid", async () => { - const res = await fetch(`${baseUrl}/fetch`, { - method: "POST", - headers: { - "Content-Type": "application/json", - "X-MCP-Proxy-Auth": `Bearer ${TEST_TOKEN}`, - }, - body: JSON.stringify({ - url: "not a valid url", - init: { method: "GET" }, - }), - }); - expect(res.status).toBe(400); - const body = (await res.json()) as { error: string }; - expect(body.error).toBe("Invalid URL"); - }); - - it("returns 400 when url is missing when auth token is valid", async () => { - const res = await fetch(`${baseUrl}/fetch`, { - method: "POST", - headers: { - "Content-Type": "application/json", - "X-MCP-Proxy-Auth": `Bearer ${TEST_TOKEN}`, - }, - body: JSON.stringify({ init: { method: "GET" } }), - }); - expect(res.status).toBe(400); - const body = (await res.json()) as { error: string }; - expect(body.error).toBe("Missing or invalid url"); - }); - - it("forwards request when auth token is valid", async () => { - const upstreamPayload = JSON.stringify({ hello: "proxy-fetch-test" }); - - await withLocalUpstream( - (req, res) => { - res.writeHead(200, { "Content-Type": "application/json" }); - res.end(upstreamPayload); - }, - async (origin) => { - const upstreamUrl = `${origin}/ok`; - - const res = await fetch(`${baseUrl}/fetch`, { - method: "POST", - headers: { - "Content-Type": "application/json", - "X-MCP-Proxy-Auth": `Bearer ${TEST_TOKEN}`, - }, - body: JSON.stringify({ - url: upstreamUrl, - init: { method: "GET" }, - }), - }); - - expect(res.status).toBe(200); - const body = (await res.json()) as { - ok: boolean; - status: number; - statusText: string; - body: string; - headers: Record; - }; - expect(body.ok).toBe(true); - expect(body.status).toBe(200); - expect(body.statusText).toBe("OK"); - expect(body.body).toBe(upstreamPayload); - expect(body.headers["content-type"]).toMatch(/application\/json/i); - }, - ); - }); - - it("mirrors upstream 404 (non-2xx) when auth token is valid", async () => { - await withLocalUpstream( - (req, res) => { - res.writeHead(404, { "Content-Type": "application/json" }); - res.end('{"error":"not_found"}'); - }, - async (origin) => { - const upstreamUrl = `${origin}/missing`; - - const res = await fetch(`${baseUrl}/fetch`, { - method: "POST", - headers: { - "Content-Type": "application/json", - "X-MCP-Proxy-Auth": `Bearer ${TEST_TOKEN}`, - }, - body: JSON.stringify({ - url: upstreamUrl, - init: { method: "GET" }, - }), - }); - - expect(res.status).toBe(404); - const body = (await res.json()) as { - ok: boolean; - status: number; - body: string; - }; - expect(body.ok).toBe(false); - expect(body.status).toBe(404); - expect(JSON.parse(body.body)).toEqual({ error: "not_found" }); - }, - ); - }); - - it("returns 403 for a link-local / cloud-metadata target (SSRF guard)", async () => { - const res = await fetch(`${baseUrl}/fetch`, { - method: "POST", - headers: { - "Content-Type": "application/json", - "X-MCP-Proxy-Auth": `Bearer ${TEST_TOKEN}`, - }, - body: JSON.stringify({ - url: "http://169.254.169.254/latest/meta-data/", - init: { method: "GET" }, - }), - }); - expect(res.status).toBe(403); - const body = (await res.json()) as { error: string }; - expect(body.error).toMatch(/blocked address/i); - }); - - it("returns 403 for the metadata IP in IPv4-mapped IPv6 form (SSRF guard)", async () => { - // The WHATWG URL parser serializes this host to ::ffff:a9fe:a9fe, which must - // still be recognized as 169.254.169.254 and blocked. - const res = await fetch(`${baseUrl}/fetch`, { - method: "POST", - headers: { - "Content-Type": "application/json", - "X-MCP-Proxy-Auth": `Bearer ${TEST_TOKEN}`, - }, - body: JSON.stringify({ - url: "http://[::ffff:169.254.169.254]/latest/meta-data/", - init: { method: "GET" }, - }), - }); - expect(res.status).toBe(403); - const body = (await res.json()) as { error: string }; - expect(body.error).toMatch(/blocked address/i); - }); - - it("blocks a redirect that points at a blocked address (SSRF guard)", async () => { - await withLocalUpstream( - (req, res) => { - res.writeHead(302, { Location: "http://169.254.169.254/latest/" }); - res.end(); - }, - async (origin) => { - const res = await fetch(`${baseUrl}/fetch`, { - method: "POST", - headers: { - "Content-Type": "application/json", - "X-MCP-Proxy-Auth": `Bearer ${TEST_TOKEN}`, - }, - body: JSON.stringify({ - url: `${origin}/redirect`, - init: { method: "GET" }, - }), - }); - expect(res.status).toBe(403); - const body = (await res.json()) as { error: string }; - expect(body.error).toMatch(/blocked address/i); - }, - ); - }); - - it("follows redirects between allowed hosts", async () => { - const payload = JSON.stringify({ hello: "redirected" }); - await withLocalUpstream( - (req, res) => { - if (req.url === "/start") { - res.writeHead(302, { Location: "/final" }); - res.end(); - return; - } - res.writeHead(200, { "Content-Type": "application/json" }); - res.end(payload); - }, - async (origin) => { - const res = await fetch(`${baseUrl}/fetch`, { - method: "POST", - headers: { - "Content-Type": "application/json", - "X-MCP-Proxy-Auth": `Bearer ${TEST_TOKEN}`, - }, - body: JSON.stringify({ - url: `${origin}/start`, - init: { method: "GET" }, - }), - }); - expect(res.status).toBe(200); - const body = (await res.json()) as { status: number; body: string }; - expect(body.status).toBe(200); - expect(body.body).toBe(payload); - }, - ); - }); -}); diff --git a/client/src/components/AppRenderer.tsx b/client/src/components/AppRenderer.tsx deleted file mode 100644 index e25f35c91..000000000 --- a/client/src/components/AppRenderer.tsx +++ /dev/null @@ -1,154 +0,0 @@ -import { useMemo, useState } from "react"; -import { Client } from "@modelcontextprotocol/sdk/client/index.js"; -import { - Tool, - ContentBlock, - CompatibilityCallToolResult, - CallToolResult, - CallToolResultSchema, - ServerNotification, - LoggingMessageNotificationParams, -} from "@modelcontextprotocol/sdk/types.js"; -import { - AppRenderer as McpUiAppRenderer, - type McpUiHostContext, - type RequestHandlerExtra, -} from "@mcp-ui/client"; -import { - type McpUiMessageRequest, - type McpUiMessageResult, -} from "@modelcontextprotocol/ext-apps/app-bridge"; -import { Alert, AlertDescription } from "@/components/ui/alert"; -import { AlertCircle } from "lucide-react"; -import { useToast } from "@/lib/hooks/useToast"; - -interface AppRendererProps { - sandboxPath: string; - tool: Tool; - mcpClient: Client | null; - toolInput?: Record; - toolResult?: CompatibilityCallToolResult | null; - onNotification?: (notification: ServerNotification) => void; -} - -const AppRenderer = ({ - sandboxPath, - tool, - mcpClient, - toolInput, - toolResult, - onNotification, -}: AppRendererProps) => { - const [error, setError] = useState(null); - const { toast } = useToast(); - - const normalizedToolResult = useMemo(() => { - if (!toolResult) { - return undefined; - } - - if ("content" in toolResult) { - const parsedResult = CallToolResultSchema.safeParse(toolResult); - return parsedResult.success ? parsedResult.data : undefined; - } - - if ("toolResult" in toolResult) { - const parsedResult = CallToolResultSchema.safeParse( - toolResult.toolResult, - ); - return parsedResult.success ? parsedResult.data : undefined; - } - - return undefined; - }, [toolResult]); - - const hostContext: McpUiHostContext = useMemo( - () => ({ - theme: document.documentElement.classList.contains("dark") - ? "dark" - : "light", - }), - [], - ); - - const handleOpenLink = async ({ url }: { url: string }) => { - let isError = true; - if (url.startsWith("https://") || url.startsWith("http://")) { - window.open(url, "_blank"); - isError = false; - } - return { isError }; - }; - - const handleMessage = async ( - params: McpUiMessageRequest["params"], - // eslint-disable-next-line @typescript-eslint/no-unused-vars - _extra: RequestHandlerExtra, - ): Promise => { - const message = params.content - .filter((block): block is ContentBlock & { type: "text" } => - Boolean(block.type === "text"), - ) - .map((block) => block.text) - .join("\n"); - - if (message) { - toast({ - description: message, - }); - } - - return {}; - }; - - const handleLoggingMessage = (params: LoggingMessageNotificationParams) => { - if (onNotification) { - onNotification({ - method: "notifications/message", - params, - } as ServerNotification); - } - }; - - if (!mcpClient) { - return ( - - - Waiting for MCP client... - - ); - } - - return ( -
- {error && ( - - - {error} - - )} - -
- setError(err.message)} - /> -
-
- ); -}; - -export default AppRenderer; diff --git a/client/src/components/AppsTab.tsx b/client/src/components/AppsTab.tsx deleted file mode 100644 index 2b6d75358..000000000 --- a/client/src/components/AppsTab.tsx +++ /dev/null @@ -1,677 +0,0 @@ -import { useEffect, useState, useCallback, useRef } from "react"; -import { TabsContent } from "@/components/ui/tabs"; -import { Button } from "@/components/ui/button"; -import { Alert, AlertDescription } from "@/components/ui/alert"; -import { - AlertCircle, - X, - Play, - Loader2, - ChevronRight, - Maximize2, - Minimize2, -} from "lucide-react"; -import { - Tool, - ServerNotification, - CompatibilityCallToolResult, -} from "@modelcontextprotocol/sdk/types.js"; -import { Client } from "@modelcontextprotocol/sdk/client/index.js"; -import { getToolUiResourceUri } from "@modelcontextprotocol/ext-apps/app-bridge"; -import AppRenderer from "./AppRenderer"; -import ListPane from "./ListPane"; -import IconDisplay, { WithIcons } from "./IconDisplay"; -import { Label } from "@/components/ui/label"; -import { Checkbox } from "@/components/ui/checkbox"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; -import { Textarea } from "@/components/ui/textarea"; -import { Input } from "@/components/ui/input"; -import DynamicJsonForm, { DynamicJsonFormRef } from "./DynamicJsonForm"; -import { JsonSchemaType, JsonValue } from "@/utils/jsonUtils"; -import { - generateDefaultValue, - isPropertyRequired, - normalizeUnionType, - resolveRef, -} from "@/utils/schemaUtils"; - -interface AppsTabProps { - sandboxPath: string; - tools: Tool[]; - listTools: () => void; - callTool: ( - name: string, - params: Record, - metadata?: Record, - runAsTask?: boolean, - ) => Promise; - prefilledToolCall?: { - id: number; - toolName: string; - params: Record; - result: CompatibilityCallToolResult; - } | null; - onPrefilledToolCallConsumed?: (callId: number) => void; - error: string | null; - mcpClient: Client | null; - onNotification?: (notification: ServerNotification) => void; -} - -// Type guard to check if a tool has UI metadata -const hasUIMetadata = (tool: Tool): boolean => { - return !!getToolUiResourceUri(tool); -}; - -const cloneToolParams = ( - source: Record, -): Record => { - try { - return structuredClone(source); - } catch { - return { ...source }; - } -}; - -const AppsTab = ({ - sandboxPath, - tools, - listTools, - callTool, - prefilledToolCall, - onPrefilledToolCallConsumed, - error, - mcpClient, - onNotification, -}: AppsTabProps) => { - const [appTools, setAppTools] = useState([]); - const [selectedTool, setSelectedTool] = useState(null); - const [params, setParams] = useState>({}); - const [isAppOpen, setIsAppOpen] = useState(false); - const [isOpeningApp, setIsOpeningApp] = useState(false); - const [isMaximized, setIsMaximized] = useState(false); - const [hasValidationErrors, setHasValidationErrors] = useState(false); - const [submittedParams, setSubmittedParams] = useState< - Record | undefined - >(undefined); - const [submittedToolResult, setSubmittedToolResult] = - useState(null); - const formRefs = useRef>({}); - const openAppRunIdRef = useRef(0); - const prefillingParamsRef = useRef | null>(null); - const consumedPrefilledCallIdRef = useRef(null); - - const buildInitialParams = useCallback((tool: Tool) => { - const initialParams = Object.entries(tool.inputSchema.properties ?? []).map( - ([key, value]) => { - const resolvedValue = resolveRef( - value as JsonSchemaType, - tool.inputSchema as JsonSchemaType, - ); - return [ - key, - generateDefaultValue( - resolvedValue, - key, - tool.inputSchema as JsonSchemaType, - ), - ]; - }, - ); - return Object.fromEntries(initialParams); - }, []); - - // Function to check if any form has validation errors - const checkValidationErrors = useCallback(() => { - const errors = Object.values(formRefs.current).some( - (ref) => ref && !ref.validateJson().isValid, - ); - setHasValidationErrors(errors); - return errors; - }, []); - - // Filter tools that have UI metadata - useEffect(() => { - const filtered = tools.filter(hasUIMetadata); - console.log("[AppsTab] Filtered app tools:", { - totalTools: tools.length, - appTools: filtered.length, - appToolNames: filtered.map((t) => t.name), - }); - setAppTools(filtered); - - // If current selected tool is no longer available, reset selection - if (selectedTool && !filtered.find((t) => t.name === selectedTool.name)) { - setSelectedTool(null); - setIsAppOpen(false); - setSubmittedParams(undefined); - setSubmittedToolResult(null); - } - }, [tools, selectedTool]); - - useEffect(() => { - if (selectedTool) { - const prefillingParams = prefillingParamsRef.current; - if (prefillingParams) { - setParams(prefillingParams); - prefillingParamsRef.current = null; - } else { - setParams(buildInitialParams(selectedTool)); - } - setHasValidationErrors(false); - formRefs.current = {}; - } else { - setParams({}); - setIsAppOpen(false); - setSubmittedParams(undefined); - setSubmittedToolResult(null); - } - }, [buildInitialParams, selectedTool]); - - useEffect(() => { - if (!prefilledToolCall) { - return; - } - - if (consumedPrefilledCallIdRef.current === prefilledToolCall.id) { - return; - } - - const matchingTool = appTools.find( - (tool) => tool.name === prefilledToolCall.toolName, - ); - if (!matchingTool) { - return; - } - - const hydratedParams = cloneToolParams(prefilledToolCall.params); - - openAppRunIdRef.current += 1; - setIsOpeningApp(false); - prefillingParamsRef.current = hydratedParams; - setSelectedTool(matchingTool); - setSubmittedParams(hydratedParams); - setSubmittedToolResult(prefilledToolCall.result); - setIsAppOpen(true); - setIsMaximized(false); - consumedPrefilledCallIdRef.current = prefilledToolCall.id; - onPrefilledToolCallConsumed?.(prefilledToolCall.id); - }, [appTools, onPrefilledToolCallConsumed, prefilledToolCall]); - - const handleRefresh = useCallback(() => { - listTools(); - }, [listTools]); - - const executeToolAndOpenApp = useCallback( - async (tool: Tool, toolParams: Record) => { - const runId = ++openAppRunIdRef.current; - const runParams = cloneToolParams(toolParams); - prefillingParamsRef.current = null; - setIsOpeningApp(true); - setSubmittedParams(runParams); - setSubmittedToolResult(null); - try { - const result = await callTool(tool.name, runParams); - - if (runId !== openAppRunIdRef.current) { - return; - } - - setSubmittedParams(runParams); - setSubmittedToolResult(result); - setIsAppOpen(true); - } catch { - if (runId !== openAppRunIdRef.current) { - return; - } - - setSubmittedToolResult(null); - setIsAppOpen(false); - } finally { - if (runId === openAppRunIdRef.current) { - setIsOpeningApp(false); - } - } - }, - [callTool], - ); - - const handleCloseApp = useCallback(() => { - openAppRunIdRef.current += 1; - setIsOpeningApp(false); - setIsAppOpen(false); - setSubmittedToolResult(null); - }, []); - - const handleOpenApp = useCallback(async () => { - if (!selectedTool || checkValidationErrors()) { - return; - } - - await executeToolAndOpenApp(selectedTool, params); - }, [checkValidationErrors, executeToolAndOpenApp, params, selectedTool]); - - const handleSelectTool = useCallback( - (tool: Tool) => { - openAppRunIdRef.current += 1; - setIsOpeningApp(false); - prefillingParamsRef.current = null; - setSelectedTool(tool); - setSubmittedParams(undefined); - setSubmittedToolResult(null); - const hasFields = - tool.inputSchema.properties && - Object.keys(tool.inputSchema.properties).length > 0; - - if (hasFields) { - setIsAppOpen(false); - return; - } - - const initialParams = buildInitialParams(tool); - void executeToolAndOpenApp(tool, initialParams); - }, - [buildInitialParams, executeToolAndOpenApp], - ); - - const handleDeselectTool = useCallback(() => { - openAppRunIdRef.current += 1; - setIsOpeningApp(false); - prefillingParamsRef.current = null; - setSelectedTool(null); - setIsAppOpen(false); - setIsMaximized(false); - setSubmittedParams(undefined); - setSubmittedToolResult(null); - }, []); - - return ( - -
- {!isMaximized && ( - { - return ( -
-
- -
-
- {tool.name} - {tool.description && ( - - {tool.description} - - )} -
- -
- ); - }} - title="MCP Apps" - buttonText="Refresh Apps" - /> - )} - -
-
-
-
- {selectedTool && ( - - )} -

- {selectedTool ? selectedTool.name : "Select an app"} -

-
-
- {selectedTool && isAppOpen && ( - - )} - {selectedTool && ( - - )} -
-
-
- -
- {error && ( - - - {error} - - )} - - {selectedTool ? ( - (() => { - const hasFields = - selectedTool.inputSchema.properties && - Object.keys(selectedTool.inputSchema.properties).length > 0; - - return ( -
- {!isAppOpen ? ( -
- {selectedTool.description && ( -

- {selectedTool.description} -

- )} - -
-

App Input

- {Object.entries( - selectedTool.inputSchema.properties ?? [], - ).map(([key, value]) => { - // First resolve any $ref references - const resolvedValue = resolveRef( - value as JsonSchemaType, - selectedTool.inputSchema as JsonSchemaType, - ); - const prop = normalizeUnionType(resolvedValue); - const inputSchema = - selectedTool.inputSchema as JsonSchemaType; - const required = isPropertyRequired( - key, - inputSchema, - ); - - return ( -
-
- - {prop.nullable ? ( -
- - setParams({ - ...params, - [key]: checked - ? null - : prop.type === "array" - ? undefined - : prop.default !== null - ? prop.default - : prop.type === "boolean" - ? false - : prop.type === "string" - ? "" - : undefined, - }) - } - /> - -
- ) : null} -
- -
- {prop.type === "boolean" ? ( -
- - setParams({ - ...params, - [key]: checked, - }) - } - /> - -
- ) : prop.type === "string" && prop.enum ? ( - - ) : prop.type === "string" ? ( -