├── .dockerignore ├── .git-blame-ignore-revs ├── .gitattributes ├── .github └── workflows │ ├── claude.yml │ ├── cli_tests.yml │ ├── e2e_tests.yml │ └── main.yml ├── .gitignore ├── .node-version ├── .npmrc ├── .prettierignore ├── .prettierrc ├── CLAUDE.md ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── Dockerfile ├── LICENSE ├── README.md ├── SECURITY.md ├── cli ├── package.json ├── scripts │ ├── cli-tests.js │ └── 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 └── tsconfig.json ├── client ├── .gitignore ├── README.md ├── bin │ ├── client.js │ └── start.js ├── components.json ├── e2e │ ├── global-teardown.js │ └── transport-type-dropdown.spec.ts ├── eslint.config.js ├── index.html ├── jest.config.cjs ├── package.json ├── playwright.config.ts ├── postcss.config.js ├── public │ └── mcp.svg ├── src │ ├── App.css │ ├── App.tsx │ ├── __mocks__ │ │ └── styleMock.js │ ├── __tests__ │ │ └── App.config.test.tsx │ ├── components │ │ ├── AuthDebugger.tsx │ │ ├── ConsoleTab.tsx │ │ ├── DynamicJsonForm.tsx │ │ ├── HistoryAndNotifications.tsx │ │ ├── JsonEditor.tsx │ │ ├── JsonView.tsx │ │ ├── ListPane.tsx │ │ ├── OAuthCallback.tsx │ │ ├── OAuthDebugCallback.tsx │ │ ├── OAuthFlowProgress.tsx │ │ ├── PingTab.tsx │ │ ├── PromptsTab.tsx │ │ ├── ResourceLinkView.tsx │ │ ├── ResourcesTab.tsx │ │ ├── RootsTab.tsx │ │ ├── SamplingRequest.tsx │ │ ├── SamplingTab.tsx │ │ ├── Sidebar.tsx │ │ ├── ToolResults.tsx │ │ ├── ToolsTab.tsx │ │ ├── __tests__ │ │ │ ├── AuthDebugger.test.tsx │ │ │ ├── DynamicJsonForm.array.test.tsx │ │ │ ├── DynamicJsonForm.test.tsx │ │ │ ├── HistoryAndNotifications.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 │ │ │ ├── tabs.tsx │ │ │ ├── textarea.tsx │ │ │ ├── toast.tsx │ │ │ ├── toaster.tsx │ │ │ └── tooltip.tsx │ ├── index.css │ ├── lib │ │ ├── auth-types.ts │ │ ├── auth.ts │ │ ├── configurationTypes.ts │ │ ├── constants.ts │ │ ├── hooks │ │ │ ├── __tests__ │ │ │ │ └── useConnection.test.tsx │ │ │ ├── useCompletionState.ts │ │ │ ├── useConnection.ts │ │ │ ├── useDraggablePane.ts │ │ │ ├── useTheme.ts │ │ │ └── useToast.ts │ │ ├── notificationTypes.ts │ │ ├── oauth-state-machine.ts │ │ └── utils.ts │ ├── main.tsx │ ├── utils │ │ ├── __tests__ │ │ │ ├── configUtils.test.ts │ │ │ ├── escapeUnicode.test.ts │ │ │ ├── jsonUtils.test.ts │ │ │ ├── oauthUtils.ts │ │ │ └── schemaUtils.test.ts │ │ ├── configUtils.ts │ │ ├── escapeUnicode.ts │ │ ├── jsonUtils.ts │ │ ├── oauthUtils.ts │ │ └── schemaUtils.ts │ └── vite-env.d.ts ├── tailwind.config.js ├── tsconfig.app.json ├── tsconfig.jest.json ├── tsconfig.json ├── tsconfig.node.json └── vite.config.ts ├── mcp-inspector.png ├── package-lock.json ├── package.json ├── sample-config.json ├── scripts ├── README.md ├── check-version-consistency.js └── update-version.js └── server ├── package.json ├── src ├── index.ts └── mcpProxy.ts └── tsconfig.json /.dockerignore: -------------------------------------------------------------------------------- 1 | # Version control 2 | .git 3 | .gitignore 4 | 5 | # Node.js 6 | node_modules 7 | npm-debug.log 8 | 9 | # Build artifacts 10 | client/dist 11 | client/build 12 | server/dist 13 | server/build 14 | 15 | # Environment variables 16 | .env 17 | .env.local 18 | .env.development 19 | .env.test 20 | .env.production 21 | 22 | # Editor files 23 | .vscode 24 | .idea 25 | 26 | # Logs 27 | logs 28 | *.log 29 | 30 | # Testing 31 | coverage 32 | 33 | # Docker 34 | Dockerfile 35 | .dockerignore -------------------------------------------------------------------------------- /.git-blame-ignore-revs: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/modelcontextprotocol/inspector/1ea8e9a9395386b0e77efaa64ccaa4c28c571d10/.git-blame-ignore-revs -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | package-lock.json linguist-generated=true 2 | -------------------------------------------------------------------------------- /.github/workflows/claude.yml: -------------------------------------------------------------------------------- 1 | name: Claude Code 2 | 3 | on: 4 | issue_comment: 5 | types: [created] 6 | pull_request_review_comment: 7 | types: [created] 8 | issues: 9 | types: [opened, assigned] 10 | pull_request_review: 11 | types: [submitted] 12 | 13 | jobs: 14 | claude: 15 | if: | 16 | ( 17 | (github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) || 18 | (github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) || 19 | (github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) || 20 | (github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude'))) 21 | ) && 22 | ( 23 | github.actor == 'ihrpr' || 24 | github.actor == 'olaservo' 25 | ) 26 | runs-on: ubuntu-latest 27 | permissions: 28 | contents: read 29 | pull-requests: read 30 | issues: read 31 | id-token: write 32 | steps: 33 | - name: Checkout repository 34 | uses: actions/checkout@v4 35 | with: 36 | fetch-depth: 1 37 | 38 | - name: Run Claude Code 39 | id: claude 40 | uses: anthropics/claude-code-action@beta 41 | with: 42 | anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} 43 | 44 | # Optional: Specify model (defaults to Claude Sonnet 4, uncomment for Claude Opus 4) 45 | # model: "claude-opus-4-20250514" 46 | 47 | # Optional: Customize the trigger phrase (default: @claude) 48 | # trigger_phrase: "/claude" 49 | 50 | # Optional: Trigger when specific user is assigned to an issue 51 | # assignee_trigger: "claude-bot" 52 | 53 | # Optional: Allow Claude to run specific commands 54 | # allowed_tools: "Bash(npm install),Bash(npm run build),Bash(npm run test:*),Bash(npm run lint:*)" 55 | 56 | # Optional: Add custom instructions for Claude to customize its behavior for your project 57 | # custom_instructions: | 58 | # Follow our coding standards 59 | # Ensure all new code has tests 60 | # Use TypeScript for new files 61 | 62 | # Optional: Custom environment variables for Claude 63 | # claude_env: | 64 | # NODE_ENV: test 65 | -------------------------------------------------------------------------------- /.github/workflows/cli_tests.yml: -------------------------------------------------------------------------------- 1 | name: CLI Tests 2 | 3 | on: 4 | push: 5 | paths: 6 | - "cli/**" 7 | pull_request: 8 | paths: 9 | - "cli/**" 10 | 11 | jobs: 12 | test: 13 | runs-on: ubuntu-latest 14 | defaults: 15 | run: 16 | working-directory: ./cli 17 | steps: 18 | - uses: actions/checkout@v4 19 | 20 | - name: Set up Node.js 21 | uses: actions/setup-node@v4 22 | with: 23 | node-version-file: package.json 24 | cache: npm 25 | 26 | - name: Install dependencies 27 | run: | 28 | cd .. 29 | npm ci --ignore-scripts 30 | 31 | - name: Build CLI 32 | run: npm run build 33 | 34 | - name: Explicitly pre-install test dependencies 35 | run: npx -y @modelcontextprotocol/server-everything --help || true 36 | 37 | - name: Run tests 38 | run: npm test 39 | env: 40 | NPM_CONFIG_YES: true 41 | CI: true 42 | -------------------------------------------------------------------------------- /.github/workflows/e2e_tests.yml: -------------------------------------------------------------------------------- 1 | name: Playwright Tests 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | branches: [main] 8 | 9 | jobs: 10 | test: 11 | # Installing Playright dependencies can take quite awhile, and also depends on GitHub CI load. 12 | timeout-minutes: 15 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - name: Install dependencies 17 | run: | 18 | sudo apt-get update 19 | sudo apt-get install -y libwoff1 20 | 21 | - uses: actions/checkout@v4 22 | 23 | - uses: actions/setup-node@v4 24 | id: setup_node 25 | with: 26 | node-version-file: package.json 27 | cache: npm 28 | 29 | # Cache Playwright browsers 30 | - name: Cache Playwright browsers 31 | id: cache-playwright 32 | uses: actions/cache@v4 33 | with: 34 | path: ~/.cache/ms-playwright # The default Playwright cache path 35 | key: ${{ runner.os }}-playwright-${{ hashFiles('package-lock.json') }} # Cache key based on OS and package-lock.json 36 | restore-keys: | 37 | ${{ runner.os }}-playwright- 38 | 39 | - name: Install dependencies 40 | run: npm ci 41 | 42 | - name: Install Playwright dependencies 43 | run: npx playwright install-deps 44 | 45 | - name: Install Playwright and browsers unless cached 46 | run: npx playwright install --with-deps 47 | if: steps.cache-playwright.outputs.cache-hit != 'true' 48 | 49 | - name: Run Playwright tests 50 | id: playwright-tests 51 | run: npm run test:e2e 52 | 53 | - name: Upload Playwright Report and Screenshots 54 | uses: actions/upload-artifact@v4 55 | if: steps.playwright-tests.conclusion != 'skipped' 56 | with: 57 | name: playwright-report 58 | path: | 59 | client/playwright-report/ 60 | client/test-results/ 61 | client/results.json 62 | retention-days: 2 63 | 64 | - name: Publish Playwright Test Summary 65 | uses: daun/playwright-report-summary@v3 66 | if: steps.playwright-tests.conclusion != 'skipped' 67 | with: 68 | create-comment: ${{ github.event.pull_request.head.repo.full_name == github.repository }} 69 | report-file: client/results.json 70 | comment-title: "🎭 Playwright E2E Test Results" 71 | job-summary: true 72 | icon-style: "emojis" 73 | custom-info: | 74 | **Test Environment:** Ubuntu Latest, Node.js ${{ steps.setup_node.outputs.node-version }} 75 | **Browsers:** Chromium, Firefox 76 | 77 | 📊 [View Detailed HTML Report](https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}) (download artifacts) 78 | test-command: "npm run test:e2e" 79 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | branches: 4 | - main 5 | 6 | pull_request: 7 | release: 8 | types: [published] 9 | 10 | jobs: 11 | build: 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | - uses: actions/checkout@v4 16 | 17 | - name: Check formatting 18 | run: npx prettier --check . 19 | 20 | - uses: actions/setup-node@v4 21 | with: 22 | node-version-file: package.json 23 | cache: npm 24 | 25 | # Working around https://github.com/npm/cli/issues/4828 26 | # - run: npm ci 27 | - run: npm install --no-package-lock 28 | 29 | - name: Check version consistency 30 | run: npm run check-version 31 | 32 | - name: Check linting 33 | working-directory: ./client 34 | run: npm run lint 35 | 36 | - name: Run client tests 37 | working-directory: ./client 38 | run: npm test 39 | 40 | - run: npm run build 41 | 42 | publish: 43 | runs-on: ubuntu-latest 44 | if: github.event_name == 'release' 45 | environment: release 46 | needs: build 47 | 48 | permissions: 49 | contents: read 50 | id-token: write 51 | 52 | steps: 53 | - uses: actions/checkout@v4 54 | - uses: actions/setup-node@v4 55 | with: 56 | node-version-file: package.json 57 | cache: npm 58 | registry-url: "https://registry.npmjs.org" 59 | 60 | # Working around https://github.com/npm/cli/issues/4828 61 | # - run: npm ci 62 | - run: npm install --no-package-lock 63 | 64 | # TODO: Add --provenance once the repo is public 65 | - run: npm run publish-all 66 | env: 67 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 68 | 69 | publish-github-container-registry: 70 | runs-on: ubuntu-latest 71 | if: github.event_name == 'release' 72 | environment: release 73 | needs: build 74 | permissions: 75 | contents: read 76 | packages: write 77 | attestations: write 78 | id-token: write 79 | steps: 80 | - uses: actions/checkout@v4 81 | 82 | - name: Log in to the Container registry 83 | uses: docker/login-action@v3 84 | with: 85 | registry: ghcr.io 86 | username: ${{ github.actor }} 87 | password: ${{ secrets.GITHUB_TOKEN }} 88 | 89 | - name: Extract metadata (tags, labels) for Docker 90 | id: meta 91 | uses: docker/metadata-action@v5 92 | with: 93 | images: ghcr.io/${{ github.repository }} 94 | 95 | - name: Set up QEMU 96 | uses: docker/setup-qemu-action@v3 97 | 98 | - name: Set up Docker Buildx 99 | uses: docker/setup-buildx-action@v3 100 | 101 | - name: Build and push Docker image 102 | id: push 103 | uses: docker/build-push-action@v6 104 | with: 105 | context: . 106 | push: true 107 | platforms: linux/amd64,linux/arm64 108 | tags: ${{ steps.meta.outputs.tags }} 109 | labels: ${{ steps.meta.outputs.labels }} 110 | 111 | - name: Generate artifact attestation 112 | uses: actions/attest-build-provenance@v2 113 | with: 114 | subject-name: ghcr.io/${{ github.repository }} 115 | subject-digest: ${{ steps.push.outputs.digest }} 116 | push-to-registry: true 117 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .vscode 3 | .idea 4 | node_modules/ 5 | *-workspace/ 6 | server/build 7 | client/dist 8 | client/tsconfig.app.tsbuildinfo 9 | client/tsconfig.node.tsbuildinfo 10 | cli/build 11 | test-output 12 | # symlinked by `npm run link:sdk`: 13 | sdk 14 | client/playwright-report/ 15 | client/results.json 16 | client/test-results/ 17 | -------------------------------------------------------------------------------- /.node-version: -------------------------------------------------------------------------------- 1 | 22.x.x 2 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | registry="https://registry.npmjs.org/" 2 | @modelcontextprotocol:registry="https://registry.npmjs.org/" 3 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | packages 2 | server/build 3 | CODE_OF_CONDUCT.md 4 | SECURITY.md 5 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/modelcontextprotocol/inspector/1ea8e9a9395386b0e77efaa64ccaa4c28c571d10/.prettierrc -------------------------------------------------------------------------------- /CLAUDE.md: -------------------------------------------------------------------------------- 1 | # MCP Inspector Development Guide 2 | 3 | ## Build Commands 4 | 5 | - Build all: `npm run build` 6 | - Build client: `npm run build-client` 7 | - Build server: `npm run build-server` 8 | - Development mode: `npm run dev` (use `npm run dev:windows` on Windows) 9 | - Format code: `npm run prettier-fix` 10 | - Client lint: `cd client && npm run lint` 11 | 12 | ## Code Style Guidelines 13 | 14 | - Use TypeScript with proper type annotations 15 | - Follow React functional component patterns with hooks 16 | - Use ES modules (import/export) not CommonJS 17 | - Use Prettier for formatting (auto-formatted on commit) 18 | - Follow existing naming conventions: 19 | - camelCase for variables and functions 20 | - PascalCase for component names and types 21 | - kebab-case for file names 22 | - Use async/await for asynchronous operations 23 | - Implement proper error handling with try/catch blocks 24 | - Use Tailwind CSS for styling in the client 25 | - Keep components small and focused on a single responsibility 26 | 27 | ## Project Organization 28 | 29 | The project is organized as a monorepo with workspaces: 30 | 31 | - `client/`: React frontend with Vite, TypeScript and Tailwind 32 | - `server/`: Express backend with TypeScript 33 | - `cli/`: Command-line interface for testing and invoking MCP server methods directly 34 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, religion, or sexual identity 10 | and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | * Demonstrating empathy and kindness toward other people 21 | * Being respectful of differing opinions, viewpoints, and experiences 22 | * Giving and gracefully accepting constructive feedback 23 | * Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | * Focusing on what is best not just for us as individuals, but for the 26 | overall community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | * The use of sexualized language or imagery, and sexual attention or 31 | advances of any kind 32 | * Trolling, insulting or derogatory comments, and personal or political attacks 33 | * Public or private harassment 34 | * Publishing others' private information, such as a physical or email 35 | address, without their explicit permission 36 | * Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official e-mail address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported to the community leaders responsible for enforcement at 63 | mcp-coc@anthropic.com. 64 | All complaints will be reviewed and investigated promptly and fairly. 65 | 66 | All community leaders are obligated to respect the privacy and security of the 67 | reporter of any incident. 68 | 69 | ## Enforcement Guidelines 70 | 71 | Community leaders will follow these Community Impact Guidelines in determining 72 | the consequences for any action they deem in violation of this Code of Conduct: 73 | 74 | ### 1. Correction 75 | 76 | **Community Impact**: Use of inappropriate language or other behavior deemed 77 | unprofessional or unwelcome in the community. 78 | 79 | **Consequence**: A private, written warning from community leaders, providing 80 | clarity around the nature of the violation and an explanation of why the 81 | behavior was inappropriate. A public apology may be requested. 82 | 83 | ### 2. Warning 84 | 85 | **Community Impact**: A violation through a single incident or series 86 | of actions. 87 | 88 | **Consequence**: A warning with consequences for continued behavior. No 89 | interaction with the people involved, including unsolicited interaction with 90 | those enforcing the Code of Conduct, for a specified period of time. This 91 | includes avoiding interactions in community spaces as well as external channels 92 | like social media. Violating these terms may lead to a temporary or 93 | permanent ban. 94 | 95 | ### 3. Temporary Ban 96 | 97 | **Community Impact**: A serious violation of community standards, including 98 | sustained inappropriate behavior. 99 | 100 | **Consequence**: A temporary ban from any sort of interaction or public 101 | communication with the community for a specified period of time. No public or 102 | private interaction with the people involved, including unsolicited interaction 103 | with those enforcing the Code of Conduct, is allowed during this period. 104 | Violating these terms may lead to a permanent ban. 105 | 106 | ### 4. Permanent Ban 107 | 108 | **Community Impact**: Demonstrating a pattern of violation of community 109 | standards, including sustained inappropriate behavior, harassment of an 110 | individual, or aggression toward or disparagement of classes of individuals. 111 | 112 | **Consequence**: A permanent ban from any sort of public interaction within 113 | the community. 114 | 115 | ## Attribution 116 | 117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 118 | version 2.0, available at 119 | https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. 120 | 121 | Community Impact Guidelines were inspired by [Mozilla's code of conduct 122 | enforcement ladder](https://github.com/mozilla/diversity). 123 | 124 | [homepage]: https://www.contributor-covenant.org 125 | 126 | For answers to common questions about this code of conduct, see the FAQ at 127 | https://www.contributor-covenant.org/faq. Translations are available at 128 | https://www.contributor-covenant.org/translations. 129 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to Model Context Protocol Inspector 2 | 3 | Thanks for your interest in contributing! This guide explains how to get involved. 4 | 5 | ## Getting Started 6 | 7 | 1. Fork the repository and clone it locally 8 | 2. Install dependencies with `npm install` 9 | 3. Run `npm run dev` to start both client and server in development mode 10 | 4. Use the web UI at http://localhost:6274 to interact with the inspector 11 | 12 | ## Development Process & Pull Requests 13 | 14 | 1. Create a new branch for your changes 15 | 2. Make your changes following existing code style and conventions. You can run `npm run prettier-check` and `npm run prettier-fix` as applicable. 16 | 3. Test changes locally by running `npm test` and `npm run test:e2e` 17 | 4. Update documentation as needed 18 | 5. Use clear commit messages explaining your changes 19 | 6. Verify all changes work as expected 20 | 7. Submit a pull request 21 | 8. PRs will be reviewed by maintainers 22 | 23 | ## Code of Conduct 24 | 25 | This project follows our [Code of Conduct](CODE_OF_CONDUCT.md). Please read it before contributing. 26 | 27 | ## Security 28 | 29 | If you find a security vulnerability, please refer to our [Security Policy](SECURITY.md) for reporting instructions. 30 | 31 | ## Questions? 32 | 33 | Feel free to [open an issue](https://github.com/modelcontextprotocol/mcp-inspector/issues) for questions or create a discussion for general topics. 34 | 35 | ## License 36 | 37 | By contributing, you agree that your contributions will be licensed under the MIT license. 38 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Build stage 2 | FROM node:24-slim AS builder 3 | 4 | # Set working directory 5 | WORKDIR /app 6 | 7 | # Copy package files for installation 8 | COPY package*.json ./ 9 | COPY .npmrc ./ 10 | COPY client/package*.json ./client/ 11 | COPY server/package*.json ./server/ 12 | COPY cli/package*.json ./cli/ 13 | 14 | # Install dependencies 15 | RUN npm ci --ignore-scripts 16 | 17 | # Copy source files 18 | COPY . . 19 | 20 | # Build the application 21 | RUN npm run build 22 | 23 | # Production stage 24 | FROM node:24-slim 25 | 26 | WORKDIR /app 27 | 28 | # Copy package files for production 29 | COPY package*.json ./ 30 | COPY .npmrc ./ 31 | COPY client/package*.json ./client/ 32 | COPY server/package*.json ./server/ 33 | COPY cli/package*.json ./cli/ 34 | 35 | # Install only production dependencies 36 | RUN npm ci --omit=dev --ignore-scripts 37 | 38 | # Copy built files from builder stage 39 | COPY --from=builder /app/client/dist ./client/dist 40 | COPY --from=builder /app/client/bin ./client/bin 41 | COPY --from=builder /app/server/build ./server/build 42 | COPY --from=builder /app/cli/build ./cli/build 43 | 44 | # Set default port values as environment variables 45 | ENV CLIENT_PORT=6274 46 | ENV SERVER_PORT=6277 47 | 48 | # Document which ports the application uses internally 49 | EXPOSE ${CLIENT_PORT} ${SERVER_PORT} 50 | 51 | # Use ENTRYPOINT with CMD for arguments 52 | ENTRYPOINT ["npm", "start"] -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Anthropic, PBC 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | Thank you for helping us keep the inspector secure. 3 | 4 | ## Reporting Security Issues 5 | 6 | This project is maintained by [Anthropic](https://www.anthropic.com/) as part of the Model Context Protocol project. 7 | 8 | The security of our systems and user data is Anthropic’s top priority. We appreciate the work of security researchers acting in good faith in identifying and reporting potential vulnerabilities. 9 | 10 | Our security program is managed on HackerOne and we ask that any validated vulnerability in this functionality be reported through their [submission form](https://hackerone.com/anthropic-vdp/reports/new?type=team&report_type=vulnerability). 11 | 12 | ## Vulnerability Disclosure Program 13 | 14 | Our Vulnerability Program Guidelines are defined on our [HackerOne program page](https://hackerone.com/anthropic-vdp). 15 | -------------------------------------------------------------------------------- /cli/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@modelcontextprotocol/inspector-cli", 3 | "version": "0.16.1", 4 | "description": "CLI for the Model Context Protocol inspector", 5 | "license": "MIT", 6 | "author": "Anthropic, PBC (https://anthropic.com)", 7 | "homepage": "https://modelcontextprotocol.io", 8 | "bugs": "https://github.com/modelcontextprotocol/inspector/issues", 9 | "main": "build/cli.js", 10 | "type": "module", 11 | "bin": { 12 | "mcp-inspector-cli": "build/cli.js" 13 | }, 14 | "files": [ 15 | "build" 16 | ], 17 | "scripts": { 18 | "build": "tsc", 19 | "postbuild": "node scripts/make-executable.js", 20 | "test": "node scripts/cli-tests.js" 21 | }, 22 | "devDependencies": {}, 23 | "dependencies": { 24 | "@modelcontextprotocol/sdk": "^1.13.1", 25 | "commander": "^13.1.0", 26 | "spawn-rx": "^5.1.2" 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /cli/scripts/make-executable.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Cross-platform script to make a file executable 3 | */ 4 | import { promises as fs } from "fs"; 5 | import { platform } from "os"; 6 | import { execSync } from "child_process"; 7 | import path from "path"; 8 | 9 | const TARGET_FILE = path.resolve("build/cli.js"); 10 | 11 | async function makeExecutable() { 12 | try { 13 | // On Unix-like systems (Linux, macOS), use chmod 14 | if (platform() !== "win32") { 15 | execSync(`chmod +x "${TARGET_FILE}"`); 16 | console.log("Made file executable with chmod"); 17 | } else { 18 | // On Windows, no need to make files "executable" in the Unix sense 19 | // Just ensure the file exists 20 | await fs.access(TARGET_FILE); 21 | console.log("File exists and is accessible on Windows"); 22 | } 23 | } catch (error) { 24 | console.error("Error making file executable:", error); 25 | process.exit(1); 26 | } 27 | } 28 | 29 | makeExecutable(); 30 | -------------------------------------------------------------------------------- /cli/src/client/connection.ts: -------------------------------------------------------------------------------- 1 | import { Client } from "@modelcontextprotocol/sdk/client/index.js"; 2 | import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js"; 3 | import { McpResponse } from "./types.js"; 4 | 5 | export const validLogLevels = [ 6 | "trace", 7 | "debug", 8 | "info", 9 | "warn", 10 | "error", 11 | ] as const; 12 | 13 | export type LogLevel = (typeof validLogLevels)[number]; 14 | 15 | export async function connect( 16 | client: Client, 17 | transport: Transport, 18 | ): Promise<void> { 19 | try { 20 | await client.connect(transport); 21 | } catch (error) { 22 | throw new Error( 23 | `Failed to connect to MCP server: ${error instanceof Error ? error.message : String(error)}`, 24 | ); 25 | } 26 | } 27 | 28 | export async function disconnect(transport: Transport): Promise<void> { 29 | try { 30 | await transport.close(); 31 | } catch (error) { 32 | throw new Error( 33 | `Failed to disconnect from MCP server: ${error instanceof Error ? error.message : String(error)}`, 34 | ); 35 | } 36 | } 37 | 38 | // Set logging level 39 | export async function setLoggingLevel( 40 | client: Client, 41 | level: LogLevel, 42 | ): Promise<McpResponse> { 43 | try { 44 | const response = await client.setLoggingLevel(level as any); 45 | return response; 46 | } catch (error) { 47 | throw new Error( 48 | `Failed to set logging level: ${error instanceof Error ? error.message : String(error)}`, 49 | ); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /cli/src/client/index.ts: -------------------------------------------------------------------------------- 1 | // Re-export everything from the client modules 2 | export * from "./connection.js"; 3 | export * from "./prompts.js"; 4 | export * from "./resources.js"; 5 | export * from "./tools.js"; 6 | export * from "./types.js"; 7 | -------------------------------------------------------------------------------- /cli/src/client/prompts.ts: -------------------------------------------------------------------------------- 1 | import { Client } from "@modelcontextprotocol/sdk/client/index.js"; 2 | import { McpResponse } from "./types.js"; 3 | 4 | // List available prompts 5 | export async function listPrompts(client: Client): Promise<McpResponse> { 6 | try { 7 | const response = await client.listPrompts(); 8 | return response; 9 | } catch (error) { 10 | throw new Error( 11 | `Failed to list prompts: ${error instanceof Error ? error.message : String(error)}`, 12 | ); 13 | } 14 | } 15 | 16 | // Get a prompt 17 | export async function getPrompt( 18 | client: Client, 19 | name: string, 20 | args?: Record<string, string>, 21 | ): Promise<McpResponse> { 22 | try { 23 | const response = await client.getPrompt({ 24 | name, 25 | arguments: args || {}, 26 | }); 27 | 28 | return response; 29 | } catch (error) { 30 | throw new Error( 31 | `Failed to get prompt: ${error instanceof Error ? error.message : String(error)}`, 32 | ); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /cli/src/client/resources.ts: -------------------------------------------------------------------------------- 1 | import { Client } from "@modelcontextprotocol/sdk/client/index.js"; 2 | import { McpResponse } from "./types.js"; 3 | 4 | // List available resources 5 | export async function listResources(client: Client): Promise<McpResponse> { 6 | try { 7 | const response = await client.listResources(); 8 | return response; 9 | } catch (error) { 10 | throw new Error( 11 | `Failed to list resources: ${error instanceof Error ? error.message : String(error)}`, 12 | ); 13 | } 14 | } 15 | 16 | // Read a resource 17 | export async function readResource( 18 | client: Client, 19 | uri: string, 20 | ): Promise<McpResponse> { 21 | try { 22 | const response = await client.readResource({ uri }); 23 | return response; 24 | } catch (error) { 25 | throw new Error( 26 | `Failed to read resource ${uri}: ${error instanceof Error ? error.message : String(error)}`, 27 | ); 28 | } 29 | } 30 | 31 | // List resource templates 32 | export async function listResourceTemplates( 33 | client: Client, 34 | ): Promise<McpResponse> { 35 | try { 36 | const response = await client.listResourceTemplates(); 37 | return response; 38 | } catch (error) { 39 | throw new Error( 40 | `Failed to list resource templates: ${error instanceof Error ? error.message : String(error)}`, 41 | ); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /cli/src/client/tools.ts: -------------------------------------------------------------------------------- 1 | import { Client } from "@modelcontextprotocol/sdk/client/index.js"; 2 | import { Tool } from "@modelcontextprotocol/sdk/types.js"; 3 | import { McpResponse } from "./types.js"; 4 | 5 | type JsonSchemaType = { 6 | type: "string" | "number" | "integer" | "boolean" | "array" | "object"; 7 | description?: string; 8 | properties?: Record<string, JsonSchemaType>; 9 | items?: JsonSchemaType; 10 | }; 11 | 12 | export async function listTools(client: Client): Promise<McpResponse> { 13 | try { 14 | const response = await client.listTools(); 15 | return response; 16 | } catch (error) { 17 | throw new Error( 18 | `Failed to list tools: ${error instanceof Error ? error.message : String(error)}`, 19 | ); 20 | } 21 | } 22 | 23 | function convertParameterValue(value: string, schema: JsonSchemaType): unknown { 24 | if (!value) { 25 | return value; 26 | } 27 | 28 | if (schema.type === "number" || schema.type === "integer") { 29 | return Number(value); 30 | } 31 | 32 | if (schema.type === "boolean") { 33 | return value.toLowerCase() === "true"; 34 | } 35 | 36 | if (schema.type === "object" || schema.type === "array") { 37 | try { 38 | return JSON.parse(value); 39 | } catch (error) { 40 | return value; 41 | } 42 | } 43 | 44 | return value; 45 | } 46 | 47 | function convertParameters( 48 | tool: Tool, 49 | params: Record<string, string>, 50 | ): Record<string, unknown> { 51 | const result: Record<string, unknown> = {}; 52 | const properties = tool.inputSchema.properties || {}; 53 | 54 | for (const [key, value] of Object.entries(params)) { 55 | const paramSchema = properties[key] as JsonSchemaType | undefined; 56 | 57 | if (paramSchema) { 58 | result[key] = convertParameterValue(value, paramSchema); 59 | } else { 60 | // If no schema is found for this parameter, keep it as string 61 | result[key] = value; 62 | } 63 | } 64 | 65 | return result; 66 | } 67 | 68 | export async function callTool( 69 | client: Client, 70 | name: string, 71 | args: Record<string, string>, 72 | ): Promise<McpResponse> { 73 | try { 74 | const toolsResponse = await listTools(client); 75 | const tools = toolsResponse.tools as Tool[]; 76 | const tool = tools.find((t) => t.name === name); 77 | 78 | let convertedArgs: Record<string, unknown> = args; 79 | 80 | if (tool) { 81 | // Convert parameters based on the tool's schema 82 | convertedArgs = convertParameters(tool, args); 83 | } 84 | 85 | const response = await client.callTool({ 86 | name: name, 87 | arguments: convertedArgs, 88 | }); 89 | return response; 90 | } catch (error) { 91 | throw new Error( 92 | `Failed to call tool ${name}: ${error instanceof Error ? error.message : String(error)}`, 93 | ); 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /cli/src/client/types.ts: -------------------------------------------------------------------------------- 1 | export type McpResponse = Record<string, unknown>; 2 | -------------------------------------------------------------------------------- /cli/src/error-handler.ts: -------------------------------------------------------------------------------- 1 | function formatError(error: unknown): string { 2 | let message: string; 3 | 4 | if (error instanceof Error) { 5 | message = error.message; 6 | } else if (typeof error === "string") { 7 | message = error; 8 | } else { 9 | message = "Unknown error"; 10 | } 11 | 12 | return message; 13 | } 14 | 15 | export function handleError(error: unknown): never { 16 | const errorMessage = formatError(error); 17 | console.error(errorMessage); 18 | 19 | process.exit(1); 20 | } 21 | -------------------------------------------------------------------------------- /cli/src/transport.ts: -------------------------------------------------------------------------------- 1 | import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js"; 2 | import { 3 | getDefaultEnvironment, 4 | StdioClientTransport, 5 | } from "@modelcontextprotocol/sdk/client/stdio.js"; 6 | import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; 7 | import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js"; 8 | import { findActualExecutable } from "spawn-rx"; 9 | 10 | export type TransportOptions = { 11 | transportType: "sse" | "stdio" | "http"; 12 | command?: string; 13 | args?: string[]; 14 | url?: string; 15 | }; 16 | 17 | function createStdioTransport(options: TransportOptions): Transport { 18 | let args: string[] = []; 19 | 20 | if (options.args !== undefined) { 21 | args = options.args; 22 | } 23 | 24 | const processEnv: Record<string, string> = {}; 25 | 26 | for (const [key, value] of Object.entries(process.env)) { 27 | if (value !== undefined) { 28 | processEnv[key] = value; 29 | } 30 | } 31 | 32 | const defaultEnv = getDefaultEnvironment(); 33 | 34 | const env: Record<string, string> = { 35 | ...processEnv, 36 | ...defaultEnv, 37 | }; 38 | 39 | const { cmd: actualCommand, args: actualArgs } = findActualExecutable( 40 | options.command ?? "", 41 | args, 42 | ); 43 | 44 | return new StdioClientTransport({ 45 | command: actualCommand, 46 | args: actualArgs, 47 | env, 48 | stderr: "pipe", 49 | }); 50 | } 51 | 52 | export function createTransport(options: TransportOptions): Transport { 53 | const { transportType } = options; 54 | 55 | try { 56 | if (transportType === "stdio") { 57 | return createStdioTransport(options); 58 | } 59 | 60 | // If not STDIO, then it must be either SSE or HTTP. 61 | if (!options.url) { 62 | throw new Error("URL must be provided for SSE or HTTP transport types."); 63 | } 64 | const url = new URL(options.url); 65 | 66 | if (transportType === "sse") { 67 | return new SSEClientTransport(url); 68 | } 69 | 70 | if (transportType === "http") { 71 | return new StreamableHTTPClientTransport(url); 72 | } 73 | 74 | throw new Error(`Unsupported transport type: ${transportType}`); 75 | } catch (error) { 76 | throw new Error( 77 | `Failed to create transport: ${error instanceof Error ? error.message : String(error)}`, 78 | ); 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /cli/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2022", 4 | "module": "NodeNext", 5 | "moduleResolution": "NodeNext", 6 | "outDir": "./build", 7 | "rootDir": "./src", 8 | "strict": true, 9 | "esModuleInterop": true, 10 | "skipLibCheck": true, 11 | "forceConsistentCasingInFileNames": true, 12 | "resolveJsonModule": true, 13 | "noUncheckedIndexedAccess": true 14 | }, 15 | "include": ["src/**/*"], 16 | "exclude": ["node_modules", "packages", "**/*.spec.ts", "build"] 17 | } 18 | -------------------------------------------------------------------------------- /client/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | -------------------------------------------------------------------------------- /client/README.md: -------------------------------------------------------------------------------- 1 | # React + TypeScript + Vite 2 | 3 | This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. 4 | 5 | Currently, two official plugins are available: 6 | 7 | - [@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 8 | - [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh 9 | 10 | ## Expanding the ESLint configuration 11 | 12 | If you are developing a production application, we recommend updating the configuration to enable type aware lint rules: 13 | 14 | - Configure the top-level `parserOptions` property like this: 15 | 16 | ```js 17 | export default tseslint.config({ 18 | languageOptions: { 19 | // other options... 20 | parserOptions: { 21 | project: ["./tsconfig.node.json", "./tsconfig.app.json"], 22 | tsconfigRootDir: import.meta.dirname, 23 | }, 24 | }, 25 | }); 26 | ``` 27 | 28 | - Replace `tseslint.configs.recommended` to `tseslint.configs.recommendedTypeChecked` or `tseslint.configs.strictTypeChecked` 29 | - Optionally add `...tseslint.configs.stylisticTypeChecked` 30 | - Install [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) and update the config: 31 | 32 | ```js 33 | // eslint.config.js 34 | import react from "eslint-plugin-react"; 35 | 36 | export default tseslint.config({ 37 | // Set the react version 38 | settings: { react: { version: "18.3" } }, 39 | plugins: { 40 | // Add the react plugin 41 | react, 42 | }, 43 | rules: { 44 | // other rules... 45 | // Enable its recommended rules 46 | ...react.configs.recommended.rules, 47 | ...react.configs["jsx-runtime"].rules, 48 | }, 49 | }); 50 | ``` 51 | -------------------------------------------------------------------------------- /client/bin/client.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import open from "open"; 4 | import { join, dirname } from "path"; 5 | import { fileURLToPath } from "url"; 6 | import handler from "serve-handler"; 7 | import http from "http"; 8 | 9 | const __dirname = dirname(fileURLToPath(import.meta.url)); 10 | const distPath = join(__dirname, "../dist"); 11 | 12 | const server = http.createServer((request, response) => { 13 | const handlerOptions = { 14 | public: distPath, 15 | rewrites: [{ source: "/**", destination: "/index.html" }], 16 | headers: [ 17 | { 18 | // Ensure index.html is never cached 19 | source: "index.html", 20 | headers: [ 21 | { 22 | key: "Cache-Control", 23 | value: "no-cache, no-store, max-age=0", 24 | }, 25 | ], 26 | }, 27 | { 28 | // Allow long-term caching for hashed assets 29 | source: "assets/**", 30 | headers: [ 31 | { 32 | key: "Cache-Control", 33 | value: "public, max-age=31536000, immutable", 34 | }, 35 | ], 36 | }, 37 | ], 38 | }; 39 | 40 | return handler(request, response, handlerOptions); 41 | }); 42 | 43 | const port = parseInt(process.env.CLIENT_PORT || "6274", 10); 44 | const host = process.env.HOST || "localhost"; 45 | server.on("listening", () => { 46 | const url = process.env.INSPECTOR_URL || `http://${host}:${port}`; 47 | console.log(`\n🚀 MCP Inspector is up and running at:\n ${url}\n`); 48 | if (process.env.MCP_AUTO_OPEN_ENABLED !== "false") { 49 | console.log(`🌐 Opening browser...`); 50 | open(url); 51 | } 52 | }); 53 | server.on("error", (err) => { 54 | if (err.message.includes(`EADDRINUSE`)) { 55 | console.error( 56 | `❌ MCP Inspector PORT IS IN USE at http://${host}:${port} ❌ `, 57 | ); 58 | } else { 59 | throw err; 60 | } 61 | }); 62 | server.listen(port, host); 63 | -------------------------------------------------------------------------------- /client/components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "new-york", 4 | "rsc": false, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "tailwind.config.js", 8 | "css": "src/index.css", 9 | "baseColor": "slate", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "@/components", 15 | "utils": "@/lib/utils", 16 | "ui": "@/components/ui", 17 | "lib": "@/lib", 18 | "hooks": "@/hooks" 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /client/e2e/global-teardown.js: -------------------------------------------------------------------------------- 1 | import { rimraf } from "rimraf"; 2 | 3 | async function globalTeardown() { 4 | if (!process.env.CI) { 5 | console.log("Cleaning up test-results directory..."); 6 | // Add a small delay to ensure all Playwright files are written 7 | await new Promise((resolve) => setTimeout(resolve, 100)); 8 | await rimraf("./e2e/test-results"); 9 | console.log("Test-results directory cleaned up."); 10 | } 11 | } 12 | 13 | export default globalTeardown; 14 | 15 | // Call the function when this script is run directly 16 | if (import.meta.url === `file://${process.argv[1]}`) { 17 | globalTeardown().catch(console.error); 18 | } 19 | -------------------------------------------------------------------------------- /client/e2e/transport-type-dropdown.spec.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from "@playwright/test"; 2 | 3 | // Adjust the URL if your dev server runs on a different port 4 | const APP_URL = "http://localhost:6274/"; 5 | 6 | test.describe("Transport Type Dropdown", () => { 7 | test("should have options for STDIO, SSE, and Streamable HTTP", async ({ 8 | page, 9 | }) => { 10 | await page.goto(APP_URL); 11 | 12 | // Wait for the Transport Type dropdown to be visible 13 | const selectTrigger = page.getByLabel("Transport Type"); 14 | await expect(selectTrigger).toBeVisible(); 15 | 16 | // Open the dropdown 17 | await selectTrigger.click(); 18 | 19 | // Check for the three options 20 | await expect(page.getByRole("option", { name: "STDIO" })).toBeVisible(); 21 | await expect(page.getByRole("option", { name: "SSE" })).toBeVisible(); 22 | await expect( 23 | page.getByRole("option", { name: "Streamable HTTP" }), 24 | ).toBeVisible(); 25 | }); 26 | 27 | test("should show Command and Arguments fields and hide URL field when Transport Type is STDIO", async ({ 28 | page, 29 | }) => { 30 | await page.goto(APP_URL); 31 | 32 | // Wait for the Transport Type dropdown to be visible 33 | const selectTrigger = page.getByLabel("Transport Type"); 34 | await expect(selectTrigger).toBeVisible(); 35 | 36 | // Open the dropdown and select STDIO 37 | await selectTrigger.click(); 38 | await page.getByRole("option", { name: "STDIO" }).click(); 39 | 40 | // Wait for the form to update 41 | await page.waitForTimeout(100); 42 | 43 | // Check that Command and Arguments fields are visible 44 | await expect(page.locator("#command-input")).toBeVisible(); 45 | await expect(page.locator("#arguments-input")).toBeVisible(); 46 | 47 | // Check that URL field is not visible 48 | await expect(page.locator("#sse-url-input")).not.toBeVisible(); 49 | 50 | // Also verify the labels are present 51 | await expect(page.getByText("Command")).toBeVisible(); 52 | await expect(page.getByText("Arguments")).toBeVisible(); 53 | await expect(page.getByText("URL")).not.toBeVisible(); 54 | }); 55 | 56 | test("should show URL field and hide Command and Arguments fields when Transport Type is SSE", async ({ 57 | page, 58 | }) => { 59 | await page.goto(APP_URL); 60 | 61 | // Wait for the Transport Type dropdown to be visible 62 | const selectTrigger = page.getByLabel("Transport Type"); 63 | await expect(selectTrigger).toBeVisible(); 64 | 65 | // Open the dropdown and select SSE 66 | await selectTrigger.click(); 67 | await page.getByRole("option", { name: "SSE" }).click(); 68 | 69 | // Wait for the form to update 70 | await page.waitForTimeout(100); 71 | 72 | // Check that URL field is visible 73 | await expect(page.locator("#sse-url-input")).toBeVisible(); 74 | 75 | // Check that Command and Arguments fields are not visible 76 | await expect(page.locator("#command-input")).not.toBeVisible(); 77 | await expect(page.locator("#arguments-input")).not.toBeVisible(); 78 | 79 | // Also verify the labels are present/absent 80 | await expect(page.getByText("URL")).toBeVisible(); 81 | await expect(page.getByText("Command")).not.toBeVisible(); 82 | await expect(page.getByText("Arguments")).not.toBeVisible(); 83 | }); 84 | 85 | test("should show URL field and hide Command and Arguments fields when Transport Type is Streamable HTTP", async ({ 86 | page, 87 | }) => { 88 | await page.goto(APP_URL); 89 | 90 | // Wait for the Transport Type dropdown to be visible 91 | const selectTrigger = page.getByLabel("Transport Type"); 92 | await expect(selectTrigger).toBeVisible(); 93 | 94 | // Open the dropdown and select Streamable HTTP 95 | await selectTrigger.click(); 96 | await page.getByRole("option", { name: "Streamable HTTP" }).click(); 97 | 98 | // Wait for the form to update 99 | await page.waitForTimeout(100); 100 | 101 | // Check that URL field is visible 102 | await expect(page.locator("#sse-url-input")).toBeVisible(); 103 | 104 | // Check that Command and Arguments fields are not visible 105 | await expect(page.locator("#command-input")).not.toBeVisible(); 106 | await expect(page.locator("#arguments-input")).not.toBeVisible(); 107 | 108 | // Also verify the labels are present/absent 109 | await expect(page.getByText("URL")).toBeVisible(); 110 | await expect(page.getByText("Command")).not.toBeVisible(); 111 | await expect(page.getByText("Arguments")).not.toBeVisible(); 112 | }); 113 | }); 114 | -------------------------------------------------------------------------------- /client/eslint.config.js: -------------------------------------------------------------------------------- 1 | import js from "@eslint/js"; 2 | import globals from "globals"; 3 | import reactHooks from "eslint-plugin-react-hooks"; 4 | import reactRefresh from "eslint-plugin-react-refresh"; 5 | import tseslint from "typescript-eslint"; 6 | 7 | export default tseslint.config( 8 | { ignores: ["dist"] }, 9 | { 10 | extends: [js.configs.recommended, ...tseslint.configs.recommended], 11 | files: ["**/*.{ts,tsx}"], 12 | languageOptions: { 13 | ecmaVersion: 2020, 14 | globals: globals.browser, 15 | }, 16 | plugins: { 17 | "react-hooks": reactHooks, 18 | "react-refresh": reactRefresh, 19 | }, 20 | rules: { 21 | ...reactHooks.configs.recommended.rules, 22 | "react-refresh/only-export-components": [ 23 | "warn", 24 | { allowConstantExport: true }, 25 | ], 26 | }, 27 | }, 28 | ); 29 | -------------------------------------------------------------------------------- /client/index.html: -------------------------------------------------------------------------------- 1 | <!doctype html> 2 | <html lang="en"> 3 | <head> 4 | <meta charset="UTF-8" /> 5 | <link rel="icon" type="image/svg+xml" href="/mcp.svg" /> 6 | <meta name="viewport" content="width=device-width, initial-scale=1.0" /> 7 | <title>MCP Inspector</title> 8 | </head> 9 | <body> 10 | <div id="root" class="w-full"></div> 11 | <script type="module" src="/src/main.tsx"></script> 12 | </body> 13 | </html> 14 | -------------------------------------------------------------------------------- /client/jest.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: "ts-jest", 3 | testEnvironment: "jest-fixed-jsdom", 4 | moduleNameMapper: { 5 | "^@/(.*)quot;: "<rootDir>/src/$1", 6 | "\\.cssquot;: "<rootDir>/src/__mocks__/styleMock.js", 7 | }, 8 | transform: { 9 | "^.+\\.tsx?quot;: [ 10 | "ts-jest", 11 | { 12 | jsx: "react-jsx", 13 | tsconfig: "tsconfig.jest.json", 14 | }, 15 | ], 16 | }, 17 | extensionsToTreatAsEsm: [".ts", ".tsx"], 18 | testRegex: "(/__tests__/.*|(\\.|/)(test|spec))\\.(jsx?|tsx?)quot;, 19 | // Exclude directories and files that don't need to be tested 20 | testPathIgnorePatterns: [ 21 | "/node_modules/", 22 | "/dist/", 23 | "/bin/", 24 | "/e2e/", 25 | "\\.config\\.(js|ts|cjs|mjs)quot;, 26 | ], 27 | // Exclude the same patterns from coverage reports 28 | coveragePathIgnorePatterns: [ 29 | "/node_modules/", 30 | "/dist/", 31 | "/bin/", 32 | "/e2e/", 33 | "\\.config\\.(js|ts|cjs|mjs)quot;, 34 | ], 35 | }; 36 | -------------------------------------------------------------------------------- /client/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@modelcontextprotocol/inspector-client", 3 | "version": "0.16.1", 4 | "description": "Client-side application for the Model Context Protocol inspector", 5 | "license": "MIT", 6 | "author": "Anthropic, PBC (https://anthropic.com)", 7 | "homepage": "https://modelcontextprotocol.io", 8 | "bugs": "https://github.com/modelcontextprotocol/inspector/issues", 9 | "type": "module", 10 | "bin": { 11 | "mcp-inspector-client": "./bin/start.js" 12 | }, 13 | "files": [ 14 | "bin", 15 | "dist" 16 | ], 17 | "scripts": { 18 | "dev": "vite --port 6274", 19 | "build": "tsc -b && vite build", 20 | "lint": "eslint .", 21 | "preview": "vite preview --port 6274", 22 | "test": "jest --config jest.config.cjs", 23 | "test:watch": "jest --config jest.config.cjs --watch", 24 | "test:e2e": "playwright test e2e && npm run cleanup:e2e", 25 | "cleanup:e2e": "node e2e/global-teardown.js" 26 | }, 27 | "dependencies": { 28 | "@modelcontextprotocol/sdk": "^1.13.1", 29 | "@radix-ui/react-checkbox": "^1.1.4", 30 | "ajv": "^6.12.6", 31 | "@radix-ui/react-dialog": "^1.1.3", 32 | "@radix-ui/react-icons": "^1.3.0", 33 | "@radix-ui/react-label": "^2.1.0", 34 | "@radix-ui/react-popover": "^1.1.3", 35 | "@radix-ui/react-select": "^2.1.2", 36 | "@radix-ui/react-slot": "^1.1.0", 37 | "@radix-ui/react-tabs": "^1.1.1", 38 | "@radix-ui/react-toast": "^1.2.6", 39 | "@radix-ui/react-tooltip": "^1.1.8", 40 | "class-variance-authority": "^0.7.0", 41 | "clsx": "^2.1.1", 42 | "cmdk": "^1.0.4", 43 | "lucide-react": "^0.523.0", 44 | "pkce-challenge": "^4.1.0", 45 | "prismjs": "^1.30.0", 46 | "react": "^18.3.1", 47 | "react-dom": "^18.3.1", 48 | "react-simple-code-editor": "^0.14.1", 49 | "serve-handler": "^6.1.6", 50 | "tailwind-merge": "^2.5.3", 51 | "tailwindcss-animate": "^1.0.7", 52 | "zod": "^3.23.8" 53 | }, 54 | "devDependencies": { 55 | "@eslint/js": "^9.11.1", 56 | "@testing-library/jest-dom": "^6.6.3", 57 | "@testing-library/react": "^16.2.0", 58 | "@types/jest": "^29.5.14", 59 | "@types/node": "^22.7.5", 60 | "@types/prismjs": "^1.26.5", 61 | "@types/react": "^18.3.10", 62 | "@types/react-dom": "^18.3.0", 63 | "@types/serve-handler": "^6.1.4", 64 | "@vitejs/plugin-react": "^4.3.2", 65 | "autoprefixer": "^10.4.20", 66 | "co": "^4.6.0", 67 | "eslint": "^9.11.1", 68 | "eslint-plugin-react-hooks": "^5.1.0-rc.0", 69 | "eslint-plugin-react-refresh": "^0.4.12", 70 | "globals": "^15.9.0", 71 | "jest": "^29.7.0", 72 | "jest-environment-jsdom": "^29.7.0", 73 | "postcss": "^8.4.47", 74 | "tailwindcss": "^3.4.13", 75 | "ts-jest": "^29.2.6", 76 | "typescript": "^5.5.3", 77 | "typescript-eslint": "^8.7.0", 78 | "vite": "^6.3.0" 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /client/playwright.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig, devices } from "@playwright/test"; 2 | 3 | /** 4 | * @see https://playwright.dev/docs/test-configuration 5 | */ 6 | export default defineConfig({ 7 | /* Run your local dev server before starting the tests */ 8 | webServer: { 9 | cwd: "..", 10 | command: "npm run dev", 11 | url: "http://localhost:6274", 12 | reuseExistingServer: !process.env.CI, 13 | }, 14 | 15 | testDir: "./e2e", 16 | outputDir: "./e2e/test-results", 17 | /* Run tests in files in parallel */ 18 | fullyParallel: true, 19 | /* Fail the build on CI if you accidentally left test.only in the source code. */ 20 | forbidOnly: !!process.env.CI, 21 | /* Retry on CI only */ 22 | retries: process.env.CI ? 2 : 0, 23 | /* Opt out of parallel tests on CI. */ 24 | workers: process.env.CI ? 1 : undefined, 25 | /* Reporter to use. See https://playwright.dev/docs/test-reporters */ 26 | reporter: process.env.CI 27 | ? [ 28 | ["html", { outputFolder: "playwright-report" }], 29 | ["json", { outputFile: "results.json" }], 30 | ["line"], 31 | ] 32 | : [["line"]], 33 | /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ 34 | use: { 35 | /* Base URL to use in actions like `await page.goto('/')`. */ 36 | baseURL: "http://localhost:6274", 37 | 38 | /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ 39 | trace: "on-first-retry", 40 | 41 | /* Take screenshots on failure */ 42 | screenshot: "only-on-failure", 43 | 44 | /* Record video on failure */ 45 | video: "retain-on-failure", 46 | }, 47 | 48 | /* Configure projects for major browsers */ 49 | projects: [ 50 | { 51 | name: "chromium", 52 | use: { ...devices["Desktop Chrome"] }, 53 | }, 54 | 55 | { 56 | name: "firefox", 57 | use: { ...devices["Desktop Firefox"] }, 58 | }, 59 | 60 | // Skip WebKit on macOS due to compatibility issues 61 | ...(process.platform !== "darwin" 62 | ? [ 63 | { 64 | name: "webkit", 65 | use: { ...devices["Desktop Safari"] }, 66 | }, 67 | ] 68 | : []), 69 | ], 70 | }); 71 | -------------------------------------------------------------------------------- /client/postcss.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /client/public/mcp.svg: -------------------------------------------------------------------------------- 1 | <svg width="180" height="180" viewBox="0 0 180 180" fill="none" xmlns="http://www.w3.org/2000/svg"> 2 | <g clip-path="url(#clip0_19_13)"> 3 | <path d="M18 84.8528L85.8822 16.9706C95.2548 7.59798 110.451 7.59798 119.823 16.9706V16.9706C129.196 26.3431 129.196 41.5391 119.823 50.9117L68.5581 102.177" stroke="black" stroke-width="12" stroke-linecap="round"/> 4 | <path d="M69.2652 101.47L119.823 50.9117C129.196 41.5391 144.392 41.5391 153.765 50.9117L154.118 51.2652C163.491 60.6378 163.491 75.8338 154.118 85.2063L92.7248 146.6C89.6006 149.724 89.6006 154.789 92.7248 157.913L105.331 170.52" stroke="black" stroke-width="12" stroke-linecap="round"/> 5 | <path d="M102.853 33.9411L52.6482 84.1457C43.2756 93.5183 43.2756 108.714 52.6482 118.087V118.087C62.0208 127.459 77.2167 127.459 86.5893 118.087L136.794 67.8822" stroke="black" stroke-width="12" stroke-linecap="round"/> 6 | </g> 7 | <defs> 8 | <clipPath id="clip0_19_13"> 9 | <rect width="180" height="180" fill="white"/> 10 | </clipPath> 11 | </defs> 12 | </svg> 13 | -------------------------------------------------------------------------------- /client/src/App.css: -------------------------------------------------------------------------------- 1 | .logo { 2 | height: 6em; 3 | padding: 1.5em; 4 | will-change: filter; 5 | transition: filter 300ms; 6 | } 7 | .logo:hover { 8 | filter: drop-shadow(0 0 2em #646cffaa); 9 | } 10 | .logo.react:hover { 11 | filter: drop-shadow(0 0 2em #61dafbaa); 12 | } 13 | 14 | @keyframes logo-spin { 15 | from { 16 | transform: rotate(0deg); 17 | } 18 | to { 19 | transform: rotate(360deg); 20 | } 21 | } 22 | 23 | @media (prefers-reduced-motion: no-preference) { 24 | a:nth-of-type(2) .logo { 25 | animation: logo-spin infinite 20s linear; 26 | } 27 | } 28 | 29 | .card { 30 | padding: 2em; 31 | } 32 | 33 | .read-the-docs { 34 | color: #888; 35 | } 36 | -------------------------------------------------------------------------------- /client/src/__mocks__/styleMock.js: -------------------------------------------------------------------------------- 1 | module.exports = {}; 2 | -------------------------------------------------------------------------------- /client/src/components/ConsoleTab.tsx: -------------------------------------------------------------------------------- 1 | import { TabsContent } from "@/components/ui/tabs"; 2 | 3 | const ConsoleTab = () => ( 4 | <TabsContent value="console" className="h-96"> 5 | <div className="bg-gray-900 text-gray-100 p-4 rounded-lg h-full font-mono text-sm overflow-auto"> 6 | <div className="opacity-50">Welcome to MCP Client Console</div> 7 | {/* Console output would go here */} 8 | </div> 9 | </TabsContent> 10 | ); 11 | 12 | export default ConsoleTab; 13 | -------------------------------------------------------------------------------- /client/src/components/HistoryAndNotifications.tsx: -------------------------------------------------------------------------------- 1 | import { ServerNotification } from "@modelcontextprotocol/sdk/types.js"; 2 | import { useState } from "react"; 3 | import JsonView from "./JsonView"; 4 | 5 | const HistoryAndNotifications = ({ 6 | requestHistory, 7 | serverNotifications, 8 | }: { 9 | requestHistory: Array<{ request: string; response?: string }>; 10 | serverNotifications: ServerNotification[]; 11 | }) => { 12 | const [expandedRequests, setExpandedRequests] = useState<{ 13 | [key: number]: boolean; 14 | }>({}); 15 | const [expandedNotifications, setExpandedNotifications] = useState<{ 16 | [key: number]: boolean; 17 | }>({}); 18 | 19 | const toggleRequestExpansion = (index: number) => { 20 | setExpandedRequests((prev) => ({ ...prev, [index]: !prev[index] })); 21 | }; 22 | 23 | const toggleNotificationExpansion = (index: number) => { 24 | setExpandedNotifications((prev) => ({ ...prev, [index]: !prev[index] })); 25 | }; 26 | 27 | return ( 28 | <div className="bg-card overflow-hidden flex h-full"> 29 | <div className="flex-1 overflow-y-auto p-4 border-r"> 30 | <h2 className="text-lg font-semibold mb-4">History</h2> 31 | {requestHistory.length === 0 ? ( 32 | <p className="text-sm text-gray-500 dark:text-gray-400 italic"> 33 | No history yet 34 | </p> 35 | ) : ( 36 | <ul className="space-y-3"> 37 | {requestHistory 38 | .slice() 39 | .reverse() 40 | .map((request, index) => ( 41 | <li 42 | key={index} 43 | className="text-sm text-foreground bg-secondary py-2 px-3 rounded" 44 | > 45 | <div 46 | className="flex justify-between items-center cursor-pointer" 47 | onClick={() => 48 | toggleRequestExpansion(requestHistory.length - 1 - index) 49 | } 50 | > 51 | <span className="font-mono"> 52 | {requestHistory.length - index}.{" "} 53 | {JSON.parse(request.request).method} 54 | </span> 55 | <span> 56 | {expandedRequests[requestHistory.length - 1 - index] 57 | ? "▼" 58 | : "▶"} 59 | </span> 60 | </div> 61 | {expandedRequests[requestHistory.length - 1 - index] && ( 62 | <> 63 | <div className="mt-2"> 64 | <div className="flex justify-between items-center mb-1"> 65 | <span className="font-semibold text-blue-600"> 66 | Request: 67 | </span> 68 | </div> 69 | 70 | <JsonView 71 | data={request.request} 72 | className="bg-background" 73 | /> 74 | </div> 75 | {request.response && ( 76 | <div className="mt-2"> 77 | <div className="flex justify-between items-center mb-1"> 78 | <span className="font-semibold text-green-600"> 79 | Response: 80 | </span> 81 | </div> 82 | <JsonView 83 | data={request.response} 84 | className="bg-background" 85 | /> 86 | </div> 87 | )} 88 | </> 89 | )} 90 | </li> 91 | ))} 92 | </ul> 93 | )} 94 | </div> 95 | <div className="flex-1 overflow-y-auto p-4"> 96 | <h2 className="text-lg font-semibold mb-4">Server Notifications</h2> 97 | {serverNotifications.length === 0 ? ( 98 | <p className="text-sm text-gray-500 dark:text-gray-400 italic"> 99 | No notifications yet 100 | </p> 101 | ) : ( 102 | <ul className="space-y-3"> 103 | {serverNotifications 104 | .slice() 105 | .reverse() 106 | .map((notification, index) => ( 107 | <li 108 | key={index} 109 | className="text-sm text-foreground bg-secondary py-2 px-3 rounded" 110 | > 111 | <div 112 | className="flex justify-between items-center cursor-pointer" 113 | onClick={() => 114 | toggleNotificationExpansion( 115 | serverNotifications.length - 1 - index, 116 | ) 117 | } 118 | > 119 | <span className="font-mono"> 120 | {serverNotifications.length - index}.{" "} 121 | {notification.method} 122 | </span> 123 | <span> 124 | {expandedNotifications[ 125 | serverNotifications.length - 1 - index 126 | ] 127 | ? "▼" 128 | : "▶"} 129 | </span> 130 | </div> 131 | {expandedNotifications[ 132 | serverNotifications.length - 1 - index 133 | ] && ( 134 | <div className="mt-2"> 135 | <div className="flex justify-between items-center mb-1"> 136 | <span className="font-semibold text-purple-600"> 137 | Details: 138 | </span> 139 | </div> 140 | <JsonView 141 | data={JSON.stringify(notification, null, 2)} 142 | className="bg-background" 143 | /> 144 | </div> 145 | )} 146 | </li> 147 | ))} 148 | </ul> 149 | )} 150 | </div> 151 | </div> 152 | ); 153 | }; 154 | 155 | export default HistoryAndNotifications; 156 | -------------------------------------------------------------------------------- /client/src/components/JsonEditor.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from "react"; 2 | import Editor from "react-simple-code-editor"; 3 | import Prism from "prismjs"; 4 | import "prismjs/components/prism-json"; 5 | import "prismjs/themes/prism.css"; 6 | 7 | interface JsonEditorProps { 8 | value: string; 9 | onChange: (value: string) => void; 10 | error?: string; 11 | } 12 | 13 | const JsonEditor = ({ 14 | value, 15 | onChange, 16 | error: externalError, 17 | }: JsonEditorProps) => { 18 | const [editorContent, setEditorContent] = useState(value || ""); 19 | const [internalError, setInternalError] = useState<string | undefined>( 20 | undefined, 21 | ); 22 | 23 | useEffect(() => { 24 | setEditorContent(value || ""); 25 | }, [value]); 26 | 27 | const handleEditorChange = (newContent: string) => { 28 | setEditorContent(newContent); 29 | setInternalError(undefined); 30 | onChange(newContent); 31 | }; 32 | 33 | const displayError = internalError || externalError; 34 | 35 | return ( 36 | <div className="relative"> 37 | <div 38 | className={`border rounded-md ${ 39 | displayError 40 | ? "border-red-500" 41 | : "border-gray-200 dark:border-gray-800" 42 | }`} 43 | > 44 | <Editor 45 | value={editorContent} 46 | onValueChange={handleEditorChange} 47 | highlight={(code) => 48 | Prism.highlight(code, Prism.languages.json, "json") 49 | } 50 | padding={10} 51 | style={{ 52 | fontFamily: '"Fira code", "Fira Mono", monospace', 53 | fontSize: 14, 54 | backgroundColor: "transparent", 55 | minHeight: "100px", 56 | }} 57 | className="w-full" 58 | /> 59 | </div> 60 | {displayError && ( 61 | <p className="text-sm text-red-500 mt-1">{displayError}</p> 62 | )} 63 | </div> 64 | ); 65 | }; 66 | 67 | export default JsonEditor; 68 | -------------------------------------------------------------------------------- /client/src/components/ListPane.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from "./ui/button"; 2 | 3 | type ListPaneProps<T> = { 4 | items: T[]; 5 | listItems: () => void; 6 | clearItems: () => void; 7 | setSelectedItem: (item: T) => void; 8 | renderItem: (item: T) => React.ReactNode; 9 | title: string; 10 | buttonText: string; 11 | isButtonDisabled?: boolean; 12 | }; 13 | 14 | const ListPane = <T extends object>({ 15 | items, 16 | listItems, 17 | clearItems, 18 | setSelectedItem, 19 | renderItem, 20 | title, 21 | buttonText, 22 | isButtonDisabled, 23 | }: ListPaneProps<T>) => ( 24 | <div className="bg-card border border-border rounded-lg shadow"> 25 | <div className="p-4 border-b border-gray-200 dark:border-border"> 26 | <h3 className="font-semibold dark:text-white">{title}</h3> 27 | </div> 28 | <div className="p-4"> 29 | <Button 30 | variant="outline" 31 | className="w-full mb-4" 32 | onClick={listItems} 33 | disabled={isButtonDisabled} 34 | > 35 | {buttonText} 36 | </Button> 37 | <Button 38 | variant="outline" 39 | className="w-full mb-4" 40 | onClick={clearItems} 41 | disabled={items.length === 0} 42 | > 43 | Clear 44 | </Button> 45 | <div className="space-y-2 overflow-y-auto max-h-96"> 46 | {items.map((item, index) => ( 47 | <div 48 | key={index} 49 | className="flex items-center py-2 px-4 rounded hover:bg-gray-50 dark:hover:bg-secondary cursor-pointer" 50 | onClick={() => setSelectedItem(item)} 51 | > 52 | {renderItem(item)} 53 | </div> 54 | ))} 55 | </div> 56 | </div> 57 | </div> 58 | ); 59 | 60 | export default ListPane; 61 | -------------------------------------------------------------------------------- /client/src/components/OAuthCallback.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef } from "react"; 2 | import { InspectorOAuthClientProvider } from "../lib/auth"; 3 | import { SESSION_KEYS } from "../lib/constants"; 4 | import { auth } from "@modelcontextprotocol/sdk/client/auth.js"; 5 | import { useToast } from "@/lib/hooks/useToast"; 6 | import { 7 | generateOAuthErrorDescription, 8 | parseOAuthCallbackParams, 9 | } from "@/utils/oauthUtils.ts"; 10 | 11 | interface OAuthCallbackProps { 12 | onConnect: (serverUrl: string) => void; 13 | } 14 | 15 | const OAuthCallback = ({ onConnect }: OAuthCallbackProps) => { 16 | const { toast } = useToast(); 17 | const hasProcessedRef = useRef(false); 18 | 19 | useEffect(() => { 20 | const handleCallback = async () => { 21 | // Skip if we've already processed this callback 22 | if (hasProcessedRef.current) { 23 | return; 24 | } 25 | hasProcessedRef.current = true; 26 | 27 | const notifyError = (description: string) => 28 | void toast({ 29 | title: "OAuth Authorization Error", 30 | description, 31 | variant: "destructive", 32 | }); 33 | 34 | const params = parseOAuthCallbackParams(window.location.search); 35 | if (!params.successful) { 36 | return notifyError(generateOAuthErrorDescription(params)); 37 | } 38 | 39 | const serverUrl = sessionStorage.getItem(SESSION_KEYS.SERVER_URL); 40 | if (!serverUrl) { 41 | return notifyError("Missing Server URL"); 42 | } 43 | 44 | let result; 45 | try { 46 | // Create an auth provider with the current server URL 47 | const serverAuthProvider = new InspectorOAuthClientProvider(serverUrl); 48 | 49 | result = await auth(serverAuthProvider, { 50 | serverUrl, 51 | authorizationCode: params.code, 52 | }); 53 | } catch (error) { 54 | console.error("OAuth callback error:", error); 55 | return notifyError(`Unexpected error occurred: ${error}`); 56 | } 57 | 58 | if (result !== "AUTHORIZED") { 59 | return notifyError( 60 | `Expected to be authorized after providing auth code, got: ${result}`, 61 | ); 62 | } 63 | 64 | // Finally, trigger auto-connect 65 | toast({ 66 | title: "Success", 67 | description: "Successfully authenticated with OAuth", 68 | variant: "default", 69 | }); 70 | onConnect(serverUrl); 71 | }; 72 | 73 | handleCallback().finally(() => { 74 | window.history.replaceState({}, document.title, "/"); 75 | }); 76 | }, [toast, onConnect]); 77 | 78 | return ( 79 | <div className="flex items-center justify-center h-screen"> 80 | <p className="text-lg text-gray-500">Processing OAuth callback...</p> 81 | </div> 82 | ); 83 | }; 84 | 85 | export default OAuthCallback; 86 | -------------------------------------------------------------------------------- /client/src/components/OAuthDebugCallback.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect } from "react"; 2 | import { SESSION_KEYS } from "../lib/constants"; 3 | import { 4 | generateOAuthErrorDescription, 5 | parseOAuthCallbackParams, 6 | } from "@/utils/oauthUtils.ts"; 7 | import { AuthDebuggerState } from "@/lib/auth-types"; 8 | 9 | interface OAuthCallbackProps { 10 | onConnect: ({ 11 | authorizationCode, 12 | errorMsg, 13 | restoredState, 14 | }: { 15 | authorizationCode?: string; 16 | errorMsg?: string; 17 | restoredState?: AuthDebuggerState; 18 | }) => void; 19 | } 20 | 21 | const OAuthDebugCallback = ({ onConnect }: OAuthCallbackProps) => { 22 | useEffect(() => { 23 | let isProcessed = false; 24 | 25 | const handleCallback = async () => { 26 | // Skip if we've already processed this callback 27 | if (isProcessed) { 28 | return; 29 | } 30 | isProcessed = true; 31 | 32 | const params = parseOAuthCallbackParams(window.location.search); 33 | if (!params.successful) { 34 | const errorMsg = generateOAuthErrorDescription(params); 35 | onConnect({ errorMsg }); 36 | return; 37 | } 38 | 39 | const serverUrl = sessionStorage.getItem(SESSION_KEYS.SERVER_URL); 40 | 41 | // Try to restore the auth state 42 | const storedState = sessionStorage.getItem( 43 | SESSION_KEYS.AUTH_DEBUGGER_STATE, 44 | ); 45 | let restoredState = null; 46 | if (storedState) { 47 | try { 48 | restoredState = JSON.parse(storedState); 49 | // Clean up the stored state 50 | sessionStorage.removeItem(SESSION_KEYS.AUTH_DEBUGGER_STATE); 51 | } catch (e) { 52 | console.error("Failed to parse stored auth state:", e); 53 | } 54 | } 55 | 56 | // ServerURL isn't set, this can happen if we've opened the 57 | // authentication request in a new tab, so we don't have the same 58 | // session storage 59 | if (!serverUrl) { 60 | // If there's no server URL, we're likely in a new tab 61 | // Just display the code for manual copying 62 | return; 63 | } 64 | 65 | if (!params.code) { 66 | onConnect({ errorMsg: "Missing authorization code" }); 67 | return; 68 | } 69 | 70 | // Instead of storing in sessionStorage, pass the code directly 71 | // to the auth state manager through onConnect, along with restored state 72 | onConnect({ authorizationCode: params.code, restoredState }); 73 | }; 74 | 75 | handleCallback().finally(() => { 76 | // Only redirect if we have the URL set, otherwise assume this was 77 | // in a new tab 78 | if (sessionStorage.getItem(SESSION_KEYS.SERVER_URL)) { 79 | window.history.replaceState({}, document.title, "/"); 80 | } 81 | }); 82 | 83 | return () => { 84 | isProcessed = true; 85 | }; 86 | }, [onConnect]); 87 | 88 | const callbackParams = parseOAuthCallbackParams(window.location.search); 89 | 90 | return ( 91 | <div className="flex items-center justify-center h-screen"> 92 | <div className="mt-4 p-4 bg-secondary rounded-md max-w-md"> 93 | <p className="mb-2 text-sm"> 94 | Please copy this authorization code and return to the Auth Debugger: 95 | </p> 96 | <code className="block p-2 bg-muted rounded-sm overflow-x-auto text-xs"> 97 | {callbackParams.successful && "code" in callbackParams 98 | ? callbackParams.code 99 | : `No code found: ${callbackParams.error}, ${callbackParams.error_description}`} 100 | </code> 101 | <p className="mt-4 text-xs text-muted-foreground"> 102 | Close this tab and paste the code in the OAuth flow to complete 103 | authentication. 104 | </p> 105 | </div> 106 | </div> 107 | ); 108 | }; 109 | 110 | export default OAuthDebugCallback; 111 | -------------------------------------------------------------------------------- /client/src/components/PingTab.tsx: -------------------------------------------------------------------------------- 1 | import { TabsContent } from "@/components/ui/tabs"; 2 | import { Button } from "@/components/ui/button"; 3 | 4 | const PingTab = ({ onPingClick }: { onPingClick: () => void }) => { 5 | return ( 6 | <TabsContent value="ping"> 7 | <div className="grid grid-cols-2 gap-4"> 8 | <div className="col-span-2 flex justify-center items-center"> 9 | <Button 10 | onClick={onPingClick} 11 | className="font-bold py-6 px-12 rounded-full" 12 | > 13 | Ping Server 14 | </Button> 15 | </div> 16 | </div> 17 | </TabsContent> 18 | ); 19 | }; 20 | 21 | export default PingTab; 22 | -------------------------------------------------------------------------------- /client/src/components/PromptsTab.tsx: -------------------------------------------------------------------------------- 1 | import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; 2 | import { Button } from "@/components/ui/button"; 3 | import { Combobox } from "@/components/ui/combobox"; 4 | import { Label } from "@/components/ui/label"; 5 | import { TabsContent } from "@/components/ui/tabs"; 6 | 7 | import { 8 | ListPromptsResult, 9 | PromptReference, 10 | ResourceReference, 11 | } from "@modelcontextprotocol/sdk/types.js"; 12 | import { AlertCircle } from "lucide-react"; 13 | import { useEffect, useState } from "react"; 14 | import ListPane from "./ListPane"; 15 | import { useCompletionState } from "@/lib/hooks/useCompletionState"; 16 | import JsonView from "./JsonView"; 17 | 18 | export type Prompt = { 19 | name: string; 20 | description?: string; 21 | arguments?: { 22 | name: string; 23 | description?: string; 24 | required?: boolean; 25 | }[]; 26 | }; 27 | 28 | const PromptsTab = ({ 29 | prompts, 30 | listPrompts, 31 | clearPrompts, 32 | getPrompt, 33 | selectedPrompt, 34 | setSelectedPrompt, 35 | handleCompletion, 36 | completionsSupported, 37 | promptContent, 38 | nextCursor, 39 | error, 40 | }: { 41 | prompts: Prompt[]; 42 | listPrompts: () => void; 43 | clearPrompts: () => void; 44 | getPrompt: (name: string, args: Record<string, string>) => void; 45 | selectedPrompt: Prompt | null; 46 | setSelectedPrompt: (prompt: Prompt | null) => void; 47 | handleCompletion: ( 48 | ref: PromptReference | ResourceReference, 49 | argName: string, 50 | value: string, 51 | ) => Promise<string[]>; 52 | completionsSupported: boolean; 53 | promptContent: string; 54 | nextCursor: ListPromptsResult["nextCursor"]; 55 | error: string | null; 56 | }) => { 57 | const [promptArgs, setPromptArgs] = useState<Record<string, string>>({}); 58 | const { completions, clearCompletions, requestCompletions } = 59 | useCompletionState(handleCompletion, completionsSupported); 60 | 61 | useEffect(() => { 62 | clearCompletions(); 63 | }, [clearCompletions, selectedPrompt]); 64 | 65 | const handleInputChange = async (argName: string, value: string) => { 66 | setPromptArgs((prev) => ({ ...prev, [argName]: value })); 67 | 68 | if (selectedPrompt) { 69 | requestCompletions( 70 | { 71 | type: "ref/prompt", 72 | name: selectedPrompt.name, 73 | }, 74 | argName, 75 | value, 76 | ); 77 | } 78 | }; 79 | 80 | const handleGetPrompt = () => { 81 | if (selectedPrompt) { 82 | getPrompt(selectedPrompt.name, promptArgs); 83 | } 84 | }; 85 | 86 | return ( 87 | <TabsContent value="prompts"> 88 | <div className="grid grid-cols-2 gap-4"> 89 | <ListPane 90 | items={prompts} 91 | listItems={listPrompts} 92 | clearItems={() => { 93 | clearPrompts(); 94 | setSelectedPrompt(null); 95 | }} 96 | setSelectedItem={(prompt) => { 97 | setSelectedPrompt(prompt); 98 | setPromptArgs({}); 99 | }} 100 | renderItem={(prompt) => ( 101 | <div className="flex flex-col items-start"> 102 | <span className="flex-1">{prompt.name}</span> 103 | <span className="text-sm text-gray-500 text-left"> 104 | {prompt.description} 105 | </span> 106 | </div> 107 | )} 108 | title="Prompts" 109 | buttonText={nextCursor ? "List More Prompts" : "List Prompts"} 110 | isButtonDisabled={!nextCursor && prompts.length > 0} 111 | /> 112 | 113 | <div className="bg-card border border-border rounded-lg shadow"> 114 | <div className="p-4 border-b border-gray-200 dark:border-border"> 115 | <h3 className="font-semibold"> 116 | {selectedPrompt ? selectedPrompt.name : "Select a prompt"} 117 | </h3> 118 | </div> 119 | <div className="p-4"> 120 | {error ? ( 121 | <Alert variant="destructive"> 122 | <AlertCircle className="h-4 w-4" /> 123 | <AlertTitle>Error</AlertTitle> 124 | <AlertDescription>{error}</AlertDescription> 125 | </Alert> 126 | ) : selectedPrompt ? ( 127 | <div className="space-y-4"> 128 | {selectedPrompt.description && ( 129 | <p className="text-sm text-gray-600 dark:text-gray-400"> 130 | {selectedPrompt.description} 131 | </p> 132 | )} 133 | {selectedPrompt.arguments?.map((arg) => ( 134 | <div key={arg.name}> 135 | <Label htmlFor={arg.name}>{arg.name}</Label> 136 | <Combobox 137 | id={arg.name} 138 | placeholder={`Enter ${arg.name}`} 139 | value={promptArgs[arg.name] || ""} 140 | onChange={(value) => handleInputChange(arg.name, value)} 141 | onInputChange={(value) => 142 | handleInputChange(arg.name, value) 143 | } 144 | options={completions[arg.name] || []} 145 | /> 146 | 147 | {arg.description && ( 148 | <p className="text-xs text-gray-500 mt-1"> 149 | {arg.description} 150 | {arg.required && ( 151 | <span className="text-xs mt-1 ml-1">(Required)</span> 152 | )} 153 | </p> 154 | )} 155 | </div> 156 | ))} 157 | <Button onClick={handleGetPrompt} className="w-full"> 158 | Get Prompt 159 | </Button> 160 | {promptContent && ( 161 | <JsonView data={promptContent} withCopyButton={false} /> 162 | )} 163 | </div> 164 | ) : ( 165 | <Alert> 166 | <AlertDescription> 167 | Select a prompt from the list to view and use it 168 | </AlertDescription> 169 | </Alert> 170 | )} 171 | </div> 172 | </div> 173 | </div> 174 | </TabsContent> 175 | ); 176 | }; 177 | 178 | export default PromptsTab; 179 | -------------------------------------------------------------------------------- /client/src/components/ResourceLinkView.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useCallback, useMemo, memo } from "react"; 2 | import JsonView from "./JsonView"; 3 | 4 | interface ResourceLinkViewProps { 5 | uri: string; 6 | name?: string; 7 | description?: string; 8 | mimeType?: string; 9 | resourceContent: string; 10 | onReadResource?: (uri: string) => void; 11 | } 12 | 13 | const ResourceLinkView = memo( 14 | ({ 15 | uri, 16 | name, 17 | description, 18 | mimeType, 19 | resourceContent, 20 | onReadResource, 21 | }: ResourceLinkViewProps) => { 22 | const [{ expanded, loading }, setState] = useState({ 23 | expanded: false, 24 | loading: false, 25 | }); 26 | 27 | const expandedContent = useMemo( 28 | () => 29 | expanded && resourceContent ? ( 30 | <div className="mt-2"> 31 | <div className="flex justify-between items-center mb-1"> 32 | <span className="font-semibold text-green-600">Resource:</span> 33 | </div> 34 | <JsonView data={resourceContent} className="bg-background" /> 35 | </div> 36 | ) : null, 37 | [expanded, resourceContent], 38 | ); 39 | 40 | const handleClick = useCallback(() => { 41 | if (!onReadResource) return; 42 | if (!expanded) { 43 | setState((prev) => ({ ...prev, expanded: true, loading: true })); 44 | onReadResource(uri); 45 | setState((prev) => ({ ...prev, loading: false })); 46 | } else { 47 | setState((prev) => ({ ...prev, expanded: false })); 48 | } 49 | }, [expanded, onReadResource, uri]); 50 | 51 | const handleKeyDown = useCallback( 52 | (e: React.KeyboardEvent) => { 53 | if ((e.key === "Enter" || e.key === " ") && onReadResource) { 54 | e.preventDefault(); 55 | handleClick(); 56 | } 57 | }, 58 | [handleClick, onReadResource], 59 | ); 60 | 61 | return ( 62 | <div className="text-sm text-foreground bg-secondary py-2 px-3 rounded"> 63 | <div 64 | className="flex justify-between items-center cursor-pointer focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-1 rounded" 65 | onClick={onReadResource ? handleClick : undefined} 66 | onKeyDown={onReadResource ? handleKeyDown : undefined} 67 | tabIndex={onReadResource ? 0 : -1} 68 | role="button" 69 | aria-expanded={expanded} 70 | aria-label={`${expanded ? "Collapse" : "Expand"} resource ${uri}`} 71 | > 72 | <div className="flex-1 min-w-0"> 73 | <div className="flex items-start justify-between gap-2 mb-1"> 74 | <span className="text-sm text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300 hover:underline px-1 py-0.5 break-all font-mono flex-1 min-w-0"> 75 | {uri} 76 | </span> 77 | <div className="flex items-center gap-2 flex-shrink-0"> 78 | {mimeType && ( 79 | <span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200"> 80 | {mimeType} 81 | </span> 82 | )} 83 | {onReadResource && ( 84 | <span className="ml-2 flex-shrink-0" aria-hidden="true"> 85 | {loading ? ( 86 | <div className="w-4 h-4 border-2 border-blue-500 border-t-transparent rounded-full animate-spin" /> 87 | ) : ( 88 | <span>{expanded ? "▼" : "▶"}</span> 89 | )} 90 | </span> 91 | )} 92 | </div> 93 | </div> 94 | {name && ( 95 | <div className="font-semibold text-sm text-gray-900 dark:text-gray-100 mb-1"> 96 | {name} 97 | </div> 98 | )} 99 | {description && ( 100 | <p className="text-sm text-gray-600 dark:text-gray-300 leading-relaxed"> 101 | {description} 102 | </p> 103 | )} 104 | </div> 105 | </div> 106 | {expandedContent} 107 | </div> 108 | ); 109 | }, 110 | ); 111 | 112 | export default ResourceLinkView; 113 | -------------------------------------------------------------------------------- /client/src/components/RootsTab.tsx: -------------------------------------------------------------------------------- 1 | import { Alert, AlertDescription } from "@/components/ui/alert"; 2 | import { Button } from "@/components/ui/button"; 3 | import { Input } from "@/components/ui/input"; 4 | import { TabsContent } from "@/components/ui/tabs"; 5 | import { Root } from "@modelcontextprotocol/sdk/types.js"; 6 | import { Plus, Minus, Save } from "lucide-react"; 7 | 8 | const RootsTab = ({ 9 | roots, 10 | setRoots, 11 | onRootsChange, 12 | }: { 13 | roots: Root[]; 14 | setRoots: React.Dispatch<React.SetStateAction<Root[]>>; 15 | onRootsChange: () => void; 16 | }) => { 17 | const addRoot = () => { 18 | setRoots((currentRoots) => [...currentRoots, { uri: "file://", name: "" }]); 19 | }; 20 | 21 | const removeRoot = (index: number) => { 22 | setRoots((currentRoots) => currentRoots.filter((_, i) => i !== index)); 23 | }; 24 | 25 | const updateRoot = (index: number, field: keyof Root, value: string) => { 26 | setRoots((currentRoots) => 27 | currentRoots.map((root, i) => 28 | i === index ? { ...root, [field]: value } : root, 29 | ), 30 | ); 31 | }; 32 | 33 | const handleSave = () => { 34 | onRootsChange(); 35 | }; 36 | 37 | return ( 38 | <TabsContent value="roots"> 39 | <div className="space-y-4"> 40 | <Alert> 41 | <AlertDescription> 42 | Configure the root directories that the server can access 43 | </AlertDescription> 44 | </Alert> 45 | 46 | {roots.map((root, index) => ( 47 | <div key={index} className="flex gap-2 items-center"> 48 | <Input 49 | placeholder="file:// URI" 50 | value={root.uri} 51 | onChange={(e) => updateRoot(index, "uri", e.target.value)} 52 | className="flex-1" 53 | /> 54 | <Button 55 | variant="destructive" 56 | size="sm" 57 | onClick={() => removeRoot(index)} 58 | > 59 | <Minus className="h-4 w-4" /> 60 | </Button> 61 | </div> 62 | ))} 63 | 64 | <div className="flex gap-2"> 65 | <Button variant="outline" onClick={addRoot}> 66 | <Plus className="h-4 w-4 mr-2" /> 67 | Add Root 68 | </Button> 69 | <Button onClick={handleSave}> 70 | <Save className="h-4 w-4 mr-2" /> 71 | Save Changes 72 | </Button> 73 | </div> 74 | </div> 75 | </TabsContent> 76 | ); 77 | }; 78 | 79 | export default RootsTab; 80 | -------------------------------------------------------------------------------- /client/src/components/SamplingRequest.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from "@/components/ui/button"; 2 | import JsonView from "./JsonView"; 3 | import { useMemo, useState } from "react"; 4 | import { 5 | CreateMessageResult, 6 | CreateMessageResultSchema, 7 | } from "@modelcontextprotocol/sdk/types.js"; 8 | import { PendingRequest } from "./SamplingTab"; 9 | import DynamicJsonForm from "./DynamicJsonForm"; 10 | import { useToast } from "@/lib/hooks/useToast"; 11 | import { JsonSchemaType, JsonValue } from "@/utils/jsonUtils"; 12 | 13 | export type SamplingRequestProps = { 14 | request: PendingRequest; 15 | onApprove: (id: number, result: CreateMessageResult) => void; 16 | onReject: (id: number) => void; 17 | }; 18 | 19 | const SamplingRequest = ({ 20 | onApprove, 21 | request, 22 | onReject, 23 | }: SamplingRequestProps) => { 24 | const { toast } = useToast(); 25 | 26 | const [messageResult, setMessageResult] = useState<JsonValue>({ 27 | model: "stub-model", 28 | stopReason: "endTurn", 29 | role: "assistant", 30 | content: { 31 | type: "text", 32 | text: "", 33 | }, 34 | }); 35 | 36 | const contentType = ( 37 | (messageResult as { [key: string]: JsonValue })?.content as { 38 | [key: string]: JsonValue; 39 | } 40 | )?.type; 41 | 42 | const schema = useMemo(() => { 43 | const s: JsonSchemaType = { 44 | type: "object", 45 | description: "Message result", 46 | properties: { 47 | model: { 48 | type: "string", 49 | default: "stub-model", 50 | description: "model name", 51 | }, 52 | stopReason: { 53 | type: "string", 54 | default: "endTurn", 55 | description: "Stop reason", 56 | }, 57 | role: { 58 | type: "string", 59 | default: "endTurn", 60 | description: "Role of the model", 61 | }, 62 | content: { 63 | type: "object", 64 | properties: { 65 | type: { 66 | type: "string", 67 | default: "text", 68 | description: "Type of content", 69 | }, 70 | }, 71 | }, 72 | }, 73 | }; 74 | 75 | if (contentType === "text" && s.properties) { 76 | s.properties.content.properties = { 77 | ...s.properties.content.properties, 78 | text: { 79 | type: "string", 80 | default: "", 81 | description: "text content", 82 | }, 83 | }; 84 | setMessageResult((prev) => ({ 85 | ...(prev as { [key: string]: JsonValue }), 86 | content: { 87 | type: contentType, 88 | text: "", 89 | }, 90 | })); 91 | } else if (contentType === "image" && s.properties) { 92 | s.properties.content.properties = { 93 | ...s.properties.content.properties, 94 | data: { 95 | type: "string", 96 | default: "", 97 | description: "Base64 encoded image data", 98 | }, 99 | mimeType: { 100 | type: "string", 101 | default: "", 102 | description: "Mime type of the image", 103 | }, 104 | }; 105 | setMessageResult((prev) => ({ 106 | ...(prev as { [key: string]: JsonValue }), 107 | content: { 108 | type: contentType, 109 | data: "", 110 | mimeType: "", 111 | }, 112 | })); 113 | } 114 | 115 | return s; 116 | }, [contentType]); 117 | 118 | const handleApprove = (id: number) => { 119 | const validationResult = CreateMessageResultSchema.safeParse(messageResult); 120 | if (!validationResult.success) { 121 | toast({ 122 | title: "Error", 123 | description: `There was an error validating the message result: ${validationResult.error.message}`, 124 | variant: "destructive", 125 | }); 126 | return; 127 | } 128 | 129 | onApprove(id, validationResult.data); 130 | }; 131 | 132 | return ( 133 | <div 134 | data-testid="sampling-request" 135 | className="flex gap-4 p-4 border rounded-lg space-y-4" 136 | > 137 | <div className="flex-1 bg-gray-50 dark:bg-gray-800 dark:text-gray-100 p-2 rounded"> 138 | <JsonView data={JSON.stringify(request.request)} /> 139 | </div> 140 | <form className="flex-1 space-y-4"> 141 | <div className="space-y-2"> 142 | <DynamicJsonForm 143 | schema={schema} 144 | value={messageResult} 145 | onChange={(newValue: JsonValue) => { 146 | setMessageResult(newValue); 147 | }} 148 | /> 149 | </div> 150 | <div className="flex space-x-2 mt-1"> 151 | <Button type="button" onClick={() => handleApprove(request.id)}> 152 | Approve 153 | </Button> 154 | <Button 155 | type="button" 156 | variant="outline" 157 | onClick={() => onReject(request.id)} 158 | > 159 | Reject 160 | </Button> 161 | </div> 162 | </form> 163 | </div> 164 | ); 165 | }; 166 | 167 | export default SamplingRequest; 168 | -------------------------------------------------------------------------------- /client/src/components/SamplingTab.tsx: -------------------------------------------------------------------------------- 1 | import { Alert, AlertDescription } from "@/components/ui/alert"; 2 | import { TabsContent } from "@/components/ui/tabs"; 3 | import { 4 | CreateMessageRequest, 5 | CreateMessageResult, 6 | } from "@modelcontextprotocol/sdk/types.js"; 7 | import SamplingRequest from "./SamplingRequest"; 8 | 9 | export type PendingRequest = { 10 | id: number; 11 | request: CreateMessageRequest; 12 | }; 13 | 14 | export type Props = { 15 | pendingRequests: PendingRequest[]; 16 | onApprove: (id: number, result: CreateMessageResult) => void; 17 | onReject: (id: number) => void; 18 | }; 19 | 20 | const SamplingTab = ({ pendingRequests, onApprove, onReject }: Props) => { 21 | return ( 22 | <TabsContent value="sampling"> 23 | <div className="h-96"> 24 | <Alert> 25 | <AlertDescription> 26 | When the server requests LLM sampling, requests will appear here for 27 | approval. 28 | </AlertDescription> 29 | </Alert> 30 | <div className="mt-4 space-y-4"> 31 | <h3 className="text-lg font-semibold">Recent Requests</h3> 32 | {pendingRequests.map((request) => ( 33 | <SamplingRequest 34 | key={request.id} 35 | request={request} 36 | onApprove={onApprove} 37 | onReject={onReject} 38 | /> 39 | ))} 40 | {pendingRequests.length === 0 && ( 41 | <p className="text-gray-500">No pending requests</p> 42 | )} 43 | </div> 44 | </div> 45 | </TabsContent> 46 | ); 47 | }; 48 | 49 | export default SamplingTab; 50 | -------------------------------------------------------------------------------- /client/src/components/__tests__/samplingRequest.test.tsx: -------------------------------------------------------------------------------- 1 | import { render, screen, fireEvent } from "@testing-library/react"; 2 | import SamplingRequest from "../SamplingRequest"; 3 | import { PendingRequest } from "../SamplingTab"; 4 | 5 | const mockRequest: PendingRequest = { 6 | id: 1, 7 | request: { 8 | method: "sampling/createMessage", 9 | params: { 10 | messages: [ 11 | { 12 | role: "user", 13 | content: { 14 | type: "text", 15 | text: "What files are in the current directory?", 16 | }, 17 | }, 18 | ], 19 | systemPrompt: "You are a helpful file system assistant.", 20 | includeContext: "thisServer", 21 | maxTokens: 100, 22 | }, 23 | }, 24 | }; 25 | 26 | describe("Form to handle sampling response", () => { 27 | const mockOnApprove = jest.fn(); 28 | const mockOnReject = jest.fn(); 29 | 30 | afterEach(() => { 31 | jest.clearAllMocks(); 32 | }); 33 | 34 | it("should call onApprove with correct text content when Approve button is clicked", () => { 35 | render( 36 | <SamplingRequest 37 | request={mockRequest} 38 | onApprove={mockOnApprove} 39 | onReject={mockOnReject} 40 | />, 41 | ); 42 | 43 | // Click the Approve button 44 | fireEvent.click(screen.getByRole("button", { name: /approve/i })); 45 | 46 | // Assert that onApprove is called with the correct arguments 47 | expect(mockOnApprove).toHaveBeenCalledWith(mockRequest.id, { 48 | model: "stub-model", 49 | stopReason: "endTurn", 50 | role: "assistant", 51 | content: { 52 | type: "text", 53 | text: "", 54 | }, 55 | }); 56 | }); 57 | 58 | it("should call onReject with correct request id when Reject button is clicked", () => { 59 | render( 60 | <SamplingRequest 61 | request={mockRequest} 62 | onApprove={mockOnApprove} 63 | onReject={mockOnReject} 64 | />, 65 | ); 66 | 67 | // Click the Approve button 68 | fireEvent.click(screen.getByRole("button", { name: /Reject/i })); 69 | 70 | // Assert that onApprove is called with the correct arguments 71 | expect(mockOnReject).toHaveBeenCalledWith(mockRequest.id); 72 | }); 73 | }); 74 | -------------------------------------------------------------------------------- /client/src/components/__tests__/samplingTab.test.tsx: -------------------------------------------------------------------------------- 1 | import { render, screen } from "@testing-library/react"; 2 | import { Tabs } from "@/components/ui/tabs"; 3 | import SamplingTab, { PendingRequest } from "../SamplingTab"; 4 | 5 | describe("Sampling tab", () => { 6 | const mockOnApprove = jest.fn(); 7 | const mockOnReject = jest.fn(); 8 | 9 | const renderSamplingTab = (pendingRequests: PendingRequest[]) => 10 | render( 11 | <Tabs defaultValue="sampling"> 12 | <SamplingTab 13 | pendingRequests={pendingRequests} 14 | onApprove={mockOnApprove} 15 | onReject={mockOnReject} 16 | /> 17 | </Tabs>, 18 | ); 19 | 20 | it("should render 'No pending requests' when there are no pending requests", () => { 21 | renderSamplingTab([]); 22 | expect( 23 | screen.getByText( 24 | "When the server requests LLM sampling, requests will appear here for approval.", 25 | ), 26 | ).toBeTruthy(); 27 | expect(screen.findByText("No pending requests")).toBeTruthy(); 28 | }); 29 | 30 | it("should render the correct number of requests", () => { 31 | renderSamplingTab( 32 | Array.from({ length: 5 }, (_, i) => ({ 33 | id: i, 34 | request: { 35 | method: "sampling/createMessage", 36 | params: { 37 | messages: [ 38 | { 39 | role: "user", 40 | content: { 41 | type: "text", 42 | text: "What files are in the current directory?", 43 | }, 44 | }, 45 | ], 46 | systemPrompt: "You are a helpful file system assistant.", 47 | includeContext: "thisServer", 48 | maxTokens: 100, 49 | }, 50 | }, 51 | })), 52 | ); 53 | expect(screen.getAllByTestId("sampling-request").length).toBe(5); 54 | }); 55 | }); 56 | -------------------------------------------------------------------------------- /client/src/components/ui/alert.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { cva, type VariantProps } from "class-variance-authority"; 3 | 4 | import { cn } from "@/lib/utils"; 5 | 6 | const alertVariants = cva( 7 | "relative w-full rounded-lg border px-4 py-3 text-sm [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground [&>svg~*]:pl-7", 8 | { 9 | variants: { 10 | variant: { 11 | default: "bg-background text-foreground", 12 | destructive: 13 | "border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive", 14 | }, 15 | }, 16 | defaultVariants: { 17 | variant: "default", 18 | }, 19 | }, 20 | ); 21 | 22 | const Alert = React.forwardRef< 23 | HTMLDivElement, 24 | React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants> 25 | >(({ className, variant, ...props }, ref) => ( 26 | <div 27 | ref={ref} 28 | role="alert" 29 | className={cn(alertVariants({ variant }), className)} 30 | {...props} 31 | /> 32 | )); 33 | Alert.displayName = "Alert"; 34 | 35 | const AlertTitle = React.forwardRef< 36 | HTMLParagraphElement, 37 | React.HTMLAttributes<HTMLHeadingElement> 38 | >(({ className, ...props }, ref) => ( 39 | <h5 40 | ref={ref} 41 | className={cn("mb-1 font-medium leading-none tracking-tight", className)} 42 | {...props} 43 | /> 44 | )); 45 | AlertTitle.displayName = "AlertTitle"; 46 | 47 | const AlertDescription = React.forwardRef< 48 | HTMLParagraphElement, 49 | React.HTMLAttributes<HTMLParagraphElement> 50 | >(({ className, ...props }, ref) => ( 51 | <div 52 | ref={ref} 53 | className={cn("text-sm [&_p]:leading-relaxed", className)} 54 | {...props} 55 | /> 56 | )); 57 | AlertDescription.displayName = "AlertDescription"; 58 | 59 | export { Alert, AlertTitle, AlertDescription }; 60 | -------------------------------------------------------------------------------- /client/src/components/ui/button.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { Slot } from "@radix-ui/react-slot"; 3 | import { cva, type VariantProps } from "class-variance-authority"; 4 | 5 | import { cn } from "@/lib/utils"; 6 | 7 | const buttonVariants = cva( 8 | "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50", 9 | { 10 | variants: { 11 | variant: { 12 | default: 13 | "bg-primary text-primary-foreground shadow hover:bg-primary/90 dark:bg-gray-800 dark:text-white dark:hover:bg-gray-700", 14 | destructive: 15 | "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90", 16 | outline: 17 | "border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground", 18 | secondary: 19 | "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80", 20 | ghost: "hover:bg-accent hover:text-accent-foreground", 21 | link: "text-primary underline-offset-4 hover:underline", 22 | }, 23 | size: { 24 | default: "h-9 px-4 py-2", 25 | sm: "h-8 rounded-md px-3 text-xs", 26 | lg: "h-10 rounded-md px-8", 27 | icon: "h-9 w-9", 28 | }, 29 | }, 30 | defaultVariants: { 31 | variant: "default", 32 | size: "default", 33 | }, 34 | }, 35 | ); 36 | 37 | export interface ButtonProps 38 | extends React.ButtonHTMLAttributes<HTMLButtonElement>, 39 | VariantProps<typeof buttonVariants> { 40 | asChild?: boolean; 41 | } 42 | 43 | const Button = React.forwardRef<HTMLButtonElement, ButtonProps>( 44 | ({ className, variant, size, asChild = false, ...props }, ref) => { 45 | const Comp = asChild ? Slot : "button"; 46 | return ( 47 | <Comp 48 | className={cn(buttonVariants({ variant, size, className }))} 49 | ref={ref} 50 | {...props} 51 | /> 52 | ); 53 | }, 54 | ); 55 | Button.displayName = "Button"; 56 | 57 | export { Button }; 58 | -------------------------------------------------------------------------------- /client/src/components/ui/checkbox.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as React from "react"; 4 | import * as CheckboxPrimitive from "@radix-ui/react-checkbox"; 5 | import { Check } from "lucide-react"; 6 | 7 | import { cn } from "@/lib/utils"; 8 | 9 | const Checkbox = React.forwardRef< 10 | React.ElementRef<typeof CheckboxPrimitive.Root>, 11 | React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root> 12 | >(({ className, ...props }, ref) => ( 13 | <CheckboxPrimitive.Root 14 | ref={ref} 15 | className={cn( 16 | "peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground", 17 | className, 18 | )} 19 | {...props} 20 | > 21 | <CheckboxPrimitive.Indicator 22 | className={cn("flex items-center justify-center text-current")} 23 | > 24 | <Check className="h-4 w-4" /> 25 | </CheckboxPrimitive.Indicator> 26 | </CheckboxPrimitive.Root> 27 | )); 28 | Checkbox.displayName = CheckboxPrimitive.Root.displayName; 29 | 30 | export { Checkbox }; 31 | -------------------------------------------------------------------------------- /client/src/components/ui/combobox.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Check, ChevronsUpDown } from "lucide-react"; 3 | import { cn } from "@/lib/utils"; 4 | import { Button } from "@/components/ui/button"; 5 | import { 6 | Command, 7 | CommandEmpty, 8 | CommandGroup, 9 | CommandInput, 10 | CommandItem, 11 | } from "@/components/ui/command"; 12 | import { 13 | Popover, 14 | PopoverContent, 15 | PopoverTrigger, 16 | } from "@/components/ui/popover"; 17 | 18 | interface ComboboxProps { 19 | value: string; 20 | onChange: (value: string) => void; 21 | onInputChange: (value: string) => void; 22 | options: string[]; 23 | placeholder?: string; 24 | emptyMessage?: string; 25 | id?: string; 26 | } 27 | 28 | export function Combobox({ 29 | value, 30 | onChange, 31 | onInputChange, 32 | options = [], 33 | placeholder = "Select...", 34 | emptyMessage = "No results found.", 35 | id, 36 | }: ComboboxProps) { 37 | const [open, setOpen] = React.useState(false); 38 | 39 | const handleSelect = React.useCallback( 40 | (option: string) => { 41 | onChange(option); 42 | setOpen(false); 43 | }, 44 | [onChange], 45 | ); 46 | 47 | const handleInputChange = React.useCallback( 48 | (value: string) => { 49 | onInputChange(value); 50 | }, 51 | [onInputChange], 52 | ); 53 | 54 | return ( 55 | <Popover open={open} onOpenChange={setOpen}> 56 | <PopoverTrigger asChild> 57 | <Button 58 | variant="outline" 59 | role="combobox" 60 | aria-expanded={open} 61 | aria-controls={id} 62 | className="w-full justify-between" 63 | > 64 | {value || placeholder} 65 | <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" /> 66 | </Button> 67 | </PopoverTrigger> 68 | <PopoverContent className="w-full p-0" align="start"> 69 | <Command shouldFilter={false} id={id}> 70 | <CommandInput 71 | placeholder={placeholder} 72 | value={value} 73 | onValueChange={handleInputChange} 74 | /> 75 | <CommandEmpty>{emptyMessage}</CommandEmpty> 76 | <CommandGroup> 77 | {options.map((option) => ( 78 | <CommandItem 79 | key={option} 80 | value={option} 81 | onSelect={() => handleSelect(option)} 82 | > 83 | <Check 84 | className={cn( 85 | "mr-2 h-4 w-4", 86 | value === option ? "opacity-100" : "opacity-0", 87 | )} 88 | /> 89 | {option} 90 | </CommandItem> 91 | ))} 92 | </CommandGroup> 93 | </Command> 94 | </PopoverContent> 95 | </Popover> 96 | ); 97 | } 98 | -------------------------------------------------------------------------------- /client/src/components/ui/command.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { type DialogProps } from "@radix-ui/react-dialog"; 3 | import { Command as CommandPrimitive } from "cmdk"; 4 | import { cn } from "@/lib/utils"; 5 | import { Dialog, DialogContent } from "@/components/ui/dialog"; 6 | import { MagnifyingGlassIcon } from "@radix-ui/react-icons"; 7 | 8 | const Command = React.forwardRef< 9 | React.ElementRef<typeof CommandPrimitive>, 10 | React.ComponentPropsWithoutRef<typeof CommandPrimitive> 11 | >(({ className, ...props }, ref) => ( 12 | <CommandPrimitive 13 | ref={ref} 14 | className={cn( 15 | "flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground", 16 | className, 17 | )} 18 | {...props} 19 | /> 20 | )); 21 | Command.displayName = CommandPrimitive.displayName; 22 | 23 | const CommandDialog = ({ children, ...props }: DialogProps) => { 24 | return ( 25 | <Dialog {...props}> 26 | <DialogContent className="overflow-hidden p-0"> 27 | <Command className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5"> 28 | {children} 29 | </Command> 30 | </DialogContent> 31 | </Dialog> 32 | ); 33 | }; 34 | 35 | const CommandInput = React.forwardRef< 36 | React.ElementRef<typeof CommandPrimitive.Input>, 37 | React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input> 38 | >(({ className, ...props }, ref) => ( 39 | <div className="flex items-center border-b px-3" cmdk-input-wrapper=""> 40 | <MagnifyingGlassIcon className="mr-2 h-4 w-4 shrink-0 opacity-50" /> 41 | <CommandPrimitive.Input 42 | ref={ref} 43 | className={cn( 44 | "flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50", 45 | className, 46 | )} 47 | {...props} 48 | /> 49 | </div> 50 | )); 51 | 52 | CommandInput.displayName = CommandPrimitive.Input.displayName; 53 | 54 | const CommandList = React.forwardRef< 55 | React.ElementRef<typeof CommandPrimitive.List>, 56 | React.ComponentPropsWithoutRef<typeof CommandPrimitive.List> 57 | >(({ className, ...props }, ref) => ( 58 | <CommandPrimitive.List 59 | ref={ref} 60 | className={cn("max-h-[300px] overflow-y-auto overflow-x-hidden", className)} 61 | {...props} 62 | /> 63 | )); 64 | 65 | CommandList.displayName = CommandPrimitive.List.displayName; 66 | 67 | const CommandEmpty = React.forwardRef< 68 | React.ElementRef<typeof CommandPrimitive.Empty>, 69 | React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty> 70 | >((props, ref) => ( 71 | <CommandPrimitive.Empty 72 | ref={ref} 73 | className="py-6 text-center text-sm" 74 | {...props} 75 | /> 76 | )); 77 | 78 | CommandEmpty.displayName = CommandPrimitive.Empty.displayName; 79 | 80 | const CommandGroup = React.forwardRef< 81 | React.ElementRef<typeof CommandPrimitive.Group>, 82 | React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group> 83 | >(({ className, ...props }, ref) => ( 84 | <CommandPrimitive.Group 85 | ref={ref} 86 | className={cn( 87 | "overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground", 88 | className, 89 | )} 90 | {...props} 91 | /> 92 | )); 93 | 94 | CommandGroup.displayName = CommandPrimitive.Group.displayName; 95 | 96 | const CommandSeparator = React.forwardRef< 97 | React.ElementRef<typeof CommandPrimitive.Separator>, 98 | React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator> 99 | >(({ className, ...props }, ref) => ( 100 | <CommandPrimitive.Separator 101 | ref={ref} 102 | className={cn("-mx-1 h-px bg-border", className)} 103 | {...props} 104 | /> 105 | )); 106 | CommandSeparator.displayName = CommandPrimitive.Separator.displayName; 107 | 108 | const CommandItem = React.forwardRef< 109 | React.ElementRef<typeof CommandPrimitive.Item>, 110 | React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item> 111 | >(({ className, ...props }, ref) => ( 112 | <CommandPrimitive.Item 113 | ref={ref} 114 | className={cn( 115 | "relative flex cursor-default gap-2 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled=true]:pointer-events-none data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0", 116 | className, 117 | )} 118 | {...props} 119 | /> 120 | )); 121 | 122 | CommandItem.displayName = CommandPrimitive.Item.displayName; 123 | 124 | const CommandShortcut = ({ 125 | className, 126 | ...props 127 | }: React.HTMLAttributes<HTMLSpanElement>) => { 128 | return ( 129 | <span 130 | className={cn( 131 | "ml-auto text-xs tracking-widest text-muted-foreground", 132 | className, 133 | )} 134 | {...props} 135 | /> 136 | ); 137 | }; 138 | CommandShortcut.displayName = "CommandShortcut"; 139 | 140 | export { 141 | Command, 142 | CommandDialog, 143 | CommandInput, 144 | CommandList, 145 | CommandEmpty, 146 | CommandGroup, 147 | CommandItem, 148 | CommandShortcut, 149 | CommandSeparator, 150 | }; 151 | -------------------------------------------------------------------------------- /client/src/components/ui/dialog.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as React from "react"; 4 | import * as DialogPrimitive from "@radix-ui/react-dialog"; 5 | import { cn } from "@/lib/utils"; 6 | import { Cross2Icon } from "@radix-ui/react-icons"; 7 | 8 | const Dialog = DialogPrimitive.Root; 9 | 10 | const DialogTrigger = DialogPrimitive.Trigger; 11 | 12 | const DialogPortal = DialogPrimitive.Portal; 13 | 14 | const DialogClose = DialogPrimitive.Close; 15 | 16 | const DialogOverlay = React.forwardRef< 17 | React.ElementRef<typeof DialogPrimitive.Overlay>, 18 | React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay> 19 | >(({ className, ...props }, ref) => ( 20 | <DialogPrimitive.Overlay 21 | ref={ref} 22 | className={cn( 23 | "fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0", 24 | className, 25 | )} 26 | {...props} 27 | /> 28 | )); 29 | DialogOverlay.displayName = DialogPrimitive.Overlay.displayName; 30 | 31 | const DialogContent = React.forwardRef< 32 | React.ElementRef<typeof DialogPrimitive.Content>, 33 | React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> 34 | >(({ className, children, ...props }, ref) => ( 35 | <DialogPortal> 36 | <DialogOverlay /> 37 | <DialogPrimitive.Content 38 | ref={ref} 39 | className={cn( 40 | "fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg", 41 | className, 42 | )} 43 | {...props} 44 | > 45 | {children} 46 | <DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground"> 47 | <Cross2Icon className="h-4 w-4" /> 48 | <span className="sr-only">Close</span> 49 | </DialogPrimitive.Close> 50 | </DialogPrimitive.Content> 51 | </DialogPortal> 52 | )); 53 | DialogContent.displayName = DialogPrimitive.Content.displayName; 54 | 55 | const DialogHeader = ({ 56 | className, 57 | ...props 58 | }: React.HTMLAttributes<HTMLDivElement>) => ( 59 | <div 60 | className={cn( 61 | "flex flex-col space-y-1.5 text-center sm:text-left", 62 | className, 63 | )} 64 | {...props} 65 | /> 66 | ); 67 | DialogHeader.displayName = "DialogHeader"; 68 | 69 | const DialogFooter = ({ 70 | className, 71 | ...props 72 | }: React.HTMLAttributes<HTMLDivElement>) => ( 73 | <div 74 | className={cn( 75 | "flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", 76 | className, 77 | )} 78 | {...props} 79 | /> 80 | ); 81 | DialogFooter.displayName = "DialogFooter"; 82 | 83 | const DialogTitle = React.forwardRef< 84 | React.ElementRef<typeof DialogPrimitive.Title>, 85 | React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title> 86 | >(({ className, ...props }, ref) => ( 87 | <DialogPrimitive.Title 88 | ref={ref} 89 | className={cn( 90 | "text-lg font-semibold leading-none tracking-tight", 91 | className, 92 | )} 93 | {...props} 94 | /> 95 | )); 96 | DialogTitle.displayName = DialogPrimitive.Title.displayName; 97 | 98 | const DialogDescription = React.forwardRef< 99 | React.ElementRef<typeof DialogPrimitive.Description>, 100 | React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description> 101 | >(({ className, ...props }, ref) => ( 102 | <DialogPrimitive.Description 103 | ref={ref} 104 | className={cn("text-sm text-muted-foreground", className)} 105 | {...props} 106 | /> 107 | )); 108 | DialogDescription.displayName = DialogPrimitive.Description.displayName; 109 | 110 | export { 111 | Dialog, 112 | DialogPortal, 113 | DialogOverlay, 114 | DialogTrigger, 115 | DialogClose, 116 | DialogContent, 117 | DialogHeader, 118 | DialogFooter, 119 | DialogTitle, 120 | DialogDescription, 121 | }; 122 | -------------------------------------------------------------------------------- /client/src/components/ui/input.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | 3 | import { cn } from "@/lib/utils"; 4 | 5 | export type InputProps = React.InputHTMLAttributes<HTMLInputElement>; 6 | 7 | const Input = React.forwardRef<HTMLInputElement, InputProps>( 8 | ({ className, type, ...props }, ref) => { 9 | return ( 10 | <input 11 | type={type} 12 | className={cn( 13 | "flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50", 14 | className, 15 | )} 16 | ref={ref} 17 | {...props} 18 | /> 19 | ); 20 | }, 21 | ); 22 | Input.displayName = "Input"; 23 | 24 | export { Input }; 25 | -------------------------------------------------------------------------------- /client/src/components/ui/label.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import * as LabelPrimitive from "@radix-ui/react-label"; 3 | import { cva, type VariantProps } from "class-variance-authority"; 4 | 5 | import { cn } from "@/lib/utils"; 6 | 7 | const labelVariants = cva( 8 | "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70", 9 | ); 10 | 11 | const Label = React.forwardRef< 12 | React.ElementRef<typeof LabelPrimitive.Root>, 13 | React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> & 14 | VariantProps<typeof labelVariants> 15 | >(({ className, ...props }, ref) => ( 16 | <LabelPrimitive.Root 17 | ref={ref} 18 | className={cn(labelVariants(), className)} 19 | {...props} 20 | /> 21 | )); 22 | Label.displayName = LabelPrimitive.Root.displayName; 23 | 24 | export { Label }; 25 | -------------------------------------------------------------------------------- /client/src/components/ui/popover.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import * as PopoverPrimitive from "@radix-ui/react-popover"; 3 | 4 | import { cn } from "@/lib/utils"; 5 | 6 | const Popover = PopoverPrimitive.Root; 7 | 8 | const PopoverTrigger = PopoverPrimitive.Trigger; 9 | 10 | const PopoverAnchor = PopoverPrimitive.Anchor; 11 | 12 | const PopoverContent = React.forwardRef< 13 | React.ElementRef<typeof PopoverPrimitive.Content>, 14 | React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content> 15 | >(({ className, align = "center", sideOffset = 4, ...props }, ref) => ( 16 | <PopoverPrimitive.Portal> 17 | <PopoverPrimitive.Content 18 | ref={ref} 19 | align={align} 20 | sideOffset={sideOffset} 21 | className={cn( 22 | "z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", 23 | className, 24 | )} 25 | {...props} 26 | /> 27 | </PopoverPrimitive.Portal> 28 | )); 29 | PopoverContent.displayName = PopoverPrimitive.Content.displayName; 30 | 31 | export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }; 32 | -------------------------------------------------------------------------------- /client/src/components/ui/select.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { 3 | CaretSortIcon, 4 | CheckIcon, 5 | ChevronDownIcon, 6 | ChevronUpIcon, 7 | } from "@radix-ui/react-icons"; 8 | import * as SelectPrimitive from "@radix-ui/react-select"; 9 | 10 | import { cn } from "@/lib/utils"; 11 | 12 | const Select = SelectPrimitive.Root; 13 | 14 | const SelectGroup = SelectPrimitive.Group; 15 | 16 | const SelectValue = SelectPrimitive.Value; 17 | 18 | const SelectTrigger = React.forwardRef< 19 | React.ElementRef<typeof SelectPrimitive.Trigger>, 20 | React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger> 21 | >(({ className, children, ...props }, ref) => ( 22 | <SelectPrimitive.Trigger 23 | ref={ref} 24 | className={cn( 25 | "flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1 hover:border-[#646cff] hover:border-1", 26 | className, 27 | )} 28 | {...props} 29 | > 30 | {children} 31 | <SelectPrimitive.Icon asChild> 32 | <CaretSortIcon className="h-4 w-4 opacity-50" /> 33 | </SelectPrimitive.Icon> 34 | </SelectPrimitive.Trigger> 35 | )); 36 | SelectTrigger.displayName = SelectPrimitive.Trigger.displayName; 37 | 38 | const SelectScrollUpButton = React.forwardRef< 39 | React.ElementRef<typeof SelectPrimitive.ScrollUpButton>, 40 | React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton> 41 | >(({ className, ...props }, ref) => ( 42 | <SelectPrimitive.ScrollUpButton 43 | ref={ref} 44 | className={cn( 45 | "flex cursor-default items-center justify-center py-1", 46 | className, 47 | )} 48 | {...props} 49 | > 50 | <ChevronUpIcon /> 51 | </SelectPrimitive.ScrollUpButton> 52 | )); 53 | SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName; 54 | 55 | const SelectScrollDownButton = React.forwardRef< 56 | React.ElementRef<typeof SelectPrimitive.ScrollDownButton>, 57 | React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton> 58 | >(({ className, ...props }, ref) => ( 59 | <SelectPrimitive.ScrollDownButton 60 | ref={ref} 61 | className={cn( 62 | "flex cursor-default items-center justify-center py-1", 63 | className, 64 | )} 65 | {...props} 66 | > 67 | <ChevronDownIcon /> 68 | </SelectPrimitive.ScrollDownButton> 69 | )); 70 | SelectScrollDownButton.displayName = 71 | SelectPrimitive.ScrollDownButton.displayName; 72 | 73 | const SelectContent = React.forwardRef< 74 | React.ElementRef<typeof SelectPrimitive.Content>, 75 | React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content> 76 | >(({ className, children, position = "popper", ...props }, ref) => ( 77 | <SelectPrimitive.Portal> 78 | <SelectPrimitive.Content 79 | ref={ref} 80 | className={cn( 81 | "relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", 82 | position === "popper" && 83 | "data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1", 84 | className, 85 | )} 86 | position={position} 87 | {...props} 88 | > 89 | <SelectScrollUpButton /> 90 | <SelectPrimitive.Viewport 91 | className={cn( 92 | "p-1", 93 | position === "popper" && 94 | "h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]", 95 | )} 96 | > 97 | {children} 98 | </SelectPrimitive.Viewport> 99 | <SelectScrollDownButton /> 100 | </SelectPrimitive.Content> 101 | </SelectPrimitive.Portal> 102 | )); 103 | SelectContent.displayName = SelectPrimitive.Content.displayName; 104 | 105 | const SelectLabel = React.forwardRef< 106 | React.ElementRef<typeof SelectPrimitive.Label>, 107 | React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label> 108 | >(({ className, ...props }, ref) => ( 109 | <SelectPrimitive.Label 110 | ref={ref} 111 | className={cn("px-2 py-1.5 text-sm font-semibold", className)} 112 | {...props} 113 | /> 114 | )); 115 | SelectLabel.displayName = SelectPrimitive.Label.displayName; 116 | 117 | const SelectItem = React.forwardRef< 118 | React.ElementRef<typeof SelectPrimitive.Item>, 119 | React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item> 120 | >(({ className, children, ...props }, ref) => ( 121 | <SelectPrimitive.Item 122 | ref={ref} 123 | className={cn( 124 | "relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50", 125 | className, 126 | )} 127 | {...props} 128 | > 129 | <span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center"> 130 | <SelectPrimitive.ItemIndicator> 131 | <CheckIcon className="h-4 w-4" /> 132 | </SelectPrimitive.ItemIndicator> 133 | </span> 134 | <SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText> 135 | </SelectPrimitive.Item> 136 | )); 137 | SelectItem.displayName = SelectPrimitive.Item.displayName; 138 | 139 | const SelectSeparator = React.forwardRef< 140 | React.ElementRef<typeof SelectPrimitive.Separator>, 141 | React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator> 142 | >(({ className, ...props }, ref) => ( 143 | <SelectPrimitive.Separator 144 | ref={ref} 145 | className={cn("-mx-1 my-1 h-px bg-muted", className)} 146 | {...props} 147 | /> 148 | )); 149 | SelectSeparator.displayName = SelectPrimitive.Separator.displayName; 150 | 151 | export { 152 | Select, 153 | SelectGroup, 154 | SelectValue, 155 | SelectTrigger, 156 | SelectContent, 157 | SelectLabel, 158 | SelectItem, 159 | SelectSeparator, 160 | SelectScrollUpButton, 161 | SelectScrollDownButton, 162 | }; 163 | -------------------------------------------------------------------------------- /client/src/components/ui/tabs.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import * as TabsPrimitive from "@radix-ui/react-tabs"; 3 | 4 | import { cn } from "@/lib/utils"; 5 | 6 | const Tabs = TabsPrimitive.Root; 7 | 8 | const TabsList = React.forwardRef< 9 | React.ElementRef<typeof TabsPrimitive.List>, 10 | React.ComponentPropsWithoutRef<typeof TabsPrimitive.List> 11 | >(({ className, ...props }, ref) => ( 12 | <TabsPrimitive.List 13 | ref={ref} 14 | className={cn( 15 | "inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground", 16 | className, 17 | )} 18 | {...props} 19 | /> 20 | )); 21 | TabsList.displayName = TabsPrimitive.List.displayName; 22 | 23 | const TabsTrigger = React.forwardRef< 24 | React.ElementRef<typeof TabsPrimitive.Trigger>, 25 | React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger> 26 | >(({ className, ...props }, ref) => ( 27 | <TabsPrimitive.Trigger 28 | ref={ref} 29 | className={cn( 30 | "inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 bg-muted data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow", 31 | className, 32 | )} 33 | {...props} 34 | /> 35 | )); 36 | TabsTrigger.displayName = TabsPrimitive.Trigger.displayName; 37 | 38 | const TabsContent = React.forwardRef< 39 | React.ElementRef<typeof TabsPrimitive.Content>, 40 | React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content> 41 | >(({ className, ...props }, ref) => ( 42 | <TabsPrimitive.Content 43 | ref={ref} 44 | className={cn( 45 | "mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2", 46 | className, 47 | )} 48 | {...props} 49 | /> 50 | )); 51 | TabsContent.displayName = TabsPrimitive.Content.displayName; 52 | 53 | export { Tabs, TabsList, TabsTrigger, TabsContent }; 54 | -------------------------------------------------------------------------------- /client/src/components/ui/textarea.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | 3 | import { cn } from "@/lib/utils"; 4 | 5 | export type TextareaProps = React.TextareaHTMLAttributes<HTMLTextAreaElement>; 6 | 7 | const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>( 8 | ({ className, ...props }, ref) => { 9 | return ( 10 | <textarea 11 | className={cn( 12 | "flex min-h-[60px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50", 13 | className, 14 | )} 15 | ref={ref} 16 | {...props} 17 | /> 18 | ); 19 | }, 20 | ); 21 | Textarea.displayName = "Textarea"; 22 | 23 | export { Textarea }; 24 | -------------------------------------------------------------------------------- /client/src/components/ui/toast.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import * as ToastPrimitives from "@radix-ui/react-toast"; 3 | import { cva, type VariantProps } from "class-variance-authority"; 4 | import { cn } from "@/lib/utils"; 5 | import { Cross2Icon } from "@radix-ui/react-icons"; 6 | 7 | const ToastProvider = ToastPrimitives.Provider; 8 | 9 | const ToastViewport = React.forwardRef< 10 | React.ElementRef<typeof ToastPrimitives.Viewport>, 11 | React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport> 12 | >(({ className, ...props }, ref) => ( 13 | <ToastPrimitives.Viewport 14 | ref={ref} 15 | className={cn( 16 | "fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]", 17 | className, 18 | )} 19 | {...props} 20 | /> 21 | )); 22 | ToastViewport.displayName = ToastPrimitives.Viewport.displayName; 23 | 24 | const toastVariants = cva( 25 | "group pointer-events-auto relative flex w-full items-center justify-between space-x-2 overflow-hidden rounded-md border p-4 pr-6 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full", 26 | { 27 | variants: { 28 | variant: { 29 | default: "border bg-background text-foreground", 30 | destructive: 31 | "destructive group border-destructive bg-destructive text-destructive-foreground", 32 | }, 33 | }, 34 | defaultVariants: { 35 | variant: "default", 36 | }, 37 | }, 38 | ); 39 | 40 | const Toast = React.forwardRef< 41 | React.ElementRef<typeof ToastPrimitives.Root>, 42 | React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> & 43 | VariantProps<typeof toastVariants> 44 | >(({ className, variant, ...props }, ref) => { 45 | return ( 46 | <ToastPrimitives.Root 47 | ref={ref} 48 | className={cn(toastVariants({ variant }), className)} 49 | {...props} 50 | /> 51 | ); 52 | }); 53 | Toast.displayName = ToastPrimitives.Root.displayName; 54 | 55 | const ToastAction = React.forwardRef< 56 | React.ElementRef<typeof ToastPrimitives.Action>, 57 | React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action> 58 | >(({ className, ...props }, ref) => ( 59 | <ToastPrimitives.Action 60 | ref={ref} 61 | className={cn( 62 | "inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium transition-colors hover:bg-secondary focus:outline-none focus:ring-1 focus:ring-ring disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive", 63 | className, 64 | )} 65 | {...props} 66 | /> 67 | )); 68 | ToastAction.displayName = ToastPrimitives.Action.displayName; 69 | 70 | const ToastClose = React.forwardRef< 71 | React.ElementRef<typeof ToastPrimitives.Close>, 72 | React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close> 73 | >(({ className, ...props }, ref) => ( 74 | <ToastPrimitives.Close 75 | ref={ref} 76 | className={cn( 77 | "absolute right-1 top-1 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-1 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600", 78 | className, 79 | )} 80 | toast-close="" 81 | {...props} 82 | > 83 | <Cross2Icon className="h-4 w-4" /> 84 | </ToastPrimitives.Close> 85 | )); 86 | ToastClose.displayName = ToastPrimitives.Close.displayName; 87 | 88 | const ToastTitle = React.forwardRef< 89 | React.ElementRef<typeof ToastPrimitives.Title>, 90 | React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title> 91 | >(({ className, ...props }, ref) => ( 92 | <ToastPrimitives.Title 93 | ref={ref} 94 | className={cn("text-sm font-semibold [&+div]:text-xs", className)} 95 | {...props} 96 | /> 97 | )); 98 | ToastTitle.displayName = ToastPrimitives.Title.displayName; 99 | 100 | const ToastDescription = React.forwardRef< 101 | React.ElementRef<typeof ToastPrimitives.Description>, 102 | React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description> 103 | >(({ className, ...props }, ref) => ( 104 | <ToastPrimitives.Description 105 | ref={ref} 106 | className={cn("text-sm opacity-90", className)} 107 | {...props} 108 | /> 109 | )); 110 | ToastDescription.displayName = ToastPrimitives.Description.displayName; 111 | 112 | type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>; 113 | 114 | type ToastActionElement = React.ReactElement<typeof ToastAction>; 115 | 116 | export { 117 | type ToastProps, 118 | type ToastActionElement, 119 | ToastProvider, 120 | ToastViewport, 121 | Toast, 122 | ToastTitle, 123 | ToastDescription, 124 | ToastClose, 125 | ToastAction, 126 | }; 127 | -------------------------------------------------------------------------------- /client/src/components/ui/toaster.tsx: -------------------------------------------------------------------------------- 1 | import { useToast } from "@/lib/hooks/useToast"; 2 | import { 3 | Toast, 4 | ToastClose, 5 | ToastDescription, 6 | ToastProvider, 7 | ToastTitle, 8 | ToastViewport, 9 | } from "@/components/ui/toast"; 10 | 11 | export function Toaster() { 12 | const { toasts } = useToast(); 13 | 14 | return ( 15 | <ToastProvider> 16 | {toasts.map(function ({ id, title, description, action, ...props }) { 17 | return ( 18 | <Toast key={id} {...props}> 19 | <div className="grid gap-1"> 20 | {title && <ToastTitle>{title}</ToastTitle>} 21 | {description && ( 22 | <ToastDescription>{description}</ToastDescription> 23 | )} 24 | </div> 25 | {action} 26 | <ToastClose /> 27 | </Toast> 28 | ); 29 | })} 30 | <ToastViewport /> 31 | </ToastProvider> 32 | ); 33 | } 34 | -------------------------------------------------------------------------------- /client/src/components/ui/tooltip.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as React from "react"; 4 | import * as TooltipPrimitive from "@radix-ui/react-tooltip"; 5 | 6 | import { cn } from "@/lib/utils"; 7 | 8 | const TooltipProvider = TooltipPrimitive.Provider; 9 | 10 | const Tooltip = TooltipPrimitive.Root; 11 | 12 | const TooltipTrigger = TooltipPrimitive.Trigger; 13 | 14 | const TooltipContent = React.forwardRef< 15 | React.ElementRef<typeof TooltipPrimitive.Content>, 16 | React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content> 17 | >(({ className, sideOffset = 4, ...props }, ref) => ( 18 | <TooltipPrimitive.Content 19 | ref={ref} 20 | sideOffset={sideOffset} 21 | className={cn( 22 | "z-50 overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-tooltip-content-transform-origin]", 23 | className, 24 | )} 25 | {...props} 26 | /> 27 | )); 28 | TooltipContent.displayName = TooltipPrimitive.Content.displayName; 29 | 30 | export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }; 31 | -------------------------------------------------------------------------------- /client/src/index.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | :root { 6 | font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif; 7 | line-height: 1.5; 8 | font-weight: 400; 9 | 10 | color-scheme: light dark; 11 | color: rgba(255, 255, 255, 0.87); 12 | background-color: #242424; 13 | 14 | font-synthesis: none; 15 | text-rendering: optimizeLegibility; 16 | -webkit-font-smoothing: antialiased; 17 | -moz-osx-font-smoothing: grayscale; 18 | } 19 | 20 | a { 21 | font-weight: 500; 22 | color: #646cff; 23 | text-decoration: inherit; 24 | } 25 | a:hover { 26 | color: #535bf2; 27 | } 28 | 29 | body { 30 | margin: 0; 31 | place-items: center; 32 | min-width: 320px; 33 | min-height: 100vh; 34 | } 35 | 36 | h1 { 37 | font-size: 3.2em; 38 | line-height: 1.1; 39 | } 40 | 41 | @media (prefers-color-scheme: light) { 42 | :root { 43 | color: #213547; 44 | background-color: #ffffff; 45 | } 46 | a:hover { 47 | color: #747bff; 48 | } 49 | } 50 | 51 | @layer base { 52 | :root { 53 | --background: 0 0% 100%; 54 | --foreground: 222.2 84% 4.9%; 55 | --card: 0 0% 100%; 56 | --card-foreground: 222.2 84% 4.9%; 57 | --popover: 0 0% 100%; 58 | --popover-foreground: 222.2 84% 4.9%; 59 | --primary: 222.2 47.4% 11.2%; 60 | --primary-foreground: 210 40% 98%; 61 | --secondary: 210 40% 96.1%; 62 | --secondary-foreground: 222.2 47.4% 11.2%; 63 | --muted: 210 40% 96.1%; 64 | --muted-foreground: 215.4 16.3% 46.9%; 65 | --accent: 210 40% 96.1%; 66 | --accent-foreground: 222.2 47.4% 11.2%; 67 | --destructive: 0 84.2% 60.2%; 68 | --destructive-foreground: 210 40% 98%; 69 | --border: 214.3 31.8% 91.4%; 70 | --input: 214.3 31.8% 91.4%; 71 | --ring: 222.2 84% 4.9%; 72 | --chart-1: 12 76% 61%; 73 | --chart-2: 173 58% 39%; 74 | --chart-3: 197 37% 24%; 75 | --chart-4: 43 74% 66%; 76 | --chart-5: 27 87% 67%; 77 | --radius: 0.5rem; 78 | } 79 | .dark { 80 | --background: 222.2 84% 4.9%; 81 | --foreground: 210 40% 98%; 82 | --card: 222.2 84% 4.9%; 83 | --card-foreground: 210 40% 98%; 84 | --popover: 222.2 84% 4.9%; 85 | --popover-foreground: 210 40% 98%; 86 | --primary: 210 40% 98%; 87 | --primary-foreground: 222.2 47.4% 11.2%; 88 | --secondary: 217.2 32.6% 17.5%; 89 | --secondary-foreground: 210 40% 98%; 90 | --muted: 217.2 32.6% 17.5%; 91 | --muted-foreground: 215 20.2% 65.1%; 92 | --accent: 217.2 32.6% 17.5%; 93 | --accent-foreground: 210 40% 98%; 94 | --destructive: 0 62.8% 30.6%; 95 | --destructive-foreground: 210 40% 98%; 96 | --border: 217.2 24% 24%; 97 | --input: 217.2 24% 24%; 98 | --ring: 212.7 26.8% 83.9%; 99 | --chart-1: 220 70% 50%; 100 | --chart-2: 160 60% 45%; 101 | --chart-3: 30 80% 55%; 102 | --chart-4: 280 65% 60%; 103 | --chart-5: 340 75% 55%; 104 | } 105 | } 106 | 107 | @layer base { 108 | * { 109 | @apply border-border; 110 | } 111 | body { 112 | @apply bg-background text-foreground; 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /client/src/lib/auth-types.ts: -------------------------------------------------------------------------------- 1 | import { 2 | OAuthMetadata, 3 | OAuthClientInformationFull, 4 | OAuthClientInformation, 5 | OAuthTokens, 6 | OAuthProtectedResourceMetadata, 7 | } from "@modelcontextprotocol/sdk/shared/auth.js"; 8 | 9 | // OAuth flow steps 10 | export type OAuthStep = 11 | | "metadata_discovery" 12 | | "client_registration" 13 | | "authorization_redirect" 14 | | "authorization_code" 15 | | "token_request" 16 | | "complete"; 17 | 18 | // Message types for inline feedback 19 | export type MessageType = "success" | "error" | "info"; 20 | 21 | export interface StatusMessage { 22 | type: MessageType; 23 | message: string; 24 | } 25 | 26 | // Single state interface for OAuth state 27 | export interface AuthDebuggerState { 28 | isInitiatingAuth: boolean; 29 | oauthTokens: OAuthTokens | null; 30 | oauthStep: OAuthStep; 31 | resourceMetadata: OAuthProtectedResourceMetadata | null; 32 | resourceMetadataError: Error | null; 33 | resource: URL | null; 34 | authServerUrl: URL | null; 35 | oauthMetadata: OAuthMetadata | null; 36 | oauthClientInfo: OAuthClientInformationFull | OAuthClientInformation | null; 37 | authorizationUrl: string | null; 38 | authorizationCode: string; 39 | latestError: Error | null; 40 | statusMessage: StatusMessage | null; 41 | validationError: string | null; 42 | } 43 | 44 | export const EMPTY_DEBUGGER_STATE: AuthDebuggerState = { 45 | isInitiatingAuth: false, 46 | oauthTokens: null, 47 | oauthStep: "metadata_discovery", 48 | oauthMetadata: null, 49 | resourceMetadata: null, 50 | resourceMetadataError: null, 51 | resource: null, 52 | authServerUrl: null, 53 | oauthClientInfo: null, 54 | authorizationUrl: null, 55 | authorizationCode: "", 56 | latestError: null, 57 | statusMessage: null, 58 | validationError: null, 59 | }; 60 | -------------------------------------------------------------------------------- /client/src/lib/auth.ts: -------------------------------------------------------------------------------- 1 | import { OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js"; 2 | import { 3 | OAuthClientInformationSchema, 4 | OAuthClientInformation, 5 | OAuthTokens, 6 | OAuthTokensSchema, 7 | OAuthClientMetadata, 8 | OAuthMetadata, 9 | } from "@modelcontextprotocol/sdk/shared/auth.js"; 10 | import { SESSION_KEYS, getServerSpecificKey } from "./constants"; 11 | 12 | export class InspectorOAuthClientProvider implements OAuthClientProvider { 13 | constructor(public serverUrl: string) { 14 | // Save the server URL to session storage 15 | sessionStorage.setItem(SESSION_KEYS.SERVER_URL, serverUrl); 16 | } 17 | 18 | get redirectUrl() { 19 | return window.location.origin + "/oauth/callback"; 20 | } 21 | 22 | get clientMetadata(): OAuthClientMetadata { 23 | return { 24 | redirect_uris: [this.redirectUrl], 25 | token_endpoint_auth_method: "none", 26 | grant_types: ["authorization_code", "refresh_token"], 27 | response_types: ["code"], 28 | client_name: "MCP Inspector", 29 | client_uri: "https://github.com/modelcontextprotocol/inspector", 30 | }; 31 | } 32 | 33 | async clientInformation() { 34 | const key = getServerSpecificKey( 35 | SESSION_KEYS.CLIENT_INFORMATION, 36 | this.serverUrl, 37 | ); 38 | const value = sessionStorage.getItem(key); 39 | if (!value) { 40 | return undefined; 41 | } 42 | 43 | return await OAuthClientInformationSchema.parseAsync(JSON.parse(value)); 44 | } 45 | 46 | saveClientInformation(clientInformation: OAuthClientInformation) { 47 | const key = getServerSpecificKey( 48 | SESSION_KEYS.CLIENT_INFORMATION, 49 | this.serverUrl, 50 | ); 51 | sessionStorage.setItem(key, JSON.stringify(clientInformation)); 52 | } 53 | 54 | async tokens() { 55 | const key = getServerSpecificKey(SESSION_KEYS.TOKENS, this.serverUrl); 56 | const tokens = sessionStorage.getItem(key); 57 | if (!tokens) { 58 | return undefined; 59 | } 60 | 61 | return await OAuthTokensSchema.parseAsync(JSON.parse(tokens)); 62 | } 63 | 64 | saveTokens(tokens: OAuthTokens) { 65 | const key = getServerSpecificKey(SESSION_KEYS.TOKENS, this.serverUrl); 66 | sessionStorage.setItem(key, JSON.stringify(tokens)); 67 | } 68 | 69 | redirectToAuthorization(authorizationUrl: URL) { 70 | window.location.href = authorizationUrl.href; 71 | } 72 | 73 | saveCodeVerifier(codeVerifier: string) { 74 | const key = getServerSpecificKey( 75 | SESSION_KEYS.CODE_VERIFIER, 76 | this.serverUrl, 77 | ); 78 | sessionStorage.setItem(key, codeVerifier); 79 | } 80 | 81 | codeVerifier() { 82 | const key = getServerSpecificKey( 83 | SESSION_KEYS.CODE_VERIFIER, 84 | this.serverUrl, 85 | ); 86 | const verifier = sessionStorage.getItem(key); 87 | if (!verifier) { 88 | throw new Error("No code verifier saved for session"); 89 | } 90 | 91 | return verifier; 92 | } 93 | 94 | clear() { 95 | sessionStorage.removeItem( 96 | getServerSpecificKey(SESSION_KEYS.CLIENT_INFORMATION, this.serverUrl), 97 | ); 98 | sessionStorage.removeItem( 99 | getServerSpecificKey(SESSION_KEYS.TOKENS, this.serverUrl), 100 | ); 101 | sessionStorage.removeItem( 102 | getServerSpecificKey(SESSION_KEYS.CODE_VERIFIER, this.serverUrl), 103 | ); 104 | } 105 | } 106 | 107 | // Overrides debug URL and allows saving server OAuth metadata to 108 | // display in debug UI. 109 | export class DebugInspectorOAuthClientProvider extends InspectorOAuthClientProvider { 110 | get redirectUrl(): string { 111 | return `${window.location.origin}/oauth/callback/debug`; 112 | } 113 | 114 | saveServerMetadata(metadata: OAuthMetadata) { 115 | const key = getServerSpecificKey( 116 | SESSION_KEYS.SERVER_METADATA, 117 | this.serverUrl, 118 | ); 119 | sessionStorage.setItem(key, JSON.stringify(metadata)); 120 | } 121 | 122 | getServerMetadata(): OAuthMetadata | null { 123 | const key = getServerSpecificKey( 124 | SESSION_KEYS.SERVER_METADATA, 125 | this.serverUrl, 126 | ); 127 | const metadata = sessionStorage.getItem(key); 128 | if (!metadata) { 129 | return null; 130 | } 131 | return JSON.parse(metadata); 132 | } 133 | 134 | clear() { 135 | super.clear(); 136 | sessionStorage.removeItem( 137 | getServerSpecificKey(SESSION_KEYS.SERVER_METADATA, this.serverUrl), 138 | ); 139 | } 140 | } 141 | -------------------------------------------------------------------------------- /client/src/lib/configurationTypes.ts: -------------------------------------------------------------------------------- 1 | export type ConfigItem = { 2 | label: string; 3 | description: string; 4 | value: string | number | boolean; 5 | is_session_item: boolean; 6 | }; 7 | 8 | /** 9 | * Configuration interface for the MCP Inspector, including settings for the MCP Client, 10 | * Proxy Server, and Inspector UI/UX. 11 | * 12 | * Note: Configuration related to which MCP Server to use or any other MCP Server 13 | * specific settings are outside the scope of this interface as of now. 14 | */ 15 | export type InspectorConfig = { 16 | /** 17 | * Maximum time in milliseconds to wait for a response from the MCP server before timing out. 18 | */ 19 | MCP_SERVER_REQUEST_TIMEOUT: ConfigItem; 20 | 21 | /** 22 | * Whether to reset the timeout on progress notifications. Useful for long-running operations that send periodic progress updates. 23 | * Refer: https://spec.modelcontextprotocol.io/specification/2025-03-26/basic/utilities/progress/#progress-flow 24 | */ 25 | MCP_REQUEST_TIMEOUT_RESET_ON_PROGRESS: ConfigItem; 26 | 27 | /** 28 | * Maximum total time in milliseconds to wait for a response from the MCP server before timing out. Used in conjunction with MCP_SERVER_REQUEST_TIMEOUT_RESET_ON_PROGRESS. 29 | * Refer: https://spec.modelcontextprotocol.io/specification/2025-03-26/basic/utilities/progress/#progress-flow 30 | */ 31 | MCP_REQUEST_MAX_TOTAL_TIMEOUT: ConfigItem; 32 | 33 | /** 34 | * The full address of the MCP Proxy Server, in case it is running on a non-default address. Example: http://10.1.1.22:5577 35 | */ 36 | MCP_PROXY_FULL_ADDRESS: ConfigItem; 37 | 38 | /** 39 | * Session token for authenticating with the MCP Proxy Server. This token is displayed in the proxy server console on startup. 40 | */ 41 | MCP_PROXY_AUTH_TOKEN: ConfigItem; 42 | }; 43 | -------------------------------------------------------------------------------- /client/src/lib/constants.ts: -------------------------------------------------------------------------------- 1 | import { InspectorConfig } from "./configurationTypes"; 2 | 3 | // OAuth-related session storage keys 4 | export const SESSION_KEYS = { 5 | CODE_VERIFIER: "mcp_code_verifier", 6 | SERVER_URL: "mcp_server_url", 7 | TOKENS: "mcp_tokens", 8 | CLIENT_INFORMATION: "mcp_client_information", 9 | SERVER_METADATA: "mcp_server_metadata", 10 | AUTH_DEBUGGER_STATE: "mcp_auth_debugger_state", 11 | } as const; 12 | 13 | // Generate server-specific session storage keys 14 | export const getServerSpecificKey = ( 15 | baseKey: string, 16 | serverUrl?: string, 17 | ): string => { 18 | if (!serverUrl) return baseKey; 19 | return `[${serverUrl}] ${baseKey}`; 20 | }; 21 | 22 | export type ConnectionStatus = 23 | | "disconnected" 24 | | "connected" 25 | | "error" 26 | | "error-connecting-to-proxy"; 27 | 28 | export const DEFAULT_MCP_PROXY_LISTEN_PORT = "6277"; 29 | 30 | /** 31 | * Default configuration for the MCP Inspector, Currently persisted in local_storage in the Browser. 32 | * Future plans: Provide json config file + Browser local_storage to override default values 33 | **/ 34 | export const DEFAULT_INSPECTOR_CONFIG: InspectorConfig = { 35 | MCP_SERVER_REQUEST_TIMEOUT: { 36 | label: "Request Timeout", 37 | description: "Timeout for requests to the MCP server (ms)", 38 | value: 10000, 39 | is_session_item: false, 40 | }, 41 | MCP_REQUEST_TIMEOUT_RESET_ON_PROGRESS: { 42 | label: "Reset Timeout on Progress", 43 | description: "Reset timeout on progress notifications", 44 | value: true, 45 | is_session_item: false, 46 | }, 47 | MCP_REQUEST_MAX_TOTAL_TIMEOUT: { 48 | label: "Maximum Total Timeout", 49 | description: 50 | "Maximum total timeout for requests sent to the MCP server (ms) (Use with progress notifications)", 51 | value: 60000, 52 | is_session_item: false, 53 | }, 54 | MCP_PROXY_FULL_ADDRESS: { 55 | label: "Inspector Proxy Address", 56 | description: 57 | "Set this if you are running the MCP Inspector Proxy on a non-default address. Example: http://10.1.1.22:5577", 58 | value: "", 59 | is_session_item: false, 60 | }, 61 | MCP_PROXY_AUTH_TOKEN: { 62 | label: "Proxy Session Token", 63 | description: 64 | "Session token for authenticating with the MCP Proxy Server (displayed in proxy console on startup)", 65 | value: "", 66 | is_session_item: true, 67 | }, 68 | } as const; 69 | -------------------------------------------------------------------------------- /client/src/lib/hooks/useCompletionState.ts: -------------------------------------------------------------------------------- 1 | import { useState, useCallback, useEffect, useRef, useMemo } from "react"; 2 | import { 3 | ResourceReference, 4 | PromptReference, 5 | } from "@modelcontextprotocol/sdk/types.js"; 6 | 7 | interface CompletionState { 8 | completions: Record<string, string[]>; 9 | loading: Record<string, boolean>; 10 | } 11 | 12 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 13 | function debounce<T extends (...args: any[]) => PromiseLike<void>>( 14 | func: T, 15 | wait: number, 16 | ): (...args: Parameters<T>) => void { 17 | let timeout: ReturnType<typeof setTimeout>; 18 | return (...args: Parameters<T>) => { 19 | clearTimeout(timeout); 20 | timeout = setTimeout(() => { 21 | void func(...args); 22 | }, wait); 23 | }; 24 | } 25 | 26 | export function useCompletionState( 27 | handleCompletion: ( 28 | ref: ResourceReference | PromptReference, 29 | argName: string, 30 | value: string, 31 | signal?: AbortSignal, 32 | ) => Promise<string[]>, 33 | completionsSupported: boolean = true, 34 | debounceMs: number = 300, 35 | ) { 36 | const [state, setState] = useState<CompletionState>({ 37 | completions: {}, 38 | loading: {}, 39 | }); 40 | 41 | const abortControllerRef = useRef<AbortController | null>(null); 42 | 43 | const cleanup = useCallback(() => { 44 | if (abortControllerRef.current) { 45 | abortControllerRef.current.abort(); 46 | abortControllerRef.current = null; 47 | } 48 | }, []); 49 | 50 | // Cleanup on unmount 51 | useEffect(() => { 52 | return cleanup; 53 | }, [cleanup]); 54 | 55 | const clearCompletions = useCallback(() => { 56 | cleanup(); 57 | setState({ 58 | completions: {}, 59 | loading: {}, 60 | }); 61 | }, [cleanup]); 62 | 63 | const requestCompletions = useMemo(() => { 64 | return debounce( 65 | async ( 66 | ref: ResourceReference | PromptReference, 67 | argName: string, 68 | value: string, 69 | ) => { 70 | if (!completionsSupported) { 71 | return; 72 | } 73 | 74 | cleanup(); 75 | 76 | const abortController = new AbortController(); 77 | abortControllerRef.current = abortController; 78 | 79 | setState((prev) => ({ 80 | ...prev, 81 | loading: { ...prev.loading, [argName]: true }, 82 | })); 83 | 84 | try { 85 | const values = await handleCompletion( 86 | ref, 87 | argName, 88 | value, 89 | abortController.signal, 90 | ); 91 | 92 | if (!abortController.signal.aborted) { 93 | setState((prev) => ({ 94 | ...prev, 95 | completions: { ...prev.completions, [argName]: values }, 96 | loading: { ...prev.loading, [argName]: false }, 97 | })); 98 | } 99 | } catch { 100 | if (!abortController.signal.aborted) { 101 | setState((prev) => ({ 102 | ...prev, 103 | loading: { ...prev.loading, [argName]: false }, 104 | })); 105 | } 106 | } finally { 107 | if (abortControllerRef.current === abortController) { 108 | abortControllerRef.current = null; 109 | } 110 | } 111 | }, 112 | debounceMs, 113 | ); 114 | }, [handleCompletion, completionsSupported, cleanup, debounceMs]); 115 | 116 | // Clear completions when support status changes 117 | useEffect(() => { 118 | if (!completionsSupported) { 119 | clearCompletions(); 120 | } 121 | }, [completionsSupported, clearCompletions]); 122 | 123 | return { 124 | ...state, 125 | clearCompletions, 126 | requestCompletions, 127 | completionsSupported, 128 | }; 129 | } 130 | -------------------------------------------------------------------------------- /client/src/lib/hooks/useDraggablePane.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useEffect, useRef, useState } from "react"; 2 | 3 | export function useDraggablePane(initialHeight: number) { 4 | const [height, setHeight] = useState(initialHeight); 5 | const [isDragging, setIsDragging] = useState(false); 6 | const dragStartY = useRef<number>(0); 7 | const dragStartHeight = useRef<number>(0); 8 | 9 | const handleDragStart = useCallback( 10 | (e: React.MouseEvent) => { 11 | setIsDragging(true); 12 | dragStartY.current = e.clientY; 13 | dragStartHeight.current = height; 14 | document.body.style.userSelect = "none"; 15 | }, 16 | [height], 17 | ); 18 | 19 | const handleDragMove = useCallback( 20 | (e: MouseEvent) => { 21 | if (!isDragging) return; 22 | const deltaY = dragStartY.current - e.clientY; 23 | const newHeight = Math.max( 24 | 100, 25 | Math.min(800, dragStartHeight.current + deltaY), 26 | ); 27 | setHeight(newHeight); 28 | }, 29 | [isDragging], 30 | ); 31 | 32 | const handleDragEnd = useCallback(() => { 33 | setIsDragging(false); 34 | document.body.style.userSelect = ""; 35 | }, []); 36 | 37 | useEffect(() => { 38 | if (isDragging) { 39 | window.addEventListener("mousemove", handleDragMove); 40 | window.addEventListener("mouseup", handleDragEnd); 41 | return () => { 42 | window.removeEventListener("mousemove", handleDragMove); 43 | window.removeEventListener("mouseup", handleDragEnd); 44 | }; 45 | } 46 | }, [isDragging, handleDragMove, handleDragEnd]); 47 | 48 | return { 49 | height, 50 | isDragging, 51 | handleDragStart, 52 | }; 53 | } 54 | 55 | export function useDraggableSidebar(initialWidth: number) { 56 | const [width, setWidth] = useState(initialWidth); 57 | const [isDragging, setIsDragging] = useState(false); 58 | const dragStartX = useRef<number>(0); 59 | const dragStartWidth = useRef<number>(0); 60 | 61 | const handleDragStart = useCallback( 62 | (e: React.MouseEvent) => { 63 | setIsDragging(true); 64 | dragStartX.current = e.clientX; 65 | dragStartWidth.current = width; 66 | document.body.style.userSelect = "none"; 67 | }, 68 | [width], 69 | ); 70 | 71 | const handleDragMove = useCallback( 72 | (e: MouseEvent) => { 73 | if (!isDragging) return; 74 | const deltaX = e.clientX - dragStartX.current; 75 | const newWidth = Math.max( 76 | 200, 77 | Math.min(600, dragStartWidth.current + deltaX), 78 | ); 79 | setWidth(newWidth); 80 | }, 81 | [isDragging], 82 | ); 83 | 84 | const handleDragEnd = useCallback(() => { 85 | setIsDragging(false); 86 | document.body.style.userSelect = ""; 87 | }, []); 88 | 89 | useEffect(() => { 90 | if (isDragging) { 91 | window.addEventListener("mousemove", handleDragMove); 92 | window.addEventListener("mouseup", handleDragEnd); 93 | return () => { 94 | window.removeEventListener("mousemove", handleDragMove); 95 | window.removeEventListener("mouseup", handleDragEnd); 96 | }; 97 | } 98 | }, [isDragging, handleDragMove, handleDragEnd]); 99 | 100 | return { 101 | width, 102 | isDragging, 103 | handleDragStart, 104 | }; 105 | } 106 | -------------------------------------------------------------------------------- /client/src/lib/hooks/useTheme.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useEffect, useMemo, useState } from "react"; 2 | 3 | type Theme = "light" | "dark" | "system"; 4 | 5 | const useTheme = (): [Theme, (mode: Theme) => void] => { 6 | const [theme, setTheme] = useState<Theme>(() => { 7 | const savedTheme = localStorage.getItem("theme") as Theme; 8 | return savedTheme || "system"; 9 | }); 10 | 11 | useEffect(() => { 12 | const darkModeMediaQuery = window.matchMedia( 13 | "(prefers-color-scheme: dark)", 14 | ); 15 | const handleDarkModeChange = (e: MediaQueryListEvent) => { 16 | if (theme === "system") { 17 | updateDocumentTheme(e.matches ? "dark" : "light"); 18 | } 19 | }; 20 | 21 | const updateDocumentTheme = (newTheme: "light" | "dark") => { 22 | document.documentElement.classList.toggle("dark", newTheme === "dark"); 23 | }; 24 | 25 | // Set initial theme based on current mode 26 | if (theme === "system") { 27 | updateDocumentTheme(darkModeMediaQuery.matches ? "dark" : "light"); 28 | } else { 29 | updateDocumentTheme(theme); 30 | } 31 | 32 | darkModeMediaQuery.addEventListener("change", handleDarkModeChange); 33 | 34 | return () => { 35 | darkModeMediaQuery.removeEventListener("change", handleDarkModeChange); 36 | }; 37 | }, [theme]); 38 | 39 | const setThemeWithSideEffect = useCallback((newTheme: Theme) => { 40 | setTheme(newTheme); 41 | localStorage.setItem("theme", newTheme); 42 | if (newTheme !== "system") { 43 | document.documentElement.classList.toggle("dark", newTheme === "dark"); 44 | } 45 | }, []); 46 | return useMemo( 47 | () => [theme, setThemeWithSideEffect], 48 | [theme, setThemeWithSideEffect], 49 | ); 50 | }; 51 | 52 | export default useTheme; 53 | -------------------------------------------------------------------------------- /client/src/lib/hooks/useToast.ts: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | // Inspired by react-hot-toast library 4 | import * as React from "react"; 5 | 6 | import type { ToastActionElement, ToastProps } from "@/components/ui/toast"; 7 | 8 | const TOAST_LIMIT = 1; 9 | const TOAST_REMOVE_DELAY = 1000000; 10 | 11 | type ToasterToast = ToastProps & { 12 | id: string; 13 | title?: React.ReactNode; 14 | description?: React.ReactNode; 15 | action?: ToastActionElement; 16 | }; 17 | 18 | let count = 0; 19 | 20 | function genId() { 21 | count = (count + 1) % Number.MAX_SAFE_INTEGER; 22 | return count.toString(); 23 | } 24 | 25 | const enum ActionType { 26 | ADD_TOAST = "ADD_TOAST", 27 | UPDATE_TOAST = "UPDATE_TOAST", 28 | DISMISS_TOAST = "DISMISS_TOAST", 29 | REMOVE_TOAST = "REMOVE_TOAST", 30 | } 31 | 32 | type Action = 33 | | { 34 | type: ActionType.ADD_TOAST; 35 | toast: ToasterToast; 36 | } 37 | | { 38 | type: ActionType.UPDATE_TOAST; 39 | toast: Partial<ToasterToast>; 40 | } 41 | | { 42 | type: ActionType.DISMISS_TOAST; 43 | toastId?: ToasterToast["id"]; 44 | } 45 | | { 46 | type: ActionType.REMOVE_TOAST; 47 | toastId?: ToasterToast["id"]; 48 | }; 49 | 50 | interface State { 51 | toasts: ToasterToast[]; 52 | } 53 | 54 | const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>(); 55 | 56 | const addToRemoveQueue = (toastId: string) => { 57 | if (toastTimeouts.has(toastId)) { 58 | return; 59 | } 60 | 61 | const timeout = setTimeout(() => { 62 | toastTimeouts.delete(toastId); 63 | dispatch({ 64 | type: ActionType.REMOVE_TOAST, 65 | toastId: toastId, 66 | }); 67 | }, TOAST_REMOVE_DELAY); 68 | 69 | toastTimeouts.set(toastId, timeout); 70 | }; 71 | 72 | export const reducer = (state: State, action: Action): State => { 73 | switch (action.type) { 74 | case ActionType.ADD_TOAST: 75 | return { 76 | ...state, 77 | toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT), 78 | }; 79 | 80 | case ActionType.UPDATE_TOAST: 81 | return { 82 | ...state, 83 | toasts: state.toasts.map((t) => 84 | t.id === action.toast.id ? { ...t, ...action.toast } : t, 85 | ), 86 | }; 87 | 88 | case ActionType.DISMISS_TOAST: { 89 | const { toastId } = action; 90 | 91 | // ! Side effects ! - This could be extracted into a dismissToast() action, 92 | // but I'll keep it here for simplicity 93 | if (toastId) { 94 | addToRemoveQueue(toastId); 95 | } else { 96 | state.toasts.forEach((toast) => { 97 | addToRemoveQueue(toast.id); 98 | }); 99 | } 100 | 101 | return { 102 | ...state, 103 | toasts: state.toasts.map((t) => 104 | t.id === toastId || toastId === undefined 105 | ? { 106 | ...t, 107 | open: false, 108 | } 109 | : t, 110 | ), 111 | }; 112 | } 113 | case ActionType.REMOVE_TOAST: 114 | if (action.toastId === undefined) { 115 | return { 116 | ...state, 117 | toasts: [], 118 | }; 119 | } 120 | return { 121 | ...state, 122 | toasts: state.toasts.filter((t) => t.id !== action.toastId), 123 | }; 124 | } 125 | }; 126 | 127 | const listeners: Array<(state: State) => void> = []; 128 | 129 | let memoryState: State = { toasts: [] }; 130 | 131 | function dispatch(action: Action) { 132 | memoryState = reducer(memoryState, action); 133 | listeners.forEach((listener) => { 134 | listener(memoryState); 135 | }); 136 | } 137 | 138 | type Toast = Omit<ToasterToast, "id">; 139 | 140 | function toast({ ...props }: Toast) { 141 | const id = genId(); 142 | 143 | const update = (props: ToasterToast) => 144 | dispatch({ 145 | type: ActionType.UPDATE_TOAST, 146 | toast: { ...props, id }, 147 | }); 148 | const dismiss = () => 149 | dispatch({ type: ActionType.DISMISS_TOAST, toastId: id }); 150 | 151 | dispatch({ 152 | type: ActionType.ADD_TOAST, 153 | toast: { 154 | ...props, 155 | id, 156 | open: true, 157 | onOpenChange: (open) => { 158 | if (!open) dismiss(); 159 | }, 160 | }, 161 | }); 162 | 163 | return { 164 | id: id, 165 | dismiss, 166 | update, 167 | }; 168 | } 169 | 170 | function useToast() { 171 | const [state, setState] = React.useState<State>(memoryState); 172 | 173 | React.useEffect(() => { 174 | listeners.push(setState); 175 | return () => { 176 | const index = listeners.indexOf(setState); 177 | if (index > -1) { 178 | listeners.splice(index, 1); 179 | } 180 | }; 181 | }, [state]); 182 | 183 | return { 184 | ...state, 185 | toast, 186 | dismiss: (toastId?: string) => 187 | dispatch({ type: ActionType.DISMISS_TOAST, toastId }), 188 | }; 189 | } 190 | 191 | export { useToast, toast }; 192 | -------------------------------------------------------------------------------- /client/src/lib/notificationTypes.ts: -------------------------------------------------------------------------------- 1 | import { 2 | NotificationSchema as BaseNotificationSchema, 3 | ClientNotificationSchema, 4 | ServerNotificationSchema, 5 | } from "@modelcontextprotocol/sdk/types.js"; 6 | import { z } from "zod"; 7 | 8 | export const StdErrNotificationSchema = BaseNotificationSchema.extend({ 9 | method: z.literal("notifications/stderr"), 10 | params: z.object({ 11 | content: z.string(), 12 | }), 13 | }); 14 | 15 | export const NotificationSchema = ClientNotificationSchema.or( 16 | StdErrNotificationSchema, 17 | ) 18 | .or(ServerNotificationSchema) 19 | .or(BaseNotificationSchema); 20 | 21 | export type StdErrNotification = z.infer<typeof StdErrNotificationSchema>; 22 | export type Notification = z.infer<typeof NotificationSchema>; 23 | -------------------------------------------------------------------------------- /client/src/lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { clsx, type ClassValue } from "clsx"; 2 | import { twMerge } from "tailwind-merge"; 3 | 4 | export function cn(...inputs: ClassValue[]) { 5 | return twMerge(clsx(inputs)); 6 | } 7 | -------------------------------------------------------------------------------- /client/src/main.tsx: -------------------------------------------------------------------------------- 1 | import { StrictMode } from "react"; 2 | import { createRoot } from "react-dom/client"; 3 | import { Toaster } from "@/components/ui/toaster.tsx"; 4 | import App from "./App.tsx"; 5 | import "./index.css"; 6 | import { TooltipProvider } from "./components/ui/tooltip.tsx"; 7 | 8 | createRoot(document.getElementById("root")!).render( 9 | <StrictMode> 10 | <TooltipProvider> 11 | <App /> 12 | </TooltipProvider> 13 | <Toaster /> 14 | </StrictMode>, 15 | ); 16 | -------------------------------------------------------------------------------- /client/src/utils/__tests__/configUtils.test.ts: -------------------------------------------------------------------------------- 1 | import { getMCPProxyAuthToken } from "../configUtils"; 2 | import { DEFAULT_INSPECTOR_CONFIG } from "../../lib/constants"; 3 | import { InspectorConfig } from "../../lib/configurationTypes"; 4 | 5 | describe("configUtils", () => { 6 | describe("getMCPProxyAuthToken", () => { 7 | test("returns token and default header name", () => { 8 | const config: InspectorConfig = { 9 | ...DEFAULT_INSPECTOR_CONFIG, 10 | MCP_PROXY_AUTH_TOKEN: { 11 | ...DEFAULT_INSPECTOR_CONFIG.MCP_PROXY_AUTH_TOKEN, 12 | value: "test-token-123", 13 | }, 14 | }; 15 | 16 | const result = getMCPProxyAuthToken(config); 17 | 18 | expect(result).toEqual({ 19 | token: "test-token-123", 20 | header: "X-MCP-Proxy-Auth", 21 | }); 22 | }); 23 | 24 | test("returns empty token when not configured", () => { 25 | const config: InspectorConfig = { 26 | ...DEFAULT_INSPECTOR_CONFIG, 27 | MCP_PROXY_AUTH_TOKEN: { 28 | ...DEFAULT_INSPECTOR_CONFIG.MCP_PROXY_AUTH_TOKEN, 29 | value: "", 30 | }, 31 | }; 32 | 33 | const result = getMCPProxyAuthToken(config); 34 | 35 | expect(result).toEqual({ 36 | token: "", 37 | header: "X-MCP-Proxy-Auth", 38 | }); 39 | }); 40 | 41 | test("always returns X-MCP-Proxy-Auth as header name", () => { 42 | const config: InspectorConfig = { 43 | ...DEFAULT_INSPECTOR_CONFIG, 44 | MCP_PROXY_AUTH_TOKEN: { 45 | ...DEFAULT_INSPECTOR_CONFIG.MCP_PROXY_AUTH_TOKEN, 46 | value: "any-token", 47 | }, 48 | }; 49 | 50 | const result = getMCPProxyAuthToken(config); 51 | 52 | expect(result.header).toBe("X-MCP-Proxy-Auth"); 53 | }); 54 | 55 | test("handles null/undefined value gracefully", () => { 56 | const config: InspectorConfig = { 57 | ...DEFAULT_INSPECTOR_CONFIG, 58 | MCP_PROXY_AUTH_TOKEN: { 59 | ...DEFAULT_INSPECTOR_CONFIG.MCP_PROXY_AUTH_TOKEN, 60 | value: null as unknown as string, 61 | }, 62 | }; 63 | 64 | const result = getMCPProxyAuthToken(config); 65 | 66 | expect(result).toEqual({ 67 | token: null, 68 | header: "X-MCP-Proxy-Auth", 69 | }); 70 | }); 71 | }); 72 | }); 73 | -------------------------------------------------------------------------------- /client/src/utils/__tests__/escapeUnicode.test.ts: -------------------------------------------------------------------------------- 1 | import { escapeUnicode } from "../escapeUnicode"; 2 | 3 | describe("escapeUnicode", () => { 4 | it("should escape Unicode characters in a string", () => { 5 | const input = { text: "你好世界" }; 6 | const expected = '{\n "text": "\\\\u4f60\\\\u597d\\\\u4e16\\\\u754c"\n}'; 7 | expect(escapeUnicode(input)).toBe(expected); 8 | }); 9 | 10 | it("should handle empty strings", () => { 11 | const input = { text: "" }; 12 | const expected = '{\n "text": ""\n}'; 13 | expect(escapeUnicode(input)).toBe(expected); 14 | }); 15 | 16 | it("should handle null and undefined values", () => { 17 | const input = { text: null, value: undefined }; 18 | const expected = '{\n "text": null\n}'; 19 | expect(escapeUnicode(input)).toBe(expected); 20 | }); 21 | 22 | it("should handle numbers and booleans", () => { 23 | const input = { number: 123, boolean: true }; 24 | const expected = '{\n "number": 123,\n "boolean": true\n}'; 25 | expect(escapeUnicode(input)).toBe(expected); 26 | }); 27 | }); 28 | -------------------------------------------------------------------------------- /client/src/utils/__tests__/oauthUtils.ts: -------------------------------------------------------------------------------- 1 | import { 2 | generateOAuthErrorDescription, 3 | parseOAuthCallbackParams, 4 | } from "@/utils/oauthUtils.ts"; 5 | 6 | describe("parseOAuthCallbackParams", () => { 7 | it("Returns successful: true and code when present", () => { 8 | expect(parseOAuthCallbackParams("?code=fake-code")).toEqual({ 9 | successful: true, 10 | code: "fake-code", 11 | }); 12 | }); 13 | it("Returns successful: false and error when error is present", () => { 14 | expect(parseOAuthCallbackParams("?error=access_denied")).toEqual({ 15 | successful: false, 16 | error: "access_denied", 17 | error_description: null, 18 | error_uri: null, 19 | }); 20 | }); 21 | it("Returns optional error metadata fields when present", () => { 22 | const search = 23 | "?error=access_denied&" + 24 | "error_description=User%20Denied%20Request&" + 25 | "error_uri=https%3A%2F%2Fexample.com%2Ferror-docs"; 26 | expect(parseOAuthCallbackParams(search)).toEqual({ 27 | successful: false, 28 | error: "access_denied", 29 | error_description: "User Denied Request", 30 | error_uri: "https://example.com/error-docs", 31 | }); 32 | }); 33 | it("Returns error when nothing present", () => { 34 | expect(parseOAuthCallbackParams("?")).toEqual({ 35 | successful: false, 36 | error: "invalid_request", 37 | error_description: "Missing code or error in response", 38 | error_uri: null, 39 | }); 40 | }); 41 | }); 42 | 43 | describe("generateOAuthErrorDescription", () => { 44 | it("When only error is present", () => { 45 | expect( 46 | generateOAuthErrorDescription({ 47 | successful: false, 48 | error: "invalid_request", 49 | error_description: null, 50 | error_uri: null, 51 | }), 52 | ).toBe("Error: invalid_request."); 53 | }); 54 | it("When error description is present", () => { 55 | expect( 56 | generateOAuthErrorDescription({ 57 | successful: false, 58 | error: "invalid_request", 59 | error_description: "The request could not be completed as dialed", 60 | error_uri: null, 61 | }), 62 | ).toEqual( 63 | "Error: invalid_request.\nDetails: The request could not be completed as dialed.", 64 | ); 65 | }); 66 | it("When all fields present", () => { 67 | expect( 68 | generateOAuthErrorDescription({ 69 | successful: false, 70 | error: "invalid_request", 71 | error_description: "The request could not be completed as dialed", 72 | error_uri: "https://example.com/error-docs", 73 | }), 74 | ).toEqual( 75 | "Error: invalid_request.\nDetails: The request could not be completed as dialed.\nMore info: https://example.com/error-docs.", 76 | ); 77 | }); 78 | }); 79 | -------------------------------------------------------------------------------- /client/src/utils/configUtils.ts: -------------------------------------------------------------------------------- 1 | import { InspectorConfig } from "@/lib/configurationTypes"; 2 | import { 3 | DEFAULT_MCP_PROXY_LISTEN_PORT, 4 | DEFAULT_INSPECTOR_CONFIG, 5 | } from "@/lib/constants"; 6 | 7 | const getSearchParam = (key: string): string | null => { 8 | try { 9 | const url = new URL(window.location.href); 10 | return url.searchParams.get(key); 11 | } catch { 12 | return null; 13 | } 14 | }; 15 | 16 | export const getMCPProxyAddress = (config: InspectorConfig): string => { 17 | let proxyFullAddress = config.MCP_PROXY_FULL_ADDRESS.value as string; 18 | if (proxyFullAddress) { 19 | proxyFullAddress = proxyFullAddress.replace(/\/+$/, ""); 20 | return proxyFullAddress; 21 | } 22 | 23 | // Check for proxy port from query params, fallback to default 24 | const proxyPort = 25 | getSearchParam("MCP_PROXY_PORT") || DEFAULT_MCP_PROXY_LISTEN_PORT; 26 | 27 | return `${window.location.protocol}//${window.location.hostname}:${proxyPort}`; 28 | }; 29 | 30 | export const getMCPServerRequestTimeout = (config: InspectorConfig): number => { 31 | return config.MCP_SERVER_REQUEST_TIMEOUT.value as number; 32 | }; 33 | 34 | export const resetRequestTimeoutOnProgress = ( 35 | config: InspectorConfig, 36 | ): boolean => { 37 | return config.MCP_REQUEST_TIMEOUT_RESET_ON_PROGRESS.value as boolean; 38 | }; 39 | 40 | export const getMCPServerRequestMaxTotalTimeout = ( 41 | config: InspectorConfig, 42 | ): number => { 43 | return config.MCP_REQUEST_MAX_TOTAL_TIMEOUT.value as number; 44 | }; 45 | 46 | export const getMCPProxyAuthToken = ( 47 | config: InspectorConfig, 48 | ): { 49 | token: string; 50 | header: string; 51 | } => { 52 | return { 53 | token: config.MCP_PROXY_AUTH_TOKEN.value as string, 54 | header: "X-MCP-Proxy-Auth", 55 | }; 56 | }; 57 | 58 | export const getInitialTransportType = (): 59 | | "stdio" 60 | | "sse" 61 | | "streamable-http" => { 62 | const param = getSearchParam("transport"); 63 | if (param === "stdio" || param === "sse" || param === "streamable-http") { 64 | return param; 65 | } 66 | return ( 67 | (localStorage.getItem("lastTransportType") as 68 | | "stdio" 69 | | "sse" 70 | | "streamable-http") || "stdio" 71 | ); 72 | }; 73 | 74 | export const getInitialSseUrl = (): string => { 75 | const param = getSearchParam("serverUrl"); 76 | if (param) return param; 77 | return localStorage.getItem("lastSseUrl") || "http://localhost:3001/sse"; 78 | }; 79 | 80 | export const getInitialCommand = (): string => { 81 | const param = getSearchParam("serverCommand"); 82 | if (param) return param; 83 | return localStorage.getItem("lastCommand") || "mcp-server-everything"; 84 | }; 85 | 86 | export const getInitialArgs = (): string => { 87 | const param = getSearchParam("serverArgs"); 88 | if (param) return param; 89 | return localStorage.getItem("lastArgs") || ""; 90 | }; 91 | 92 | // Returns a map of config key -> value from query params if present 93 | export const getConfigOverridesFromQueryParams = ( 94 | defaultConfig: InspectorConfig, 95 | ): Partial<InspectorConfig> => { 96 | const url = new URL(window.location.href); 97 | const overrides: Partial<InspectorConfig> = {}; 98 | for (const key of Object.keys(defaultConfig)) { 99 | const param = url.searchParams.get(key); 100 | if (param !== null) { 101 | // Try to coerce to correct type based on default value 102 | const defaultValue = defaultConfig[key as keyof InspectorConfig].value; 103 | let value: string | number | boolean = param; 104 | if (typeof defaultValue === "number") { 105 | value = Number(param); 106 | } else if (typeof defaultValue === "boolean") { 107 | value = param === "true"; 108 | } 109 | overrides[key as keyof InspectorConfig] = { 110 | ...defaultConfig[key as keyof InspectorConfig], 111 | value, 112 | }; 113 | } 114 | } 115 | return overrides; 116 | }; 117 | 118 | export const initializeInspectorConfig = ( 119 | localStorageKey: string, 120 | ): InspectorConfig => { 121 | // Read persistent config from localStorage 122 | const savedPersistentConfig = localStorage.getItem(localStorageKey); 123 | // Read ephemeral config from sessionStorage 124 | const savedEphemeralConfig = sessionStorage.getItem( 125 | `${localStorageKey}_ephemeral`, 126 | ); 127 | 128 | // Start with default config 129 | let baseConfig = { ...DEFAULT_INSPECTOR_CONFIG }; 130 | 131 | // Apply saved persistent config 132 | if (savedPersistentConfig) { 133 | const parsedPersistentConfig = JSON.parse(savedPersistentConfig); 134 | baseConfig = { ...baseConfig, ...parsedPersistentConfig }; 135 | } 136 | 137 | // Apply saved ephemeral config 138 | if (savedEphemeralConfig) { 139 | const parsedEphemeralConfig = JSON.parse(savedEphemeralConfig); 140 | baseConfig = { ...baseConfig, ...parsedEphemeralConfig }; 141 | } 142 | 143 | // Ensure all config items have the latest labels/descriptions from defaults 144 | for (const [key, value] of Object.entries(baseConfig)) { 145 | baseConfig[key as keyof InspectorConfig] = { 146 | ...value, 147 | label: DEFAULT_INSPECTOR_CONFIG[key as keyof InspectorConfig].label, 148 | description: 149 | DEFAULT_INSPECTOR_CONFIG[key as keyof InspectorConfig].description, 150 | is_session_item: 151 | DEFAULT_INSPECTOR_CONFIG[key as keyof InspectorConfig].is_session_item, 152 | }; 153 | } 154 | 155 | // Apply query param overrides 156 | const overrides = getConfigOverridesFromQueryParams(DEFAULT_INSPECTOR_CONFIG); 157 | return { ...baseConfig, ...overrides }; 158 | }; 159 | 160 | export const saveInspectorConfig = ( 161 | localStorageKey: string, 162 | config: InspectorConfig, 163 | ): void => { 164 | const persistentConfig: Partial<InspectorConfig> = {}; 165 | const ephemeralConfig: Partial<InspectorConfig> = {}; 166 | 167 | // Split config based on is_session_item flag 168 | for (const [key, value] of Object.entries(config)) { 169 | if (value.is_session_item) { 170 | ephemeralConfig[key as keyof InspectorConfig] = value; 171 | } else { 172 | persistentConfig[key as keyof InspectorConfig] = value; 173 | } 174 | } 175 | 176 | // Save persistent config to localStorage 177 | localStorage.setItem(localStorageKey, JSON.stringify(persistentConfig)); 178 | 179 | // Save ephemeral config to sessionStorage 180 | sessionStorage.setItem( 181 | `${localStorageKey}_ephemeral`, 182 | JSON.stringify(ephemeralConfig), 183 | ); 184 | }; 185 | -------------------------------------------------------------------------------- /client/src/utils/escapeUnicode.ts: -------------------------------------------------------------------------------- 1 | // Utility function to escape Unicode characters 2 | export function escapeUnicode(obj: unknown): string { 3 | return JSON.stringify( 4 | obj, 5 | (_key: string, value) => { 6 | if (typeof value === "string") { 7 | // Replace non-ASCII characters with their Unicode escape sequences 8 | return value.replace(/[^\0-\x7F]/g, (char) => { 9 | return "\\u" + ("0000" + char.charCodeAt(0).toString(16)).slice(-4); 10 | }); 11 | } 12 | return value; 13 | }, 14 | 2, 15 | ); 16 | } 17 | -------------------------------------------------------------------------------- /client/src/utils/jsonUtils.ts: -------------------------------------------------------------------------------- 1 | export type JsonValue = 2 | | string 3 | | number 4 | | boolean 5 | | null 6 | | undefined 7 | | JsonValue[] 8 | | { [key: string]: JsonValue }; 9 | 10 | export type JsonSchemaType = { 11 | type: 12 | | "string" 13 | | "number" 14 | | "integer" 15 | | "boolean" 16 | | "array" 17 | | "object" 18 | | "null"; 19 | description?: string; 20 | required?: string[]; 21 | default?: JsonValue; 22 | properties?: Record<string, JsonSchemaType>; 23 | items?: JsonSchemaType; 24 | }; 25 | 26 | export type JsonObject = { [key: string]: JsonValue }; 27 | 28 | export type DataType = 29 | | "string" 30 | | "number" 31 | | "bigint" 32 | | "boolean" 33 | | "symbol" 34 | | "undefined" 35 | | "object" 36 | | "function" 37 | | "array" 38 | | "null"; 39 | 40 | export function getDataType(value: JsonValue): DataType { 41 | if (Array.isArray(value)) return "array"; 42 | if (value === null) return "null"; 43 | return typeof value; 44 | } 45 | 46 | export function tryParseJson(str: string): { 47 | success: boolean; 48 | data: JsonValue; 49 | } { 50 | const trimmed = str.trim(); 51 | if ( 52 | !(trimmed.startsWith("{") && trimmed.endsWith("}")) && 53 | !(trimmed.startsWith("[") && trimmed.endsWith("]")) 54 | ) { 55 | return { success: false, data: str }; 56 | } 57 | try { 58 | return { success: true, data: JSON.parse(str) }; 59 | } catch { 60 | return { success: false, data: str }; 61 | } 62 | } 63 | 64 | /** 65 | * Updates a value at a specific path in a nested JSON structure 66 | * @param obj The original JSON value 67 | * @param path Array of keys/indices representing the path to the value 68 | * @param value The new value to set 69 | * @returns A new JSON value with the updated path 70 | */ 71 | export function updateValueAtPath( 72 | obj: JsonValue, 73 | path: string[], 74 | value: JsonValue, 75 | ): JsonValue { 76 | if (path.length === 0) return value; 77 | 78 | if (obj === null || obj === undefined) { 79 | obj = !isNaN(Number(path[0])) ? [] : {}; 80 | } 81 | 82 | if (Array.isArray(obj)) { 83 | return updateArray(obj, path, value); 84 | } else if (typeof obj === "object" && obj !== null) { 85 | return updateObject(obj as JsonObject, path, value); 86 | } else { 87 | console.error( 88 | `Cannot update path ${path.join(".")} in non-object/array value:`, 89 | obj, 90 | ); 91 | return obj; 92 | } 93 | } 94 | 95 | /** 96 | * Updates an array at a specific path 97 | */ 98 | function updateArray( 99 | array: JsonValue[], 100 | path: string[], 101 | value: JsonValue, 102 | ): JsonValue[] { 103 | const [index, ...restPath] = path; 104 | const arrayIndex = Number(index); 105 | 106 | if (isNaN(arrayIndex)) { 107 | console.error(`Invalid array index: ${index}`); 108 | return array; 109 | } 110 | 111 | if (arrayIndex < 0) { 112 | console.error(`Array index out of bounds: ${arrayIndex} < 0`); 113 | return array; 114 | } 115 | 116 | let newArray: JsonValue[] = []; 117 | for (let i = 0; i < array.length; i++) { 118 | newArray[i] = i in array ? array[i] : null; 119 | } 120 | 121 | if (arrayIndex >= newArray.length) { 122 | const extendedArray: JsonValue[] = new Array(arrayIndex).fill(null); 123 | // Copy over the existing elements (now guaranteed to be dense) 124 | for (let i = 0; i < newArray.length; i++) { 125 | extendedArray[i] = newArray[i]; 126 | } 127 | newArray = extendedArray; 128 | } 129 | 130 | if (restPath.length === 0) { 131 | newArray[arrayIndex] = value; 132 | } else { 133 | newArray[arrayIndex] = updateValueAtPath( 134 | newArray[arrayIndex], 135 | restPath, 136 | value, 137 | ); 138 | } 139 | return newArray; 140 | } 141 | 142 | /** 143 | * Updates an object at a specific path 144 | */ 145 | function updateObject( 146 | obj: JsonObject, 147 | path: string[], 148 | value: JsonValue, 149 | ): JsonObject { 150 | const [key, ...restPath] = path; 151 | 152 | // Validate object key 153 | if (typeof key !== "string") { 154 | console.error(`Invalid object key: ${key}`); 155 | return obj; 156 | } 157 | 158 | const newObj = { ...obj }; 159 | 160 | if (restPath.length === 0) { 161 | newObj[key] = value; 162 | } else { 163 | // Ensure key exists 164 | if (!(key in newObj)) { 165 | newObj[key] = {}; 166 | } 167 | newObj[key] = updateValueAtPath(newObj[key], restPath, value); 168 | } 169 | return newObj; 170 | } 171 | 172 | /** 173 | * Gets a value at a specific path in a nested JSON structure 174 | * @param obj The JSON value to traverse 175 | * @param path Array of keys/indices representing the path to the value 176 | * @param defaultValue Value to return if path doesn't exist 177 | * @returns The value at the path, or defaultValue if not found 178 | */ 179 | export function getValueAtPath( 180 | obj: JsonValue, 181 | path: string[], 182 | defaultValue: JsonValue = null, 183 | ): JsonValue { 184 | if (path.length === 0) return obj; 185 | 186 | const [first, ...rest] = path; 187 | 188 | if (obj === null || obj === undefined) { 189 | return defaultValue; 190 | } 191 | 192 | if (Array.isArray(obj)) { 193 | const index = Number(first); 194 | if (isNaN(index) || index < 0 || index >= obj.length) { 195 | return defaultValue; 196 | } 197 | return getValueAtPath(obj[index], rest, defaultValue); 198 | } 199 | 200 | if (typeof obj === "object" && obj !== null) { 201 | if (!(first in obj)) { 202 | return defaultValue; 203 | } 204 | return getValueAtPath((obj as JsonObject)[first], rest, defaultValue); 205 | } 206 | 207 | return defaultValue; 208 | } 209 | -------------------------------------------------------------------------------- /client/src/utils/oauthUtils.ts: -------------------------------------------------------------------------------- 1 | // The parsed query parameters returned by the Authorization Server 2 | // representing either a valid authorization_code or an error 3 | // ref: https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1-12#section-4.1.2 4 | type CallbackParams = 5 | | { 6 | successful: true; 7 | // The authorization code is generated by the authorization server. 8 | code: string; 9 | } 10 | | { 11 | successful: false; 12 | // The OAuth 2.1 Error Code. 13 | // Usually one of: 14 | // ``` 15 | // invalid_request, unauthorized_client, access_denied, unsupported_response_type, 16 | // invalid_scope, server_error, temporarily_unavailable 17 | // ``` 18 | error: string; 19 | // Human-readable ASCII text providing additional information, used to assist the 20 | // developer in understanding the error that occurred. 21 | error_description: string | null; 22 | // A URI identifying a human-readable web page with information about the error, 23 | // used to provide the client developer with additional information about the error. 24 | error_uri: string | null; 25 | }; 26 | 27 | export const parseOAuthCallbackParams = (location: string): CallbackParams => { 28 | const params = new URLSearchParams(location); 29 | 30 | const code = params.get("code"); 31 | if (code) { 32 | return { successful: true, code }; 33 | } 34 | 35 | const error = params.get("error"); 36 | const error_description = params.get("error_description"); 37 | const error_uri = params.get("error_uri"); 38 | 39 | if (error) { 40 | return { successful: false, error, error_description, error_uri }; 41 | } 42 | 43 | return { 44 | successful: false, 45 | error: "invalid_request", 46 | error_description: "Missing code or error in response", 47 | error_uri: null, 48 | }; 49 | }; 50 | 51 | export const generateOAuthErrorDescription = ( 52 | params: Extract<CallbackParams, { successful: false }>, 53 | ): string => { 54 | const error = params.error; 55 | const errorDescription = params.error_description; 56 | const errorUri = params.error_uri; 57 | 58 | return [ 59 | `Error: ${error}.`, 60 | errorDescription ? `Details: ${errorDescription}.` : "", 61 | errorUri ? `More info: ${errorUri}.` : "", 62 | ] 63 | .filter(Boolean) 64 | .join("\n"); 65 | }; 66 | -------------------------------------------------------------------------------- /client/src/utils/schemaUtils.ts: -------------------------------------------------------------------------------- 1 | import type { JsonValue, JsonSchemaType, JsonObject } from "./jsonUtils"; 2 | import Ajv from "ajv"; 3 | import type { ValidateFunction } from "ajv"; 4 | import type { Tool } from "@modelcontextprotocol/sdk/types.js"; 5 | 6 | const ajv = new Ajv(); 7 | 8 | // Cache for compiled validators 9 | const toolOutputValidators = new Map<string, ValidateFunction>(); 10 | 11 | /** 12 | * Compiles and caches output schema validators for a list of tools 13 | * Following the same pattern as SDK's Client.cacheToolOutputSchemas 14 | * @param tools Array of tools that may have output schemas 15 | */ 16 | export function cacheToolOutputSchemas(tools: Tool[]): void { 17 | toolOutputValidators.clear(); 18 | for (const tool of tools) { 19 | if (tool.outputSchema) { 20 | try { 21 | const validator = ajv.compile(tool.outputSchema); 22 | toolOutputValidators.set(tool.name, validator); 23 | } catch (error) { 24 | console.warn( 25 | `Failed to compile output schema for tool ${tool.name}:`, 26 | error, 27 | ); 28 | } 29 | } 30 | } 31 | } 32 | 33 | /** 34 | * Gets the cached output schema validator for a tool 35 | * Following the same pattern as SDK's Client.getToolOutputValidator 36 | * @param toolName Name of the tool 37 | * @returns The compiled validator function, or undefined if not found 38 | */ 39 | export function getToolOutputValidator( 40 | toolName: string, 41 | ): ValidateFunction | undefined { 42 | return toolOutputValidators.get(toolName); 43 | } 44 | 45 | /** 46 | * Validates structured content against a tool's output schema 47 | * Returns validation result with detailed error messages 48 | * @param toolName Name of the tool 49 | * @param structuredContent The structured content to validate 50 | * @returns An object with isValid boolean and optional error message 51 | */ 52 | export function validateToolOutput( 53 | toolName: string, 54 | structuredContent: unknown, 55 | ): { isValid: boolean; error?: string } { 56 | const validator = getToolOutputValidator(toolName); 57 | if (!validator) { 58 | return { isValid: true }; // No validator means no schema to validate against 59 | } 60 | 61 | const isValid = validator(structuredContent); 62 | if (!isValid) { 63 | return { 64 | isValid: false, 65 | error: ajv.errorsText(validator.errors), 66 | }; 67 | } 68 | 69 | return { isValid: true }; 70 | } 71 | 72 | /** 73 | * Checks if a tool has an output schema 74 | * @param toolName Name of the tool 75 | * @returns true if the tool has an output schema 76 | */ 77 | export function hasOutputSchema(toolName: string): boolean { 78 | return toolOutputValidators.has(toolName); 79 | } 80 | 81 | /** 82 | * Generates a default value based on a JSON schema type 83 | * @param schema The JSON schema definition 84 | * @param propertyName Optional property name for checking if it's required in parent schema 85 | * @param parentSchema Optional parent schema to check required array 86 | * @returns A default value matching the schema type 87 | */ 88 | export function generateDefaultValue( 89 | schema: JsonSchemaType, 90 | propertyName?: string, 91 | parentSchema?: JsonSchemaType, 92 | ): JsonValue { 93 | if ("default" in schema && schema.default !== undefined) { 94 | return schema.default; 95 | } 96 | 97 | // Check if this property is required in the parent schema 98 | const isRequired = 99 | propertyName && parentSchema 100 | ? isPropertyRequired(propertyName, parentSchema) 101 | : false; 102 | 103 | switch (schema.type) { 104 | case "string": 105 | return isRequired ? "" : undefined; 106 | case "number": 107 | case "integer": 108 | return isRequired ? 0 : undefined; 109 | case "boolean": 110 | return isRequired ? false : undefined; 111 | case "array": 112 | return []; 113 | case "object": { 114 | if (!schema.properties) return {}; 115 | 116 | const obj: JsonObject = {}; 117 | // Only include properties that are required according to the schema's required array 118 | Object.entries(schema.properties).forEach(([key, prop]) => { 119 | if (isPropertyRequired(key, schema)) { 120 | const value = generateDefaultValue(prop, key, schema); 121 | if (value !== undefined) { 122 | obj[key] = value; 123 | } 124 | } 125 | }); 126 | return obj; 127 | } 128 | case "null": 129 | return null; 130 | default: 131 | return undefined; 132 | } 133 | } 134 | 135 | /** 136 | * Helper function to check if a property is required in a schema 137 | * @param propertyName The name of the property to check 138 | * @param schema The parent schema containing the required array 139 | * @returns true if the property is required, false otherwise 140 | */ 141 | export function isPropertyRequired( 142 | propertyName: string, 143 | schema: JsonSchemaType, 144 | ): boolean { 145 | return schema.required?.includes(propertyName) ?? false; 146 | } 147 | 148 | /** 149 | * Formats a field key into a human-readable label 150 | * @param key The field key to format 151 | * @returns A formatted label string 152 | */ 153 | export function formatFieldLabel(key: string): string { 154 | return key 155 | .replace(/([A-Z])/g, " $1") // Insert space before capital letters 156 | .replace(/_/g, " ") // Replace underscores with spaces 157 | .replace(/^\w/, (c) => c.toUpperCase()); // Capitalize first letter 158 | } 159 | -------------------------------------------------------------------------------- /client/src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// <reference types="vite/client" /> 2 | -------------------------------------------------------------------------------- /client/tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | import animate from "tailwindcss-animate"; 3 | export default { 4 | darkMode: ["class"], 5 | content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"], 6 | theme: { 7 | extend: { 8 | borderRadius: { 9 | lg: "var(--radius)", 10 | md: "calc(var(--radius) - 2px)", 11 | sm: "calc(var(--radius) - 4px)", 12 | }, 13 | colors: { 14 | background: "hsl(var(--background))", 15 | foreground: "hsl(var(--foreground))", 16 | card: { 17 | DEFAULT: "hsl(var(--card))", 18 | foreground: "hsl(var(--card-foreground))", 19 | }, 20 | popover: { 21 | DEFAULT: "hsl(var(--popover))", 22 | foreground: "hsl(var(--popover-foreground))", 23 | }, 24 | primary: { 25 | DEFAULT: "hsl(var(--primary))", 26 | foreground: "hsl(var(--primary-foreground))", 27 | }, 28 | secondary: { 29 | DEFAULT: "hsl(var(--secondary))", 30 | foreground: "hsl(var(--secondary-foreground))", 31 | }, 32 | muted: { 33 | DEFAULT: "hsl(var(--muted))", 34 | foreground: "hsl(var(--muted-foreground))", 35 | }, 36 | accent: { 37 | DEFAULT: "hsl(var(--accent))", 38 | foreground: "hsl(var(--accent-foreground))", 39 | }, 40 | destructive: { 41 | DEFAULT: "hsl(var(--destructive))", 42 | foreground: "hsl(var(--destructive-foreground))", 43 | }, 44 | border: "hsl(var(--border))", 45 | input: "hsl(var(--input))", 46 | ring: "hsl(var(--ring))", 47 | chart: { 48 | 1: "hsl(var(--chart-1))", 49 | 2: "hsl(var(--chart-2))", 50 | 3: "hsl(var(--chart-3))", 51 | 4: "hsl(var(--chart-4))", 52 | 5: "hsl(var(--chart-5))", 53 | }, 54 | }, 55 | }, 56 | }, 57 | plugins: [animate], 58 | }; 59 | -------------------------------------------------------------------------------- /client/tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "paths": { 5 | "@/*": ["./src/*"] 6 | }, 7 | 8 | "target": "ES2020", 9 | "useDefineForClassFields": true, 10 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 11 | "module": "ESNext", 12 | "skipLibCheck": true, 13 | 14 | /* Bundler mode */ 15 | "moduleResolution": "bundler", 16 | "allowImportingTsExtensions": true, 17 | "isolatedModules": true, 18 | "moduleDetection": "force", 19 | "noEmit": true, 20 | "jsx": "react-jsx", 21 | 22 | /* Linting */ 23 | "strict": true, 24 | "noUnusedLocals": true, 25 | "noUnusedParameters": true, 26 | "noFallthroughCasesInSwitch": true, 27 | "resolveJsonModule": true, 28 | "types": ["jest", "@testing-library/jest-dom", "node"] 29 | }, 30 | "include": ["src"] 31 | } 32 | -------------------------------------------------------------------------------- /client/tsconfig.jest.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.app.json", 3 | "compilerOptions": { 4 | "jsx": "react-jsx", 5 | "esModuleInterop": true, 6 | "module": "ESNext", 7 | "moduleResolution": "node" 8 | }, 9 | "include": ["src"] 10 | } 11 | -------------------------------------------------------------------------------- /client/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "files": [], 3 | "references": [ 4 | { "path": "./tsconfig.app.json" }, 5 | { "path": "./tsconfig.node.json" } 6 | ], 7 | "compilerOptions": { 8 | "baseUrl": ".", 9 | "paths": { 10 | "@/*": ["./src/*"] 11 | } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /client/tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2022", 4 | "lib": ["ES2023"], 5 | "module": "ESNext", 6 | "skipLibCheck": true, 7 | 8 | /* Bundler mode */ 9 | "moduleResolution": "bundler", 10 | "allowImportingTsExtensions": true, 11 | "isolatedModules": true, 12 | "moduleDetection": "force", 13 | "noEmit": true, 14 | 15 | /* Linting */ 16 | "strict": true, 17 | "noUnusedLocals": true, 18 | "noUnusedParameters": true, 19 | "noFallthroughCasesInSwitch": true 20 | }, 21 | "include": ["vite.config.ts"] 22 | } 23 | -------------------------------------------------------------------------------- /client/vite.config.ts: -------------------------------------------------------------------------------- 1 | import react from "@vitejs/plugin-react"; 2 | import path from "path"; 3 | import { defineConfig } from "vite"; 4 | 5 | // https://vitejs.dev/config/ 6 | export default defineConfig({ 7 | plugins: [react()], 8 | server: { 9 | host: true, 10 | }, 11 | resolve: { 12 | alias: { 13 | "@": path.resolve(__dirname, "./src"), 14 | }, 15 | }, 16 | build: { 17 | minify: false, 18 | rollupOptions: { 19 | output: { 20 | manualChunks: undefined, 21 | }, 22 | }, 23 | }, 24 | }); 25 | -------------------------------------------------------------------------------- /mcp-inspector.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/modelcontextprotocol/inspector/1ea8e9a9395386b0e77efaa64ccaa4c28c571d10/mcp-inspector.png -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@modelcontextprotocol/inspector", 3 | "version": "0.16.1", 4 | "description": "Model Context Protocol inspector", 5 | "license": "MIT", 6 | "author": "Anthropic, PBC (https://anthropic.com)", 7 | "homepage": "https://modelcontextprotocol.io", 8 | "bugs": "https://github.com/modelcontextprotocol/inspector/issues", 9 | "type": "module", 10 | "bin": { 11 | "mcp-inspector": "cli/build/cli.js" 12 | }, 13 | "files": [ 14 | "client/bin", 15 | "client/dist", 16 | "server/build", 17 | "cli/build" 18 | ], 19 | "workspaces": [ 20 | "client", 21 | "server", 22 | "cli" 23 | ], 24 | "scripts": { 25 | "build": "npm run build-server && npm run build-client && npm run build-cli", 26 | "build-server": "cd server && npm run build", 27 | "build-client": "cd client && npm run build", 28 | "build-cli": "cd cli && npm run build", 29 | "clean": "rimraf ./node_modules ./client/node_modules ./cli/node_modules ./build ./client/dist ./server/build ./cli/build ./package-lock.json && npm install", 30 | "dev": "node client/bin/start.js --dev", 31 | "dev:windows": "node client/bin/start.js --dev", 32 | "dev:sdk": "npm run link:sdk && concurrently \"npm run dev\" \"cd sdk && npm run build:esm:w\"", 33 | "link:sdk": "(test -d sdk || ln -sf ${MCP_SDK:-$PWD/../typescript-sdk} sdk) && (cd sdk && npm link && (test -d node_modules || npm i)) && npm link @modelcontextprotocol/sdk", 34 | "unlink:sdk": "(cd sdk && npm unlink -g) && rm sdk && npm unlink @modelcontextprotocol/sdk", 35 | "start": "node client/bin/start.js", 36 | "start-server": "cd server && npm run start", 37 | "start-client": "cd client && npm run preview", 38 | "test": "npm run prettier-check && cd client && npm test", 39 | "test-cli": "cd cli && npm run test", 40 | "test:e2e": "MCP_AUTO_OPEN_ENABLED=false npm run test:e2e --workspace=client", 41 | "prettier-fix": "prettier --write .", 42 | "prettier-check": "prettier --check .", 43 | "lint": "prettier --check . && cd client && npm run lint", 44 | "prepare": "npm run build", 45 | "publish-all": "npm publish --workspaces --access public && npm publish --access public", 46 | "update-version": "node scripts/update-version.js", 47 | "check-version": "node scripts/check-version-consistency.js" 48 | }, 49 | "dependencies": { 50 | "@modelcontextprotocol/inspector-cli": "^0.16.1", 51 | "@modelcontextprotocol/inspector-client": "^0.16.1", 52 | "@modelcontextprotocol/inspector-server": "^0.16.1", 53 | "@modelcontextprotocol/sdk": "^1.13.1", 54 | "concurrently": "^9.0.1", 55 | "open": "^10.1.0", 56 | "shell-quote": "^1.8.2", 57 | "spawn-rx": "^5.1.2", 58 | "ts-node": "^10.9.2", 59 | "zod": "^3.23.8" 60 | }, 61 | "devDependencies": { 62 | "@playwright/test": "^1.52.0", 63 | "@types/jest": "^29.5.14", 64 | "@types/node": "^22.7.5", 65 | "@types/shell-quote": "^1.7.5", 66 | "jest-fixed-jsdom": "^0.0.9", 67 | "prettier": "3.3.3", 68 | "rimraf": "^6.0.1", 69 | "typescript": "^5.4.2" 70 | }, 71 | "engines": { 72 | "node": ">=22.7.5" 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /sample-config.json: -------------------------------------------------------------------------------- 1 | { 2 | "mcpServers": { 3 | "everything": { 4 | "command": "npx", 5 | "args": ["@modelcontextprotocol/server-everything"], 6 | "env": { 7 | "HELLO": "Hello MCP!" 8 | } 9 | }, 10 | "myserver": { 11 | "command": "node", 12 | "args": ["build/index.js", "arg1", "arg2"], 13 | "env": { 14 | "KEY": "value", 15 | "KEY2": "value2" 16 | } 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /scripts/README.md: -------------------------------------------------------------------------------- 1 | # Version Management Scripts 2 | 3 | This directory contains scripts for managing version consistency across the monorepo. 4 | 5 | ## Scripts 6 | 7 | ### update-version.js 8 | 9 | Updates the version across all package.json files in the monorepo and updates package-lock.json. 10 | 11 | **Usage:** 12 | 13 | ```bash 14 | npm run update-version <new-version> 15 | # Example: 16 | npm run update-version 0.14.3 17 | ``` 18 | 19 | This script will: 20 | 21 | 1. Update the version in all package.json files (root, client, server, cli) 22 | 2. Update workspace dependencies in the root package.json 23 | 3. Run `npm install` to update package-lock.json 24 | 4. Provide next steps for committing and tagging 25 | 26 | ### check-version-consistency.js 27 | 28 | Checks that all packages have consistent versions and that package-lock.json is up to date. 29 | 30 | **Usage:** 31 | 32 | ```bash 33 | npm run check-version 34 | ``` 35 | 36 | This script checks: 37 | 38 | 1. All package.json files have the same version 39 | 2. Workspace dependencies in root package.json match the current version 40 | 3. package-lock.json version matches package.json 41 | 4. Workspace packages in package-lock.json have the correct versions 42 | 43 | This check runs automatically in CI on every PR and push to main. 44 | 45 | ## CI Integration 46 | 47 | The version consistency check is integrated into the GitHub Actions workflow (`.github/workflows/main.yml`) and will fail the build if: 48 | 49 | - Package versions are inconsistent 50 | - package-lock.json is out of sync 51 | 52 | ## Common Workflows 53 | 54 | ### Bumping version for a release: 55 | 56 | ```bash 57 | # Update to new version 58 | npm run update-version 0.15.0 59 | 60 | # Verify everything is correct 61 | npm run check-version 62 | 63 | # Commit the changes 64 | git add -A 65 | git commit -m "chore: bump version to 0.15.0" 66 | 67 | # Create a tag 68 | git tag 0.15.0 69 | 70 | # Push changes and tag 71 | git push && git push --tags 72 | ``` 73 | -------------------------------------------------------------------------------- /scripts/check-version-consistency.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import fs from "fs"; 4 | import path from "path"; 5 | import { fileURLToPath } from "url"; 6 | 7 | const __filename = fileURLToPath(import.meta.url); 8 | const __dirname = path.dirname(__filename); 9 | 10 | /** 11 | * Checks version consistency across all package.json files in the monorepo 12 | * Exits with code 1 if versions are inconsistent 13 | * Usage: node scripts/check-version-consistency.js 14 | */ 15 | 16 | console.log("🔍 Checking version consistency across packages...\n"); 17 | 18 | // List of package.json files to check 19 | const packagePaths = [ 20 | "package.json", 21 | "client/package.json", 22 | "server/package.json", 23 | "cli/package.json", 24 | ]; 25 | 26 | const versions = new Map(); 27 | const errors = []; 28 | 29 | // Read version from each package.json 30 | packagePaths.forEach((packagePath) => { 31 | const fullPath = path.join(__dirname, "..", packagePath); 32 | 33 | if (!fs.existsSync(fullPath)) { 34 | console.warn(`⚠️ Skipping ${packagePath} - file not found`); 35 | return; 36 | } 37 | 38 | try { 39 | const packageJson = JSON.parse(fs.readFileSync(fullPath, "utf8")); 40 | const version = packageJson.version; 41 | const packageName = packageJson.name || packagePath; 42 | 43 | versions.set(packagePath, { 44 | name: packageName, 45 | version: version, 46 | dependencies: packageJson.dependencies || {}, 47 | }); 48 | 49 | console.log(`📦 ${packagePath}:`); 50 | console.log(` Name: ${packageName}`); 51 | console.log(` Version: ${version}`); 52 | } catch (error) { 53 | errors.push(`Failed to read ${packagePath}: ${error.message}`); 54 | } 55 | }); 56 | 57 | if (errors.length > 0) { 58 | console.error("\n❌ Errors occurred while reading package files:"); 59 | errors.forEach((error) => console.error(` - ${error}`)); 60 | process.exit(1); 61 | } 62 | 63 | // Check if all versions match 64 | const allVersions = Array.from(versions.values()).map((v) => v.version); 65 | const uniqueVersions = [...new Set(allVersions)]; 66 | 67 | console.log("\n📊 Version Summary:"); 68 | console.log(` Total packages: ${versions.size}`); 69 | console.log(` Unique versions: ${uniqueVersions.length}`); 70 | 71 | if (uniqueVersions.length > 1) { 72 | console.error("\n❌ Version mismatch detected!"); 73 | console.error(" Found versions: " + uniqueVersions.join(", ")); 74 | 75 | console.error("\n Package versions:"); 76 | versions.forEach((info, path) => { 77 | console.error(` - ${path}: ${info.version}`); 78 | }); 79 | } else { 80 | console.log(` ✅ All packages are at version: ${uniqueVersions[0]}`); 81 | } 82 | 83 | // Check workspace dependencies in root package.json 84 | const rootPackage = versions.get("package.json"); 85 | if (rootPackage) { 86 | console.log("\n🔗 Checking workspace dependencies..."); 87 | const expectedVersion = rootPackage.version; 88 | let dependencyErrors = false; 89 | 90 | Object.entries(rootPackage.dependencies).forEach(([dep, version]) => { 91 | if (dep.startsWith("@modelcontextprotocol/inspector-")) { 92 | const expectedDepVersion = `^${expectedVersion}`; 93 | if (version !== expectedDepVersion) { 94 | console.error( 95 | ` ❌ ${dep}: ${version} (expected ${expectedDepVersion})`, 96 | ); 97 | dependencyErrors = true; 98 | } else { 99 | console.log(` ✅ ${dep}: ${version}`); 100 | } 101 | } 102 | }); 103 | 104 | if (dependencyErrors) { 105 | errors.push("Workspace dependency versions do not match package versions"); 106 | } 107 | } 108 | 109 | // Check if package-lock.json is up to date 110 | console.log("\n🔒 Checking package-lock.json..."); 111 | const lockPath = path.join(__dirname, "..", "package-lock.json"); 112 | let lockFileError = false; 113 | 114 | if (!fs.existsSync(lockPath)) { 115 | console.error(" ❌ package-lock.json not found"); 116 | lockFileError = true; 117 | } else { 118 | try { 119 | const lockFile = JSON.parse(fs.readFileSync(lockPath, "utf8")); 120 | const lockVersion = lockFile.version; 121 | const expectedVersion = rootPackage?.version || uniqueVersions[0]; 122 | 123 | if (lockVersion !== expectedVersion) { 124 | console.error( 125 | ` ❌ package-lock.json version (${lockVersion}) does not match package.json version (${expectedVersion})`, 126 | ); 127 | lockFileError = true; 128 | } else { 129 | console.log(` ✅ package-lock.json version matches: ${lockVersion}`); 130 | } 131 | 132 | // Check workspace package versions in lock file 133 | if (lockFile.packages) { 134 | const workspacePackages = [ 135 | { path: "client", name: "@modelcontextprotocol/inspector-client" }, 136 | { path: "server", name: "@modelcontextprotocol/inspector-server" }, 137 | { path: "cli", name: "@modelcontextprotocol/inspector-cli" }, 138 | ]; 139 | 140 | workspacePackages.forEach(({ path, name }) => { 141 | const lockPkgPath = lockFile.packages[path]; 142 | if (lockPkgPath && lockPkgPath.version !== expectedVersion) { 143 | console.error( 144 | ` ❌ ${name} in lock file: ${lockPkgPath.version} (expected ${expectedVersion})`, 145 | ); 146 | lockFileError = true; 147 | } 148 | }); 149 | } 150 | } catch (error) { 151 | console.error(` ❌ Failed to parse package-lock.json: ${error.message}`); 152 | lockFileError = true; 153 | } 154 | } 155 | 156 | // Final result 157 | console.log("\n🎯 Result:"); 158 | if (uniqueVersions.length === 1 && errors.length === 0 && !lockFileError) { 159 | console.log(" ✅ Version consistency check passed!"); 160 | process.exit(0); 161 | } else { 162 | console.error(" ❌ Version consistency check failed!"); 163 | if (uniqueVersions.length > 1) { 164 | console.error(" - Package versions are not consistent"); 165 | } 166 | if (errors.length > 0) { 167 | console.error(" - " + errors.join("\n - ")); 168 | } 169 | if (lockFileError) { 170 | console.error(" - package-lock.json is out of sync"); 171 | } 172 | console.error( 173 | '\n💡 Run "npm run update-version <new-version>" to fix version inconsistencies', 174 | ); 175 | console.error(' or run "npm install" to update package-lock.json'); 176 | process.exit(1); 177 | } 178 | -------------------------------------------------------------------------------- /scripts/update-version.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import fs from "fs"; 4 | import path from "path"; 5 | import { execSync } from "child_process"; 6 | import { fileURLToPath } from "url"; 7 | 8 | const __filename = fileURLToPath(import.meta.url); 9 | const __dirname = path.dirname(__filename); 10 | 11 | /** 12 | * Updates version across all package.json files in the monorepo 13 | * Usage: node scripts/update-version.js <new-version> 14 | * Example: node scripts/update-version.js 0.14.2 15 | */ 16 | 17 | const newVersion = process.argv[2]; 18 | 19 | if (!newVersion) { 20 | console.error("❌ Please provide a version number"); 21 | console.error("Usage: node scripts/update-version.js <new-version>"); 22 | console.error("Example: node scripts/update-version.js 0.14.2"); 23 | process.exit(1); 24 | } 25 | 26 | // Validate version format 27 | const versionRegex = /^\d+\.\d+\.\d+(-[\w.]+)?$/; 28 | if (!versionRegex.test(newVersion)) { 29 | console.error( 30 | "❌ Invalid version format. Please use semantic versioning (e.g., 1.2.3 or 1.2.3-beta.1)", 31 | ); 32 | process.exit(1); 33 | } 34 | 35 | console.log(`🔄 Updating all packages to version ${newVersion}...`); 36 | 37 | // List of package.json files to update 38 | const packagePaths = [ 39 | "package.json", 40 | "client/package.json", 41 | "server/package.json", 42 | "cli/package.json", 43 | ]; 44 | 45 | const updatedFiles = []; 46 | 47 | // Update version in each package.json 48 | packagePaths.forEach((packagePath) => { 49 | const fullPath = path.join(__dirname, "..", packagePath); 50 | 51 | if (!fs.existsSync(fullPath)) { 52 | console.warn(`⚠️ Skipping ${packagePath} - file not found`); 53 | return; 54 | } 55 | 56 | try { 57 | const packageJson = JSON.parse(fs.readFileSync(fullPath, "utf8")); 58 | const oldVersion = packageJson.version; 59 | packageJson.version = newVersion; 60 | 61 | // Update workspace dependencies in root package.json 62 | if (packagePath === "package.json" && packageJson.dependencies) { 63 | Object.keys(packageJson.dependencies).forEach((dep) => { 64 | if (dep.startsWith("@modelcontextprotocol/inspector-")) { 65 | packageJson.dependencies[dep] = `^${newVersion}`; 66 | } 67 | }); 68 | } 69 | 70 | fs.writeFileSync(fullPath, JSON.stringify(packageJson, null, 2) + "\n"); 71 | updatedFiles.push(packagePath); 72 | console.log( 73 | `✅ Updated ${packagePath} from ${oldVersion} to ${newVersion}`, 74 | ); 75 | } catch (error) { 76 | console.error(`❌ Failed to update ${packagePath}:`, error.message); 77 | process.exit(1); 78 | } 79 | }); 80 | 81 | console.log("\n📝 Summary:"); 82 | console.log(`Updated ${updatedFiles.length} files to version ${newVersion}`); 83 | 84 | // Update package-lock.json 85 | console.log("\n🔒 Updating package-lock.json..."); 86 | try { 87 | execSync("npm install", { stdio: "inherit" }); 88 | console.log("✅ package-lock.json updated successfully"); 89 | } catch (error) { 90 | console.error("❌ Failed to update package-lock.json:", error.message); 91 | console.error('Please run "npm install" manually'); 92 | process.exit(1); 93 | } 94 | 95 | console.log("\n✨ Version update complete!"); 96 | console.log("\nNext steps:"); 97 | console.log("1. Review the changes: git diff"); 98 | console.log( 99 | '2. Commit the changes: git add -A && git commit -m "chore: bump version to ' + 100 | newVersion + 101 | '"', 102 | ); 103 | console.log("3. Create a git tag: git tag v" + newVersion); 104 | console.log("4. Push changes and tag: git push && git push --tags"); 105 | -------------------------------------------------------------------------------- /server/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@modelcontextprotocol/inspector-server", 3 | "version": "0.16.1", 4 | "description": "Server-side application for the Model Context Protocol inspector", 5 | "license": "MIT", 6 | "author": "Anthropic, PBC (https://anthropic.com)", 7 | "homepage": "https://modelcontextprotocol.io", 8 | "bugs": "https://github.com/modelcontextprotocol/inspector/issues", 9 | "type": "module", 10 | "bin": { 11 | "mcp-inspector-server": "build/index.js" 12 | }, 13 | "files": [ 14 | "build" 15 | ], 16 | "scripts": { 17 | "build": "tsc", 18 | "start": "node build/index.js", 19 | "dev": "tsx watch --clear-screen=false src/index.ts", 20 | "dev:windows": "tsx watch --clear-screen=false src/index.ts < NUL" 21 | }, 22 | "devDependencies": { 23 | "@types/cors": "^2.8.17", 24 | "@types/express": "^4.17.21", 25 | "@types/ws": "^8.5.12", 26 | "tsx": "^4.19.0", 27 | "typescript": "^5.6.2" 28 | }, 29 | "dependencies": { 30 | "@modelcontextprotocol/sdk": "^1.13.1", 31 | "cors": "^2.8.5", 32 | "express": "^5.1.0", 33 | "ws": "^8.18.0", 34 | "zod": "^3.23.8" 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /server/src/mcpProxy.ts: -------------------------------------------------------------------------------- 1 | import { Transport } from "@modelcontextprotocol/sdk/shared/transport.js"; 2 | import { isJSONRPCRequest } from "@modelcontextprotocol/sdk/types.js"; 3 | 4 | function onClientError(error: Error) { 5 | console.error("Error from inspector client:", error); 6 | } 7 | 8 | function onServerError(error: Error) { 9 | if (error?.cause && JSON.stringify(error.cause).includes("ECONNREFUSED")) { 10 | console.error("Connection refused. Is the MCP server running?"); 11 | } else if (error.message && error.message.includes("404")) { 12 | console.error("Error accessing endpoint (HTTP 404)"); 13 | } else { 14 | console.error("Error from MCP server:", error); 15 | } 16 | } 17 | 18 | export default function mcpProxy({ 19 | transportToClient, 20 | transportToServer, 21 | }: { 22 | transportToClient: Transport; 23 | transportToServer: Transport; 24 | }) { 25 | let transportToClientClosed = false; 26 | let transportToServerClosed = false; 27 | 28 | let reportedServerSession = false; 29 | 30 | transportToClient.onmessage = (message) => { 31 | transportToServer.send(message).catch((error) => { 32 | // Send error response back to client if it was a request (has id) and connection is still open 33 | if (isJSONRPCRequest(message) && !transportToClientClosed) { 34 | const errorResponse = { 35 | jsonrpc: "2.0" as const, 36 | id: message.id, 37 | error: { 38 | code: -32001, 39 | message: error.message, 40 | data: error, 41 | }, 42 | }; 43 | transportToClient.send(errorResponse).catch(onClientError); 44 | } 45 | }); 46 | }; 47 | 48 | transportToServer.onmessage = (message) => { 49 | if (!reportedServerSession) { 50 | if (transportToServer.sessionId) { 51 | // Can only report for StreamableHttp 52 | console.error( 53 | "Proxy <-> Server sessionId: " + transportToServer.sessionId, 54 | ); 55 | } 56 | reportedServerSession = true; 57 | } 58 | transportToClient.send(message).catch(onClientError); 59 | }; 60 | 61 | transportToClient.onclose = () => { 62 | if (transportToServerClosed) { 63 | return; 64 | } 65 | 66 | transportToClientClosed = true; 67 | transportToServer.close().catch(onServerError); 68 | }; 69 | 70 | transportToServer.onclose = () => { 71 | if (transportToClientClosed) { 72 | return; 73 | } 74 | transportToServerClosed = true; 75 | transportToClient.close().catch(onClientError); 76 | }; 77 | 78 | transportToClient.onerror = onClientError; 79 | transportToServer.onerror = onServerError; 80 | } 81 | -------------------------------------------------------------------------------- /server/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2022", 4 | "module": "Node16", 5 | "moduleResolution": "Node16", 6 | "outDir": "./build", 7 | "rootDir": "./src", 8 | "strict": true, 9 | "esModuleInterop": true, 10 | "skipLibCheck": true, 11 | "forceConsistentCasingInFileNames": true, 12 | "resolveJsonModule": true 13 | }, 14 | "include": ["src/**/*"], 15 | "exclude": ["node_modules", "packages", "**/*.spec.ts"] 16 | } 17 | --------------------------------------------------------------------------------