├── .gitattributes ├── .github └── workflows │ └── main.yml ├── .gitignore ├── .npmrc ├── .prettierignore ├── .prettierrc ├── CLAUDE.md ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── 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 ├── eslint.config.js ├── index.html ├── jest.config.cjs ├── package.json ├── postcss.config.js ├── public │ └── mcp.svg ├── src │ ├── App.css │ ├── App.tsx │ ├── __mocks__ │ │ └── styleMock.js │ ├── components │ │ ├── AuthDebugger.tsx │ │ ├── ConsoleTab.tsx │ │ ├── DynamicJsonForm.tsx │ │ ├── History.tsx │ │ ├── JsonEditor.tsx │ │ ├── JsonView.tsx │ │ ├── ListPane.tsx │ │ ├── OAuthCallback.tsx │ │ ├── OAuthDebugCallback.tsx │ │ ├── OAuthFlowProgress.tsx │ │ ├── PingTab.tsx │ │ ├── PromptsTab.tsx │ │ ├── ResourcesTab.tsx │ │ ├── RootsTab.tsx │ │ ├── SamplingRequest.tsx │ │ ├── SamplingTab.tsx │ │ ├── Sidebar.tsx │ │ ├── ToolResults.tsx │ │ ├── ToolsTab.tsx │ │ ├── __tests__ │ │ │ ├── AuthDebugger.test.tsx │ │ │ ├── DynamicJsonForm.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__ │ │ │ ├── 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 └── server ├── package.json ├── src ├── index.ts └── mcpProxy.ts └── tsconfig.json /.gitattributes: -------------------------------------------------------------------------------- 1 | package-lock.json linguist-generated=true 2 | -------------------------------------------------------------------------------- /.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: 18 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 linting 30 | working-directory: ./client 31 | run: npm run lint 32 | 33 | - name: Run client tests 34 | working-directory: ./client 35 | run: npm test 36 | 37 | - run: npm run build 38 | 39 | publish: 40 | runs-on: ubuntu-latest 41 | if: github.event_name == 'release' 42 | environment: release 43 | needs: build 44 | 45 | permissions: 46 | contents: read 47 | id-token: write 48 | 49 | steps: 50 | - uses: actions/checkout@v4 51 | - uses: actions/setup-node@v4 52 | with: 53 | node-version: 18 54 | cache: npm 55 | registry-url: "https://registry.npmjs.org" 56 | 57 | # Working around https://github.com/npm/cli/issues/4828 58 | # - run: npm ci 59 | - run: npm install --no-package-lock 60 | 61 | # TODO: Add --provenance once the repo is public 62 | - run: npm run publish-all 63 | env: 64 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 65 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.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/bf85cebb2f245390929769a2b827b8b9972bfb74/.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 | - `bin/`: CLI scripts 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://127.0.0.1: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` 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 | -------------------------------------------------------------------------------- /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.13.0", 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.11.5", 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/cli.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import { Command } from "commander"; 4 | import fs from "node:fs"; 5 | import path from "node:path"; 6 | import { dirname, resolve } from "path"; 7 | import { spawnPromise } from "spawn-rx"; 8 | import { fileURLToPath } from "url"; 9 | 10 | const __dirname = dirname(fileURLToPath(import.meta.url)); 11 | 12 | type Args = { 13 | command: string; 14 | args: string[]; 15 | envArgs: Record; 16 | cli: boolean; 17 | }; 18 | 19 | type CliOptions = { 20 | e?: Record; 21 | config?: string; 22 | server?: string; 23 | cli?: boolean; 24 | }; 25 | 26 | type ServerConfig = { 27 | command: string; 28 | args?: string[]; 29 | env?: Record; 30 | }; 31 | 32 | function handleError(error: unknown): never { 33 | let message: string; 34 | 35 | if (error instanceof Error) { 36 | message = error.message; 37 | } else if (typeof error === "string") { 38 | message = error; 39 | } else { 40 | message = "Unknown error"; 41 | } 42 | 43 | console.error(message); 44 | 45 | process.exit(1); 46 | } 47 | 48 | function delay(ms: number): Promise { 49 | return new Promise((resolve) => setTimeout(resolve, ms, true)); 50 | } 51 | 52 | async function runWebClient(args: Args): Promise { 53 | const inspectorServerPath = resolve( 54 | __dirname, 55 | "../../", 56 | "server", 57 | "build", 58 | "index.js", 59 | ); 60 | 61 | // Path to the client entry point 62 | const inspectorClientPath = resolve( 63 | __dirname, 64 | "../../", 65 | "client", 66 | "bin", 67 | "client.js", 68 | ); 69 | 70 | const CLIENT_PORT: string = process.env.CLIENT_PORT ?? "6274"; 71 | const SERVER_PORT: string = process.env.SERVER_PORT ?? "6277"; 72 | 73 | console.log("Starting MCP inspector..."); 74 | 75 | const abort = new AbortController(); 76 | let cancelled: boolean = false; 77 | process.on("SIGINT", () => { 78 | cancelled = true; 79 | abort.abort(); 80 | }); 81 | 82 | let server: ReturnType; 83 | let serverOk: unknown; 84 | 85 | try { 86 | server = spawnPromise( 87 | "node", 88 | [ 89 | inspectorServerPath, 90 | ...(args.command ? [`--env`, args.command] : []), 91 | ...(args.args ? [`--args=${args.args.join(" ")}`] : []), 92 | ], 93 | { 94 | env: { 95 | ...process.env, 96 | PORT: SERVER_PORT, 97 | MCP_ENV_VARS: JSON.stringify(args.envArgs), 98 | }, 99 | signal: abort.signal, 100 | echoOutput: true, 101 | }, 102 | ); 103 | 104 | // Make sure server started before starting client 105 | serverOk = await Promise.race([server, delay(2 * 1000)]); 106 | } catch (error) {} 107 | 108 | if (serverOk) { 109 | try { 110 | await spawnPromise("node", [inspectorClientPath], { 111 | env: { ...process.env, PORT: CLIENT_PORT }, 112 | signal: abort.signal, 113 | echoOutput: true, 114 | }); 115 | } catch (e) { 116 | if (!cancelled || process.env.DEBUG) throw e; 117 | } 118 | } 119 | } 120 | 121 | async function runCli(args: Args): Promise { 122 | const projectRoot = resolve(__dirname, ".."); 123 | const cliPath = resolve(projectRoot, "build", "index.js"); 124 | 125 | const abort = new AbortController(); 126 | 127 | let cancelled = false; 128 | 129 | process.on("SIGINT", () => { 130 | cancelled = true; 131 | abort.abort(); 132 | }); 133 | 134 | try { 135 | await spawnPromise("node", [cliPath, args.command, ...args.args], { 136 | env: { ...process.env, ...args.envArgs }, 137 | signal: abort.signal, 138 | echoOutput: true, 139 | }); 140 | } catch (e) { 141 | if (!cancelled || process.env.DEBUG) { 142 | throw e; 143 | } 144 | } 145 | } 146 | 147 | function loadConfigFile(configPath: string, serverName: string): ServerConfig { 148 | try { 149 | const resolvedConfigPath = path.isAbsolute(configPath) 150 | ? configPath 151 | : path.resolve(process.cwd(), configPath); 152 | 153 | if (!fs.existsSync(resolvedConfigPath)) { 154 | throw new Error(`Config file not found: ${resolvedConfigPath}`); 155 | } 156 | 157 | const configContent = fs.readFileSync(resolvedConfigPath, "utf8"); 158 | const parsedConfig = JSON.parse(configContent); 159 | 160 | if (!parsedConfig.mcpServers || !parsedConfig.mcpServers[serverName]) { 161 | const availableServers = Object.keys(parsedConfig.mcpServers || {}).join( 162 | ", ", 163 | ); 164 | throw new Error( 165 | `Server '${serverName}' not found in config file. Available servers: ${availableServers}`, 166 | ); 167 | } 168 | 169 | const serverConfig = parsedConfig.mcpServers[serverName]; 170 | 171 | return serverConfig; 172 | } catch (err: unknown) { 173 | if (err instanceof SyntaxError) { 174 | throw new Error(`Invalid JSON in config file: ${err.message}`); 175 | } 176 | 177 | throw err; 178 | } 179 | } 180 | 181 | function parseKeyValuePair( 182 | value: string, 183 | previous: Record = {}, 184 | ): Record { 185 | const parts = value.split("="); 186 | const key = parts[0]; 187 | const val = parts.slice(1).join("="); 188 | 189 | if (val === undefined || val === "") { 190 | throw new Error( 191 | `Invalid parameter format: ${value}. Use key=value format.`, 192 | ); 193 | } 194 | 195 | return { ...previous, [key as string]: val }; 196 | } 197 | 198 | function parseArgs(): Args { 199 | const program = new Command(); 200 | 201 | const argSeparatorIndex = process.argv.indexOf("--"); 202 | let preArgs = process.argv; 203 | let postArgs: string[] = []; 204 | 205 | if (argSeparatorIndex !== -1) { 206 | preArgs = process.argv.slice(0, argSeparatorIndex); 207 | postArgs = process.argv.slice(argSeparatorIndex + 1); 208 | } 209 | 210 | program 211 | .name("inspector-bin") 212 | .allowExcessArguments() 213 | .allowUnknownOption() 214 | .option( 215 | "-e ", 216 | "environment variables in KEY=VALUE format", 217 | parseKeyValuePair, 218 | {}, 219 | ) 220 | .option("--config ", "config file path") 221 | .option("--server ", "server name from config file") 222 | .option("--cli", "enable CLI mode"); 223 | 224 | // Parse only the arguments before -- 225 | program.parse(preArgs); 226 | 227 | const options = program.opts() as CliOptions; 228 | const remainingArgs = program.args; 229 | 230 | // Add back any arguments that came after -- 231 | const finalArgs = [...remainingArgs, ...postArgs]; 232 | 233 | // Validate that config and server are provided together 234 | if ( 235 | (options.config && !options.server) || 236 | (!options.config && options.server) 237 | ) { 238 | throw new Error( 239 | "Both --config and --server must be provided together. If you specify one, you must specify the other.", 240 | ); 241 | } 242 | 243 | // If config file is specified, load and use the options from the file. We must merge the args 244 | // from the command line and the file together, or we will miss the method options (--method, 245 | // etc.) 246 | if (options.config && options.server) { 247 | const config = loadConfigFile(options.config, options.server); 248 | 249 | return { 250 | command: config.command, 251 | args: [...(config.args || []), ...finalArgs], 252 | envArgs: { ...(config.env || {}), ...(options.e || {}) }, 253 | cli: options.cli || false, 254 | }; 255 | } 256 | 257 | // Otherwise use command line arguments 258 | const command = finalArgs[0] || ""; 259 | const args = finalArgs.slice(1); 260 | 261 | return { 262 | command, 263 | args, 264 | envArgs: options.e || {}, 265 | cli: options.cli || false, 266 | }; 267 | } 268 | 269 | async function main(): Promise { 270 | process.on("uncaughtException", (error) => { 271 | handleError(error); 272 | }); 273 | 274 | try { 275 | const args = parseArgs(); 276 | 277 | if (args.cli) { 278 | runCli(args); 279 | } else { 280 | await runWebClient(args); 281 | } 282 | } catch (error) { 283 | handleError(error); 284 | } 285 | } 286 | 287 | main(); 288 | -------------------------------------------------------------------------------- /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 { 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 { 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 { 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 { 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, 21 | ): Promise { 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 { 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 { 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 { 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; 9 | items?: JsonSchemaType; 10 | }; 11 | 12 | export async function listTools(client: Client): Promise { 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, 50 | ): Record { 51 | const result: Record = {}; 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, 72 | ): Promise { 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 = 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; 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/index.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import { Client } from "@modelcontextprotocol/sdk/client/index.js"; 4 | import { Command } from "commander"; 5 | import { 6 | callTool, 7 | connect, 8 | disconnect, 9 | getPrompt, 10 | listPrompts, 11 | listResources, 12 | listResourceTemplates, 13 | listTools, 14 | LogLevel, 15 | McpResponse, 16 | readResource, 17 | setLoggingLevel, 18 | validLogLevels, 19 | } from "./client/index.js"; 20 | import { handleError } from "./error-handler.js"; 21 | import { createTransport, TransportOptions } from "./transport.js"; 22 | 23 | type Args = { 24 | target: string[]; 25 | method?: string; 26 | promptName?: string; 27 | promptArgs?: Record; 28 | uri?: string; 29 | logLevel?: LogLevel; 30 | toolName?: string; 31 | toolArg?: Record; 32 | }; 33 | 34 | function createTransportOptions(target: string[]): TransportOptions { 35 | if (target.length === 0) { 36 | throw new Error( 37 | "Target is required. Specify a URL or a command to execute.", 38 | ); 39 | } 40 | 41 | const [command, ...commandArgs] = target; 42 | 43 | if (!command) { 44 | throw new Error("Command is required."); 45 | } 46 | 47 | const isUrl = command.startsWith("http://") || command.startsWith("https://"); 48 | 49 | if (isUrl && commandArgs.length > 0) { 50 | throw new Error("Arguments cannot be passed to a URL-based MCP server."); 51 | } 52 | 53 | return { 54 | transportType: isUrl ? "sse" : "stdio", 55 | command: isUrl ? undefined : command, 56 | args: isUrl ? undefined : commandArgs, 57 | url: isUrl ? command : undefined, 58 | }; 59 | } 60 | 61 | async function callMethod(args: Args): Promise { 62 | const transportOptions = createTransportOptions(args.target); 63 | const transport = createTransport(transportOptions); 64 | const client = new Client({ 65 | name: "inspector-cli", 66 | version: "0.5.1", 67 | }); 68 | 69 | try { 70 | await connect(client, transport); 71 | 72 | let result: McpResponse; 73 | 74 | // Tools methods 75 | if (args.method === "tools/list") { 76 | result = await listTools(client); 77 | } else if (args.method === "tools/call") { 78 | if (!args.toolName) { 79 | throw new Error( 80 | "Tool name is required for tools/call method. Use --tool-name to specify the tool name.", 81 | ); 82 | } 83 | 84 | result = await callTool(client, args.toolName, args.toolArg || {}); 85 | } 86 | // Resources methods 87 | else if (args.method === "resources/list") { 88 | result = await listResources(client); 89 | } else if (args.method === "resources/read") { 90 | if (!args.uri) { 91 | throw new Error( 92 | "URI is required for resources/read method. Use --uri to specify the resource URI.", 93 | ); 94 | } 95 | 96 | result = await readResource(client, args.uri); 97 | } else if (args.method === "resources/templates/list") { 98 | result = await listResourceTemplates(client); 99 | } 100 | // Prompts methods 101 | else if (args.method === "prompts/list") { 102 | result = await listPrompts(client); 103 | } else if (args.method === "prompts/get") { 104 | if (!args.promptName) { 105 | throw new Error( 106 | "Prompt name is required for prompts/get method. Use --prompt-name to specify the prompt name.", 107 | ); 108 | } 109 | 110 | result = await getPrompt(client, args.promptName, args.promptArgs || {}); 111 | } 112 | // Logging methods 113 | else if (args.method === "logging/setLevel") { 114 | if (!args.logLevel) { 115 | throw new Error( 116 | "Log level is required for logging/setLevel method. Use --log-level to specify the log level.", 117 | ); 118 | } 119 | 120 | result = await setLoggingLevel(client, args.logLevel); 121 | } else { 122 | throw new Error( 123 | `Unsupported method: ${args.method}. Supported methods include: tools/list, tools/call, resources/list, resources/read, resources/templates/list, prompts/list, prompts/get, logging/setLevel`, 124 | ); 125 | } 126 | 127 | console.log(JSON.stringify(result, null, 2)); 128 | } finally { 129 | try { 130 | await disconnect(transport); 131 | } catch (disconnectError) { 132 | throw disconnectError; 133 | } 134 | } 135 | } 136 | 137 | function parseKeyValuePair( 138 | value: string, 139 | previous: Record = {}, 140 | ): Record { 141 | const parts = value.split("="); 142 | const key = parts[0]; 143 | const val = parts.slice(1).join("="); 144 | 145 | if (val === undefined || val === "") { 146 | throw new Error( 147 | `Invalid parameter format: ${value}. Use key=value format.`, 148 | ); 149 | } 150 | 151 | return { ...previous, [key as string]: val }; 152 | } 153 | 154 | function parseArgs(): Args { 155 | const program = new Command(); 156 | 157 | // Find if there's a -- in the arguments and split them 158 | const argSeparatorIndex = process.argv.indexOf("--"); 159 | let preArgs = process.argv; 160 | let postArgs: string[] = []; 161 | 162 | if (argSeparatorIndex !== -1) { 163 | preArgs = process.argv.slice(0, argSeparatorIndex); 164 | postArgs = process.argv.slice(argSeparatorIndex + 1); 165 | } 166 | 167 | program 168 | .name("inspector-cli") 169 | .allowUnknownOption() 170 | .argument("", "Command and arguments or URL of the MCP server") 171 | // 172 | // Method selection 173 | // 174 | .option("--method ", "Method to invoke") 175 | // 176 | // Tool-related options 177 | // 178 | .option("--tool-name ", "Tool name (for tools/call method)") 179 | .option( 180 | "--tool-arg ", 181 | "Tool argument as key=value pair", 182 | parseKeyValuePair, 183 | {}, 184 | ) 185 | // 186 | // Resource-related options 187 | // 188 | .option("--uri ", "URI of the resource (for resources/read method)") 189 | // 190 | // Prompt-related options 191 | // 192 | .option( 193 | "--prompt-name ", 194 | "Name of the prompt (for prompts/get method)", 195 | ) 196 | .option( 197 | "--prompt-args ", 198 | "Prompt arguments as key=value pairs", 199 | parseKeyValuePair, 200 | {}, 201 | ) 202 | // 203 | // Logging options 204 | // 205 | .option( 206 | "--log-level ", 207 | "Logging level (for logging/setLevel method)", 208 | (value: string) => { 209 | if (!validLogLevels.includes(value as any)) { 210 | throw new Error( 211 | `Invalid log level: ${value}. Valid levels are: ${validLogLevels.join(", ")}`, 212 | ); 213 | } 214 | 215 | return value as LogLevel; 216 | }, 217 | ); 218 | 219 | // Parse only the arguments before -- 220 | program.parse(preArgs); 221 | 222 | const options = program.opts() as Omit; 223 | let remainingArgs = program.args; 224 | 225 | // Add back any arguments that came after -- 226 | const finalArgs = [...remainingArgs, ...postArgs]; 227 | 228 | if (!options.method) { 229 | throw new Error( 230 | "Method is required. Use --method to specify the method to invoke.", 231 | ); 232 | } 233 | 234 | return { 235 | target: finalArgs, 236 | ...options, 237 | }; 238 | } 239 | 240 | async function main(): Promise { 241 | process.on("uncaughtException", (error) => { 242 | handleError(error); 243 | }); 244 | 245 | try { 246 | const args = parseArgs(); 247 | await callMethod(args); 248 | } catch (error) { 249 | handleError(error); 250 | } 251 | } 252 | 253 | main(); 254 | -------------------------------------------------------------------------------- /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 type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js"; 7 | import { findActualExecutable } from "spawn-rx"; 8 | 9 | export type TransportOptions = { 10 | transportType: "sse" | "stdio"; 11 | command?: string; 12 | args?: string[]; 13 | url?: string; 14 | }; 15 | 16 | function createSSETransport(options: TransportOptions): Transport { 17 | const baseUrl = new URL(options.url ?? ""); 18 | const sseUrl = new URL("/sse", baseUrl); 19 | 20 | return new SSEClientTransport(sseUrl); 21 | } 22 | 23 | function createStdioTransport(options: TransportOptions): Transport { 24 | let args: string[] = []; 25 | 26 | if (options.args !== undefined) { 27 | args = options.args; 28 | } 29 | 30 | const processEnv: Record = {}; 31 | 32 | for (const [key, value] of Object.entries(process.env)) { 33 | if (value !== undefined) { 34 | processEnv[key] = value; 35 | } 36 | } 37 | 38 | const defaultEnv = getDefaultEnvironment(); 39 | 40 | const env: Record = { 41 | ...processEnv, 42 | ...defaultEnv, 43 | }; 44 | 45 | const { cmd: actualCommand, args: actualArgs } = findActualExecutable( 46 | options.command ?? "", 47 | args, 48 | ); 49 | 50 | return new StdioClientTransport({ 51 | command: actualCommand, 52 | args: actualArgs, 53 | env, 54 | stderr: "pipe", 55 | }); 56 | } 57 | 58 | export function createTransport(options: TransportOptions): Transport { 59 | const { transportType } = options; 60 | 61 | try { 62 | if (transportType === "stdio") { 63 | return createStdioTransport(options); 64 | } 65 | 66 | if (transportType === "sse") { 67 | return createSSETransport(options); 68 | } 69 | 70 | throw new Error(`Unsupported transport type: ${transportType}`); 71 | } catch (error) { 72 | throw new Error( 73 | `Failed to create transport: ${error instanceof Error ? error.message : String(error)}`, 74 | ); 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /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 { join, dirname } from "path"; 4 | import { fileURLToPath } from "url"; 5 | import handler from "serve-handler"; 6 | import http from "http"; 7 | 8 | const __dirname = dirname(fileURLToPath(import.meta.url)); 9 | const distPath = join(__dirname, "../dist"); 10 | 11 | const server = http.createServer((request, response) => { 12 | const handlerOptions = { 13 | public: distPath, 14 | rewrites: [{ source: "/**", destination: "/index.html" }], 15 | headers: [ 16 | { 17 | // Ensure index.html is never cached 18 | source: "index.html", 19 | headers: [ 20 | { 21 | key: "Cache-Control", 22 | value: "no-cache, no-store, max-age=0", 23 | }, 24 | ], 25 | }, 26 | { 27 | // Allow long-term caching for hashed assets 28 | source: "assets/**", 29 | headers: [ 30 | { 31 | key: "Cache-Control", 32 | value: "public, max-age=31536000, immutable", 33 | }, 34 | ], 35 | }, 36 | ], 37 | }; 38 | 39 | return handler(request, response, handlerOptions); 40 | }); 41 | 42 | const port = process.env.PORT || 6274; 43 | server.on("listening", () => { 44 | console.log( 45 | `🔍 MCP Inspector is up and running at http://127.0.0.1:${port} 🚀`, 46 | ); 47 | }); 48 | server.on("error", (err) => { 49 | if (err.message.includes(`EADDRINUSE`)) { 50 | console.error( 51 | `❌ MCP Inspector PORT IS IN USE at http://127.0.0.1:${port} ❌ `, 52 | ); 53 | } else { 54 | throw err; 55 | } 56 | }); 57 | server.listen(port); 58 | -------------------------------------------------------------------------------- /client/bin/start.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import open from "open"; 4 | import { resolve, dirname } from "path"; 5 | import { spawnPromise } from "spawn-rx"; 6 | import { fileURLToPath } from "url"; 7 | 8 | const __dirname = dirname(fileURLToPath(import.meta.url)); 9 | 10 | function delay(ms) { 11 | return new Promise((resolve) => setTimeout(resolve, ms, true)); 12 | } 13 | 14 | async function main() { 15 | // Parse command line arguments 16 | const args = process.argv.slice(2); 17 | const envVars = {}; 18 | const mcpServerArgs = []; 19 | let command = null; 20 | let parsingFlags = true; 21 | 22 | for (let i = 0; i < args.length; i++) { 23 | const arg = args[i]; 24 | 25 | if (parsingFlags && arg === "--") { 26 | parsingFlags = false; 27 | continue; 28 | } 29 | 30 | if (parsingFlags && arg === "-e" && i + 1 < args.length) { 31 | const envVar = args[++i]; 32 | const equalsIndex = envVar.indexOf("="); 33 | 34 | if (equalsIndex !== -1) { 35 | const key = envVar.substring(0, equalsIndex); 36 | const value = envVar.substring(equalsIndex + 1); 37 | envVars[key] = value; 38 | } else { 39 | envVars[envVar] = ""; 40 | } 41 | } else if (!command) { 42 | command = arg; 43 | } else { 44 | mcpServerArgs.push(arg); 45 | } 46 | } 47 | 48 | const inspectorServerPath = resolve( 49 | __dirname, 50 | "../..", 51 | "server", 52 | "build", 53 | "index.js", 54 | ); 55 | 56 | // Path to the client entry point 57 | const inspectorClientPath = resolve( 58 | __dirname, 59 | "../..", 60 | "client", 61 | "bin", 62 | "client.js", 63 | ); 64 | 65 | const CLIENT_PORT = process.env.CLIENT_PORT ?? "6274"; 66 | const SERVER_PORT = process.env.SERVER_PORT ?? "6277"; 67 | 68 | console.log("Starting MCP inspector..."); 69 | 70 | const abort = new AbortController(); 71 | 72 | let cancelled = false; 73 | process.on("SIGINT", () => { 74 | cancelled = true; 75 | abort.abort(); 76 | }); 77 | let server, serverOk; 78 | try { 79 | server = spawnPromise( 80 | "node", 81 | [ 82 | inspectorServerPath, 83 | ...(command ? [`--env`, command] : []), 84 | ...(mcpServerArgs ? [`--args=${mcpServerArgs.join(" ")}`] : []), 85 | ], 86 | { 87 | env: { 88 | ...process.env, 89 | PORT: SERVER_PORT, 90 | MCP_ENV_VARS: JSON.stringify(envVars), 91 | }, 92 | signal: abort.signal, 93 | echoOutput: true, 94 | }, 95 | ); 96 | 97 | // Make sure server started before starting client 98 | serverOk = await Promise.race([server, delay(2 * 1000)]); 99 | } catch (error) {} 100 | 101 | if (serverOk) { 102 | try { 103 | if (process.env.MCP_AUTO_OPEN_ENABLED !== "false") { 104 | open(`http://127.0.0.1:${CLIENT_PORT}`); 105 | } 106 | await spawnPromise("node", [inspectorClientPath], { 107 | env: { ...process.env, PORT: CLIENT_PORT }, 108 | signal: abort.signal, 109 | echoOutput: true, 110 | }); 111 | } catch (e) { 112 | if (!cancelled || process.env.DEBUG) throw e; 113 | } 114 | } 115 | 116 | return 0; 117 | } 118 | 119 | main() 120 | .then((_) => process.exit(0)) 121 | .catch((e) => { 122 | console.error(e); 123 | process.exit(1); 124 | }); 125 | -------------------------------------------------------------------------------- /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/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 | 2 | 3 | 4 | 5 | 6 | 7 | MCP Inspector 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /client/jest.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: "ts-jest", 3 | testEnvironment: "jest-fixed-jsdom", 4 | moduleNameMapper: { 5 | "^@/(.*)$": "/src/$1", 6 | "\\.css$": "/src/__mocks__/styleMock.js", 7 | }, 8 | transform: { 9 | "^.+\\.tsx?$": [ 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?)$", 19 | // Exclude directories and files that don't need to be tested 20 | testPathIgnorePatterns: [ 21 | "/node_modules/", 22 | "/dist/", 23 | "/bin/", 24 | "\\.config\\.(js|ts|cjs|mjs)$", 25 | ], 26 | // Exclude the same patterns from coverage reports 27 | coveragePathIgnorePatterns: [ 28 | "/node_modules/", 29 | "/dist/", 30 | "/bin/", 31 | "\\.config\\.(js|ts|cjs|mjs)$", 32 | ], 33 | }; 34 | -------------------------------------------------------------------------------- /client/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@modelcontextprotocol/inspector-client", 3 | "version": "0.13.0", 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 | }, 25 | "dependencies": { 26 | "@modelcontextprotocol/sdk": "^1.11.5", 27 | "@radix-ui/react-checkbox": "^1.1.4", 28 | "ajv": "^6.12.6", 29 | "@radix-ui/react-dialog": "^1.1.3", 30 | "@radix-ui/react-icons": "^1.3.0", 31 | "@radix-ui/react-label": "^2.1.0", 32 | "@radix-ui/react-popover": "^1.1.3", 33 | "@radix-ui/react-select": "^2.1.2", 34 | "@radix-ui/react-slot": "^1.1.0", 35 | "@radix-ui/react-tabs": "^1.1.1", 36 | "@radix-ui/react-toast": "^1.2.6", 37 | "@radix-ui/react-tooltip": "^1.1.8", 38 | "class-variance-authority": "^0.7.0", 39 | "clsx": "^2.1.1", 40 | "cmdk": "^1.0.4", 41 | "lucide-react": "^0.447.0", 42 | "pkce-challenge": "^4.1.0", 43 | "prismjs": "^1.30.0", 44 | "react": "^18.3.1", 45 | "react-dom": "^18.3.1", 46 | "react-simple-code-editor": "^0.14.1", 47 | "serve-handler": "^6.1.6", 48 | "tailwind-merge": "^2.5.3", 49 | "tailwindcss-animate": "^1.0.7", 50 | "zod": "^3.23.8" 51 | }, 52 | "devDependencies": { 53 | "@eslint/js": "^9.11.1", 54 | "@testing-library/jest-dom": "^6.6.3", 55 | "@testing-library/react": "^16.2.0", 56 | "@types/jest": "^29.5.14", 57 | "@types/node": "^22.7.5", 58 | "@types/prismjs": "^1.26.5", 59 | "@types/react": "^18.3.10", 60 | "@types/react-dom": "^18.3.0", 61 | "@types/serve-handler": "^6.1.4", 62 | "@vitejs/plugin-react": "^4.3.2", 63 | "autoprefixer": "^10.4.20", 64 | "co": "^4.6.0", 65 | "eslint": "^9.11.1", 66 | "eslint-plugin-react-hooks": "^5.1.0-rc.0", 67 | "eslint-plugin-react-refresh": "^0.4.12", 68 | "globals": "^15.9.0", 69 | "jest": "^29.7.0", 70 | "jest-environment-jsdom": "^29.7.0", 71 | "postcss": "^8.4.47", 72 | "tailwindcss": "^3.4.13", 73 | "ts-jest": "^29.2.6", 74 | "typescript": "^5.5.3", 75 | "typescript-eslint": "^8.7.0", 76 | "vite": "^6.3.0" 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /client/postcss.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /client/public/mcp.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /client/src/App.css: -------------------------------------------------------------------------------- 1 | #root { 2 | margin: 0 auto; 3 | } 4 | 5 | .logo { 6 | height: 6em; 7 | padding: 1.5em; 8 | will-change: filter; 9 | transition: filter 300ms; 10 | } 11 | .logo:hover { 12 | filter: drop-shadow(0 0 2em #646cffaa); 13 | } 14 | .logo.react:hover { 15 | filter: drop-shadow(0 0 2em #61dafbaa); 16 | } 17 | 18 | @keyframes logo-spin { 19 | from { 20 | transform: rotate(0deg); 21 | } 22 | to { 23 | transform: rotate(360deg); 24 | } 25 | } 26 | 27 | @media (prefers-reduced-motion: no-preference) { 28 | a:nth-of-type(2) .logo { 29 | animation: logo-spin infinite 20s linear; 30 | } 31 | } 32 | 33 | .card { 34 | padding: 2em; 35 | } 36 | 37 | .read-the-docs { 38 | color: #888; 39 | } 40 | -------------------------------------------------------------------------------- /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 | 5 |
6 |
Welcome to MCP Client Console
7 | {/* Console output would go here */} 8 |
9 |
10 | ); 11 | 12 | export default ConsoleTab; 13 | -------------------------------------------------------------------------------- /client/src/components/History.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 |
29 |
30 |

History

31 | {requestHistory.length === 0 ? ( 32 |

33 | No history yet 34 |

35 | ) : ( 36 |
    37 | {requestHistory 38 | .slice() 39 | .reverse() 40 | .map((request, index) => ( 41 |
  • 45 |
    48 | toggleRequestExpansion(requestHistory.length - 1 - index) 49 | } 50 | > 51 | 52 | {requestHistory.length - index}.{" "} 53 | {JSON.parse(request.request).method} 54 | 55 | 56 | {expandedRequests[requestHistory.length - 1 - index] 57 | ? "▼" 58 | : "▶"} 59 | 60 |
    61 | {expandedRequests[requestHistory.length - 1 - index] && ( 62 | <> 63 |
    64 |
    65 | 66 | Request: 67 | 68 |
    69 | 70 | 74 |
    75 | {request.response && ( 76 |
    77 |
    78 | 79 | Response: 80 | 81 |
    82 | 86 |
    87 | )} 88 | 89 | )} 90 |
  • 91 | ))} 92 |
93 | )} 94 |
95 |
96 |

Server Notifications

97 | {serverNotifications.length === 0 ? ( 98 |

99 | No notifications yet 100 |

101 | ) : ( 102 |
    103 | {serverNotifications 104 | .slice() 105 | .reverse() 106 | .map((notification, index) => ( 107 |
  • 111 |
    toggleNotificationExpansion(index)} 114 | > 115 | 116 | {serverNotifications.length - index}.{" "} 117 | {notification.method} 118 | 119 | {expandedNotifications[index] ? "▼" : "▶"} 120 |
    121 | {expandedNotifications[index] && ( 122 |
    123 |
    124 | 125 | Details: 126 | 127 |
    128 | 132 |
    133 | )} 134 |
  • 135 | ))} 136 |
137 | )} 138 |
139 |
140 | ); 141 | }; 142 | 143 | export default HistoryAndNotifications; 144 | -------------------------------------------------------------------------------- /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( 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 |
37 |
44 | 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 |
60 | {displayError && ( 61 |

{displayError}

62 | )} 63 |
64 | ); 65 | }; 66 | 67 | export default JsonEditor; 68 | -------------------------------------------------------------------------------- /client/src/components/ListPane.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from "./ui/button"; 2 | 3 | type ListPaneProps = { 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 = ({ 15 | items, 16 | listItems, 17 | clearItems, 18 | setSelectedItem, 19 | renderItem, 20 | title, 21 | buttonText, 22 | isButtonDisabled, 23 | }: ListPaneProps) => ( 24 |
25 |
26 |

{title}

27 |
28 |
29 | 37 | 45 |
46 | {items.map((item, index) => ( 47 |
setSelectedItem(item)} 51 | > 52 | {renderItem(item)} 53 |
54 | ))} 55 |
56 |
57 |
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 |
80 |

Processing OAuth callback...

81 |
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 | 8 | interface OAuthCallbackProps { 9 | onConnect: ({ 10 | authorizationCode, 11 | errorMsg, 12 | }: { 13 | authorizationCode?: string; 14 | errorMsg?: string; 15 | }) => void; 16 | } 17 | 18 | const OAuthDebugCallback = ({ onConnect }: OAuthCallbackProps) => { 19 | useEffect(() => { 20 | let isProcessed = false; 21 | 22 | const handleCallback = async () => { 23 | // Skip if we've already processed this callback 24 | if (isProcessed) { 25 | return; 26 | } 27 | isProcessed = true; 28 | 29 | const params = parseOAuthCallbackParams(window.location.search); 30 | if (!params.successful) { 31 | const errorMsg = generateOAuthErrorDescription(params); 32 | onConnect({ errorMsg }); 33 | return; 34 | } 35 | 36 | const serverUrl = sessionStorage.getItem(SESSION_KEYS.SERVER_URL); 37 | 38 | // ServerURL isn't set, this can happen if we've opened the 39 | // authentication request in a new tab, so we don't have the same 40 | // session storage 41 | if (!serverUrl) { 42 | // If there's no server URL, we're likely in a new tab 43 | // Just display the code for manual copying 44 | return; 45 | } 46 | 47 | if (!params.code) { 48 | onConnect({ errorMsg: "Missing authorization code" }); 49 | return; 50 | } 51 | 52 | // Instead of storing in sessionStorage, pass the code directly 53 | // to the auth state manager through onConnect 54 | onConnect({ authorizationCode: params.code }); 55 | }; 56 | 57 | handleCallback().finally(() => { 58 | // Only redirect if we have the URL set, otherwise assume this was 59 | // in a new tab 60 | if (sessionStorage.getItem(SESSION_KEYS.SERVER_URL)) { 61 | window.history.replaceState({}, document.title, "/"); 62 | } 63 | }); 64 | 65 | return () => { 66 | isProcessed = true; 67 | }; 68 | }, [onConnect]); 69 | 70 | const callbackParams = parseOAuthCallbackParams(window.location.search); 71 | 72 | return ( 73 |
74 |
75 |

76 | Please copy this authorization code and return to the Auth Debugger: 77 |

78 | 79 | {callbackParams.successful && "code" in callbackParams 80 | ? callbackParams.code 81 | : `No code found: ${callbackParams.error}, ${callbackParams.error_description}`} 82 | 83 |

84 | Close this tab and paste the code in the OAuth flow to complete 85 | authentication. 86 |

87 |
88 |
89 | ); 90 | }; 91 | 92 | export default OAuthDebugCallback; 93 | -------------------------------------------------------------------------------- /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 | 7 |
8 |
9 | 15 |
16 |
17 |
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) => 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; 52 | completionsSupported: boolean; 53 | promptContent: string; 54 | nextCursor: ListPromptsResult["nextCursor"]; 55 | error: string | null; 56 | }) => { 57 | const [promptArgs, setPromptArgs] = useState>({}); 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 | 88 |
89 | { 93 | clearPrompts(); 94 | setSelectedPrompt(null); 95 | }} 96 | setSelectedItem={(prompt) => { 97 | setSelectedPrompt(prompt); 98 | setPromptArgs({}); 99 | }} 100 | renderItem={(prompt) => ( 101 | <> 102 | {prompt.name} 103 | 104 | {prompt.description} 105 | 106 | 107 | )} 108 | title="Prompts" 109 | buttonText={nextCursor ? "List More Prompts" : "List Prompts"} 110 | isButtonDisabled={!nextCursor && prompts.length > 0} 111 | /> 112 | 113 |
114 |
115 |

116 | {selectedPrompt ? selectedPrompt.name : "Select a prompt"} 117 |

118 |
119 |
120 | {error ? ( 121 | 122 | 123 | Error 124 | {error} 125 | 126 | ) : selectedPrompt ? ( 127 |
128 | {selectedPrompt.description && ( 129 |

130 | {selectedPrompt.description} 131 |

132 | )} 133 | {selectedPrompt.arguments?.map((arg) => ( 134 |
135 | 136 | handleInputChange(arg.name, value)} 141 | onInputChange={(value) => 142 | handleInputChange(arg.name, value) 143 | } 144 | options={completions[arg.name] || []} 145 | /> 146 | 147 | {arg.description && ( 148 |

149 | {arg.description} 150 | {arg.required && ( 151 | (Required) 152 | )} 153 |

154 | )} 155 |
156 | ))} 157 | 160 | {promptContent && ( 161 | 162 | )} 163 |
164 | ) : ( 165 | 166 | 167 | Select a prompt from the list to view and use it 168 | 169 | 170 | )} 171 |
172 |
173 |
174 |
175 | ); 176 | }; 177 | 178 | export default PromptsTab; 179 | -------------------------------------------------------------------------------- /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>; 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 | 39 |
40 | 41 | 42 | Configure the root directories that the server can access 43 | 44 | 45 | 46 | {roots.map((root, index) => ( 47 |
48 | updateRoot(index, "uri", e.target.value)} 52 | className="flex-1" 53 | /> 54 | 61 |
62 | ))} 63 | 64 |
65 | 69 | 73 |
74 |
75 |
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({ 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 |
137 |
138 | 139 |
140 |
141 |
142 | { 146 | setMessageResult(newValue); 147 | }} 148 | /> 149 |
150 |
151 | 154 | 161 |
162 |
163 |
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 | 23 |
24 | 25 | 26 | When the server requests LLM sampling, requests will appear here for 27 | approval. 28 | 29 | 30 |
31 |

Recent Requests

32 | {pendingRequests.map((request) => ( 33 | 39 | ))} 40 | {pendingRequests.length === 0 && ( 41 |

No pending requests

42 | )} 43 |
44 |
45 |
46 | ); 47 | }; 48 | 49 | export default SamplingTab; 50 | -------------------------------------------------------------------------------- /client/src/components/ToolResults.tsx: -------------------------------------------------------------------------------- 1 | import JsonView from "./JsonView"; 2 | import { 3 | CallToolResultSchema, 4 | CompatibilityCallToolResult, 5 | Tool, 6 | } from "@modelcontextprotocol/sdk/types.js"; 7 | import { validateToolOutput, hasOutputSchema } from "@/utils/schemaUtils"; 8 | 9 | interface ToolResultsProps { 10 | toolResult: CompatibilityCallToolResult | null; 11 | selectedTool: Tool | null; 12 | } 13 | 14 | const checkContentCompatibility = ( 15 | structuredContent: unknown, 16 | unstructuredContent: Array<{ 17 | type: string; 18 | text?: string; 19 | [key: string]: unknown; 20 | }>, 21 | ): { isCompatible: boolean; message: string } => { 22 | if ( 23 | unstructuredContent.length !== 1 || 24 | unstructuredContent[0].type !== "text" 25 | ) { 26 | return { 27 | isCompatible: false, 28 | message: "Unstructured content is not a single text block", 29 | }; 30 | } 31 | 32 | const textContent = unstructuredContent[0].text; 33 | if (!textContent) { 34 | return { 35 | isCompatible: false, 36 | message: "Text content is empty", 37 | }; 38 | } 39 | 40 | try { 41 | const parsedContent = JSON.parse(textContent); 42 | const isEqual = 43 | JSON.stringify(parsedContent) === JSON.stringify(structuredContent); 44 | 45 | if (isEqual) { 46 | return { 47 | isCompatible: true, 48 | message: "Unstructured content matches structured content", 49 | }; 50 | } else { 51 | return { 52 | isCompatible: false, 53 | message: "Parsed JSON does not match structured content", 54 | }; 55 | } 56 | } catch { 57 | return { 58 | isCompatible: false, 59 | message: "Unstructured content is not valid JSON", 60 | }; 61 | } 62 | }; 63 | 64 | const ToolResults = ({ toolResult, selectedTool }: ToolResultsProps) => { 65 | if (!toolResult) return null; 66 | 67 | if ("content" in toolResult) { 68 | const parsedResult = CallToolResultSchema.safeParse(toolResult); 69 | if (!parsedResult.success) { 70 | return ( 71 | <> 72 |

Invalid Tool Result:

73 | 74 |

Errors:

75 | {parsedResult.error.errors.map((error, idx) => ( 76 | 77 | ))} 78 | 79 | ); 80 | } 81 | const structuredResult = parsedResult.data; 82 | const isError = structuredResult.isError ?? false; 83 | 84 | let validationResult = null; 85 | const toolHasOutputSchema = 86 | selectedTool && hasOutputSchema(selectedTool.name); 87 | 88 | if (toolHasOutputSchema) { 89 | if (!structuredResult.structuredContent && !isError) { 90 | validationResult = { 91 | isValid: false, 92 | error: 93 | "Tool has an output schema but did not return structured content", 94 | }; 95 | } else if (structuredResult.structuredContent) { 96 | validationResult = validateToolOutput( 97 | selectedTool.name, 98 | structuredResult.structuredContent, 99 | ); 100 | } 101 | } 102 | 103 | let compatibilityResult = null; 104 | if ( 105 | structuredResult.structuredContent && 106 | structuredResult.content.length > 0 && 107 | selectedTool && 108 | hasOutputSchema(selectedTool.name) 109 | ) { 110 | compatibilityResult = checkContentCompatibility( 111 | structuredResult.structuredContent, 112 | structuredResult.content, 113 | ); 114 | } 115 | 116 | return ( 117 | <> 118 |

119 | Tool Result:{" "} 120 | {isError ? ( 121 | Error 122 | ) : ( 123 | Success 124 | )} 125 |

126 | {structuredResult.structuredContent && ( 127 |
128 |
Structured Content:
129 |
130 | 131 | {validationResult && ( 132 |
139 | {validationResult.isValid ? ( 140 | "✓ Valid according to output schema" 141 | ) : ( 142 | <>✗ Validation Error: {validationResult.error} 143 | )} 144 |
145 | )} 146 |
147 |
148 | )} 149 | {!structuredResult.structuredContent && 150 | validationResult && 151 | !validationResult.isValid && ( 152 |
153 |
154 | ✗ Validation Error: {validationResult.error} 155 |
156 |
157 | )} 158 | {structuredResult.content.length > 0 && ( 159 |
160 | {structuredResult.structuredContent && ( 161 | <> 162 |
163 | Unstructured Content: 164 |
165 | {compatibilityResult && ( 166 |
173 | {compatibilityResult.isCompatible ? "✓" : "⚠"}{" "} 174 | {compatibilityResult.message} 175 |
176 | )} 177 | 178 | )} 179 | {structuredResult.content.map((item, index) => ( 180 |
181 | {item.type === "text" && ( 182 | 183 | )} 184 | {item.type === "image" && ( 185 | Tool result image 190 | )} 191 | {item.type === "resource" && 192 | (item.resource?.mimeType?.startsWith("audio/") ? ( 193 | 200 | ) : ( 201 | 202 | ))} 203 |
204 | ))} 205 |
206 | )} 207 | 208 | ); 209 | } else if ("toolResult" in toolResult) { 210 | return ( 211 | <> 212 |

Tool Result (Legacy):

213 | 214 | 215 | ); 216 | } 217 | 218 | return null; 219 | }; 220 | 221 | export default ToolResults; 222 | -------------------------------------------------------------------------------- /client/src/components/__tests__/DynamicJsonForm.test.tsx: -------------------------------------------------------------------------------- 1 | import { render, screen, fireEvent, waitFor } from "@testing-library/react"; 2 | import { describe, it, expect, jest } from "@jest/globals"; 3 | import DynamicJsonForm from "../DynamicJsonForm"; 4 | import type { JsonSchemaType } from "@/utils/jsonUtils"; 5 | 6 | describe("DynamicJsonForm String Fields", () => { 7 | const renderForm = (props = {}) => { 8 | const defaultProps = { 9 | schema: { 10 | type: "string" as const, 11 | description: "Test string field", 12 | } satisfies JsonSchemaType, 13 | value: undefined, 14 | onChange: jest.fn(), 15 | }; 16 | return render(); 17 | }; 18 | 19 | describe("Type Validation", () => { 20 | it("should handle numeric input as string type", () => { 21 | const onChange = jest.fn(); 22 | renderForm({ onChange }); 23 | 24 | const input = screen.getByRole("textbox"); 25 | fireEvent.change(input, { target: { value: "123321" } }); 26 | 27 | expect(onChange).toHaveBeenCalledWith("123321"); 28 | // Verify the value is a string, not a number 29 | expect(typeof onChange.mock.calls[0][0]).toBe("string"); 30 | }); 31 | 32 | it("should render as text input, not number input", () => { 33 | renderForm(); 34 | const input = screen.getByRole("textbox"); 35 | expect(input).toHaveProperty("type", "text"); 36 | }); 37 | }); 38 | }); 39 | 40 | describe("DynamicJsonForm Integer Fields", () => { 41 | const renderForm = (props = {}) => { 42 | const defaultProps = { 43 | schema: { 44 | type: "integer" as const, 45 | description: "Test integer field", 46 | } satisfies JsonSchemaType, 47 | value: undefined, 48 | onChange: jest.fn(), 49 | }; 50 | return render(); 51 | }; 52 | 53 | describe("Basic Operations", () => { 54 | it("should render number input with step=1", () => { 55 | renderForm(); 56 | const input = screen.getByRole("spinbutton"); 57 | expect(input).toHaveProperty("type", "number"); 58 | expect(input).toHaveProperty("step", "1"); 59 | }); 60 | 61 | it("should pass integer values to onChange", () => { 62 | const onChange = jest.fn(); 63 | renderForm({ onChange }); 64 | 65 | const input = screen.getByRole("spinbutton"); 66 | fireEvent.change(input, { target: { value: "42" } }); 67 | 68 | expect(onChange).toHaveBeenCalledWith(42); 69 | // Verify the value is a number, not a string 70 | expect(typeof onChange.mock.calls[0][0]).toBe("number"); 71 | }); 72 | 73 | it("should not pass string values to onChange", () => { 74 | const onChange = jest.fn(); 75 | renderForm({ onChange }); 76 | 77 | const input = screen.getByRole("spinbutton"); 78 | fireEvent.change(input, { target: { value: "abc" } }); 79 | 80 | expect(onChange).not.toHaveBeenCalled(); 81 | }); 82 | }); 83 | 84 | describe("Edge Cases", () => { 85 | it("should handle non-numeric input by not calling onChange", () => { 86 | const onChange = jest.fn(); 87 | renderForm({ onChange }); 88 | 89 | const input = screen.getByRole("spinbutton"); 90 | fireEvent.change(input, { target: { value: "abc" } }); 91 | 92 | expect(onChange).not.toHaveBeenCalled(); 93 | }); 94 | }); 95 | }); 96 | 97 | describe("DynamicJsonForm Complex Fields", () => { 98 | const renderForm = (props = {}) => { 99 | const defaultProps = { 100 | schema: { 101 | type: "object", 102 | properties: { 103 | // The simplified JsonSchemaType does not accept oneOf fields 104 | // But they exist in the more-complete JsonSchema7Type 105 | nested: { oneOf: [{ type: "string" }, { type: "integer" }] }, 106 | }, 107 | } as unknown as JsonSchemaType, 108 | value: undefined, 109 | onChange: jest.fn(), 110 | }; 111 | return render(); 112 | }; 113 | 114 | describe("Basic Operations", () => { 115 | it("should render textbox and autoformat button, but no switch-to-form button", () => { 116 | renderForm(); 117 | const input = screen.getByRole("textbox"); 118 | expect(input).toHaveProperty("type", "textarea"); 119 | const buttons = screen.getAllByRole("button"); 120 | expect(buttons).toHaveLength(1); 121 | expect(buttons[0]).toHaveProperty("textContent", "Format JSON"); 122 | }); 123 | 124 | it("should pass changed values to onChange", () => { 125 | const onChange = jest.fn(); 126 | renderForm({ onChange }); 127 | 128 | const input = screen.getByRole("textbox"); 129 | fireEvent.change(input, { 130 | target: { value: `{ "nested": "i am string" }` }, 131 | }); 132 | 133 | // The onChange handler is debounced when using the JSON view, so we need to wait a little bit 134 | waitFor(() => { 135 | expect(onChange).toHaveBeenCalledWith(`{ "nested": "i am string" }`); 136 | }); 137 | }); 138 | }); 139 | }); 140 | -------------------------------------------------------------------------------- /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 | , 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 | , 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 | 12 | 17 | , 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 & VariantProps 25 | >(({ className, variant, ...props }, ref) => ( 26 |
32 | )); 33 | Alert.displayName = "Alert"; 34 | 35 | const AlertTitle = React.forwardRef< 36 | HTMLParagraphElement, 37 | React.HTMLAttributes 38 | >(({ className, ...props }, ref) => ( 39 |
44 | )); 45 | AlertTitle.displayName = "AlertTitle"; 46 | 47 | const AlertDescription = React.forwardRef< 48 | HTMLParagraphElement, 49 | React.HTMLAttributes 50 | >(({ className, ...props }, ref) => ( 51 |
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, 39 | VariantProps { 40 | asChild?: boolean; 41 | } 42 | 43 | const Button = React.forwardRef( 44 | ({ className, variant, size, asChild = false, ...props }, ref) => { 45 | const Comp = asChild ? Slot : "button"; 46 | return ( 47 | 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, 11 | React.ComponentPropsWithoutRef 12 | >(({ className, ...props }, ref) => ( 13 | 21 | 24 | 25 | 26 | 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 | 56 | 57 | 67 | 68 | 69 | 70 | 75 | {emptyMessage} 76 | 77 | {options.map((option) => ( 78 | handleSelect(option)} 82 | > 83 | 89 | {option} 90 | 91 | ))} 92 | 93 | 94 | 95 | 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, 10 | React.ComponentPropsWithoutRef 11 | >(({ className, ...props }, ref) => ( 12 | 20 | )); 21 | Command.displayName = CommandPrimitive.displayName; 22 | 23 | const CommandDialog = ({ children, ...props }: DialogProps) => { 24 | return ( 25 | 26 | 27 | 28 | {children} 29 | 30 | 31 | 32 | ); 33 | }; 34 | 35 | const CommandInput = React.forwardRef< 36 | React.ElementRef, 37 | React.ComponentPropsWithoutRef 38 | >(({ className, ...props }, ref) => ( 39 |
40 | 41 | 49 |
50 | )); 51 | 52 | CommandInput.displayName = CommandPrimitive.Input.displayName; 53 | 54 | const CommandList = React.forwardRef< 55 | React.ElementRef, 56 | React.ComponentPropsWithoutRef 57 | >(({ className, ...props }, ref) => ( 58 | 63 | )); 64 | 65 | CommandList.displayName = CommandPrimitive.List.displayName; 66 | 67 | const CommandEmpty = React.forwardRef< 68 | React.ElementRef, 69 | React.ComponentPropsWithoutRef 70 | >((props, ref) => ( 71 | 76 | )); 77 | 78 | CommandEmpty.displayName = CommandPrimitive.Empty.displayName; 79 | 80 | const CommandGroup = React.forwardRef< 81 | React.ElementRef, 82 | React.ComponentPropsWithoutRef 83 | >(({ className, ...props }, ref) => ( 84 | 92 | )); 93 | 94 | CommandGroup.displayName = CommandPrimitive.Group.displayName; 95 | 96 | const CommandSeparator = React.forwardRef< 97 | React.ElementRef, 98 | React.ComponentPropsWithoutRef 99 | >(({ className, ...props }, ref) => ( 100 | 105 | )); 106 | CommandSeparator.displayName = CommandPrimitive.Separator.displayName; 107 | 108 | const CommandItem = React.forwardRef< 109 | React.ElementRef, 110 | React.ComponentPropsWithoutRef 111 | >(({ className, ...props }, ref) => ( 112 | 120 | )); 121 | 122 | CommandItem.displayName = CommandPrimitive.Item.displayName; 123 | 124 | const CommandShortcut = ({ 125 | className, 126 | ...props 127 | }: React.HTMLAttributes) => { 128 | return ( 129 | 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, 18 | React.ComponentPropsWithoutRef 19 | >(({ className, ...props }, ref) => ( 20 | 28 | )); 29 | DialogOverlay.displayName = DialogPrimitive.Overlay.displayName; 30 | 31 | const DialogContent = React.forwardRef< 32 | React.ElementRef, 33 | React.ComponentPropsWithoutRef 34 | >(({ className, children, ...props }, ref) => ( 35 | 36 | 37 | 45 | {children} 46 | 47 | 48 | Close 49 | 50 | 51 | 52 | )); 53 | DialogContent.displayName = DialogPrimitive.Content.displayName; 54 | 55 | const DialogHeader = ({ 56 | className, 57 | ...props 58 | }: React.HTMLAttributes) => ( 59 |
66 | ); 67 | DialogHeader.displayName = "DialogHeader"; 68 | 69 | const DialogFooter = ({ 70 | className, 71 | ...props 72 | }: React.HTMLAttributes) => ( 73 |
80 | ); 81 | DialogFooter.displayName = "DialogFooter"; 82 | 83 | const DialogTitle = React.forwardRef< 84 | React.ElementRef, 85 | React.ComponentPropsWithoutRef 86 | >(({ className, ...props }, ref) => ( 87 | 95 | )); 96 | DialogTitle.displayName = DialogPrimitive.Title.displayName; 97 | 98 | const DialogDescription = React.forwardRef< 99 | React.ElementRef, 100 | React.ComponentPropsWithoutRef 101 | >(({ className, ...props }, ref) => ( 102 | 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; 6 | 7 | const Input = React.forwardRef( 8 | ({ className, type, ...props }, ref) => { 9 | return ( 10 | 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, 13 | React.ComponentPropsWithoutRef & 14 | VariantProps 15 | >(({ className, ...props }, ref) => ( 16 | 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, 14 | React.ComponentPropsWithoutRef 15 | >(({ className, align = "center", sideOffset = 4, ...props }, ref) => ( 16 | 17 | 27 | 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, 20 | React.ComponentPropsWithoutRef 21 | >(({ className, children, ...props }, ref) => ( 22 | span]:line-clamp-1 hover:border-[#646cff] hover:border-1", 26 | className, 27 | )} 28 | {...props} 29 | > 30 | {children} 31 | 32 | 33 | 34 | 35 | )); 36 | SelectTrigger.displayName = SelectPrimitive.Trigger.displayName; 37 | 38 | const SelectScrollUpButton = React.forwardRef< 39 | React.ElementRef, 40 | React.ComponentPropsWithoutRef 41 | >(({ className, ...props }, ref) => ( 42 | 50 | 51 | 52 | )); 53 | SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName; 54 | 55 | const SelectScrollDownButton = React.forwardRef< 56 | React.ElementRef, 57 | React.ComponentPropsWithoutRef 58 | >(({ className, ...props }, ref) => ( 59 | 67 | 68 | 69 | )); 70 | SelectScrollDownButton.displayName = 71 | SelectPrimitive.ScrollDownButton.displayName; 72 | 73 | const SelectContent = React.forwardRef< 74 | React.ElementRef, 75 | React.ComponentPropsWithoutRef 76 | >(({ className, children, position = "popper", ...props }, ref) => ( 77 | 78 | 89 | 90 | 97 | {children} 98 | 99 | 100 | 101 | 102 | )); 103 | SelectContent.displayName = SelectPrimitive.Content.displayName; 104 | 105 | const SelectLabel = React.forwardRef< 106 | React.ElementRef, 107 | React.ComponentPropsWithoutRef 108 | >(({ className, ...props }, ref) => ( 109 | 114 | )); 115 | SelectLabel.displayName = SelectPrimitive.Label.displayName; 116 | 117 | const SelectItem = React.forwardRef< 118 | React.ElementRef, 119 | React.ComponentPropsWithoutRef 120 | >(({ className, children, ...props }, ref) => ( 121 | 129 | 130 | 131 | 132 | 133 | 134 | {children} 135 | 136 | )); 137 | SelectItem.displayName = SelectPrimitive.Item.displayName; 138 | 139 | const SelectSeparator = React.forwardRef< 140 | React.ElementRef, 141 | React.ComponentPropsWithoutRef 142 | >(({ className, ...props }, ref) => ( 143 | 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, 10 | React.ComponentPropsWithoutRef 11 | >(({ className, ...props }, ref) => ( 12 | 20 | )); 21 | TabsList.displayName = TabsPrimitive.List.displayName; 22 | 23 | const TabsTrigger = React.forwardRef< 24 | React.ElementRef, 25 | React.ComponentPropsWithoutRef 26 | >(({ className, ...props }, ref) => ( 27 | 35 | )); 36 | TabsTrigger.displayName = TabsPrimitive.Trigger.displayName; 37 | 38 | const TabsContent = React.forwardRef< 39 | React.ElementRef, 40 | React.ComponentPropsWithoutRef 41 | >(({ className, ...props }, ref) => ( 42 | 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; 6 | 7 | const Textarea = React.forwardRef( 8 | ({ className, ...props }, ref) => { 9 | return ( 10 |