Repository: modelcontextprotocol/inspector Branch: main Commit: 7c8b031ffac6 Files: 185 Total size: 1.1 MB Directory structure: gitextract_mkor3vr7/ ├── .dockerignore ├── .git-blame-ignore-revs ├── .gitattributes ├── .github/ │ ├── ISSUE_TEMPLATE/ │ │ └── bug_report.md │ ├── dependabot.yml │ ├── pull_request_template.md │ └── workflows/ │ ├── claude.yml │ ├── cli_tests.yml │ ├── e2e_tests.yml │ └── main.yml ├── .gitignore ├── .husky/ │ └── pre-commit ├── .mcp.json ├── .node-version ├── .npmrc ├── .prettierignore ├── .prettierrc ├── AGENTS.md ├── CLAUDE.md ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── Dockerfile ├── LICENSE ├── README.md ├── SECURITY.md ├── cli/ │ ├── LICENSE │ ├── __tests__/ │ │ ├── README.md │ │ ├── cli.test.ts │ │ ├── headers.test.ts │ │ ├── helpers/ │ │ │ ├── assertions.ts │ │ │ ├── cli-runner.ts │ │ │ ├── fixtures.ts │ │ │ ├── test-fixtures.ts │ │ │ ├── test-server-http.ts │ │ │ └── test-server-stdio.ts │ │ ├── metadata.test.ts │ │ └── tools.test.ts │ ├── package.json │ ├── scripts/ │ │ └── make-executable.js │ ├── src/ │ │ ├── cli.ts │ │ ├── client/ │ │ │ ├── connection.ts │ │ │ ├── index.ts │ │ │ ├── prompts.ts │ │ │ ├── resources.ts │ │ │ ├── tools.ts │ │ │ └── types.ts │ │ ├── error-handler.ts │ │ ├── index.ts │ │ ├── transport.ts │ │ └── utils/ │ │ └── awaitable-log.ts │ ├── tsconfig.json │ └── vitest.config.ts ├── client/ │ ├── .gitignore │ ├── LICENSE │ ├── README.md │ ├── bin/ │ │ ├── client.js │ │ └── start.js │ ├── components.json │ ├── e2e/ │ │ ├── cli-arguments.spec.ts │ │ ├── global-teardown.js │ │ ├── startup-state.spec.ts │ │ └── transport-type-dropdown.spec.ts │ ├── eslint.config.js │ ├── index.html │ ├── jest.config.cjs │ ├── package.json │ ├── playwright.config.ts │ ├── postcss.config.js │ ├── src/ │ │ ├── App.css │ │ ├── App.tsx │ │ ├── __mocks__/ │ │ │ └── styleMock.js │ │ ├── __tests__/ │ │ │ ├── App.config.test.tsx │ │ │ ├── App.routing.test.tsx │ │ │ ├── App.samplingNavigation.test.tsx │ │ │ └── App.toolsAppsPrefill.test.tsx │ │ ├── components/ │ │ │ ├── AppRenderer.tsx │ │ │ ├── AppsTab.tsx │ │ │ ├── AuthDebugger.tsx │ │ │ ├── ConsoleTab.tsx │ │ │ ├── CustomHeaders.tsx │ │ │ ├── DynamicJsonForm.tsx │ │ │ ├── ElicitationRequest.tsx │ │ │ ├── ElicitationTab.tsx │ │ │ ├── HistoryAndNotifications.tsx │ │ │ ├── IconDisplay.tsx │ │ │ ├── JsonEditor.tsx │ │ │ ├── JsonView.tsx │ │ │ ├── ListPane.tsx │ │ │ ├── MetadataTab.tsx │ │ │ ├── OAuthCallback.tsx │ │ │ ├── OAuthDebugCallback.tsx │ │ │ ├── OAuthFlowProgress.tsx │ │ │ ├── PingTab.tsx │ │ │ ├── PromptsTab.tsx │ │ │ ├── ResourceLinkView.tsx │ │ │ ├── ResourcesTab.tsx │ │ │ ├── RootsTab.tsx │ │ │ ├── SamplingRequest.tsx │ │ │ ├── SamplingTab.tsx │ │ │ ├── Sidebar.tsx │ │ │ ├── TasksTab.tsx │ │ │ ├── ToolResults.tsx │ │ │ ├── ToolsTab.tsx │ │ │ ├── __tests__/ │ │ │ │ ├── AppRenderer.test.tsx │ │ │ │ ├── AppsTab.test.tsx │ │ │ │ ├── AuthDebugger.test.tsx │ │ │ │ ├── DynamicJsonForm.array.test.tsx │ │ │ │ ├── DynamicJsonForm.test.tsx │ │ │ │ ├── ElicitationRequest.test.tsx │ │ │ │ ├── ElicitationTab.test.tsx │ │ │ │ ├── HistoryAndNotifications.test.tsx │ │ │ │ ├── ListPane.test.tsx │ │ │ │ ├── MetadataTab.test.tsx │ │ │ │ ├── ResourcesTab.test.tsx │ │ │ │ ├── Sidebar.test.tsx │ │ │ │ ├── ToolsTab.test.tsx │ │ │ │ ├── samplingRequest.test.tsx │ │ │ │ └── samplingTab.test.tsx │ │ │ └── ui/ │ │ │ ├── alert.tsx │ │ │ ├── button.tsx │ │ │ ├── checkbox.tsx │ │ │ ├── combobox.tsx │ │ │ ├── command.tsx │ │ │ ├── dialog.tsx │ │ │ ├── input.tsx │ │ │ ├── label.tsx │ │ │ ├── popover.tsx │ │ │ ├── select.tsx │ │ │ ├── switch.tsx │ │ │ ├── tabs.tsx │ │ │ ├── textarea.tsx │ │ │ ├── toast.tsx │ │ │ ├── toaster.tsx │ │ │ └── tooltip.tsx │ │ ├── index.css │ │ ├── lib/ │ │ │ ├── __tests__/ │ │ │ │ └── auth.test.ts │ │ │ ├── auth-types.ts │ │ │ ├── auth.ts │ │ │ ├── configurationTypes.ts │ │ │ ├── constants.ts │ │ │ ├── hooks/ │ │ │ │ ├── __tests__/ │ │ │ │ │ └── useConnection.test.tsx │ │ │ │ ├── useCompletionState.ts │ │ │ │ ├── useConnection.ts │ │ │ │ ├── useCopy.ts │ │ │ │ ├── useDraggablePane.ts │ │ │ │ ├── useTheme.ts │ │ │ │ └── useToast.ts │ │ │ ├── notificationTypes.ts │ │ │ ├── oauth-state-machine.ts │ │ │ ├── types/ │ │ │ │ └── customHeaders.ts │ │ │ └── utils.ts │ │ ├── main.tsx │ │ ├── utils/ │ │ │ ├── __tests__/ │ │ │ │ ├── configUtils.test.ts │ │ │ │ ├── escapeUnicode.test.ts │ │ │ │ ├── jsonUtils.test.ts │ │ │ │ ├── oauthUtils.test.ts │ │ │ │ ├── paramUtils.test.ts │ │ │ │ ├── schemaUtils.test.ts │ │ │ │ └── urlValidation.test.ts │ │ │ ├── configUtils.ts │ │ │ ├── escapeUnicode.ts │ │ │ ├── jsonUtils.ts │ │ │ ├── metaUtils.ts │ │ │ ├── oauthUtils.ts │ │ │ ├── paramUtils.ts │ │ │ ├── schemaUtils.ts │ │ │ └── urlValidation.ts │ │ └── vite-env.d.ts │ ├── tailwind.config.js │ ├── tsconfig.app.json │ ├── tsconfig.jest.json │ ├── tsconfig.json │ ├── tsconfig.node.json │ └── vite.config.ts ├── package.json ├── sample-config.json ├── scripts/ │ ├── README.md │ ├── check-version-consistency.js │ └── update-version.js └── server/ ├── LICENSE ├── package.json ├── src/ │ ├── index.ts │ └── mcpProxy.ts ├── static/ │ └── sandbox_proxy.html └── tsconfig.json ================================================ FILE CONTENTS ================================================ ================================================ FILE: .dockerignore ================================================ # Version control .git .gitignore # Node.js node_modules npm-debug.log # Build artifacts client/dist client/build server/dist server/build # Environment variables .env .env.local .env.development .env.test .env.production # Editor files .vscode .idea # Logs logs *.log # Testing coverage # Docker Dockerfile .dockerignore ================================================ FILE: .git-blame-ignore-revs ================================================ ================================================ FILE: .gitattributes ================================================ package-lock.json linguist-generated=true ================================================ FILE: .github/ISSUE_TEMPLATE/bug_report.md ================================================ --- 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. ================================================ FILE: .github/dependabot.yml ================================================ version: 2 updates: - package-ecosystem: "github-actions" directory: "/" schedule: interval: "weekly" ================================================ FILE: .github/pull_request_template.md ================================================ ## 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 ================================================ FILE: .github/workflows/claude.yml ================================================ 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')) || (github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) || (github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) || (github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude'))) ) 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." ================================================ FILE: .github/workflows/cli_tests.yml ================================================ 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 ================================================ FILE: .github/workflows/e2e_tests.yml ================================================ 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@v6 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" ================================================ FILE: .github/workflows/main.yml ================================================ on: push: branches: - main pull_request: release: types: [published] jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - name: Check formatting run: npx prettier --check . - 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 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 publish: runs-on: ubuntu-latest if: github.event_name == 'release' environment: release needs: build permissions: contents: read 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" # Working around https://github.com/npm/cli/issues/4828 # - run: npm ci - run: npm install --no-package-lock # TODO: Add --provenance once the repo is public - run: npm run publish-all env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} publish-github-container-registry: runs-on: ubuntu-latest if: github.event_name == 'release' environment: release needs: build permissions: contents: read packages: write attestations: write id-token: write steps: - uses: actions/checkout@v6 - name: Log in to the Container registry uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Extract metadata (tags, labels) for Docker id: meta uses: docker/metadata-action@v5 with: images: ghcr.io/${{ github.repository }} - name: Set up QEMU uses: docker/setup-qemu-action@v3 - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - name: Build and push Docker image id: push uses: docker/build-push-action@v6 with: context: . push: true platforms: linux/amd64,linux/arm64 tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} - name: Generate artifact attestation uses: actions/attest-build-provenance@v3 with: subject-name: ghcr.io/${{ github.repository }} subject-digest: ${{ steps.push.outputs.digest }} push-to-registry: true ================================================ FILE: .gitignore ================================================ .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 ================================================ FILE: .husky/pre-commit ================================================ npx lint-staged git update-index --again ================================================ FILE: .mcp.json ================================================ { "mcpServers": { "mcp-docs": { "type": "http", "url": "https://modelcontextprotocol.io/mcp" } } } ================================================ FILE: .node-version ================================================ 22.x.x ================================================ FILE: .npmrc ================================================ registry="https://registry.npmjs.org/" @modelcontextprotocol:registry="https://registry.npmjs.org/" ================================================ FILE: .prettierignore ================================================ packages server/build CODE_OF_CONDUCT.md SECURITY.md mcp.json .claude/settings.local.json ================================================ FILE: .prettierrc ================================================ ================================================ FILE: AGENTS.md ================================================ # MCP Inspector Development Guide > **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. ## Build Commands - 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` ## Code Style Guidelines - 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 ## Project Organization The project is organized as a monorepo with workspaces: - `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 ================================================ FILE: CLAUDE.md ================================================ @./AGENTS.md ================================================ FILE: CODE_OF_CONDUCT.md ================================================ # 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. ================================================ FILE: CONTRIBUTING.md ================================================ # 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. ================================================ FILE: Dockerfile ================================================ # 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 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"] ================================================ FILE: LICENSE ================================================ 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. ================================================ FILE: README.md ================================================ # MCP Inspector The MCP inspector is a developer tool for testing and debugging MCP servers. ![MCP Inspector Screenshot](https://raw.githubusercontent.com/modelcontextprotocol/inspector/main/mcp-inspector.png) ## 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: ```bash npx @modelcontextprotocol/inspector ``` The server will start up and the UI will be accessible at `http://localhost:6274`. ### Docker Container You can also start it in a Docker container with the following command: ```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 ``` 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: ```bash # Pass arguments only npx @modelcontextprotocol/inspector node build/index.js arg1 arg2 # Pass environment variables only npx @modelcontextprotocol/inspector -e key=value -e key2=$VALUE2 node build/index.js # 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 ``` 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: ```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: ``` 🔑 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. **Alternative: Manual configuration** - If you already have the inspector open: 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 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: ```bash DANGEROUSLY_OMIT_AUTH=true npm start ``` --- **🚨 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: ```bash MCP_PROXY_AUTH_TOKEN=$(openssl rand -hex 32) npm start ``` #### Local-only Binding 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: ```bash HOST=0.0.0.0 npm start ``` **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. #### DNS Rebinding Protection 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): ```bash ALLOWED_ORIGINS=http://localhost:6274,http://localhost:8000 npm start ``` ### Configuration The MCP Inspector supports the following configuration settings. To change them, click on the `Configuration` button in the MCP Inspector UI: | 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 | **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. These settings can be adjusted in real-time through the UI and will persist across sessions. The inspector also supports configuration files to store settings for different MCP servers. This is useful when working with multiple servers or complex configurations: ```bash npx @modelcontextprotocol/inspector --config path/to/config.json --server everything ``` 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" } } } } ``` #### Transport Types in Config Files The inspector automatically detects the transport type from your config file. You can specify different transport types: **STDIO (default):** ```json { "mcpServers": { "my-stdio-server": { "type": "stdio", "command": "npx", "args": ["@modelcontextprotocol/server-everything"] } } } ``` **SSE (Server-Sent Events):** ```json { "mcpServers": { "my-sse-server": { "type": "sse", "url": "http://localhost:3000/sse" } } } ``` **Streamable HTTP:** ```json { "mcpServers": { "my-http-server": { "type": "streamable-http", "url": "http://localhost:3000/mcp" } } } ``` #### Default Server Selection You can launch the inspector without specifying a server name if your config has: 1. **A single server** - automatically selected: ```bash # Automatically uses "my-server" if it's the only one npx @modelcontextprotocol/inspector --config mcp.json ``` 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"] } } } ``` > **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. You can also set the initial `transport` type, `serverUrl`, `serverCommand`, and `serverArgs` via query params, for example: ``` 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 ``` You can also set initial config settings via query params, for example: ``` 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 ``` Note that if both the query param and the corresponding localStorage item are set, the query param will take precedence. ### From this repository If you're working on the inspector itself: Development mode: ```bash npm run dev # 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 ``` > **Note for Windows users:** > On Windows, use the following command instead: > > ```bash > npm run dev:windows > ``` Production mode: ```bash npm run build npm start ``` ### CLI Mode 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. ```bash npx @modelcontextprotocol/inspector --cli node build/index.js ``` 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}' # List available resources npx @modelcontextprotocol/inspector --cli node build/index.js --method resources/list # List available prompts npx @modelcontextprotocol/inspector --cli node build/index.js --method prompts/list # 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 # List resources from a remote server npx @modelcontextprotocol/inspector --cli https://my-mcp-server.example.com --method resources/list ``` ### 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 | ## Tool Input Validation Guidelines When implementing or modifying tool input parameter handling in the Inspector: - **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 These guidelines maintain clean parameter passing and proper separation of concerns between the Inspector client and MCP servers. ## License This project is licensed under the MIT License—see the [LICENSE](LICENSE) file for details. ================================================ FILE: SECURITY.md ================================================ # 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) ================================================ FILE: cli/LICENSE ================================================ 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. ================================================ FILE: cli/__tests__/README.md ================================================ # 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 ================================================ FILE: cli/__tests__/cli.test.ts ================================================ 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); }); }); }); ================================================ FILE: cli/__tests__/headers.test.ts ================================================ import { describe, it, expect } from "vitest"; import { runCli } from "./helpers/cli-runner.js"; import { expectCliFailure, expectOutputContains, expectCliSuccess, } from "./helpers/assertions.js"; import { createTestServerHttp } from "./helpers/test-server-http.js"; import { createEchoTool, createTestServerInfo, } from "./helpers/test-fixtures.js"; describe("Header Parsing and Validation", () => { describe("Valid Headers", () => { it("should parse valid single header and send it to server", async () => { const server = createTestServerHttp({ serverInfo: createTestServerInfo(), tools: [createEchoTool()], }); try { const port = await server.start("http"); const serverUrl = `${server.getUrl()}/mcp`; const result = await runCli([ serverUrl, "--cli", "--method", "tools/list", "--transport", "http", "--header", "Authorization: Bearer token123", ]); expectCliSuccess(result); // Check that the server received the request with the correct headers const recordedRequests = server.getRecordedRequests(); expect(recordedRequests.length).toBeGreaterThan(0); // Find the tools/list request (should be the last one) const toolsListRequest = recordedRequests[recordedRequests.length - 1]; expect(toolsListRequest).toBeDefined(); expect(toolsListRequest.method).toBe("tools/list"); // Express normalizes headers to lowercase expect(toolsListRequest.headers).toHaveProperty("authorization"); expect(toolsListRequest.headers?.authorization).toBe("Bearer token123"); } finally { await server.stop(); } }); it("should parse multiple headers", async () => { const server = createTestServerHttp({ serverInfo: createTestServerInfo(), tools: [createEchoTool()], }); try { const port = await server.start("http"); const serverUrl = `${server.getUrl()}/mcp`; const result = await runCli([ serverUrl, "--cli", "--method", "tools/list", "--transport", "http", "--header", "Authorization: Bearer token123", "--header", "X-API-Key: secret123", ]); expectCliSuccess(result); const recordedRequests = server.getRecordedRequests(); const toolsListRequest = recordedRequests[recordedRequests.length - 1]; expect(toolsListRequest.method).toBe("tools/list"); expect(toolsListRequest.headers?.authorization).toBe("Bearer token123"); expect(toolsListRequest.headers?.["x-api-key"]).toBe("secret123"); } finally { await server.stop(); } }); it("should handle header with colons in value", async () => { const server = createTestServerHttp({ serverInfo: createTestServerInfo(), tools: [createEchoTool()], }); try { const port = await server.start("http"); const serverUrl = `${server.getUrl()}/mcp`; const result = await runCli([ serverUrl, "--cli", "--method", "tools/list", "--transport", "http", "--header", "X-Time: 2023:12:25:10:30:45", ]); expectCliSuccess(result); const recordedRequests = server.getRecordedRequests(); const toolsListRequest = recordedRequests[recordedRequests.length - 1]; expect(toolsListRequest.method).toBe("tools/list"); expect(toolsListRequest.headers?.["x-time"]).toBe( "2023:12:25:10:30:45", ); } finally { await server.stop(); } }); it("should handle whitespace in headers", async () => { const server = createTestServerHttp({ serverInfo: createTestServerInfo(), tools: [createEchoTool()], }); try { const port = await server.start("http"); const serverUrl = `${server.getUrl()}/mcp`; const result = await runCli([ serverUrl, "--cli", "--method", "tools/list", "--transport", "http", "--header", " X-Header : value with spaces ", ]); expectCliSuccess(result); const recordedRequests = server.getRecordedRequests(); const toolsListRequest = recordedRequests[recordedRequests.length - 1]; expect(toolsListRequest.method).toBe("tools/list"); // Header values should be trimmed by the CLI parser expect(toolsListRequest.headers?.["x-header"]).toBe( "value with spaces", ); } finally { await server.stop(); } }); }); describe("Invalid Header Formats", () => { it("should reject header format without colon", async () => { const result = await runCli([ "https://example.com", "--cli", "--method", "tools/list", "--transport", "http", "--header", "InvalidHeader", ]); expectCliFailure(result); expectOutputContains(result, "Invalid header format"); }); it("should reject header format with empty name", async () => { const result = await runCli([ "https://example.com", "--cli", "--method", "tools/list", "--transport", "http", "--header", ": value", ]); expectCliFailure(result); expectOutputContains(result, "Invalid header format"); }); it("should reject header format with empty value", async () => { const result = await runCli([ "https://example.com", "--cli", "--method", "tools/list", "--transport", "http", "--header", "Header:", ]); expectCliFailure(result); expectOutputContains(result, "Invalid header format"); }); }); }); ================================================ FILE: cli/__tests__/helpers/assertions.ts ================================================ import { expect } from "vitest"; import type { CliResult } from "./cli-runner.js"; function formatCliOutput(result: CliResult): string { const out = result.stdout?.trim() || "(empty)"; const err = result.stderr?.trim() || "(empty)"; return `stdout: ${out}\nstderr: ${err}`; } /** * Assert that CLI command succeeded (exit code 0) */ export function expectCliSuccess(result: CliResult) { expect( result.exitCode, `CLI exited with code ${result.exitCode}. ${formatCliOutput(result)}`, ).toBe(0); } /** * Assert that CLI command failed (non-zero exit code) */ export function expectCliFailure(result: CliResult) { expect( result.exitCode, `CLI unexpectedly exited with code ${result.exitCode}. ${formatCliOutput(result)}`, ).not.toBe(0); } /** * Assert that output contains expected text */ export function expectOutputContains(result: CliResult, text: string) { expect(result.output).toContain(text); } /** * Assert that output contains valid JSON * Uses stdout (not stderr) since JSON is written to stdout and warnings go to stderr */ export function expectValidJson(result: CliResult) { expect(() => JSON.parse(result.stdout)).not.toThrow(); return JSON.parse(result.stdout); } /** * Assert that output contains JSON with error flag */ export function expectJsonError(result: CliResult) { const json = expectValidJson(result); expect(json.isError).toBe(true); return json; } /** * Assert that output contains expected JSON structure */ export function expectJsonStructure(result: CliResult, expectedKeys: string[]) { const json = expectValidJson(result); expectedKeys.forEach((key) => { expect(json).toHaveProperty(key); }); return json; } ================================================ FILE: cli/__tests__/helpers/cli-runner.ts ================================================ 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); } }); }); } ================================================ FILE: cli/__tests__/helpers/fixtures.ts ================================================ import * as fs from "fs"; import * as path from "path"; import * as os from "os"; import * as crypto from "crypto"; import { getTestMcpServerCommand } from "./test-server-stdio.js"; /** * Sentinel value for tests that don't need a real server * (tests that expect failure before connecting) */ export const NO_SERVER_SENTINEL = "invalid-command-that-does-not-exist"; /** * Create a sample test config with test-stdio and test-http servers * Returns a temporary config file path that should be cleaned up with deleteConfigFile() * @param httpUrl - Optional full URL (including /mcp path) for test-http server. * If not provided, uses a placeholder URL. The test-http server exists * to test server selection logic and may not actually be used. */ export function createSampleTestConfig(httpUrl?: string): string { const { command, args } = getTestMcpServerCommand(); return createTestConfig({ mcpServers: { "test-stdio": { type: "stdio", command, args, env: { HELLO: "Hello MCP!", }, }, "test-http": { type: "streamable-http", url: httpUrl || "http://localhost:3001/mcp", }, }, }); } /** * Create a temporary directory for test files * Uses crypto.randomUUID() to ensure uniqueness even when called in parallel */ function createTempDir(prefix: string = "mcp-inspector-test-"): string { const uniqueId = crypto.randomUUID(); const tempDir = path.join(os.tmpdir(), `${prefix}${uniqueId}`); fs.mkdirSync(tempDir, { recursive: true }); return tempDir; } /** * Clean up temporary directory */ function cleanupTempDir(dir: string) { try { fs.rmSync(dir, { recursive: true, force: true }); } catch (err) { // Ignore cleanup errors } } /** * Create a test config file */ export function createTestConfig(config: { mcpServers: Record; }): string { const tempDir = createTempDir("mcp-inspector-config-"); const configPath = path.join(tempDir, "config.json"); fs.writeFileSync(configPath, JSON.stringify(config, null, 2)); return configPath; } /** * Create an invalid config file (malformed JSON) */ export function createInvalidConfig(): string { const tempDir = createTempDir("mcp-inspector-config-"); const configPath = path.join(tempDir, "invalid-config.json"); fs.writeFileSync(configPath, '{\n "mcpServers": {\n "invalid": {'); return configPath; } /** * Delete a config file and its containing directory */ export function deleteConfigFile(configPath: string): void { cleanupTempDir(path.dirname(configPath)); } ================================================ FILE: cli/__tests__/helpers/test-fixtures.ts ================================================ /** * 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(), ], }; } ================================================ FILE: cli/__tests__/helpers/test-server-http.ts ================================================ 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); } ================================================ FILE: cli/__tests__/helpers/test-server-stdio.ts ================================================ #!/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); }); } ================================================ FILE: cli/__tests__/metadata.test.ts ================================================ import { describe, it, expect } from "vitest"; import { runCli } from "./helpers/cli-runner.js"; import { expectCliSuccess, expectCliFailure, expectValidJson, } from "./helpers/assertions.js"; import { createTestServerHttp } from "./helpers/test-server-http.js"; import { createEchoTool, createAddTool, createTestServerInfo, } from "./helpers/test-fixtures.js"; import { NO_SERVER_SENTINEL } from "./helpers/fixtures.js"; describe("Metadata Tests", () => { describe("General Metadata", () => { it("should work with tools/list", 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", "--metadata", "client=test-client", "--transport", "http", ]); expectCliSuccess(result); const json = expectValidJson(result); expect(json).toHaveProperty("tools"); // Validate metadata was sent const recordedRequests = server.getRecordedRequests(); const toolsListRequest = recordedRequests.find( (r) => r.method === "tools/list", ); expect(toolsListRequest).toBeDefined(); expect(toolsListRequest?.metadata).toEqual({ client: "test-client" }); } finally { await server.stop(); } }); it("should work with resources/list", async () => { const server = createTestServerHttp({ serverInfo: createTestServerInfo(), resources: [ { uri: "test://resource", name: "test-resource", text: "test content", }, ], }); try { await server.start("http"); const serverUrl = `${server.getUrl()}/mcp`; const result = await runCli([ serverUrl, "--cli", "--method", "resources/list", "--metadata", "client=test-client", "--transport", "http", ]); expectCliSuccess(result); const json = expectValidJson(result); expect(json).toHaveProperty("resources"); // Validate metadata was sent const recordedRequests = server.getRecordedRequests(); const resourcesListRequest = recordedRequests.find( (r) => r.method === "resources/list", ); expect(resourcesListRequest).toBeDefined(); expect(resourcesListRequest?.metadata).toEqual({ client: "test-client", }); } finally { await server.stop(); } }); it("should work with prompts/list", async () => { const server = createTestServerHttp({ serverInfo: createTestServerInfo(), prompts: [ { name: "test-prompt", description: "A test prompt", }, ], }); try { await server.start("http"); const serverUrl = `${server.getUrl()}/mcp`; const result = await runCli([ serverUrl, "--cli", "--method", "prompts/list", "--metadata", "client=test-client", "--transport", "http", ]); expectCliSuccess(result); const json = expectValidJson(result); expect(json).toHaveProperty("prompts"); // Validate metadata was sent const recordedRequests = server.getRecordedRequests(); const promptsListRequest = recordedRequests.find( (r) => r.method === "prompts/list", ); expect(promptsListRequest).toBeDefined(); expect(promptsListRequest?.metadata).toEqual({ client: "test-client", }); } finally { await server.stop(); } }); it("should work with resources/read", async () => { const server = createTestServerHttp({ serverInfo: createTestServerInfo(), resources: [ { uri: "test://resource", name: "test-resource", text: "test content", }, ], }); try { await server.start("http"); const serverUrl = `${server.getUrl()}/mcp`; const result = await runCli([ serverUrl, "--cli", "--method", "resources/read", "--uri", "test://resource", "--metadata", "client=test-client", "--transport", "http", ]); expectCliSuccess(result); const json = expectValidJson(result); expect(json).toHaveProperty("contents"); // Validate metadata was sent const recordedRequests = server.getRecordedRequests(); const readRequest = recordedRequests.find( (r) => r.method === "resources/read", ); expect(readRequest).toBeDefined(); expect(readRequest?.metadata).toEqual({ client: "test-client" }); } finally { await server.stop(); } }); it("should work with prompts/get", async () => { const server = createTestServerHttp({ serverInfo: createTestServerInfo(), prompts: [ { name: "test-prompt", description: "A test prompt", }, ], }); try { await server.start("http"); const serverUrl = `${server.getUrl()}/mcp`; const result = await runCli([ serverUrl, "--cli", "--method", "prompts/get", "--prompt-name", "test-prompt", "--metadata", "client=test-client", "--transport", "http", ]); expectCliSuccess(result); const json = expectValidJson(result); expect(json).toHaveProperty("messages"); // Validate metadata was sent const recordedRequests = server.getRecordedRequests(); const getPromptRequest = recordedRequests.find( (r) => r.method === "prompts/get", ); expect(getPromptRequest).toBeDefined(); expect(getPromptRequest?.metadata).toEqual({ client: "test-client" }); } finally { await server.stop(); } }); }); describe("Tool-Specific Metadata", () => { it("should work with tools/call", 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/call", "--tool-name", "echo", "--tool-arg", "message=hello world", "--tool-metadata", "client=test-client", "--transport", "http", ]); expectCliSuccess(result); const json = expectValidJson(result); expect(json).toHaveProperty("content"); // Validate metadata was sent const recordedRequests = server.getRecordedRequests(); const toolCallRequest = recordedRequests.find( (r) => r.method === "tools/call", ); expect(toolCallRequest).toBeDefined(); expect(toolCallRequest?.metadata).toEqual({ client: "test-client" }); } finally { await server.stop(); } }); it("should work with complex tool", async () => { const server = createTestServerHttp({ serverInfo: createTestServerInfo(), tools: [createAddTool()], }); try { await server.start("http"); const serverUrl = `${server.getUrl()}/mcp`; const result = await runCli([ serverUrl, "--cli", "--method", "tools/call", "--tool-name", "add", "--tool-arg", "a=10", "b=20", "--tool-metadata", "client=test-client", "--transport", "http", ]); expectCliSuccess(result); const json = expectValidJson(result); expect(json).toHaveProperty("content"); // Validate metadata was sent const recordedRequests = server.getRecordedRequests(); const toolCallRequest = recordedRequests.find( (r) => r.method === "tools/call", ); expect(toolCallRequest).toBeDefined(); expect(toolCallRequest?.metadata).toEqual({ client: "test-client" }); } finally { await server.stop(); } }); }); describe("Metadata Merging", () => { it("should merge general and tool-specific metadata (tool-specific overrides)", 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/call", "--tool-name", "echo", "--tool-arg", "message=hello world", "--metadata", "client=general-client", "shared_key=shared_value", "--tool-metadata", "client=tool-specific-client", "--transport", "http", ]); expectCliSuccess(result); // Validate metadata was merged correctly (tool-specific overrides general) const recordedRequests = server.getRecordedRequests(); const toolCallRequest = recordedRequests.find( (r) => r.method === "tools/call", ); expect(toolCallRequest).toBeDefined(); expect(toolCallRequest?.metadata).toEqual({ client: "tool-specific-client", // Tool-specific overrides general shared_key: "shared_value", // General metadata is preserved }); } finally { await server.stop(); } }); }); describe("Metadata Parsing", () => { it("should handle numeric values", 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", "--metadata", "integer_value=42", "decimal_value=3.14159", "negative_value=-10", "--transport", "http", ]); expectCliSuccess(result); // Validate metadata values are sent as strings const recordedRequests = server.getRecordedRequests(); const toolsListRequest = recordedRequests.find( (r) => r.method === "tools/list", ); expect(toolsListRequest).toBeDefined(); expect(toolsListRequest?.metadata).toEqual({ integer_value: "42", decimal_value: "3.14159", negative_value: "-10", }); } finally { await server.stop(); } }); it("should handle JSON values", 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", "--metadata", 'json_object="{\\"key\\":\\"value\\"}"', 'json_array="[1,2,3]"', 'json_string="\\"quoted\\""', "--transport", "http", ]); expectCliSuccess(result); // Validate JSON values are sent as strings const recordedRequests = server.getRecordedRequests(); const toolsListRequest = recordedRequests.find( (r) => r.method === "tools/list", ); expect(toolsListRequest).toBeDefined(); expect(toolsListRequest?.metadata).toEqual({ json_object: '{"key":"value"}', json_array: "[1,2,3]", json_string: '"quoted"', }); } finally { await server.stop(); } }); it("should handle special characters", 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", "--metadata", "unicode=🚀🎉✨", "special_chars=!@#$%^&*()", "spaces=hello world with spaces", "--transport", "http", ]); expectCliSuccess(result); // Validate special characters are preserved const recordedRequests = server.getRecordedRequests(); const toolsListRequest = recordedRequests.find( (r) => r.method === "tools/list", ); expect(toolsListRequest).toBeDefined(); expect(toolsListRequest?.metadata).toEqual({ unicode: "🚀🎉✨", special_chars: "!@#$%^&*()", spaces: "hello world with spaces", }); } finally { await server.stop(); } }); }); describe("Metadata Edge Cases", () => { it("should handle single metadata entry", 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", "--metadata", "single_key=single_value", "--transport", "http", ]); expectCliSuccess(result); // Validate single metadata entry const recordedRequests = server.getRecordedRequests(); const toolsListRequest = recordedRequests.find( (r) => r.method === "tools/list", ); expect(toolsListRequest).toBeDefined(); expect(toolsListRequest?.metadata).toEqual({ single_key: "single_value", }); } finally { await server.stop(); } }); it("should handle many metadata entries", 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", "--metadata", "key1=value1", "key2=value2", "key3=value3", "key4=value4", "key5=value5", "--transport", "http", ]); expectCliSuccess(result); // Validate all metadata entries const recordedRequests = server.getRecordedRequests(); const toolsListRequest = recordedRequests.find( (r) => r.method === "tools/list", ); expect(toolsListRequest).toBeDefined(); expect(toolsListRequest?.metadata).toEqual({ key1: "value1", key2: "value2", key3: "value3", key4: "value4", key5: "value5", }); } finally { await server.stop(); } }); }); describe("Metadata Error Cases", () => { it("should fail with invalid metadata format (missing equals)", async () => { const result = await runCli([ NO_SERVER_SENTINEL, "--cli", "--method", "tools/list", "--metadata", "invalid_format_no_equals", ]); expectCliFailure(result); }); it("should fail with invalid tool-metadata format (missing equals)", async () => { const result = await runCli([ NO_SERVER_SENTINEL, "--cli", "--method", "tools/call", "--tool-name", "echo", "--tool-arg", "message=test", "--tool-metadata", "invalid_format_no_equals", ]); expectCliFailure(result); }); }); describe("Metadata Impact", () => { it("should handle tool-specific metadata precedence over general", 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/call", "--tool-name", "echo", "--tool-arg", "message=precedence test", "--metadata", "client=general-client", "--tool-metadata", "client=tool-specific-client", "--transport", "http", ]); expectCliSuccess(result); // Validate tool-specific metadata overrides general const recordedRequests = server.getRecordedRequests(); const toolCallRequest = recordedRequests.find( (r) => r.method === "tools/call", ); expect(toolCallRequest).toBeDefined(); expect(toolCallRequest?.metadata).toEqual({ client: "tool-specific-client", }); } finally { await server.stop(); } }); it("should work with resources methods", async () => { const server = createTestServerHttp({ serverInfo: createTestServerInfo(), resources: [ { uri: "test://resource", name: "test-resource", text: "test content", }, ], }); try { await server.start("http"); const serverUrl = `${server.getUrl()}/mcp`; const result = await runCli([ serverUrl, "--cli", "--method", "resources/list", "--metadata", "resource_client=test-resource-client", "--transport", "http", ]); expectCliSuccess(result); // Validate metadata was sent const recordedRequests = server.getRecordedRequests(); const resourcesListRequest = recordedRequests.find( (r) => r.method === "resources/list", ); expect(resourcesListRequest).toBeDefined(); expect(resourcesListRequest?.metadata).toEqual({ resource_client: "test-resource-client", }); } finally { await server.stop(); } }); it("should work with prompts methods", async () => { const server = createTestServerHttp({ serverInfo: createTestServerInfo(), prompts: [ { name: "test-prompt", description: "A test prompt", }, ], }); try { await server.start("http"); const serverUrl = `${server.getUrl()}/mcp`; const result = await runCli([ serverUrl, "--cli", "--method", "prompts/get", "--prompt-name", "test-prompt", "--metadata", "prompt_client=test-prompt-client", "--transport", "http", ]); expectCliSuccess(result); // Validate metadata was sent const recordedRequests = server.getRecordedRequests(); const getPromptRequest = recordedRequests.find( (r) => r.method === "prompts/get", ); expect(getPromptRequest).toBeDefined(); expect(getPromptRequest?.metadata).toEqual({ prompt_client: "test-prompt-client", }); } finally { await server.stop(); } }); }); describe("Metadata Validation", () => { it("should handle special characters in keys", 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/call", "--tool-name", "echo", "--tool-arg", "message=special keys test", "--metadata", "key-with-dashes=value1", "key_with_underscores=value2", "key.with.dots=value3", "--transport", "http", ]); expectCliSuccess(result); // Validate special characters in keys are preserved const recordedRequests = server.getRecordedRequests(); const toolCallRequest = recordedRequests.find( (r) => r.method === "tools/call", ); expect(toolCallRequest).toBeDefined(); expect(toolCallRequest?.metadata).toEqual({ "key-with-dashes": "value1", key_with_underscores: "value2", "key.with.dots": "value3", }); } finally { await server.stop(); } }); }); describe("Metadata Integration", () => { it("should work with all MCP methods", 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", "--metadata", "integration_test=true", "test_phase=all_methods", "--transport", "http", ]); expectCliSuccess(result); // Validate metadata was sent const recordedRequests = server.getRecordedRequests(); const toolsListRequest = recordedRequests.find( (r) => r.method === "tools/list", ); expect(toolsListRequest).toBeDefined(); expect(toolsListRequest?.metadata).toEqual({ integration_test: "true", test_phase: "all_methods", }); } finally { await server.stop(); } }); it("should handle complex metadata scenario", 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/call", "--tool-name", "echo", "--tool-arg", "message=complex test", "--metadata", "session_id=12345", "user_id=67890", "timestamp=2024-01-01T00:00:00Z", "request_id=req-abc-123", "--tool-metadata", "tool_session=session-xyz-789", "execution_context=test", "priority=high", "--transport", "http", ]); expectCliSuccess(result); // Validate complex metadata merging const recordedRequests = server.getRecordedRequests(); const toolCallRequest = recordedRequests.find( (r) => r.method === "tools/call", ); expect(toolCallRequest).toBeDefined(); expect(toolCallRequest?.metadata).toEqual({ session_id: "12345", user_id: "67890", timestamp: "2024-01-01T00:00:00Z", request_id: "req-abc-123", tool_session: "session-xyz-789", execution_context: "test", priority: "high", }); } finally { await server.stop(); } }); it("should handle metadata parsing validation", 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/call", "--tool-name", "echo", "--tool-arg", "message=parsing validation test", "--metadata", "valid_key=valid_value", "numeric_key=123", "boolean_key=true", 'json_key=\'{"test":"value"}\'', "special_key=!@#$%^&*()", "unicode_key=🚀🎉✨", "--transport", "http", ]); expectCliSuccess(result); // Validate all value types are sent as strings // Note: The CLI parses metadata values, so single-quoted JSON strings // are preserved with their quotes const recordedRequests = server.getRecordedRequests(); const toolCallRequest = recordedRequests.find( (r) => r.method === "tools/call", ); expect(toolCallRequest).toBeDefined(); expect(toolCallRequest?.metadata).toEqual({ valid_key: "valid_value", numeric_key: "123", boolean_key: "true", json_key: '\'{"test":"value"}\'', // Single quotes are preserved special_key: "!@#$%^&*()", unicode_key: "🚀🎉✨", }); } finally { await server.stop(); } }); }); }); ================================================ FILE: cli/__tests__/tools.test.ts ================================================ import { describe, it, expect } from "vitest"; import { runCli } from "./helpers/cli-runner.js"; import { expectCliSuccess, expectCliFailure, expectValidJson, expectJsonError, } from "./helpers/assertions.js"; import { getTestMcpServerCommand } from "./helpers/test-server-stdio.js"; describe("Tool Tests", () => { describe("Tool Discovery", () => { it("should list available tools", 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); expect(json.tools.length).toBeGreaterThan(0); // Validate that tools have required properties expect(json.tools[0]).toHaveProperty("name"); expect(json.tools[0]).toHaveProperty("description"); // 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"); }); }); describe("JSON Argument Parsing", () => { it("should handle string arguments (backward compatibility)", async () => { const { command, args } = getTestMcpServerCommand(); const result = await runCli([ command, ...args, "--cli", "--method", "tools/call", "--tool-name", "echo", "--tool-arg", "message=hello world", ]); 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 world"); }); it("should handle integer number arguments", async () => { const { command, args } = getTestMcpServerCommand(); const result = await runCli([ command, ...args, "--cli", "--method", "tools/call", "--tool-name", "get-sum", "--tool-arg", "a=42", "b=58", ]); 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"); // test-mcp-server returns JSON with {result: a+b} const resultData = JSON.parse(json.content[0].text); expect(resultData.result).toBe(100); }); it("should handle decimal number arguments", async () => { const { command, args } = getTestMcpServerCommand(); const result = await runCli([ command, ...args, "--cli", "--method", "tools/call", "--tool-name", "get-sum", "--tool-arg", "a=19.99", "b=20.01", ]); 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"); // test-mcp-server returns JSON with {result: a+b} const resultData = JSON.parse(json.content[0].text); expect(resultData.result).toBeCloseTo(40.0, 2); }); it("should handle boolean arguments - true", async () => { const { command, args } = getTestMcpServerCommand(); const result = await runCli([ command, ...args, "--cli", "--method", "tools/call", "--tool-name", "get-annotated-message", "--tool-arg", "messageType=success", "includeImage=true", ]); expectCliSuccess(result); const json = expectValidJson(result); expect(json).toHaveProperty("content"); expect(Array.isArray(json.content)).toBe(true); // Should have both text and image content expect(json.content.length).toBeGreaterThan(1); const hasImage = json.content.some((item: any) => item.type === "image"); expect(hasImage).toBe(true); }); it("should handle boolean arguments - false", async () => { const { command, args } = getTestMcpServerCommand(); const result = await runCli([ command, ...args, "--cli", "--method", "tools/call", "--tool-name", "get-annotated-message", "--tool-arg", "messageType=error", "includeImage=false", ]); expectCliSuccess(result); const json = expectValidJson(result); expect(json).toHaveProperty("content"); expect(Array.isArray(json.content)).toBe(true); // Should only have text content, no image const hasImage = json.content.some((item: any) => item.type === "image"); expect(hasImage).toBe(false); // test-mcp-server returns "This is a {messageType} message" expect(json.content[0].text.toLowerCase()).toContain("error"); }); it("should handle null arguments", async () => { const { command, args } = getTestMcpServerCommand(); const result = await runCli([ command, ...args, "--cli", "--method", "tools/call", "--tool-name", "echo", "--tool-arg", 'message="null"', ]); expectCliSuccess(result); const json = expectValidJson(result); expect(json).toHaveProperty("content"); expect(Array.isArray(json.content)).toBe(true); expect(json.content[0]).toHaveProperty("type", "text"); // The string "null" should be passed through expect(json.content[0].text).toBe("Echo: null"); }); it("should handle multiple arguments with mixed types", async () => { const { command, args } = getTestMcpServerCommand(); const result = await runCli([ command, ...args, "--cli", "--method", "tools/call", "--tool-name", "get-sum", "--tool-arg", "a=42.5", "b=57.5", ]); 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"); // test-mcp-server returns JSON with {result: a+b} const resultData = JSON.parse(json.content[0].text); expect(resultData.result).toBeCloseTo(100.0, 1); }); }); describe("JSON Parsing Edge Cases", () => { it("should fall back to string for invalid JSON", async () => { const { command, args } = getTestMcpServerCommand(); const result = await runCli([ command, ...args, "--cli", "--method", "tools/call", "--tool-name", "echo", "--tool-arg", "message={invalid json}", ]); expectCliSuccess(result); const json = expectValidJson(result); expect(json).toHaveProperty("content"); expect(Array.isArray(json.content)).toBe(true); expect(json.content[0]).toHaveProperty("type", "text"); // Should treat invalid JSON as a string expect(json.content[0].text).toBe("Echo: {invalid json}"); }); it("should handle empty string value", async () => { const { command, args } = getTestMcpServerCommand(); const result = await runCli([ command, ...args, "--cli", "--method", "tools/call", "--tool-name", "echo", "--tool-arg", 'message=""', ]); expectCliSuccess(result); const json = expectValidJson(result); expect(json).toHaveProperty("content"); expect(Array.isArray(json.content)).toBe(true); expect(json.content[0]).toHaveProperty("type", "text"); // Empty string should be preserved expect(json.content[0].text).toBe("Echo: "); }); it("should handle special characters in strings", async () => { const { command, args } = getTestMcpServerCommand(); const result = await runCli([ command, ...args, "--cli", "--method", "tools/call", "--tool-name", "echo", "--tool-arg", 'message="C:\\\\Users\\\\test"', ]); expectCliSuccess(result); const json = expectValidJson(result); expect(json).toHaveProperty("content"); expect(Array.isArray(json.content)).toBe(true); expect(json.content[0]).toHaveProperty("type", "text"); // Special characters should be preserved expect(json.content[0].text).toContain("C:"); expect(json.content[0].text).toContain("Users"); expect(json.content[0].text).toContain("test"); }); it("should handle unicode characters", async () => { const { command, args } = getTestMcpServerCommand(); const result = await runCli([ command, ...args, "--cli", "--method", "tools/call", "--tool-name", "echo", "--tool-arg", 'message="🚀🎉✨"', ]); expectCliSuccess(result); const json = expectValidJson(result); expect(json).toHaveProperty("content"); expect(Array.isArray(json.content)).toBe(true); expect(json.content[0]).toHaveProperty("type", "text"); // Unicode characters should be preserved expect(json.content[0].text).toContain("🚀"); expect(json.content[0].text).toContain("🎉"); expect(json.content[0].text).toContain("✨"); }); it("should handle arguments with equals signs in values", async () => { const { command, args } = getTestMcpServerCommand(); const result = await runCli([ command, ...args, "--cli", "--method", "tools/call", "--tool-name", "echo", "--tool-arg", "message=2+2=4", ]); expectCliSuccess(result); const json = expectValidJson(result); expect(json).toHaveProperty("content"); expect(Array.isArray(json.content)).toBe(true); expect(json.content[0]).toHaveProperty("type", "text"); // Equals signs in values should be preserved expect(json.content[0].text).toBe("Echo: 2+2=4"); }); it("should handle base64-like strings", async () => { const { command, args } = getTestMcpServerCommand(); const base64String = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0="; const result = await runCli([ command, ...args, "--cli", "--method", "tools/call", "--tool-name", "echo", "--tool-arg", `message=${base64String}`, ]); expectCliSuccess(result); const json = expectValidJson(result); expect(json).toHaveProperty("content"); expect(Array.isArray(json.content)).toBe(true); expect(json.content[0]).toHaveProperty("type", "text"); // Base64-like strings should be preserved expect(json.content[0].text).toBe(`Echo: ${base64String}`); }); }); describe("Tool Error Handling", () => { it("should fail with nonexistent tool", async () => { const { command, args } = getTestMcpServerCommand(); const result = await runCli([ command, ...args, "--cli", "--method", "tools/call", "--tool-name", "nonexistent_tool", "--tool-arg", "message=test", ]); // CLI returns exit code 0 but includes isError: true in JSON expectJsonError(result); }); it("should fail when tool name is missing", async () => { const { command, args } = getTestMcpServerCommand(); const result = await runCli([ command, ...args, "--cli", "--method", "tools/call", "--tool-arg", "message=test", ]); expectCliFailure(result); }); it("should fail with invalid tool argument format", async () => { const { command, args } = getTestMcpServerCommand(); const result = await runCli([ command, ...args, "--cli", "--method", "tools/call", "--tool-name", "echo", "--tool-arg", "invalid_format_no_equals", ]); expectCliFailure(result); }); }); describe("Prompt JSON Arguments", () => { it("should handle prompt with JSON 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("content"); expect(json.messages[0].content).toHaveProperty("type", "text"); // Validate that the arguments were actually used in the response // test-mcp-server formats it as "This is a prompt with arguments: city={city}, state={state}" expect(json.messages[0].content.text).toContain("city=New York"); expect(json.messages[0].content.text).toContain("state=NY"); }); it("should handle prompt with simple arguments", async () => { // Note: simple-prompt doesn't accept arguments, but the CLI should still // accept the command and the server should ignore the arguments const { command, args } = getTestMcpServerCommand(); const result = await runCli([ command, ...args, "--cli", "--method", "prompts/get", "--prompt-name", "simple-prompt", "--prompt-args", "name=test", "count=5", ]); 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("content"); expect(json.messages[0].content).toHaveProperty("type", "text"); // test-mcp-server's simple-prompt returns standard message (ignoring args) expect(json.messages[0].content.text).toBe( "This is a simple prompt for testing purposes.", ); }); }); describe("Backward Compatibility", () => { it("should support existing string-only usage", async () => { const { command, args } = getTestMcpServerCommand(); const result = await runCli([ command, ...args, "--cli", "--method", "tools/call", "--tool-name", "echo", "--tool-arg", "message=hello", ]); expectCliSuccess(result); const json = expectValidJson(result); expect(json).toHaveProperty("content"); expect(Array.isArray(json.content)).toBe(true); expect(json.content[0]).toHaveProperty("type", "text"); expect(json.content[0].text).toBe("Echo: hello"); }); it("should support multiple string arguments", async () => { const { command, args } = getTestMcpServerCommand(); const result = await runCli([ command, ...args, "--cli", "--method", "tools/call", "--tool-name", "get-sum", "--tool-arg", "a=10", "b=20", ]); 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"); // test-mcp-server returns JSON with {result: a+b} const resultData = JSON.parse(json.content[0].text); expect(resultData.result).toBe(30); }); }); }); ================================================ FILE: cli/package.json ================================================ { "name": "@modelcontextprotocol/inspector-cli", "version": "0.21.1", "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", "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.0.17" }, "dependencies": { "@modelcontextprotocol/sdk": "^1.25.2", "commander": "^13.1.0", "express": "^5.2.1", "spawn-rx": "^5.1.2" } } ================================================ FILE: cli/scripts/make-executable.js ================================================ /** * 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(); ================================================ FILE: cli/src/cli.ts ================================================ #!/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(); ================================================ FILE: cli/src/client/connection.ts ================================================ 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)}`, ); } } ================================================ FILE: cli/src/client/index.ts ================================================ // 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"; ================================================ FILE: cli/src/client/prompts.ts ================================================ 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)}`, ); } } ================================================ FILE: cli/src/client/resources.ts ================================================ 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)}`, ); } } ================================================ FILE: cli/src/client/tools.ts ================================================ 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)}`, ); } } ================================================ FILE: cli/src/client/types.ts ================================================ export type McpResponse = Record; ================================================ FILE: cli/src/error-handler.ts ================================================ 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); } ================================================ FILE: cli/src/index.ts ================================================ #!/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(); ================================================ FILE: cli/src/transport.ts ================================================ 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)}`, ); } } ================================================ FILE: cli/src/utils/awaitable-log.ts ================================================ export function awaitableLog(logValue: string): Promise { return new Promise((resolve) => { process.stdout.write(logValue, () => { resolve(); }); }); } ================================================ FILE: cli/tsconfig.json ================================================ { "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"] } ================================================ FILE: cli/vitest.config.ts ================================================ 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 }, }); ================================================ FILE: client/.gitignore ================================================ # Logs logs *.log npm-debug.log* yarn-debug.log* yarn-error.log* pnpm-debug.log* lerna-debug.log* node_modules dist dist-ssr *.local # Editor directories and files .vscode/* !.vscode/extensions.json .idea .DS_Store *.suo *.ntvs* *.njsproj *.sln *.sw? ================================================ FILE: client/LICENSE ================================================ 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. ================================================ FILE: client/README.md ================================================ # 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, }, }); ``` ================================================ FILE: client/bin/client.js ================================================ #!/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); ================================================ FILE: client/bin/start.js ================================================ #!/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); }); ================================================ FILE: client/components.json ================================================ { "$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" } } ================================================ FILE: client/e2e/cli-arguments.spec.ts ================================================ 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(); }); }); ================================================ FILE: client/e2e/global-teardown.js ================================================ 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); } ================================================ FILE: client/e2e/startup-state.spec.ts ================================================ 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("#"); }); }); ================================================ FILE: client/e2e/transport-type-dropdown.spec.ts ================================================ 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(); }); }); ================================================ FILE: client/eslint.config.js ================================================ 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 }, ], }, }, ); ================================================ FILE: client/index.html ================================================ MCP Inspector
================================================ FILE: client/jest.config.cjs ================================================ 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, }; ================================================ FILE: client/package.json ================================================ { "name": "@modelcontextprotocol/inspector-client", "version": "0.21.1", "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", "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.1.11" } } ================================================ FILE: client/playwright.config.ts ================================================ 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"] }, }, ] : []), ], }); ================================================ FILE: client/postcss.config.js ================================================ export default { plugins: { tailwindcss: {}, autoprefixer: {}, }, }; ================================================ FILE: client/src/App.css ================================================ .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; } ================================================ FILE: client/src/App.tsx ================================================ 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 { 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 [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; setPendingElicitationRequests((prev) => [ ...prev, { id: nextRequestId.current++, request: { id: nextRequestId.current, message: request.params.message, requestedSchema: request.params.requestedSchema, }, 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 stateMachine = new OAuthStateMachine(sseUrl, (updates) => { currentState = { ...currentState, ...updates }; }); 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], ); 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) => { if (fetchingResources.has(uri) || resourceContentMap[uri]) { return; } console.log("[App] Reading resource:", uri); setFetchingResources((prev) => new Set(prev).add(uri)); 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); setResourceContentMap((prev) => ({ ...prev, [uri]: JSON.stringify({ error: 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; 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} /> ); 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) => { clearError("resources"); readResource(uri); }} 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} 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; ================================================ FILE: client/src/__mocks__/styleMock.js ================================================ module.exports = {}; ================================================ FILE: client/src/__tests__/App.config.test.tsx ================================================ 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(); }); }); ================================================ FILE: client/src/__tests__/App.routing.test.tsx ================================================ 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(""); }); }); }); ================================================ FILE: client/src/__tests__/App.samplingNavigation.test.tsx ================================================ 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"); }); }); }); ================================================ FILE: client/src/__tests__/App.toolsAppsPrefill.test.tsx ================================================ 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"); }); }); }); ================================================ FILE: client/src/components/AppRenderer.tsx ================================================ 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; ================================================ FILE: client/src/components/AppsTab.tsx ================================================ 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" ? (