Compare commits

..

1 Commits

Author SHA1 Message Date
Ashwin Bhat
8cd1dc7dd4 fix resource reading type error 2024-10-15 14:58:48 -07:00
199 changed files with 20703 additions and 23598 deletions

3
.gitattributes vendored
View File

@@ -1 +1,2 @@
package-lock.json linguist-generated=true
yarn.lock linguist-generated=true
packages/**/* linguist-generated=true

8
.gitignore vendored
View File

@@ -1,11 +1,5 @@
.DS_Store
.vscode
.idea
node_modules/
*-workspace/
node_modules
server/build
client/dist
client/tsconfig.app.tsbuildinfo
client/tsconfig.node.tsbuildinfo
cli/build
test-output

2
.npmrc
View File

@@ -1,2 +0,0 @@
registry="https://registry.npmjs.org/"
@modelcontextprotocol:registry="https://registry.npmjs.org/"

View File

@@ -1,4 +1,2 @@
packages
server/build
CODE_OF_CONDUCT.md
SECURITY.md

View File

@@ -1,33 +0,0 @@
# MCP Inspector Development Guide
## Build Commands
- Build all: `npm run build`
- Build client: `npm run build-client`
- Build server: `npm run build-server`
- Development mode: `npm run dev` (use `npm run dev:windows` on Windows)
- Format code: `npm run prettier-fix`
- Client lint: `cd client && npm run lint`
## Code Style Guidelines
- Use TypeScript with proper type annotations
- Follow React functional component patterns with hooks
- Use ES modules (import/export) not CommonJS
- Use Prettier for formatting (auto-formatted on commit)
- Follow existing naming conventions:
- camelCase for variables and functions
- PascalCase for component names and types
- kebab-case for file names
- Use async/await for asynchronous operations
- Implement proper error handling with try/catch blocks
- Use Tailwind CSS for styling in the client
- Keep components small and focused on a single responsibility
## Project Organization
The project is organized as a monorepo with workspaces:
- `client/`: React frontend with Vite, TypeScript and Tailwind
- `server/`: Express backend with TypeScript
- `bin/`: CLI scripts

View File

@@ -1,128 +0,0 @@
# Contributor Covenant Code of Conduct
## Our Pledge
We as members, contributors, and leaders pledge to make participation in our
community a harassment-free experience for everyone, regardless of age, body
size, visible or invisible disability, ethnicity, sex characteristics, gender
identity and expression, level of experience, education, socio-economic status,
nationality, personal appearance, race, religion, or sexual identity
and orientation.
We pledge to act and interact in ways that contribute to an open, welcoming,
diverse, inclusive, and healthy community.
## Our Standards
Examples of behavior that contributes to a positive environment for our
community include:
* Demonstrating empathy and kindness toward other people
* Being respectful of differing opinions, viewpoints, and experiences
* Giving and gracefully accepting constructive feedback
* Accepting responsibility and apologizing to those affected by our mistakes,
and learning from the experience
* Focusing on what is best not just for us as individuals, but for the
overall community
Examples of unacceptable behavior include:
* The use of sexualized language or imagery, and sexual attention or
advances of any kind
* Trolling, insulting or derogatory comments, and personal or political attacks
* Public or private harassment
* Publishing others' private information, such as a physical or email
address, without their explicit permission
* Other conduct which could reasonably be considered inappropriate in a
professional setting
## Enforcement Responsibilities
Community leaders are responsible for clarifying and enforcing our standards of
acceptable behavior and will take appropriate and fair corrective action in
response to any behavior that they deem inappropriate, threatening, offensive,
or harmful.
Community leaders have the right and responsibility to remove, edit, or reject
comments, commits, code, wiki edits, issues, and other contributions that are
not aligned to this Code of Conduct, and will communicate reasons for moderation
decisions when appropriate.
## Scope
This Code of Conduct applies within all community spaces, and also applies when
an individual is officially representing the community in public spaces.
Examples of representing our community include using an official e-mail address,
posting via an official social media account, or acting as an appointed
representative at an online or offline event.
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be
reported to the community leaders responsible for enforcement at
mcp-coc@anthropic.com.
All complaints will be reviewed and investigated promptly and fairly.
All community leaders are obligated to respect the privacy and security of the
reporter of any incident.
## Enforcement Guidelines
Community leaders will follow these Community Impact Guidelines in determining
the consequences for any action they deem in violation of this Code of Conduct:
### 1. Correction
**Community Impact**: Use of inappropriate language or other behavior deemed
unprofessional or unwelcome in the community.
**Consequence**: A private, written warning from community leaders, providing
clarity around the nature of the violation and an explanation of why the
behavior was inappropriate. A public apology may be requested.
### 2. Warning
**Community Impact**: A violation through a single incident or series
of actions.
**Consequence**: A warning with consequences for continued behavior. No
interaction with the people involved, including unsolicited interaction with
those enforcing the Code of Conduct, for a specified period of time. This
includes avoiding interactions in community spaces as well as external channels
like social media. Violating these terms may lead to a temporary or
permanent ban.
### 3. Temporary Ban
**Community Impact**: A serious violation of community standards, including
sustained inappropriate behavior.
**Consequence**: A temporary ban from any sort of interaction or public
communication with the community for a specified period of time. No public or
private interaction with the people involved, including unsolicited interaction
with those enforcing the Code of Conduct, is allowed during this period.
Violating these terms may lead to a permanent ban.
### 4. Permanent Ban
**Community Impact**: Demonstrating a pattern of violation of community
standards, including sustained inappropriate behavior, harassment of an
individual, or aggression toward or disparagement of classes of individuals.
**Consequence**: A permanent ban from any sort of public interaction within
the community.
## Attribution
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
version 2.0, available at
https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
Community Impact Guidelines were inspired by [Mozilla's code of conduct
enforcement ladder](https://github.com/mozilla/diversity).
[homepage]: https://www.contributor-covenant.org
For answers to common questions about this code of conduct, see the FAQ at
https://www.contributor-covenant.org/faq. Translations are available at
https://www.contributor-covenant.org/translations.

View File

@@ -1,37 +0,0 @@
# Contributing to Model Context Protocol Inspector
Thanks for your interest in contributing! This guide explains how to get involved.
## Getting Started
1. Fork the repository and clone it locally
2. Install dependencies with `npm install`
3. Run `npm run dev` to start both client and server in development mode
4. Use the web UI at http://127.0.0.1:6274 to interact with the inspector
## Development Process & Pull Requests
1. Create a new branch for your changes
2. Make your changes following existing code style and conventions. You can run `npm run prettier-check` and `npm run prettier-fix` as applicable.
3. Test changes locally by running `npm test`
4. Update documentation as needed
5. Use clear commit messages explaining your changes
6. Verify all changes work as expected
7. Submit a pull request
8. PRs will be reviewed by maintainers
## Code of Conduct
This project follows our [Code of Conduct](CODE_OF_CONDUCT.md). Please read it before contributing.
## Security
If you find a security vulnerability, please refer to our [Security Policy](SECURITY.md) for reporting instructions.
## Questions?
Feel free to [open an issue](https://github.com/modelcontextprotocol/mcp-inspector/issues) for questions or create a discussion for general topics.
## License
By contributing, you agree that your contributions will be licensed under the MIT license.

View File

@@ -1,39 +0,0 @@
FROM node:20-alpine AS builder
WORKDIR /app
COPY . .
RUN npm install
RUN cd client && \
npm install && \
npm run build
RUN cd server && \
npm install && \
npm run build
RUN cd cli && \
npm install && \
npm run build
FROM node:20-alpine
WORKDIR /app
COPY --from=builder /app/client/bin ./client/bin
COPY --from=builder /app/client/dist ./client/dist
COPY --from=builder /app/server/build ./server/build
COPY --from=builder /app/cli/build ./cli/build
COPY --from=builder /app/package*.json ./
COPY --from=builder /app/sample-config.json ./
RUN npm ci --omit=dev --ignore-scripts
ENV NODE_ENV=production
CMD ["node", "client/bin/start.js"]

21
LICENSE
View File

@@ -1,21 +0,0 @@
MIT License
Copyright (c) 2024 Anthropic, PBC
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

254
README.md
View File

@@ -2,259 +2,23 @@
The MCP inspector is a developer tool for testing and debugging MCP servers.
![MCP Inspector Screenshot](https://raw.githubusercontent.com/modelcontextprotocol/inspector/main/mcp-inspector.png)
## Running the Inspector
### Requirements
- Node.js: ^22.7.5
### From an MCP server repository
To inspect an MCP server implementation, there's no need to clone this repo. Instead, use `npx`. For example, if your server is built at `build/index.js`:
Setup:
```bash
npx @modelcontextprotocol/inspector node build/index.js
yarn
```
You can pass both arguments and environment variables to your MCP server. Arguments are passed directly to your server, while environment variables can be set using the `-e` flag:
You can run it in dev mode via:
```bash
# Pass arguments only
npx @modelcontextprotocol/inspector node build/index.js arg1 arg2
# Pass environment variables only
npx @modelcontextprotocol/inspector -e key=value -e key2=$VALUE2 node build/index.js
# Pass both environment variables and arguments
npx @modelcontextprotocol/inspector -e key=value -e key2=$VALUE2 node build/index.js arg1 arg2
# Use -- to separate inspector flags from server arguments
npx @modelcontextprotocol/inspector -e key=$VALUE -- node build/index.js -e server-flag
yarn dev
```
The inspector runs both an MCP Inspector (MCPI) client UI (default port 6274) and an MCP Proxy (MCPP) server (default port 6277). Open the MCPI client UI in your browser to use the inspector. (These ports are derived from the T9 dialpad mapping of MCPI and MCPP respectively, as a mnemonic). You can customize the ports if needed:
This will start both the client and server.
To run in production mode:
```bash
CLIENT_PORT=8080 SERVER_PORT=9000 npx @modelcontextprotocol/inspector node build/index.js
yarn build
yarn start
```
For more details on ways to use the inspector, see the [Inspector section of the MCP docs site](https://modelcontextprotocol.io/docs/tools/inspector). For help with debugging, see the [Debugging guide](https://modelcontextprotocol.io/docs/tools/debugging).
### Servers File Export
The MCP Inspector provides convenient buttons to export server launch configurations for use in clients such as Cursor, Claude Code, or the Inspector's CLI. The file is usually called `mcp.json`.
- **Server Entry** - Copies a single server configuration entry to your clipboard. This can be added to your `mcp.json` file inside the `mcpServers` object with your preferred server name.
**STDIO transport example:**
```json
{
"command": "node",
"args": ["build/index.js", "--debug"],
"env": {
"API_KEY": "your-api-key",
"DEBUG": "true"
}
}
```
**SSE transport example:**
```json
{
"type": "sse",
"url": "http://localhost:3000/events",
"note": "For SSE connections, add this URL directly in Client"
}
```
- **Servers File** - Copies a complete MCP configuration file structure to your clipboard, with your current server configuration added as `default-server`. This can be saved directly as `mcp.json`.
**STDIO transport example:**
```json
{
"mcpServers": {
"default-server": {
"command": "node",
"args": ["build/index.js", "--debug"],
"env": {
"API_KEY": "your-api-key",
"DEBUG": "true"
}
}
}
}
```
**SSE transport example:**
```json
{
"mcpServers": {
"default-server": {
"type": "sse",
"url": "http://localhost:3000/events",
"note": "For SSE connections, add this URL directly in Client"
}
}
}
```
These buttons appear in the Inspector UI after you've configured your server settings, making it easy to save and reuse your configurations.
For SSE transport connections, the Inspector provides similar functionality for both buttons. The "Server Entry" button copies the SSE URL configuration that can be added to your existing configuration file, while the "Servers File" button creates a complete configuration file containing the SSE URL for direct use in clients.
You can paste the Server Entry into your existing `mcp.json` file under your chosen server name, or use the complete Servers File payload to create a new configuration file.
### Authentication
The inspector supports bearer token authentication for SSE connections. Enter your token in the UI when connecting to an MCP server, and it will be sent in the Authorization header. You can override the header name using the input field in the sidebar.
### Security Considerations
The MCP Inspector includes a proxy server that can run and communicate with local MCP processes. The proxy server should not be exposed to untrusted networks as it has permissions to spawn local processes and can connect to any specified MCP server.
### Configuration
The MCP Inspector supports the following configuration settings. To change them, click on the `Configuration` button in the MCP Inspector UI:
| Setting | Description | Default |
| --------------------------------------- | ------------------------------------------------------------------------------------------------------------- | ------- |
| `MCP_SERVER_REQUEST_TIMEOUT` | Timeout for requests to the MCP server (ms) | 10000 |
| `MCP_REQUEST_TIMEOUT_RESET_ON_PROGRESS` | Reset timeout on progress notifications | true |
| `MCP_REQUEST_MAX_TOTAL_TIMEOUT` | Maximum total timeout for requests sent to the MCP server (ms) (Use with progress notifications) | 60000 |
| `MCP_PROXY_FULL_ADDRESS` | Set this if you are running the MCP Inspector Proxy on a non-default address. Example: http://10.1.1.22:5577 | "" |
| `MCP_AUTO_OPEN_ENABLED` | Enable automatic browser opening when inspector starts. Only as environment var, not configurable in browser. | true |
These settings can be adjusted in real-time through the UI and will persist across sessions.
The inspector also supports configuration files to store settings for different MCP servers. This is useful when working with multiple servers or complex configurations:
```bash
npx @modelcontextprotocol/inspector --config path/to/config.json --server everything
```
Example server configuration file:
```json
{
"mcpServers": {
"everything": {
"command": "npx",
"args": ["@modelcontextprotocol/server-everything"],
"env": {
"hello": "Hello MCP!"
}
},
"my-server": {
"command": "node",
"args": ["build/index.js", "arg1", "arg2"],
"env": {
"key": "value",
"key2": "value2"
}
}
}
}
```
> **Tip:** You can easily generate this configuration format using the **Server Entry** and **Servers File** buttons in the Inspector UI, as described in the Servers File Export section above.
You can also set the initial `transport` type, `serverUrl`, `serverCommand`, and `serverArgs` via query params, for example:
```
http://localhost:6274/?transport=sse&serverUrl=http://localhost:8787/sse
http://localhost:6274/?transport=streamable-http&serverUrl=http://localhost:8787/mcp
http://localhost:6274/?transport=stdio&serverCommand=npx&serverArgs=arg1%20arg2
```
You can also set initial config settings via query params, for example:
```
http://localhost:6274/?MCP_SERVER_REQUEST_TIMEOUT=10000&MCP_REQUEST_TIMEOUT_RESET_ON_PROGRESS=false&MCP_PROXY_FULL_ADDRESS=http://10.1.1.22:5577
```
Note that if both the query param and the corresponding localStorage item are set, the query param will take precedence.
### From this repository
If you're working on the inspector itself:
Development mode:
```bash
npm run dev
```
> **Note for Windows users:**
> On Windows, use the following command instead:
>
> ```bash
> npm run dev:windows
> ```
Production mode:
```bash
npm run build
npm start
```
### CLI Mode
CLI mode enables programmatic interaction with MCP servers from the command line, ideal for scripting, automation, and integration with coding assistants. This creates an efficient feedback loop for MCP server development.
```bash
npx @modelcontextprotocol/inspector --cli node build/index.js
```
The CLI mode supports most operations across tools, resources, and prompts. A few examples:
```bash
# Basic usage
npx @modelcontextprotocol/inspector --cli node build/index.js
# With config file
npx @modelcontextprotocol/inspector --cli --config path/to/config.json --server myserver
# List available tools
npx @modelcontextprotocol/inspector --cli node build/index.js --method tools/list
# Call a specific tool
npx @modelcontextprotocol/inspector --cli node build/index.js --method tools/call --tool-name mytool --tool-arg key=value --tool-arg another=value2
# List available resources
npx @modelcontextprotocol/inspector --cli node build/index.js --method resources/list
# List available prompts
npx @modelcontextprotocol/inspector --cli node build/index.js --method prompts/list
# Connect to a remote MCP server
npx @modelcontextprotocol/inspector --cli https://my-mcp-server.example.com
# Call a tool on a remote server
npx @modelcontextprotocol/inspector --cli https://my-mcp-server.example.com --method tools/call --tool-name remotetool --tool-arg param=value
# List resources from a remote server
npx @modelcontextprotocol/inspector --cli https://my-mcp-server.example.com --method resources/list
```
### UI Mode vs CLI Mode: When to Use Each
| Use Case | UI Mode | CLI Mode |
| ------------------------ | ------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Server Development** | Visual interface for interactive testing and debugging during development | Scriptable commands for quick testing and continuous integration; creates feedback loops with AI coding assistants like Cursor for rapid development |
| **Resource Exploration** | Interactive browser with hierarchical navigation and JSON visualization | Programmatic listing and reading for automation and scripting |
| **Tool Testing** | Form-based parameter input with real-time response visualization | Command-line tool execution with JSON output for scripting |
| **Prompt Engineering** | Interactive sampling with streaming responses and visual comparison | Batch processing of prompts with machine-readable output |
| **Debugging** | Request history, visualized errors, and real-time notifications | Direct JSON output for log analysis and integration with other tools |
| **Automation** | N/A | Ideal for CI/CD pipelines, batch processing, and integration with coding assistants |
| **Learning MCP** | Rich visual interface helps new users understand server capabilities | Simplified commands for focused learning of specific endpoints |
## License
This project is licensed under the MIT License—see the [LICENSE](LICENSE) file for details.

View File

@@ -1,14 +0,0 @@
# Security Policy
Thank you for helping us keep the inspector secure.
## Reporting Security Issues
This project is maintained by [Anthropic](https://www.anthropic.com/) as part of the Model Context Protocol project.
The security of our systems and user data is Anthropics top priority. We appreciate the work of security researchers acting in good faith in identifying and reporting potential vulnerabilities.
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).
## Vulnerability Disclosure Program
Our Vulnerability Program Guidelines are defined on our [HackerOne program page](https://hackerone.com/anthropic-vdp).

View File

@@ -1,28 +0,0 @@
{
"name": "@modelcontextprotocol/inspector-cli",
"version": "0.12.0",
"description": "CLI for the Model Context Protocol inspector",
"license": "MIT",
"author": "Anthropic, PBC (https://anthropic.com)",
"homepage": "https://modelcontextprotocol.io",
"bugs": "https://github.com/modelcontextprotocol/inspector/issues",
"main": "build/cli.js",
"type": "module",
"bin": {
"mcp-inspector-cli": "build/cli.js"
},
"files": [
"build"
],
"scripts": {
"build": "tsc",
"postbuild": "node scripts/make-executable.js",
"test": "node scripts/cli-tests.js"
},
"devDependencies": {},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.11.0",
"commander": "^13.1.0",
"spawn-rx": "^5.1.2"
}
}

View File

@@ -1,633 +0,0 @@
#!/usr/bin/env node
// Colors for output
const colors = {
GREEN: "\x1b[32m",
YELLOW: "\x1b[33m",
RED: "\x1b[31m",
BLUE: "\x1b[34m",
ORANGE: "\x1b[33m",
NC: "\x1b[0m", // No Color
};
import fs from "fs";
import path from "path";
import { execSync, spawn } from "child_process";
import os from "os";
import { fileURLToPath } from "url";
// Get directory paths with ESM compatibility
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// Track test results
let PASSED_TESTS = 0;
let FAILED_TESTS = 0;
let SKIPPED_TESTS = 0;
let TOTAL_TESTS = 0;
console.log(
`${colors.YELLOW}=== MCP Inspector CLI Test Script ===${colors.NC}`,
);
console.log(
`${colors.BLUE}This script tests the MCP Inspector CLI's ability to handle various command line options:${colors.NC}`,
);
console.log(`${colors.BLUE}- Basic CLI mode${colors.NC}`);
console.log(`${colors.BLUE}- Environment variables (-e)${colors.NC}`);
console.log(`${colors.BLUE}- Config file (--config)${colors.NC}`);
console.log(`${colors.BLUE}- Server selection (--server)${colors.NC}`);
console.log(`${colors.BLUE}- Method selection (--method)${colors.NC}`);
console.log(
`${colors.BLUE}- Tool-related options (--tool-name, --tool-arg)${colors.NC}`,
);
console.log(`${colors.BLUE}- Resource-related options (--uri)${colors.NC}`);
console.log(
`${colors.BLUE}- Prompt-related options (--prompt-name, --prompt-args)${colors.NC}`,
);
console.log(`${colors.BLUE}- Logging options (--log-level)${colors.NC}\n`);
// Get directory paths
const SCRIPTS_DIR = __dirname;
const PROJECT_ROOT = path.join(SCRIPTS_DIR, "../../");
const BUILD_DIR = path.resolve(SCRIPTS_DIR, "../build");
// Define the test server command using npx
const TEST_CMD = "npx";
const TEST_ARGS = ["@modelcontextprotocol/server-everything"];
// Create output directory for test results
const OUTPUT_DIR = path.join(SCRIPTS_DIR, "test-output");
if (!fs.existsSync(OUTPUT_DIR)) {
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
}
// Create a temporary directory for test files
const TEMP_DIR = fs.mkdirSync(path.join(os.tmpdir(), "mcp-inspector-tests"), {
recursive: true,
});
process.on("exit", () => {
try {
fs.rmSync(TEMP_DIR, { recursive: true, force: true });
} catch (err) {
console.error(
`${colors.RED}Failed to remove temp directory: ${err.message}${colors.NC}`,
);
}
});
// Use the existing sample config file
console.log(
`${colors.BLUE}Using existing sample config file: ${PROJECT_ROOT}/sample-config.json${colors.NC}`,
);
try {
const sampleConfig = fs.readFileSync(
path.join(PROJECT_ROOT, "sample-config.json"),
"utf8",
);
console.log(sampleConfig);
} catch (error) {
console.error(
`${colors.RED}Error reading sample config: ${error.message}${colors.NC}`,
);
}
// Create an invalid config file for testing
const invalidConfigPath = path.join(TEMP_DIR, "invalid-config.json");
fs.writeFileSync(invalidConfigPath, '{\n "mcpServers": {\n "invalid": {');
// Function to run a basic test
async function runBasicTest(testName, ...args) {
const outputFile = path.join(
OUTPUT_DIR,
`${testName.replace(/\//g, "_")}.log`,
);
console.log(`\n${colors.YELLOW}Testing: ${testName}${colors.NC}`);
TOTAL_TESTS++;
// Run the command and capture output
console.log(
`${colors.BLUE}Command: node ${BUILD_DIR}/cli.js ${args.join(" ")}${colors.NC}`,
);
try {
// Create a write stream for the output file
const outputStream = fs.createWriteStream(outputFile);
// Spawn the process
return new Promise((resolve) => {
const child = spawn("node", [path.join(BUILD_DIR, "cli.js"), ...args], {
stdio: ["ignore", "pipe", "pipe"],
});
// Pipe stdout and stderr to the output file
child.stdout.pipe(outputStream);
child.stderr.pipe(outputStream);
// Also capture output for display
let output = "";
child.stdout.on("data", (data) => {
output += data.toString();
});
child.stderr.on("data", (data) => {
output += data.toString();
});
child.on("close", (code) => {
outputStream.end();
if (code === 0) {
console.log(`${colors.GREEN}✓ Test passed: ${testName}${colors.NC}`);
console.log(`${colors.BLUE}First few lines of output:${colors.NC}`);
const firstFewLines = output
.split("\n")
.slice(0, 5)
.map((line) => ` ${line}`)
.join("\n");
console.log(firstFewLines);
PASSED_TESTS++;
resolve(true);
} else {
console.log(`${colors.RED}✗ Test failed: ${testName}${colors.NC}`);
console.log(`${colors.RED}Error output:${colors.NC}`);
console.log(
output
.split("\n")
.map((line) => ` ${line}`)
.join("\n"),
);
FAILED_TESTS++;
// Stop after any error is encountered
console.log(
`${colors.YELLOW}Stopping tests due to error. Please validate and fix before continuing.${colors.NC}`,
);
process.exit(1);
}
});
});
} catch (error) {
console.error(
`${colors.RED}Error running test: ${error.message}${colors.NC}`,
);
FAILED_TESTS++;
process.exit(1);
}
}
// Function to run an error test (expected to fail)
async function runErrorTest(testName, ...args) {
const outputFile = path.join(
OUTPUT_DIR,
`${testName.replace(/\//g, "_")}.log`,
);
console.log(`\n${colors.YELLOW}Testing error case: ${testName}${colors.NC}`);
TOTAL_TESTS++;
// Run the command and capture output
console.log(
`${colors.BLUE}Command: node ${BUILD_DIR}/cli.js ${args.join(" ")}${colors.NC}`,
);
try {
// Create a write stream for the output file
const outputStream = fs.createWriteStream(outputFile);
// Spawn the process
return new Promise((resolve) => {
const child = spawn("node", [path.join(BUILD_DIR, "cli.js"), ...args], {
stdio: ["ignore", "pipe", "pipe"],
});
// Pipe stdout and stderr to the output file
child.stdout.pipe(outputStream);
child.stderr.pipe(outputStream);
// Also capture output for display
let output = "";
child.stdout.on("data", (data) => {
output += data.toString();
});
child.stderr.on("data", (data) => {
output += data.toString();
});
child.on("close", (code) => {
outputStream.end();
// For error tests, we expect a non-zero exit code
if (code !== 0) {
console.log(
`${colors.GREEN}✓ Error test passed: ${testName}${colors.NC}`,
);
console.log(`${colors.BLUE}Error output (expected):${colors.NC}`);
const firstFewLines = output
.split("\n")
.slice(0, 5)
.map((line) => ` ${line}`)
.join("\n");
console.log(firstFewLines);
PASSED_TESTS++;
resolve(true);
} else {
console.log(
`${colors.RED}✗ Error test failed: ${testName} (expected error but got success)${colors.NC}`,
);
console.log(`${colors.RED}Output:${colors.NC}`);
console.log(
output
.split("\n")
.map((line) => ` ${line}`)
.join("\n"),
);
FAILED_TESTS++;
// Stop after any error is encountered
console.log(
`${colors.YELLOW}Stopping tests due to error. Please validate and fix before continuing.${colors.NC}`,
);
process.exit(1);
}
});
});
} catch (error) {
console.error(
`${colors.RED}Error running test: ${error.message}${colors.NC}`,
);
FAILED_TESTS++;
process.exit(1);
}
}
// Run all tests
async function runTests() {
console.log(
`\n${colors.YELLOW}=== Running Basic CLI Mode Tests ===${colors.NC}`,
);
// Test 1: Basic CLI mode with method
await runBasicTest(
"basic_cli_mode",
TEST_CMD,
...TEST_ARGS,
"--cli",
"--method",
"tools/list",
);
// Test 2: CLI mode with non-existent method (should fail)
await runErrorTest(
"nonexistent_method",
TEST_CMD,
...TEST_ARGS,
"--cli",
"--method",
"nonexistent/method",
);
// Test 3: CLI mode without method (should fail)
await runErrorTest("missing_method", TEST_CMD, ...TEST_ARGS, "--cli");
console.log(
`\n${colors.YELLOW}=== Running Environment Variable Tests ===${colors.NC}`,
);
// Test 4: CLI mode with environment variables
await runBasicTest(
"env_variables",
TEST_CMD,
...TEST_ARGS,
"-e",
"KEY1=value1",
"-e",
"KEY2=value2",
"--cli",
"--method",
"tools/list",
);
// Test 5: CLI mode with invalid environment variable format (should fail)
await runErrorTest(
"invalid_env_format",
TEST_CMD,
...TEST_ARGS,
"-e",
"INVALID_FORMAT",
"--cli",
"--method",
"tools/list",
);
// Test 5b: CLI mode with environment variable containing equals sign in value
await runBasicTest(
"env_variable_with_equals",
TEST_CMD,
...TEST_ARGS,
"-e",
"API_KEY=abc123=xyz789==",
"--cli",
"--method",
"tools/list",
);
// Test 5c: CLI mode with environment variable containing base64-encoded value
await runBasicTest(
"env_variable_with_base64",
TEST_CMD,
...TEST_ARGS,
"-e",
"JWT_TOKEN=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0=",
"--cli",
"--method",
"tools/list",
);
console.log(
`\n${colors.YELLOW}=== Running Config File Tests ===${colors.NC}`,
);
// Test 6: Using config file with CLI mode
await runBasicTest(
"config_file",
"--config",
path.join(PROJECT_ROOT, "sample-config.json"),
"--server",
"everything",
"--cli",
"--method",
"tools/list",
);
// Test 7: Using config file without server name (should fail)
await runErrorTest(
"config_without_server",
"--config",
path.join(PROJECT_ROOT, "sample-config.json"),
"--cli",
"--method",
"tools/list",
);
// Test 8: Using server name without config file (should fail)
await runErrorTest(
"server_without_config",
"--server",
"everything",
"--cli",
"--method",
"tools/list",
);
// Test 9: Using non-existent config file (should fail)
await runErrorTest(
"nonexistent_config",
"--config",
"./nonexistent-config.json",
"--server",
"everything",
"--cli",
"--method",
"tools/list",
);
// Test 10: Using invalid config file format (should fail)
await runErrorTest(
"invalid_config",
"--config",
invalidConfigPath,
"--server",
"everything",
"--cli",
"--method",
"tools/list",
);
// Test 11: Using config file with non-existent server (should fail)
await runErrorTest(
"nonexistent_server",
"--config",
path.join(PROJECT_ROOT, "sample-config.json"),
"--server",
"nonexistent",
"--cli",
"--method",
"tools/list",
);
console.log(
`\n${colors.YELLOW}=== Running Tool-Related Tests ===${colors.NC}`,
);
// Test 12: CLI mode with tool call
await runBasicTest(
"tool_call",
TEST_CMD,
...TEST_ARGS,
"--cli",
"--method",
"tools/call",
"--tool-name",
"echo",
"--tool-arg",
"message=Hello",
);
// Test 13: CLI mode with tool call but missing tool name (should fail)
await runErrorTest(
"missing_tool_name",
TEST_CMD,
...TEST_ARGS,
"--cli",
"--method",
"tools/call",
"--tool-arg",
"message=Hello",
);
// Test 14: CLI mode with tool call but invalid tool args format (should fail)
await runErrorTest(
"invalid_tool_args",
TEST_CMD,
...TEST_ARGS,
"--cli",
"--method",
"tools/call",
"--tool-name",
"echo",
"--tool-arg",
"invalid_format",
);
// Test 15: CLI mode with multiple tool args
await runBasicTest(
"multiple_tool_args",
TEST_CMD,
...TEST_ARGS,
"--cli",
"--method",
"tools/call",
"--tool-name",
"add",
"--tool-arg",
"a=1",
"b=2",
);
console.log(
`\n${colors.YELLOW}=== Running Resource-Related Tests ===${colors.NC}`,
);
// Test 16: CLI mode with resource read
await runBasicTest(
"resource_read",
TEST_CMD,
...TEST_ARGS,
"--cli",
"--method",
"resources/read",
"--uri",
"test://static/resource/1",
);
// Test 17: CLI mode with resource read but missing URI (should fail)
await runErrorTest(
"missing_uri",
TEST_CMD,
...TEST_ARGS,
"--cli",
"--method",
"resources/read",
);
console.log(
`\n${colors.YELLOW}=== Running Prompt-Related Tests ===${colors.NC}`,
);
// Test 18: CLI mode with prompt get
await runBasicTest(
"prompt_get",
TEST_CMD,
...TEST_ARGS,
"--cli",
"--method",
"prompts/get",
"--prompt-name",
"simple_prompt",
);
// Test 19: CLI mode with prompt get and args
await runBasicTest(
"prompt_get_with_args",
TEST_CMD,
...TEST_ARGS,
"--cli",
"--method",
"prompts/get",
"--prompt-name",
"complex_prompt",
"--prompt-args",
"temperature=0.7",
"style=concise",
);
// Test 20: CLI mode with prompt get but missing prompt name (should fail)
await runErrorTest(
"missing_prompt_name",
TEST_CMD,
...TEST_ARGS,
"--cli",
"--method",
"prompts/get",
);
console.log(`\n${colors.YELLOW}=== Running Logging Tests ===${colors.NC}`);
// Test 21: CLI mode with log level
await runBasicTest(
"log_level",
TEST_CMD,
...TEST_ARGS,
"--cli",
"--method",
"logging/setLevel",
"--log-level",
"debug",
);
// Test 22: CLI mode with invalid log level (should fail)
await runErrorTest(
"invalid_log_level",
TEST_CMD,
...TEST_ARGS,
"--cli",
"--method",
"logging/setLevel",
"--log-level",
"invalid",
);
console.log(
`\n${colors.YELLOW}=== Running Combined Option Tests ===${colors.NC}`,
);
// Note about the combined options issue
console.log(
`${colors.BLUE}Testing combined options with environment variables and config file.${colors.NC}`,
);
// Test 23: CLI mode with config file, environment variables, and tool call
await runBasicTest(
"combined_options",
"--config",
path.join(PROJECT_ROOT, "sample-config.json"),
"--server",
"everything",
"-e",
"CLI_ENV_VAR=cli_value",
"--cli",
"--method",
"tools/list",
);
// Test 24: CLI mode with all possible options (that make sense together)
await runBasicTest(
"all_options",
"--config",
path.join(PROJECT_ROOT, "sample-config.json"),
"--server",
"everything",
"-e",
"CLI_ENV_VAR=cli_value",
"--cli",
"--method",
"tools/call",
"--tool-name",
"echo",
"--tool-arg",
"message=Hello",
"--log-level",
"debug",
);
// Print test summary
console.log(`\n${colors.YELLOW}=== Test Summary ===${colors.NC}`);
console.log(`${colors.GREEN}Passed: ${PASSED_TESTS}${colors.NC}`);
console.log(`${colors.RED}Failed: ${FAILED_TESTS}${colors.NC}`);
console.log(`${colors.ORANGE}Skipped: ${SKIPPED_TESTS}${colors.NC}`);
console.log(`Total: ${TOTAL_TESTS}`);
console.log(
`${colors.BLUE}Detailed logs saved to: ${OUTPUT_DIR}${colors.NC}`,
);
console.log(`\n${colors.GREEN}All tests completed!${colors.NC}`);
}
// Run all tests
runTests().catch((error) => {
console.error(
`${colors.RED}Tests failed with error: ${error.message}${colors.NC}`,
);
process.exit(1);
});

View File

@@ -1,29 +0,0 @@
/**
* Cross-platform script to make a file executable
*/
import { promises as fs } from "fs";
import { platform } from "os";
import { execSync } from "child_process";
import path from "path";
const TARGET_FILE = path.resolve("build/cli.js");
async function makeExecutable() {
try {
// On Unix-like systems (Linux, macOS), use chmod
if (platform() !== "win32") {
execSync(`chmod +x "${TARGET_FILE}"`);
console.log("Made file executable with chmod");
} else {
// On Windows, no need to make files "executable" in the Unix sense
// Just ensure the file exists
await fs.access(TARGET_FILE);
console.log("File exists and is accessible on Windows");
}
} catch (error) {
console.error("Error making file executable:", error);
process.exit(1);
}
}
makeExecutable();

View File

@@ -1,287 +0,0 @@
#!/usr/bin/env node
import { Command } from "commander";
import fs from "node:fs";
import path from "node:path";
import { dirname, resolve } from "path";
import { spawnPromise } from "spawn-rx";
import { fileURLToPath } from "url";
const __dirname = dirname(fileURLToPath(import.meta.url));
type Args = {
command: string;
args: string[];
envArgs: Record<string, string>;
cli: boolean;
};
type CliOptions = {
e?: Record<string, string>;
config?: string;
server?: string;
cli?: boolean;
};
type ServerConfig = {
command: string;
args?: string[];
env?: Record<string, string>;
};
function handleError(error: unknown): never {
let message: string;
if (error instanceof Error) {
message = error.message;
} else if (typeof error === "string") {
message = error;
} else {
message = "Unknown error";
}
console.error(message);
process.exit(1);
}
function delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms, true));
}
async function runWebClient(args: Args): Promise<void> {
const inspectorServerPath = resolve(
__dirname,
"../../",
"server",
"build",
"index.js",
);
// Path to the client entry point
const inspectorClientPath = resolve(
__dirname,
"../../",
"client",
"bin",
"client.js",
);
const CLIENT_PORT: string = process.env.CLIENT_PORT ?? "6274";
const SERVER_PORT: string = process.env.SERVER_PORT ?? "6277";
console.log("Starting MCP inspector...");
const abort = new AbortController();
let cancelled: boolean = false;
process.on("SIGINT", () => {
cancelled = true;
abort.abort();
});
let server: ReturnType<typeof spawnPromise>;
let serverOk: unknown;
try {
server = spawnPromise(
"node",
[
inspectorServerPath,
...(args.command ? [`--env`, args.command] : []),
...(args.args ? [`--args=${args.args.join(" ")}`] : []),
],
{
env: {
...process.env,
PORT: SERVER_PORT,
MCP_ENV_VARS: JSON.stringify(args.envArgs),
},
signal: abort.signal,
echoOutput: true,
},
);
// Make sure server started before starting client
serverOk = await Promise.race([server, delay(2 * 1000)]);
} catch (error) {}
if (serverOk) {
try {
await spawnPromise("node", [inspectorClientPath], {
env: { ...process.env, PORT: CLIENT_PORT },
signal: abort.signal,
echoOutput: true,
});
} catch (e) {
if (!cancelled || process.env.DEBUG) throw e;
}
}
}
async function runCli(args: Args): Promise<void> {
const projectRoot = resolve(__dirname, "..");
const cliPath = resolve(projectRoot, "build", "index.js");
const abort = new AbortController();
let cancelled = false;
process.on("SIGINT", () => {
cancelled = true;
abort.abort();
});
try {
await spawnPromise("node", [cliPath, args.command, ...args.args], {
env: { ...process.env, ...args.envArgs },
signal: abort.signal,
echoOutput: true,
});
} catch (e) {
if (!cancelled || process.env.DEBUG) {
throw e;
}
}
}
function loadConfigFile(configPath: string, serverName: string): ServerConfig {
try {
const resolvedConfigPath = path.isAbsolute(configPath)
? configPath
: path.resolve(process.cwd(), configPath);
if (!fs.existsSync(resolvedConfigPath)) {
throw new Error(`Config file not found: ${resolvedConfigPath}`);
}
const configContent = fs.readFileSync(resolvedConfigPath, "utf8");
const parsedConfig = JSON.parse(configContent);
if (!parsedConfig.mcpServers || !parsedConfig.mcpServers[serverName]) {
const availableServers = Object.keys(parsedConfig.mcpServers || {}).join(
", ",
);
throw new Error(
`Server '${serverName}' not found in config file. Available servers: ${availableServers}`,
);
}
const serverConfig = parsedConfig.mcpServers[serverName];
return serverConfig;
} catch (err: unknown) {
if (err instanceof SyntaxError) {
throw new Error(`Invalid JSON in config file: ${err.message}`);
}
throw err;
}
}
function parseKeyValuePair(
value: string,
previous: Record<string, string> = {},
): Record<string, string> {
const parts = value.split("=");
const key = parts[0];
const val = parts.slice(1).join("=");
if (val === undefined || val === "") {
throw new Error(
`Invalid parameter format: ${value}. Use key=value format.`,
);
}
return { ...previous, [key as string]: val };
}
function parseArgs(): Args {
const program = new Command();
const argSeparatorIndex = process.argv.indexOf("--");
let preArgs = process.argv;
let postArgs: string[] = [];
if (argSeparatorIndex !== -1) {
preArgs = process.argv.slice(0, argSeparatorIndex);
postArgs = process.argv.slice(argSeparatorIndex + 1);
}
program
.name("inspector-bin")
.allowExcessArguments()
.allowUnknownOption()
.option(
"-e <env>",
"environment variables in KEY=VALUE format",
parseKeyValuePair,
{},
)
.option("--config <path>", "config file path")
.option("--server <n>", "server name from config file")
.option("--cli", "enable CLI mode");
// Parse only the arguments before --
program.parse(preArgs);
const options = program.opts() as CliOptions;
const remainingArgs = program.args;
// Add back any arguments that came after --
const finalArgs = [...remainingArgs, ...postArgs];
// Validate that config and server are provided together
if (
(options.config && !options.server) ||
(!options.config && options.server)
) {
throw new Error(
"Both --config and --server must be provided together. If you specify one, you must specify the other.",
);
}
// If config file is specified, load and use the options from the file. We must merge the args
// from the command line and the file together, or we will miss the method options (--method,
// etc.)
if (options.config && options.server) {
const config = loadConfigFile(options.config, options.server);
return {
command: config.command,
args: [...(config.args || []), ...finalArgs],
envArgs: { ...(config.env || {}), ...(options.e || {}) },
cli: options.cli || false,
};
}
// Otherwise use command line arguments
const command = finalArgs[0] || "";
const args = finalArgs.slice(1);
return {
command,
args,
envArgs: options.e || {},
cli: options.cli || false,
};
}
async function main(): Promise<void> {
process.on("uncaughtException", (error) => {
handleError(error);
});
try {
const args = parseArgs();
if (args.cli) {
runCli(args);
} else {
await runWebClient(args);
}
} catch (error) {
handleError(error);
}
}
main();

View File

@@ -1,51 +0,0 @@
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
import { McpResponse } from "./types.js";
export const validLogLevels = [
"trace",
"debug",
"info",
"warn",
"error",
] as const;
export type LogLevel = (typeof validLogLevels)[number];
export async function connect(
client: Client,
transport: Transport,
): Promise<void> {
try {
await client.connect(transport);
} catch (error) {
throw new Error(
`Failed to connect to MCP server: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
export async function disconnect(transport: Transport): Promise<void> {
try {
await transport.close();
} catch (error) {
throw new Error(
`Failed to disconnect from MCP server: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
// Set logging level
export async function setLoggingLevel(
client: Client,
level: LogLevel,
): Promise<McpResponse> {
try {
const response = await client.setLoggingLevel(level as any);
return response;
} catch (error) {
throw new Error(
`Failed to set logging level: ${error instanceof Error ? error.message : String(error)}`,
);
}
}

View File

@@ -1,6 +0,0 @@
// Re-export everything from the client modules
export * from "./connection.js";
export * from "./prompts.js";
export * from "./resources.js";
export * from "./tools.js";
export * from "./types.js";

View File

@@ -1,34 +0,0 @@
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { McpResponse } from "./types.js";
// List available prompts
export async function listPrompts(client: Client): Promise<McpResponse> {
try {
const response = await client.listPrompts();
return response;
} catch (error) {
throw new Error(
`Failed to list prompts: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
// Get a prompt
export async function getPrompt(
client: Client,
name: string,
args?: Record<string, string>,
): Promise<McpResponse> {
try {
const response = await client.getPrompt({
name,
arguments: args || {},
});
return response;
} catch (error) {
throw new Error(
`Failed to get prompt: ${error instanceof Error ? error.message : String(error)}`,
);
}
}

View File

@@ -1,43 +0,0 @@
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { McpResponse } from "./types.js";
// List available resources
export async function listResources(client: Client): Promise<McpResponse> {
try {
const response = await client.listResources();
return response;
} catch (error) {
throw new Error(
`Failed to list resources: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
// Read a resource
export async function readResource(
client: Client,
uri: string,
): Promise<McpResponse> {
try {
const response = await client.readResource({ uri });
return response;
} catch (error) {
throw new Error(
`Failed to read resource ${uri}: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
// List resource templates
export async function listResourceTemplates(
client: Client,
): Promise<McpResponse> {
try {
const response = await client.listResourceTemplates();
return response;
} catch (error) {
throw new Error(
`Failed to list resource templates: ${error instanceof Error ? error.message : String(error)}`,
);
}
}

View File

@@ -1,95 +0,0 @@
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { Tool } from "@modelcontextprotocol/sdk/types.js";
import { McpResponse } from "./types.js";
type JsonSchemaType = {
type: "string" | "number" | "integer" | "boolean" | "array" | "object";
description?: string;
properties?: Record<string, JsonSchemaType>;
items?: JsonSchemaType;
};
export async function listTools(client: Client): Promise<McpResponse> {
try {
const response = await client.listTools();
return response;
} catch (error) {
throw new Error(
`Failed to list tools: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
function convertParameterValue(value: string, schema: JsonSchemaType): unknown {
if (!value) {
return value;
}
if (schema.type === "number" || schema.type === "integer") {
return Number(value);
}
if (schema.type === "boolean") {
return value.toLowerCase() === "true";
}
if (schema.type === "object" || schema.type === "array") {
try {
return JSON.parse(value);
} catch (error) {
return value;
}
}
return value;
}
function convertParameters(
tool: Tool,
params: Record<string, string>,
): Record<string, unknown> {
const result: Record<string, unknown> = {};
const properties = tool.inputSchema.properties || {};
for (const [key, value] of Object.entries(params)) {
const paramSchema = properties[key] as JsonSchemaType | undefined;
if (paramSchema) {
result[key] = convertParameterValue(value, paramSchema);
} else {
// If no schema is found for this parameter, keep it as string
result[key] = value;
}
}
return result;
}
export async function callTool(
client: Client,
name: string,
args: Record<string, string>,
): Promise<McpResponse> {
try {
const toolsResponse = await listTools(client);
const tools = toolsResponse.tools as Tool[];
const tool = tools.find((t) => t.name === name);
let convertedArgs: Record<string, unknown> = args;
if (tool) {
// Convert parameters based on the tool's schema
convertedArgs = convertParameters(tool, args);
}
const response = await client.callTool({
name: name,
arguments: convertedArgs,
});
return response;
} catch (error) {
throw new Error(
`Failed to call tool ${name}: ${error instanceof Error ? error.message : String(error)}`,
);
}
}

View File

@@ -1 +0,0 @@
export type McpResponse = Record<string, unknown>;

View File

@@ -1,20 +0,0 @@
function formatError(error: unknown): string {
let message: string;
if (error instanceof Error) {
message = error.message;
} else if (typeof error === "string") {
message = error;
} else {
message = "Unknown error";
}
return message;
}
export function handleError(error: unknown): never {
const errorMessage = formatError(error);
console.error(errorMessage);
process.exit(1);
}

View File

@@ -1,253 +0,0 @@
#!/usr/bin/env node
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { Command } from "commander";
import {
callTool,
connect,
disconnect,
getPrompt,
listPrompts,
listResources,
listResourceTemplates,
listTools,
LogLevel,
McpResponse,
readResource,
setLoggingLevel,
validLogLevels,
} from "./client/index.js";
import { handleError } from "./error-handler.js";
import { createTransport, TransportOptions } from "./transport.js";
type Args = {
target: string[];
method?: string;
promptName?: string;
promptArgs?: Record<string, string>;
uri?: string;
logLevel?: LogLevel;
toolName?: string;
toolArg?: Record<string, string>;
};
function createTransportOptions(target: string[]): TransportOptions {
if (target.length === 0) {
throw new Error(
"Target is required. Specify a URL or a command to execute.",
);
}
const [command, ...commandArgs] = target;
if (!command) {
throw new Error("Command is required.");
}
const isUrl = command.startsWith("http://") || command.startsWith("https://");
if (isUrl && commandArgs.length > 0) {
throw new Error("Arguments cannot be passed to a URL-based MCP server.");
}
return {
transportType: isUrl ? "sse" : "stdio",
command: isUrl ? undefined : command,
args: isUrl ? undefined : commandArgs,
url: isUrl ? command : undefined,
};
}
async function callMethod(args: Args): Promise<void> {
const transportOptions = createTransportOptions(args.target);
const transport = createTransport(transportOptions);
const client = new Client({
name: "inspector-cli",
version: "0.5.1",
});
try {
await connect(client, transport);
let result: McpResponse;
// Tools methods
if (args.method === "tools/list") {
result = await listTools(client);
} else if (args.method === "tools/call") {
if (!args.toolName) {
throw new Error(
"Tool name is required for tools/call method. Use --tool-name to specify the tool name.",
);
}
result = await callTool(client, args.toolName, args.toolArg || {});
}
// Resources methods
else if (args.method === "resources/list") {
result = await listResources(client);
} else if (args.method === "resources/read") {
if (!args.uri) {
throw new Error(
"URI is required for resources/read method. Use --uri to specify the resource URI.",
);
}
result = await readResource(client, args.uri);
} else if (args.method === "resources/templates/list") {
result = await listResourceTemplates(client);
}
// Prompts methods
else if (args.method === "prompts/list") {
result = await listPrompts(client);
} else if (args.method === "prompts/get") {
if (!args.promptName) {
throw new Error(
"Prompt name is required for prompts/get method. Use --prompt-name to specify the prompt name.",
);
}
result = await getPrompt(client, args.promptName, args.promptArgs || {});
}
// Logging methods
else if (args.method === "logging/setLevel") {
if (!args.logLevel) {
throw new Error(
"Log level is required for logging/setLevel method. Use --log-level to specify the log level.",
);
}
result = await setLoggingLevel(client, args.logLevel);
} else {
throw new Error(
`Unsupported method: ${args.method}. Supported methods include: tools/list, tools/call, resources/list, resources/read, resources/templates/list, prompts/list, prompts/get, logging/setLevel`,
);
}
console.log(JSON.stringify(result, null, 2));
} finally {
try {
await disconnect(transport);
} catch (disconnectError) {
throw disconnectError;
}
}
}
function parseKeyValuePair(
value: string,
previous: Record<string, string> = {},
): Record<string, string> {
const parts = value.split("=");
const key = parts[0];
const val = parts.slice(1).join("=");
if (val === undefined || val === "") {
throw new Error(
`Invalid parameter format: ${value}. Use key=value format.`,
);
}
return { ...previous, [key as string]: val };
}
function parseArgs(): Args {
const program = new Command();
// Find if there's a -- in the arguments and split them
const argSeparatorIndex = process.argv.indexOf("--");
let preArgs = process.argv;
let postArgs: string[] = [];
if (argSeparatorIndex !== -1) {
preArgs = process.argv.slice(0, argSeparatorIndex);
postArgs = process.argv.slice(argSeparatorIndex + 1);
}
program
.name("inspector-cli")
.allowUnknownOption()
.argument("<target...>", "Command and arguments or URL of the MCP server")
//
// Method selection
//
.option("--method <method>", "Method to invoke")
//
// Tool-related options
//
.option("--tool-name <toolName>", "Tool name (for tools/call method)")
.option(
"--tool-arg <pairs...>",
"Tool argument as key=value pair",
parseKeyValuePair,
{},
)
//
// Resource-related options
//
.option("--uri <uri>", "URI of the resource (for resources/read method)")
//
// Prompt-related options
//
.option(
"--prompt-name <promptName>",
"Name of the prompt (for prompts/get method)",
)
.option(
"--prompt-args <pairs...>",
"Prompt arguments as key=value pairs",
parseKeyValuePair,
{},
)
//
// Logging options
//
.option(
"--log-level <level>",
"Logging level (for logging/setLevel method)",
(value: string) => {
if (!validLogLevels.includes(value as any)) {
throw new Error(
`Invalid log level: ${value}. Valid levels are: ${validLogLevels.join(", ")}`,
);
}
return value as LogLevel;
},
);
// Parse only the arguments before --
program.parse(preArgs);
const options = program.opts() as Omit<Args, "target">;
let remainingArgs = program.args;
// Add back any arguments that came after --
const finalArgs = [...remainingArgs, ...postArgs];
if (!options.method) {
throw new Error(
"Method is required. Use --method to specify the method to invoke.",
);
}
return {
target: finalArgs,
...options,
};
}
async function main(): Promise<void> {
process.on("uncaughtException", (error) => {
handleError(error);
});
try {
const args = parseArgs();
await callMethod(args);
} catch (error) {
handleError(error);
}
}
main();

View File

@@ -1,76 +0,0 @@
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
import {
getDefaultEnvironment,
StdioClientTransport,
} from "@modelcontextprotocol/sdk/client/stdio.js";
import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
import { findActualExecutable } from "spawn-rx";
export type TransportOptions = {
transportType: "sse" | "stdio";
command?: string;
args?: string[];
url?: string;
};
function createSSETransport(options: TransportOptions): Transport {
const baseUrl = new URL(options.url ?? "");
const sseUrl = new URL("/sse", baseUrl);
return new SSEClientTransport(sseUrl);
}
function createStdioTransport(options: TransportOptions): Transport {
let args: string[] = [];
if (options.args !== undefined) {
args = options.args;
}
const processEnv: Record<string, string> = {};
for (const [key, value] of Object.entries(process.env)) {
if (value !== undefined) {
processEnv[key] = value;
}
}
const defaultEnv = getDefaultEnvironment();
const env: Record<string, string> = {
...processEnv,
...defaultEnv,
};
const { cmd: actualCommand, args: actualArgs } = findActualExecutable(
options.command ?? "",
args,
);
return new StdioClientTransport({
command: actualCommand,
args: actualArgs,
env,
stderr: "pipe",
});
}
export function createTransport(options: TransportOptions): Transport {
const { transportType } = options;
try {
if (transportType === "stdio") {
return createStdioTransport(options);
}
if (transportType === "sse") {
return createSSETransport(options);
}
throw new Error(`Unsupported transport type: ${transportType}`);
} catch (error) {
throw new Error(
`Failed to create transport: ${error instanceof Error ? error.message : String(error)}`,
);
}
}

View File

@@ -1,17 +0,0 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "./build",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"noUncheckedIndexedAccess": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "packages", "**/*.spec.ts", "build"]
}

View File

@@ -1,57 +0,0 @@
#!/usr/bin/env node
import { join, dirname } from "path";
import { fileURLToPath } from "url";
import handler from "serve-handler";
import http from "http";
const __dirname = dirname(fileURLToPath(import.meta.url));
const distPath = join(__dirname, "../dist");
const server = http.createServer((request, response) => {
const handlerOptions = {
public: distPath,
rewrites: [{ source: "/**", destination: "/index.html" }],
headers: [
{
// Ensure index.html is never cached
source: "index.html",
headers: [
{
key: "Cache-Control",
value: "no-cache, no-store, max-age=0",
},
],
},
{
// Allow long-term caching for hashed assets
source: "assets/**",
headers: [
{
key: "Cache-Control",
value: "public, max-age=31536000, immutable",
},
],
},
],
};
return handler(request, response, handlerOptions);
});
const port = process.env.PORT || 6274;
server.on("listening", () => {
console.log(
`🔍 MCP Inspector is up and running at http://127.0.0.1:${port} 🚀`,
);
});
server.on("error", (err) => {
if (err.message.includes(`EADDRINUSE`)) {
console.error(
`❌ MCP Inspector PORT IS IN USE at http://127.0.0.1:${port}`,
);
} else {
throw err;
}
});
server.listen(port);

View File

@@ -1,124 +0,0 @@
#!/usr/bin/env node
import open from "open";
import { resolve, dirname } from "path";
import { spawnPromise } from "spawn-rx";
import { fileURLToPath } from "url";
const __dirname = dirname(fileURLToPath(import.meta.url));
function delay(ms) {
return new Promise((resolve) => setTimeout(resolve, ms, true));
}
async function main() {
// Parse command line arguments
const args = process.argv.slice(2);
const envVars = {};
const mcpServerArgs = [];
let command = null;
let parsingFlags = true;
for (let i = 0; i < args.length; i++) {
const arg = args[i];
if (parsingFlags && arg === "--") {
parsingFlags = false;
continue;
}
if (parsingFlags && arg === "-e" && i + 1 < args.length) {
const envVar = args[++i];
const equalsIndex = envVar.indexOf("=");
if (equalsIndex !== -1) {
const key = envVar.substring(0, equalsIndex);
const value = envVar.substring(equalsIndex + 1);
envVars[key] = value;
} else {
envVars[envVar] = "";
}
} else if (!command) {
command = arg;
} else {
mcpServerArgs.push(arg);
}
}
const inspectorServerPath = resolve(
__dirname,
"../..",
"server",
"build",
"index.js",
);
// Path to the client entry point
const inspectorClientPath = resolve(
__dirname,
"../..",
"client",
"bin",
"client.js",
);
const CLIENT_PORT = process.env.CLIENT_PORT ?? "6274";
const SERVER_PORT = process.env.SERVER_PORT ?? "6277";
console.log("Starting MCP inspector...");
const abort = new AbortController();
let cancelled = false;
process.on("SIGINT", () => {
cancelled = true;
abort.abort();
});
let server, serverOk;
try {
server = spawnPromise(
"node",
[
inspectorServerPath,
...(command ? [`--env`, command] : []),
...(mcpServerArgs ? [`--args=${mcpServerArgs.join(" ")}`] : []),
],
{
env: {
...process.env,
PORT: SERVER_PORT,
MCP_ENV_VARS: JSON.stringify(envVars),
},
signal: abort.signal,
echoOutput: true,
},
);
// Make sure server started before starting client
serverOk = await Promise.race([server, delay(2 * 1000)]);
} catch (error) {}
if (serverOk) {
try {
if (process.env.MCP_AUTO_OPEN_ENABLED !== "false") {
open(`http://127.0.0.1:${CLIENT_PORT}`);
}
await spawnPromise("node", [inspectorClientPath], {
env: { ...process.env, PORT: CLIENT_PORT },
signal: abort.signal,
echoOutput: true,
});
} catch (e) {
if (!cancelled || process.env.DEBUG) throw e;
}
}
return 0;
}
main()
.then((_) => process.exit(0))
.catch((e) => {
console.error(e);
process.exit(1);
});

View File

@@ -2,9 +2,9 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/mcp.svg" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>MCP Inspector</title>
<title>Vite + React + TS</title>
</head>
<body>
<div id="root"></div>

View File

@@ -1,33 +0,0 @@
module.exports = {
preset: "ts-jest",
testEnvironment: "jest-fixed-jsdom",
moduleNameMapper: {
"^@/(.*)$": "<rootDir>/src/$1",
"\\.css$": "<rootDir>/src/__mocks__/styleMock.js",
},
transform: {
"^.+\\.tsx?$": [
"ts-jest",
{
jsx: "react-jsx",
tsconfig: "tsconfig.jest.json",
},
],
},
extensionsToTreatAsEsm: [".ts", ".tsx"],
testRegex: "(/__tests__/.*|(\\.|/)(test|spec))\\.(jsx?|tsx?)$",
// Exclude directories and files that don't need to be tested
testPathIgnorePatterns: [
"/node_modules/",
"/dist/",
"/bin/",
"\\.config\\.(js|ts|cjs|mjs)$",
],
// Exclude the same patterns from coverage reports
coveragePathIgnorePatterns: [
"/node_modules/",
"/dist/",
"/bin/",
"\\.config\\.(js|ts|cjs|mjs)$",
],
};

View File

@@ -1,77 +1,44 @@
{
"name": "@modelcontextprotocol/inspector-client",
"version": "0.12.0",
"description": "Client-side application for the Model Context Protocol inspector",
"license": "MIT",
"author": "Anthropic, PBC (https://anthropic.com)",
"homepage": "https://modelcontextprotocol.io",
"bugs": "https://github.com/modelcontextprotocol/inspector/issues",
"name": "client",
"private": true,
"version": "0.0.0",
"type": "module",
"bin": {
"mcp-inspector-client": "./bin/start.js"
},
"files": [
"bin",
"dist"
],
"scripts": {
"dev": "vite --port 6274",
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview --port 6274",
"test": "jest --config jest.config.cjs",
"test:watch": "jest --config jest.config.cjs --watch"
"preview": "vite preview"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.11.0",
"@radix-ui/react-checkbox": "^1.1.4",
"@radix-ui/react-dialog": "^1.1.3",
"@radix-ui/react-icons": "^1.3.0",
"@radix-ui/react-label": "^2.1.0",
"@radix-ui/react-popover": "^1.1.3",
"@radix-ui/react-select": "^2.1.2",
"@radix-ui/react-slot": "^1.1.0",
"@radix-ui/react-tabs": "^1.1.1",
"@radix-ui/react-toast": "^1.2.6",
"@radix-ui/react-tooltip": "^1.1.8",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
"cmdk": "^1.0.4",
"lucide-react": "^0.447.0",
"pkce-challenge": "^4.1.0",
"prismjs": "^1.30.0",
"mcp-typescript": "file:../packages/mcp-typescript",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-simple-code-editor": "^0.14.1",
"serve-handler": "^6.1.6",
"tailwind-merge": "^2.5.3",
"tailwindcss-animate": "^1.0.7",
"zod": "^3.23.8"
"tailwindcss-animate": "^1.0.7"
},
"devDependencies": {
"@eslint/js": "^9.11.1",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^14.2.1",
"@types/jest": "^29.5.14",
"@types/node": "^22.7.5",
"@types/prismjs": "^1.26.5",
"@types/react": "^18.3.10",
"@types/react-dom": "^18.3.0",
"@types/serve-handler": "^6.1.4",
"@vitejs/plugin-react": "^4.3.2",
"autoprefixer": "^10.4.20",
"co": "^4.6.0",
"eslint": "^9.11.1",
"eslint-plugin-react-hooks": "^5.1.0-rc.0",
"eslint-plugin-react-refresh": "^0.4.12",
"globals": "^15.9.0",
"jest": "^29.7.0",
"jest-environment-jsdom": "^29.7.0",
"postcss": "^8.4.47",
"tailwindcss": "^3.4.13",
"ts-jest": "^29.2.6",
"typescript": "^5.5.3",
"typescript-eslint": "^8.7.0",
"vite": "^6.3.0"
"vite": "^5.4.8"
}
}

View File

@@ -3,4 +3,4 @@ export default {
tailwindcss: {},
autoprefixer: {},
},
};
}

View File

@@ -1,12 +0,0 @@
<svg width="180" height="180" viewBox="0 0 180 180" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_19_13)">
<path d="M18 84.8528L85.8822 16.9706C95.2548 7.59798 110.451 7.59798 119.823 16.9706V16.9706C129.196 26.3431 129.196 41.5391 119.823 50.9117L68.5581 102.177" stroke="black" stroke-width="12" stroke-linecap="round"/>
<path d="M69.2652 101.47L119.823 50.9117C129.196 41.5391 144.392 41.5391 153.765 50.9117L154.118 51.2652C163.491 60.6378 163.491 75.8338 154.118 85.2063L92.7248 146.6C89.6006 149.724 89.6006 154.789 92.7248 157.913L105.331 170.52" stroke="black" stroke-width="12" stroke-linecap="round"/>
<path d="M102.853 33.9411L52.6482 84.1457C43.2756 93.5183 43.2756 108.714 52.6482 118.087V118.087C62.0208 127.459 77.2167 127.459 86.5893 118.087L136.794 67.8822" stroke="black" stroke-width="12" stroke-linecap="round"/>
</g>
<defs>
<clipPath id="clip0_19_13">
<rect width="180" height="180" fill="white"/>
</clipPath>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 973 B

1
client/public/vite.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -1,5 +1,8 @@
#root {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;
}
.logo {

File diff suppressed because it is too large Load Diff

View File

@@ -1 +0,0 @@
module.exports = {};

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

@@ -1,260 +0,0 @@
import { useCallback, useMemo } from "react";
import { Button } from "@/components/ui/button";
import { DebugInspectorOAuthClientProvider } from "../lib/auth";
import { auth } from "@modelcontextprotocol/sdk/client/auth.js";
import { AlertCircle } from "lucide-react";
import { AuthDebuggerState } from "../lib/auth-types";
import { OAuthFlowProgress } from "./OAuthFlowProgress";
import { OAuthStateMachine } from "../lib/oauth-state-machine";
export interface AuthDebuggerProps {
serverUrl: string;
onBack: () => void;
authState: AuthDebuggerState;
updateAuthState: (updates: Partial<AuthDebuggerState>) => void;
}
interface StatusMessageProps {
message: { type: "error" | "success" | "info"; message: string };
}
const StatusMessage = ({ message }: StatusMessageProps) => {
let bgColor: string;
let textColor: string;
let borderColor: string;
switch (message.type) {
case "error":
bgColor = "bg-red-50";
textColor = "text-red-700";
borderColor = "border-red-200";
break;
case "success":
bgColor = "bg-green-50";
textColor = "text-green-700";
borderColor = "border-green-200";
break;
case "info":
default:
bgColor = "bg-blue-50";
textColor = "text-blue-700";
borderColor = "border-blue-200";
break;
}
return (
<div
className={`p-3 rounded-md border ${bgColor} ${borderColor} ${textColor} mb-4`}
>
<div className="flex items-center gap-2">
<AlertCircle className="h-4 w-4" />
<p className="text-sm">{message.message}</p>
</div>
</div>
);
};
const AuthDebugger = ({
serverUrl: serverUrl,
onBack,
authState,
updateAuthState,
}: AuthDebuggerProps) => {
const startOAuthFlow = useCallback(() => {
if (!serverUrl) {
updateAuthState({
statusMessage: {
type: "error",
message:
"Please enter a server URL in the sidebar before authenticating",
},
});
return;
}
updateAuthState({
oauthStep: "metadata_discovery",
authorizationUrl: null,
statusMessage: null,
latestError: null,
});
}, [serverUrl, updateAuthState]);
const stateMachine = useMemo(
() => new OAuthStateMachine(serverUrl, updateAuthState),
[serverUrl, updateAuthState],
);
const proceedToNextStep = useCallback(async () => {
if (!serverUrl) return;
try {
updateAuthState({
isInitiatingAuth: true,
statusMessage: null,
latestError: null,
});
await stateMachine.executeStep(authState);
} catch (error) {
console.error("OAuth flow error:", error);
updateAuthState({
latestError: error instanceof Error ? error : new Error(String(error)),
});
} finally {
updateAuthState({ isInitiatingAuth: false });
}
}, [serverUrl, authState, updateAuthState, stateMachine]);
const handleQuickOAuth = useCallback(async () => {
if (!serverUrl) {
updateAuthState({
statusMessage: {
type: "error",
message:
"Please enter a server URL in the sidebar before authenticating",
},
});
return;
}
updateAuthState({ isInitiatingAuth: true, statusMessage: null });
try {
const serverAuthProvider = new DebugInspectorOAuthClientProvider(
serverUrl,
);
await auth(serverAuthProvider, { serverUrl: serverUrl });
updateAuthState({
statusMessage: {
type: "info",
message: "Starting OAuth authentication process...",
},
});
} catch (error) {
console.error("OAuth initialization error:", error);
updateAuthState({
statusMessage: {
type: "error",
message: `Failed to start OAuth flow: ${error instanceof Error ? error.message : String(error)}`,
},
});
} finally {
updateAuthState({ isInitiatingAuth: false });
}
}, [serverUrl, updateAuthState]);
const handleClearOAuth = useCallback(() => {
if (serverUrl) {
const serverAuthProvider = new DebugInspectorOAuthClientProvider(
serverUrl,
);
serverAuthProvider.clear();
updateAuthState({
oauthTokens: null,
oauthStep: "metadata_discovery",
latestError: null,
oauthClientInfo: null,
authorizationCode: "",
validationError: null,
oauthMetadata: null,
statusMessage: {
type: "success",
message: "OAuth tokens cleared successfully",
},
});
// Clear success message after 3 seconds
setTimeout(() => {
updateAuthState({ statusMessage: null });
}, 3000);
}
}, [serverUrl, updateAuthState]);
return (
<div className="w-full p-4">
<div className="flex justify-between items-center mb-6">
<h2 className="text-2xl font-bold">Authentication Settings</h2>
<Button variant="outline" onClick={onBack}>
Back to Connect
</Button>
</div>
<div className="w-full space-y-6">
<div className="flex flex-col gap-6">
<div className="grid w-full gap-2">
<p className="text-muted-foreground mb-4">
Configure authentication settings for your MCP server connection.
</p>
<div className="rounded-md border p-6 space-y-6">
<h3 className="text-lg font-medium">OAuth Authentication</h3>
<p className="text-sm text-muted-foreground mb-2">
Use OAuth to securely authenticate with the MCP server.
</p>
{authState.statusMessage && (
<StatusMessage message={authState.statusMessage} />
)}
{authState.loading ? (
<p>Loading authentication status...</p>
) : (
<div className="space-y-4">
{authState.oauthTokens && (
<div className="space-y-2">
<p className="text-sm font-medium">Access Token:</p>
<div className="bg-muted p-2 rounded-md text-xs overflow-x-auto">
{authState.oauthTokens.access_token.substring(0, 25)}...
</div>
</div>
)}
<div className="flex gap-4">
<Button
variant="outline"
onClick={startOAuthFlow}
disabled={authState.isInitiatingAuth}
>
{authState.oauthTokens
? "Guided Token Refresh"
: "Guided OAuth Flow"}
</Button>
<Button
onClick={handleQuickOAuth}
disabled={authState.isInitiatingAuth}
>
{authState.isInitiatingAuth
? "Initiating..."
: authState.oauthTokens
? "Quick Refresh"
: "Quick OAuth Flow"}
</Button>
<Button variant="outline" onClick={handleClearOAuth}>
Clear OAuth State
</Button>
</div>
<p className="text-xs text-muted-foreground">
Choose "Guided" for step-by-step instructions or "Quick" for
the standard automatic flow.
</p>
</div>
)}
</div>
<OAuthFlowProgress
serverUrl={serverUrl}
authState={authState}
updateAuthState={updateAuthState}
proceedToNextStep={proceedToNextStep}
/>
</div>
</div>
</div>
</div>
);
};
export default AuthDebugger;

View File

@@ -1,308 +0,0 @@
import { useState, useEffect, useCallback, useRef } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import JsonEditor from "./JsonEditor";
import { updateValueAtPath } from "@/utils/jsonUtils";
import { generateDefaultValue } from "@/utils/schemaUtils";
import type { JsonValue, JsonSchemaType } from "@/utils/jsonUtils";
interface DynamicJsonFormProps {
schema: JsonSchemaType;
value: JsonValue;
onChange: (value: JsonValue) => void;
maxDepth?: number;
}
const isSimpleObject = (schema: JsonSchemaType): boolean => {
const supportedTypes = ["string", "number", "integer", "boolean", "null"];
if (supportedTypes.includes(schema.type)) return true;
if (schema.type !== "object") return false;
return Object.values(schema.properties ?? {}).every((prop) =>
supportedTypes.includes(prop.type),
);
};
const DynamicJsonForm = ({
schema,
value,
onChange,
maxDepth = 3,
}: DynamicJsonFormProps) => {
const isOnlyJSON = !isSimpleObject(schema);
const [isJsonMode, setIsJsonMode] = useState(isOnlyJSON);
const [jsonError, setJsonError] = useState<string>();
// Store the raw JSON string to allow immediate feedback during typing
// while deferring parsing until the user stops typing
const [rawJsonValue, setRawJsonValue] = useState<string>(
JSON.stringify(value ?? generateDefaultValue(schema), null, 2),
);
// Use a ref to manage debouncing timeouts to avoid parsing JSON
// on every keystroke which would be inefficient and error-prone
const timeoutRef = useRef<ReturnType<typeof setTimeout>>();
// Debounce JSON parsing and parent updates to handle typing gracefully
const debouncedUpdateParent = useCallback(
(jsonString: string) => {
// Clear any existing timeout
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
// Set a new timeout
timeoutRef.current = setTimeout(() => {
try {
const parsed = JSON.parse(jsonString);
onChange(parsed);
setJsonError(undefined);
} catch {
// Don't set error during normal typing
}
}, 300);
},
[onChange, setJsonError],
);
// Update rawJsonValue when value prop changes
useEffect(() => {
if (!isJsonMode) {
setRawJsonValue(
JSON.stringify(value ?? generateDefaultValue(schema), null, 2),
);
}
}, [value, schema, isJsonMode]);
const handleSwitchToFormMode = () => {
if (isJsonMode) {
// When switching to Form mode, ensure we have valid JSON
try {
const parsed = JSON.parse(rawJsonValue);
// Update the parent component's state with the parsed value
onChange(parsed);
// Switch to form mode
setIsJsonMode(false);
} catch (err) {
setJsonError(err instanceof Error ? err.message : "Invalid JSON");
}
} else {
// Update raw JSON value when switching to JSON mode
setRawJsonValue(
JSON.stringify(value ?? generateDefaultValue(schema), null, 2),
);
setIsJsonMode(true);
}
};
const formatJson = () => {
try {
const jsonStr = rawJsonValue.trim();
if (!jsonStr) {
return;
}
const formatted = JSON.stringify(JSON.parse(jsonStr), null, 2);
setRawJsonValue(formatted);
debouncedUpdateParent(formatted);
setJsonError(undefined);
} catch (err) {
setJsonError(err instanceof Error ? err.message : "Invalid JSON");
}
};
const renderFormFields = (
propSchema: JsonSchemaType,
currentValue: JsonValue,
path: string[] = [],
depth: number = 0,
) => {
if (
depth >= maxDepth &&
(propSchema.type === "object" || propSchema.type === "array")
) {
// Render as JSON editor when max depth is reached
return (
<JsonEditor
value={JSON.stringify(
currentValue ?? generateDefaultValue(propSchema),
null,
2,
)}
onChange={(newValue) => {
try {
const parsed = JSON.parse(newValue);
handleFieldChange(path, parsed);
setJsonError(undefined);
} catch (err) {
setJsonError(err instanceof Error ? err.message : "Invalid JSON");
}
}}
error={jsonError}
/>
);
}
switch (propSchema.type) {
case "string":
return (
<Input
type="text"
value={(currentValue as string) ?? ""}
onChange={(e) => {
const val = e.target.value;
// Allow clearing non-required fields by setting undefined
// This preserves the distinction between empty string and unset
if (!val && !propSchema.required) {
handleFieldChange(path, undefined);
} else {
handleFieldChange(path, val);
}
}}
placeholder={propSchema.description}
required={propSchema.required}
/>
);
case "number":
return (
<Input
type="number"
value={(currentValue as number)?.toString() ?? ""}
onChange={(e) => {
const val = e.target.value;
// Allow clearing non-required number fields
// This preserves the distinction between 0 and unset
if (!val && !propSchema.required) {
handleFieldChange(path, undefined);
} else {
const num = Number(val);
if (!isNaN(num)) {
handleFieldChange(path, num);
}
}
}}
placeholder={propSchema.description}
required={propSchema.required}
/>
);
case "integer":
return (
<Input
type="number"
step="1"
value={(currentValue as number)?.toString() ?? ""}
onChange={(e) => {
const val = e.target.value;
// Allow clearing non-required integer fields
// This preserves the distinction between 0 and unset
if (!val && !propSchema.required) {
handleFieldChange(path, undefined);
} else {
const num = Number(val);
// Only update if it's a valid integer
if (!isNaN(num) && Number.isInteger(num)) {
handleFieldChange(path, num);
}
}
}}
placeholder={propSchema.description}
required={propSchema.required}
/>
);
case "boolean":
return (
<Input
type="checkbox"
checked={(currentValue as boolean) ?? false}
onChange={(e) => handleFieldChange(path, e.target.checked)}
className="w-4 h-4"
required={propSchema.required}
/>
);
default:
return null;
}
};
const handleFieldChange = (path: string[], fieldValue: JsonValue) => {
if (path.length === 0) {
onChange(fieldValue);
return;
}
try {
const newValue = updateValueAtPath(value, path, fieldValue);
onChange(newValue);
} catch (error) {
console.error("Failed to update form value:", error);
onChange(value);
}
};
const shouldUseJsonMode =
schema.type === "object" &&
(!schema.properties || Object.keys(schema.properties).length === 0);
useEffect(() => {
if (shouldUseJsonMode && !isJsonMode) {
setIsJsonMode(true);
}
}, [shouldUseJsonMode, isJsonMode]);
return (
<div className="space-y-4">
<div className="flex justify-end space-x-2">
{isJsonMode && (
<Button
type="button"
variant="outline"
size="sm"
onClick={formatJson}
>
Format JSON
</Button>
)}
{!isOnlyJSON && (
<Button variant="outline" size="sm" onClick={handleSwitchToFormMode}>
{isJsonMode ? "Switch to Form" : "Switch to JSON"}
</Button>
)}
</div>
{isJsonMode ? (
<JsonEditor
value={rawJsonValue}
onChange={(newValue) => {
// Always update local state
setRawJsonValue(newValue);
// Use the debounced function to attempt parsing and updating parent
debouncedUpdateParent(newValue);
}}
error={jsonError}
/>
) : // If schema type is object but value is not an object or is empty, and we have actual JSON data,
// render a simple representation of the JSON data
schema.type === "object" &&
(typeof value !== "object" ||
value === null ||
Object.keys(value).length === 0) &&
rawJsonValue &&
rawJsonValue !== "{}" ? (
<div className="space-y-4 border rounded-md p-4">
<p className="text-sm text-gray-500">
Form view not available for this JSON structure. Using simplified
view:
</p>
<pre className="bg-gray-50 dark:bg-gray-800 dark:text-gray-100 p-4 rounded text-sm overflow-auto">
{rawJsonValue}
</pre>
<p className="text-sm text-gray-500">
Use JSON mode for full editing capabilities.
</p>
</div>
) : (
renderFormFields(schema, value)
)}
</div>
);
};
export default DynamicJsonForm;

View File

@@ -1,36 +1,26 @@
import { ServerNotification } from "@modelcontextprotocol/sdk/types.js";
import { useState } from "react";
import JsonView from "./JsonView";
import { Copy } from "lucide-react";
const HistoryAndNotifications = ({
const History = ({
requestHistory,
serverNotifications,
}: {
requestHistory: Array<{ request: string; response?: string }>;
serverNotifications: ServerNotification[];
requestHistory: Array<{ request: string; response: string | null }>;
}) => {
const [expandedRequests, setExpandedRequests] = useState<{
[key: number]: boolean;
}>({});
const [expandedNotifications, setExpandedNotifications] = useState<{
[key: number]: boolean;
}>({});
const toggleRequestExpansion = (index: number) => {
setExpandedRequests((prev) => ({ ...prev, [index]: !prev[index] }));
};
const toggleNotificationExpansion = (index: number) => {
setExpandedNotifications((prev) => ({ ...prev, [index]: !prev[index] }));
const copyToClipboard = (text: string) => {
navigator.clipboard.writeText(text);
};
return (
<div className="bg-card overflow-hidden flex h-full">
<div className="flex-1 overflow-y-auto p-4 border-r">
<div className="w-64 bg-white shadow-md p-4 overflow-y-auto">
<h2 className="text-lg font-semibold mb-4">History</h2>
{requestHistory.length === 0 ? (
<p className="text-sm text-gray-500 italic">No history yet</p>
) : (
<ul className="space-y-3">
{requestHistory
.slice()
@@ -38,7 +28,7 @@ const HistoryAndNotifications = ({
.map((request, index) => (
<li
key={index}
className="text-sm text-foreground bg-secondary p-2 rounded"
className="text-sm text-gray-600 bg-gray-100 p-2 rounded"
>
<div
className="flex justify-between items-center cursor-pointer"
@@ -63,12 +53,16 @@ const HistoryAndNotifications = ({
<span className="font-semibold text-blue-600">
Request:
</span>
<button
onClick={() => copyToClipboard(request.request)}
className="text-blue-500 hover:text-blue-700"
>
<Copy size={16} />
</button>
</div>
<JsonView
data={request.request}
className="bg-background"
/>
<pre className="whitespace-pre-wrap break-words bg-blue-50 p-2 rounded">
{JSON.stringify(JSON.parse(request.request), null, 2)}
</pre>
</div>
{request.response && (
<div className="mt-2">
@@ -76,11 +70,16 @@ const HistoryAndNotifications = ({
<span className="font-semibold text-green-600">
Response:
</span>
<button
onClick={() => copyToClipboard(request.response!)}
className="text-blue-500 hover:text-blue-700"
>
<Copy size={16} />
</button>
</div>
<JsonView
data={request.response}
className="bg-background"
/>
<pre className="whitespace-pre-wrap break-words bg-green-50 p-2 rounded">
{JSON.stringify(JSON.parse(request.response), null, 2)}
</pre>
</div>
)}
</>
@@ -88,52 +87,8 @@ const HistoryAndNotifications = ({
</li>
))}
</ul>
)}
</div>
<div className="flex-1 overflow-y-auto p-4">
<h2 className="text-lg font-semibold mb-4">Server Notifications</h2>
{serverNotifications.length === 0 ? (
<p className="text-sm text-gray-500 italic">No notifications yet</p>
) : (
<ul className="space-y-3">
{serverNotifications
.slice()
.reverse()
.map((notification, index) => (
<li
key={index}
className="text-sm text-foreground bg-secondary p-2 rounded"
>
<div
className="flex justify-between items-center cursor-pointer"
onClick={() => toggleNotificationExpansion(index)}
>
<span className="font-mono">
{serverNotifications.length - index}.{" "}
{notification.method}
</span>
<span>{expandedNotifications[index] ? "▼" : "▶"}</span>
</div>
{expandedNotifications[index] && (
<div className="mt-2">
<div className="flex justify-between items-center mb-1">
<span className="font-semibold text-purple-600">
Details:
</span>
</div>
<JsonView
data={JSON.stringify(notification, null, 2)}
className="bg-background"
/>
</div>
)}
</li>
))}
</ul>
)}
</div>
</div>
);
};
export default HistoryAndNotifications;
export default History;

View File

@@ -1,67 +0,0 @@
import { useState, useEffect } from "react";
import Editor from "react-simple-code-editor";
import Prism from "prismjs";
import "prismjs/components/prism-json";
import "prismjs/themes/prism.css";
interface JsonEditorProps {
value: string;
onChange: (value: string) => void;
error?: string;
}
const JsonEditor = ({
value,
onChange,
error: externalError,
}: JsonEditorProps) => {
const [editorContent, setEditorContent] = useState(value || "");
const [internalError, setInternalError] = useState<string | undefined>(
undefined,
);
useEffect(() => {
setEditorContent(value || "");
}, [value]);
const handleEditorChange = (newContent: string) => {
setEditorContent(newContent);
setInternalError(undefined);
onChange(newContent);
};
const displayError = internalError || externalError;
return (
<div className="relative">
<div
className={`border rounded-md ${
displayError
? "border-red-500"
: "border-gray-200 dark:border-gray-800"
}`}
>
<Editor
value={editorContent}
onValueChange={handleEditorChange}
highlight={(code) =>
Prism.highlight(code, Prism.languages.json, "json")
}
padding={10}
style={{
fontFamily: '"Fira code", "Fira Mono", monospace',
fontSize: 14,
backgroundColor: "transparent",
minHeight: "100px",
}}
className="w-full"
/>
</div>
{displayError && (
<p className="text-sm text-red-500 mt-1">{displayError}</p>
)}
</div>
);
};
export default JsonEditor;

View File

@@ -1,286 +0,0 @@
import { useState, memo, useMemo, useCallback, useEffect } from "react";
import type { JsonValue } from "@/utils/jsonUtils";
import clsx from "clsx";
import { Copy, CheckCheck } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useToast } from "@/lib/hooks/useToast";
import { getDataType, tryParseJson } from "@/utils/jsonUtils";
interface JsonViewProps {
data: unknown;
name?: string;
initialExpandDepth?: number;
className?: string;
withCopyButton?: boolean;
isError?: boolean;
}
const JsonView = memo(
({
data,
name,
initialExpandDepth = 3,
className,
withCopyButton = true,
isError = false,
}: JsonViewProps) => {
const { toast } = useToast();
const [copied, setCopied] = useState(false);
useEffect(() => {
let timeoutId: NodeJS.Timeout;
if (copied) {
timeoutId = setTimeout(() => {
setCopied(false);
}, 500);
}
return () => {
if (timeoutId) {
clearTimeout(timeoutId);
}
};
}, [copied]);
const normalizedData = useMemo(() => {
return typeof data === "string"
? tryParseJson(data).success
? tryParseJson(data).data
: data
: data;
}, [data]);
const handleCopy = useCallback(() => {
try {
navigator.clipboard.writeText(
typeof normalizedData === "string"
? normalizedData
: JSON.stringify(normalizedData, null, 2),
);
setCopied(true);
} catch (error) {
toast({
title: "Error",
description: `There was an error coping result into the clipboard: ${error instanceof Error ? error.message : String(error)}`,
variant: "destructive",
});
}
}, [toast, normalizedData]);
return (
<div className={clsx("p-4 border rounded relative", className)}>
{withCopyButton && (
<Button
size="icon"
variant="ghost"
className="absolute top-2 right-2"
onClick={handleCopy}
>
{copied ? (
<CheckCheck className="size-4 dark:text-green-700 text-green-600" />
) : (
<Copy className="size-4 text-foreground" />
)}
</Button>
)}
<div className="font-mono text-sm transition-all duration-300">
<JsonNode
data={normalizedData as JsonValue}
name={name}
depth={0}
initialExpandDepth={initialExpandDepth}
isError={isError}
/>
</div>
</div>
);
},
);
JsonView.displayName = "JsonView";
interface JsonNodeProps {
data: JsonValue;
name?: string;
depth: number;
initialExpandDepth: number;
isError?: boolean;
}
const JsonNode = memo(
({
data,
name,
depth = 0,
initialExpandDepth,
isError = false,
}: JsonNodeProps) => {
const [isExpanded, setIsExpanded] = useState(depth < initialExpandDepth);
const [typeStyleMap] = useState<Record<string, string>>({
number: "text-blue-600",
boolean: "text-amber-600",
null: "text-purple-600",
undefined: "text-gray-600",
string: "text-green-600 group-hover:text-green-500",
error: "text-red-600 group-hover:text-red-500",
default: "text-gray-700",
});
const dataType = getDataType(data);
const renderCollapsible = (isArray: boolean) => {
const items = isArray
? (data as JsonValue[])
: Object.entries(data as Record<string, JsonValue>);
const itemCount = items.length;
const isEmpty = itemCount === 0;
const symbolMap = {
open: isArray ? "[" : "{",
close: isArray ? "]" : "}",
collapsed: isArray ? "[ ... ]" : "{ ... }",
empty: isArray ? "[]" : "{}",
};
if (isEmpty) {
return (
<div className="flex items-center">
{name && (
<span className="mr-1 text-gray-600 dark:text-gray-400">
{name}:
</span>
)}
<span className="text-gray-500">{symbolMap.empty}</span>
</div>
);
}
return (
<div className="flex flex-col">
<div
className="flex items-center mr-1 rounded cursor-pointer group hover:bg-gray-800/10 dark:hover:bg-gray-800/20"
onClick={() => setIsExpanded(!isExpanded)}
>
{name && (
<span className="mr-1 text-gray-600 dark:text-gray-400 dark:group-hover:text-gray-100 group-hover:text-gray-400">
{name}:
</span>
)}
{isExpanded ? (
<span className="text-gray-600 dark:text-gray-400 dark:group-hover:text-gray-100 group-hover:text-gray-400">
{symbolMap.open}
</span>
) : (
<>
<span className="text-gray-600 dark:group-hover:text-gray-100 group-hover:text-gray-400">
{symbolMap.collapsed}
</span>
<span className="ml-1 text-gray-700 dark:group-hover:text-gray-100 group-hover:text-gray-400">
{itemCount} {itemCount === 1 ? "item" : "items"}
</span>
</>
)}
</div>
{isExpanded && (
<>
<div className="pl-2 ml-4 border-l border-gray-200 dark:border-gray-800">
{isArray
? (items as JsonValue[]).map((item, index) => (
<div key={index} className="my-1">
<JsonNode
data={item}
name={`${index}`}
depth={depth + 1}
initialExpandDepth={initialExpandDepth}
/>
</div>
))
: (items as [string, JsonValue][]).map(([key, value]) => (
<div key={key} className="my-1">
<JsonNode
data={value}
name={key}
depth={depth + 1}
initialExpandDepth={initialExpandDepth}
/>
</div>
))}
</div>
<div className="text-gray-600 dark:text-gray-400">
{symbolMap.close}
</div>
</>
)}
</div>
);
};
const renderString = (value: string) => {
const maxLength = 100;
const isTooLong = value.length > maxLength;
if (!isTooLong) {
return (
<div className="flex mr-1 rounded hover:bg-gray-800/20">
{name && (
<span className="mr-1 text-gray-600 dark:text-gray-400">
{name}:
</span>
)}
<pre
className={clsx(
isError ? typeStyleMap.error : typeStyleMap.string,
"break-all whitespace-pre-wrap",
)}
>
"{value}"
</pre>
</div>
);
}
return (
<div className="flex mr-1 rounded group hover:bg-gray-800/20">
{name && (
<span className="mr-1 text-gray-600 dark:text-gray-400 dark:group-hover:text-gray-100 group-hover:text-gray-400">
{name}:
</span>
)}
<pre
className={clsx(
isError ? typeStyleMap.error : typeStyleMap.string,
"cursor-pointer break-all whitespace-pre-wrap",
)}
onClick={() => setIsExpanded(!isExpanded)}
title={isExpanded ? "Click to collapse" : "Click to expand"}
>
{isExpanded ? `"${value}"` : `"${value.slice(0, maxLength)}..."`}
</pre>
</div>
);
};
switch (dataType) {
case "object":
case "array":
return renderCollapsible(dataType === "array");
case "string":
return renderString(data as string);
default:
return (
<div className="flex items-center mr-1 rounded hover:bg-gray-800/20">
{name && (
<span className="mr-1 text-gray-600 dark:text-gray-400">
{name}:
</span>
)}
<span className={typeStyleMap[dataType] || typeStyleMap.default}>
{data === null ? "null" : String(data)}
</span>
</div>
);
}
},
);
JsonNode.displayName = "JsonNode";
export default JsonView;

View File

@@ -3,50 +3,33 @@ import { Button } from "./ui/button";
type ListPaneProps<T> = {
items: T[];
listItems: () => void;
clearItems: () => void;
setSelectedItem: (item: T) => void;
renderItem: (item: T) => React.ReactNode;
title: string;
buttonText: string;
isButtonDisabled?: boolean;
};
const ListPane = <T extends object>({
items,
listItems,
clearItems,
setSelectedItem,
renderItem,
title,
buttonText,
isButtonDisabled,
}: ListPaneProps<T>) => (
<div className="bg-card rounded-lg shadow">
<div className="p-4 border-b border-gray-200 dark:border-gray-800">
<h3 className="font-semibold dark:text-white">{title}</h3>
<div className="bg-white rounded-lg shadow">
<div className="p-4 border-b border-gray-200">
<h3 className="font-semibold">{title}</h3>
</div>
<div className="p-4">
<Button
variant="outline"
className="w-full mb-4"
onClick={listItems}
disabled={isButtonDisabled}
>
<Button variant="outline" className="w-full mb-4" onClick={listItems}>
{buttonText}
</Button>
<Button
variant="outline"
className="w-full mb-4"
onClick={clearItems}
disabled={items.length === 0}
>
Clear
</Button>
<div className="space-y-2 overflow-y-auto max-h-96">
<div className="space-y-2">
{items.map((item, index) => (
<div
key={index}
className="flex items-center p-2 rounded hover:bg-gray-50 dark:hover:bg-gray-700 cursor-pointer"
className="flex items-center p-2 rounded hover:bg-gray-50 cursor-pointer"
onClick={() => setSelectedItem(item)}
>
{renderItem(item)}

View File

@@ -0,0 +1,33 @@
import { Bell } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { TabsContent } from "@/components/ui/tabs";
const NotificationsTab = () => (
<TabsContent value="notifications" className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-4">
<div className="flex space-x-2">
<Input placeholder="Notification method" />
<Button>
<Bell className="w-4 h-4 mr-2" />
Send
</Button>
</div>
<Textarea
placeholder="Notification parameters (JSON)"
className="h-64 font-mono"
/>
</div>
<div className="bg-white rounded-lg shadow p-4">
<h3 className="font-semibold mb-4">Recent Notifications</h3>
<div className="space-y-2">
{/* Notification history would go here */}
</div>
</div>
</div>
</TabsContent>
);
export default NotificationsTab;

View File

@@ -1,85 +0,0 @@
import { useEffect, useRef } from "react";
import { InspectorOAuthClientProvider } from "../lib/auth";
import { SESSION_KEYS } from "../lib/constants";
import { auth } from "@modelcontextprotocol/sdk/client/auth.js";
import { useToast } from "@/lib/hooks/useToast";
import {
generateOAuthErrorDescription,
parseOAuthCallbackParams,
} from "@/utils/oauthUtils.ts";
interface OAuthCallbackProps {
onConnect: (serverUrl: string) => void;
}
const OAuthCallback = ({ onConnect }: OAuthCallbackProps) => {
const { toast } = useToast();
const hasProcessedRef = useRef(false);
useEffect(() => {
const handleCallback = async () => {
// Skip if we've already processed this callback
if (hasProcessedRef.current) {
return;
}
hasProcessedRef.current = true;
const notifyError = (description: string) =>
void toast({
title: "OAuth Authorization Error",
description,
variant: "destructive",
});
const params = parseOAuthCallbackParams(window.location.search);
if (!params.successful) {
return notifyError(generateOAuthErrorDescription(params));
}
const serverUrl = sessionStorage.getItem(SESSION_KEYS.SERVER_URL);
if (!serverUrl) {
return notifyError("Missing Server URL");
}
let result;
try {
// Create an auth provider with the current server URL
const serverAuthProvider = new InspectorOAuthClientProvider(serverUrl);
result = await auth(serverAuthProvider, {
serverUrl,
authorizationCode: params.code,
});
} catch (error) {
console.error("OAuth callback error:", error);
return notifyError(`Unexpected error occurred: ${error}`);
}
if (result !== "AUTHORIZED") {
return notifyError(
`Expected to be authorized after providing auth code, got: ${result}`,
);
}
// Finally, trigger auto-connect
toast({
title: "Success",
description: "Successfully authenticated with OAuth",
variant: "default",
});
onConnect(serverUrl);
};
handleCallback().finally(() => {
window.history.replaceState({}, document.title, "/");
});
}, [toast, onConnect]);
return (
<div className="flex items-center justify-center h-screen">
<p className="text-lg text-gray-500">Processing OAuth callback...</p>
</div>
);
};
export default OAuthCallback;

View File

@@ -1,92 +0,0 @@
import { useEffect } from "react";
import { SESSION_KEYS } from "../lib/constants";
import {
generateOAuthErrorDescription,
parseOAuthCallbackParams,
} from "@/utils/oauthUtils.ts";
interface OAuthCallbackProps {
onConnect: ({
authorizationCode,
errorMsg,
}: {
authorizationCode?: string;
errorMsg?: string;
}) => void;
}
const OAuthDebugCallback = ({ onConnect }: OAuthCallbackProps) => {
useEffect(() => {
let isProcessed = false;
const handleCallback = async () => {
// Skip if we've already processed this callback
if (isProcessed) {
return;
}
isProcessed = true;
const params = parseOAuthCallbackParams(window.location.search);
if (!params.successful) {
const errorMsg = generateOAuthErrorDescription(params);
onConnect({ errorMsg });
return;
}
const serverUrl = sessionStorage.getItem(SESSION_KEYS.SERVER_URL);
// ServerURL isn't set, this can happen if we've opened the
// authentication request in a new tab, so we don't have the same
// session storage
if (!serverUrl) {
// If there's no server URL, we're likely in a new tab
// Just display the code for manual copying
return;
}
if (!params.code) {
onConnect({ errorMsg: "Missing authorization code" });
return;
}
// Instead of storing in sessionStorage, pass the code directly
// to the auth state manager through onConnect
onConnect({ authorizationCode: params.code });
};
handleCallback().finally(() => {
// Only redirect if we have the URL set, otherwise assume this was
// in a new tab
if (sessionStorage.getItem(SESSION_KEYS.SERVER_URL)) {
window.history.replaceState({}, document.title, "/");
}
});
return () => {
isProcessed = true;
};
}, [onConnect]);
const callbackParams = parseOAuthCallbackParams(window.location.search);
return (
<div className="flex items-center justify-center h-screen">
<div className="mt-4 p-4 bg-secondary rounded-md max-w-md">
<p className="mb-2 text-sm">
Please copy this authorization code and return to the Auth Debugger:
</p>
<code className="block p-2 bg-muted rounded-sm overflow-x-auto text-xs">
{callbackParams.successful && "code" in callbackParams
? callbackParams.code
: `No code found: ${callbackParams.error}, ${callbackParams.error_description}`}
</code>
<p className="mt-4 text-xs text-muted-foreground">
Close this tab and paste the code in the OAuth flow to complete
authentication.
</p>
</div>
</div>
);
};
export default OAuthDebugCallback;

View File

@@ -1,259 +0,0 @@
import { AuthDebuggerState, OAuthStep } from "@/lib/auth-types";
import { CheckCircle2, Circle, ExternalLink } from "lucide-react";
import { Button } from "./ui/button";
import { DebugInspectorOAuthClientProvider } from "@/lib/auth";
interface OAuthStepProps {
label: string;
isComplete: boolean;
isCurrent: boolean;
error?: Error | null;
children?: React.ReactNode;
}
const OAuthStepDetails = ({
label,
isComplete,
isCurrent,
error,
children,
}: OAuthStepProps) => {
return (
<div>
<div
className={`flex items-center p-2 rounded-md ${isCurrent ? "bg-accent" : ""}`}
>
{isComplete ? (
<CheckCircle2 className="h-5 w-5 text-green-500 mr-2" />
) : (
<Circle className="h-5 w-5 text-muted-foreground mr-2" />
)}
<span className={`${isCurrent ? "font-medium" : ""}`}>{label}</span>
</div>
{/* Show children if current step or complete and children exist */}
{(isCurrent || isComplete) && children && (
<div className="ml-7 mt-1">{children}</div>
)}
{/* Display error if current step and an error exists */}
{isCurrent && error && (
<div className="ml-7 mt-2 p-3 border border-red-300 bg-red-50 rounded-md">
<p className="text-sm font-medium text-red-700">Error:</p>
<p className="text-xs text-red-600 mt-1">{error.message}</p>
</div>
)}
</div>
);
};
interface OAuthFlowProgressProps {
serverUrl: string;
authState: AuthDebuggerState;
updateAuthState: (updates: Partial<AuthDebuggerState>) => void;
proceedToNextStep: () => Promise<void>;
}
export const OAuthFlowProgress = ({
serverUrl,
authState,
updateAuthState,
proceedToNextStep,
}: OAuthFlowProgressProps) => {
const provider = new DebugInspectorOAuthClientProvider(serverUrl);
const steps: Array<OAuthStep> = [
"metadata_discovery",
"client_registration",
"authorization_redirect",
"authorization_code",
"token_request",
"complete",
];
const currentStepIdx = steps.findIndex((s) => s === authState.oauthStep);
// Helper to get step props
const getStepProps = (stepName: OAuthStep) => ({
isComplete:
currentStepIdx > steps.indexOf(stepName) ||
currentStepIdx === steps.length - 1, // last step is "complete"
isCurrent: authState.oauthStep === stepName,
error: authState.oauthStep === stepName ? authState.latestError : null,
});
return (
<div className="rounded-md border p-6 space-y-4 mt-4">
<h3 className="text-lg font-medium">OAuth Flow Progress</h3>
<p className="text-sm text-muted-foreground">
Follow these steps to complete OAuth authentication with the server.
</p>
<div className="space-y-3">
<OAuthStepDetails
label="Metadata Discovery"
{...getStepProps("metadata_discovery")}
>
{provider.getServerMetadata() && (
<details className="text-xs mt-2">
<summary className="cursor-pointer text-muted-foreground font-medium">
Retrieved OAuth Metadata from {serverUrl}
/.well-known/oauth-authorization-server
</summary>
<pre className="mt-2 p-2 bg-muted rounded-md overflow-auto max-h-[300px]">
{JSON.stringify(provider.getServerMetadata(), null, 2)}
</pre>
</details>
)}
</OAuthStepDetails>
<OAuthStepDetails
label="Client Registration"
{...getStepProps("client_registration")}
>
{authState.oauthClientInfo && (
<details className="text-xs mt-2">
<summary className="cursor-pointer text-muted-foreground font-medium">
Registered Client Information
</summary>
<pre className="mt-2 p-2 bg-muted rounded-md overflow-auto max-h-[300px]">
{JSON.stringify(authState.oauthClientInfo, null, 2)}
</pre>
</details>
)}
</OAuthStepDetails>
<OAuthStepDetails
label="Preparing Authorization"
{...getStepProps("authorization_redirect")}
>
{authState.authorizationUrl && (
<div className="mt-2 p-3 border rounded-md bg-muted">
<p className="font-medium mb-2 text-sm">Authorization URL:</p>
<div className="flex items-center gap-2">
<p className="text-xs break-all">
{authState.authorizationUrl}
</p>
<a
href={authState.authorizationUrl}
target="_blank"
rel="noopener noreferrer"
className="flex items-center text-blue-500 hover:text-blue-700"
aria-label="Open authorization URL in new tab"
title="Open authorization URL"
>
<ExternalLink className="h-4 w-4" />
</a>
</div>
<p className="text-xs text-muted-foreground mt-2">
Click the link to authorize in your browser. After
authorization, you'll be redirected back to continue the flow.
</p>
</div>
)}
</OAuthStepDetails>
<OAuthStepDetails
label="Request Authorization and acquire authorization code"
{...getStepProps("authorization_code")}
>
<div className="mt-3">
<label
htmlFor="authCode"
className="block text-sm font-medium mb-1"
>
Authorization Code
</label>
<div className="flex gap-2">
<input
id="authCode"
value={authState.authorizationCode}
onChange={(e) => {
updateAuthState({
authorizationCode: e.target.value,
validationError: null,
});
}}
placeholder="Enter the code from the authorization server"
className={`flex h-9 w-full rounded-md border bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 ${
authState.validationError ? "border-red-500" : "border-input"
}`}
/>
</div>
{authState.validationError && (
<p className="text-xs text-red-600 mt-1">
{authState.validationError}
</p>
)}
<p className="text-xs text-muted-foreground mt-1">
Once you've completed authorization in the link, paste the code
here.
</p>
</div>
</OAuthStepDetails>
<OAuthStepDetails
label="Token Request"
{...getStepProps("token_request")}
>
{authState.oauthMetadata && (
<details className="text-xs mt-2">
<summary className="cursor-pointer text-muted-foreground font-medium">
Token Request Details
</summary>
<div className="mt-2 p-2 bg-muted rounded-md">
<p className="font-medium">Token Endpoint:</p>
<code className="block mt-1 text-xs overflow-x-auto">
{authState.oauthMetadata.token_endpoint}
</code>
</div>
</details>
)}
</OAuthStepDetails>
<OAuthStepDetails
label="Authentication Complete"
{...getStepProps("complete")}
>
{authState.oauthTokens && (
<details className="text-xs mt-2">
<summary className="cursor-pointer text-muted-foreground font-medium">
Access Tokens
</summary>
<p className="mt-1 text-sm">
Authentication successful! You can now use the authenticated
connection. These tokens will be used automatically for server
requests.
</p>
<pre className="mt-2 p-2 bg-muted rounded-md overflow-auto max-h-[300px]">
{JSON.stringify(authState.oauthTokens, null, 2)}
</pre>
</details>
)}
</OAuthStepDetails>
</div>
<div className="flex gap-3 mt-4">
{authState.oauthStep !== "complete" && (
<>
<Button
onClick={proceedToNextStep}
disabled={authState.isInitiatingAuth}
>
{authState.isInitiatingAuth ? "Processing..." : "Continue"}
</Button>
</>
)}
{authState.oauthStep === "authorization_redirect" &&
authState.authorizationUrl && (
<Button
variant="outline"
onClick={() => window.open(authState.authorizationUrl!, "_blank")}
>
Open in New Tab
</Button>
)}
</div>
</div>
);
};

View File

@@ -1,21 +0,0 @@
import { TabsContent } from "@/components/ui/tabs";
import { Button } from "@/components/ui/button";
const PingTab = ({ onPingClick }: { onPingClick: () => void }) => {
return (
<TabsContent value="ping">
<div className="grid grid-cols-2 gap-4">
<div className="col-span-2 flex justify-center items-center">
<Button
onClick={onPingClick}
className="font-bold py-6 px-12 rounded-full"
>
Ping Server
</Button>
</div>
</div>
</TabsContent>
);
};
export default PingTab;

View File

@@ -1,19 +1,12 @@
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { Combobox } from "@/components/ui/combobox";
import { Label } from "@/components/ui/label";
import { TabsContent } from "@/components/ui/tabs";
import {
ListPromptsResult,
PromptReference,
ResourceReference,
} from "@modelcontextprotocol/sdk/types.js";
import { AlertCircle } from "lucide-react";
import { useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { TabsContent } from "@/components/ui/tabs";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { useState } from "react";
import { Label } from "@/components/ui/label";
import ListPane from "./ListPane";
import { useCompletionState } from "@/lib/hooks/useCompletionState";
import JsonView from "./JsonView";
export type Prompt = {
name: string;
@@ -28,53 +21,24 @@ export type Prompt = {
const PromptsTab = ({
prompts,
listPrompts,
clearPrompts,
getPrompt,
selectedPrompt,
setSelectedPrompt,
handleCompletion,
completionsSupported,
promptContent,
nextCursor,
error,
}: {
prompts: Prompt[];
listPrompts: () => void;
clearPrompts: () => void;
getPrompt: (name: string, args: Record<string, string>) => void;
selectedPrompt: Prompt | null;
setSelectedPrompt: (prompt: Prompt | null) => void;
handleCompletion: (
ref: PromptReference | ResourceReference,
argName: string,
value: string,
) => Promise<string[]>;
completionsSupported: boolean;
setSelectedPrompt: (prompt: Prompt) => void;
promptContent: string;
nextCursor: ListPromptsResult["nextCursor"];
error: string | null;
}) => {
const [promptArgs, setPromptArgs] = useState<Record<string, string>>({});
const { completions, clearCompletions, requestCompletions } =
useCompletionState(handleCompletion, completionsSupported);
useEffect(() => {
clearCompletions();
}, [clearCompletions, selectedPrompt]);
const handleInputChange = async (argName: string, value: string) => {
const handleInputChange = (argName: string, value: string) => {
setPromptArgs((prev) => ({ ...prev, [argName]: value }));
if (selectedPrompt) {
requestCompletions(
{
type: "ref/prompt",
name: selectedPrompt.name,
},
argName,
value,
);
}
};
const handleGetPrompt = () => {
@@ -84,15 +48,10 @@ const PromptsTab = ({
};
return (
<TabsContent value="prompts">
<div className="grid grid-cols-2 gap-4">
<TabsContent value="prompts" className="grid grid-cols-2 gap-4">
<ListPane
items={prompts}
listItems={listPrompts}
clearItems={() => {
clearPrompts();
setSelectedPrompt(null);
}}
setSelectedItem={(prompt) => {
setSelectedPrompt(prompt);
setPromptArgs({});
@@ -100,18 +59,15 @@ const PromptsTab = ({
renderItem={(prompt) => (
<>
<span className="flex-1">{prompt.name}</span>
<span className="text-sm text-gray-500">
{prompt.description}
</span>
<span className="text-sm text-gray-500">{prompt.description}</span>
</>
)}
title="Prompts"
buttonText={nextCursor ? "List More Prompts" : "List Prompts"}
isButtonDisabled={!nextCursor && prompts.length > 0}
buttonText="List Prompts"
/>
<div className="bg-card rounded-lg shadow">
<div className="p-4 border-b border-gray-200 dark:border-gray-800">
<div className="bg-white rounded-lg shadow">
<div className="p-4 border-b border-gray-200">
<h3 className="font-semibold">
{selectedPrompt ? selectedPrompt.name : "Select a prompt"}
</h3>
@@ -133,17 +89,14 @@ const PromptsTab = ({
{selectedPrompt.arguments?.map((arg) => (
<div key={arg.name}>
<Label htmlFor={arg.name}>{arg.name}</Label>
<Combobox
<Input
id={arg.name}
placeholder={`Enter ${arg.name}`}
value={promptArgs[arg.name] || ""}
onChange={(value) => handleInputChange(arg.name, value)}
onInputChange={(value) =>
handleInputChange(arg.name, value)
onChange={(e) =>
handleInputChange(arg.name, e.target.value)
}
options={completions[arg.name] || []}
/>
{arg.description && (
<p className="text-xs text-gray-500 mt-1">
{arg.description}
@@ -158,7 +111,11 @@ const PromptsTab = ({
Get Prompt
</Button>
{promptContent && (
<JsonView data={promptContent} withCopyButton={false} />
<Textarea
value={promptContent}
readOnly
className="h-64 font-mono"
/>
)}
</div>
) : (
@@ -170,7 +127,6 @@ const PromptsTab = ({
)}
</div>
</div>
</div>
</TabsContent>
);
};

View File

@@ -0,0 +1,33 @@
import { TabsContent } from "@/components/ui/tabs";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Send } from "lucide-react";
const RequestsTab = () => (
<TabsContent value="requests" className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-4">
<div className="flex space-x-2">
<Input placeholder="Method name" />
<Button>
<Send className="w-4 h-4 mr-2" />
Send
</Button>
</div>
<Textarea
placeholder="Request parameters (JSON)"
className="h-64 font-mono"
/>
</div>
<div>
<div className="bg-gray-50 p-4 rounded-lg h-96 font-mono text-sm overflow-auto">
<div className="text-gray-500 mb-2">Response:</div>
{/* Response content would go here */}
</div>
</div>
</div>
</TabsContent>
);
export default RequestsTab;

View File

@@ -1,131 +1,34 @@
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { FileText, ChevronRight, AlertCircle, RefreshCw } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Combobox } from "@/components/ui/combobox";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { TabsContent } from "@/components/ui/tabs";
import {
ListResourcesResult,
Resource,
ResourceTemplate,
ListResourceTemplatesResult,
ResourceReference,
PromptReference,
} from "@modelcontextprotocol/sdk/types.js";
import { AlertCircle, ChevronRight, FileText, RefreshCw } from "lucide-react";
import { Resource } from "mcp-typescript/types.js";
import ListPane from "./ListPane";
import { useEffect, useState } from "react";
import { useCompletionState } from "@/lib/hooks/useCompletionState";
import JsonView from "./JsonView";
const ResourcesTab = ({
resources,
resourceTemplates,
listResources,
clearResources,
listResourceTemplates,
clearResourceTemplates,
readResource,
selectedResource,
setSelectedResource,
resourceSubscriptionsSupported,
resourceSubscriptions,
subscribeToResource,
unsubscribeFromResource,
handleCompletion,
completionsSupported,
resourceContent,
nextCursor,
nextTemplateCursor,
error,
}: {
resources: Resource[];
resourceTemplates: ResourceTemplate[];
listResources: () => void;
clearResources: () => void;
listResourceTemplates: () => void;
clearResourceTemplates: () => void;
readResource: (uri: string) => void;
readResource: (uri: URL) => void;
selectedResource: Resource | null;
setSelectedResource: (resource: Resource | null) => void;
handleCompletion: (
ref: ResourceReference | PromptReference,
argName: string,
value: string,
) => Promise<string[]>;
completionsSupported: boolean;
setSelectedResource: (resource: Resource) => void;
resourceContent: string;
nextCursor: ListResourcesResult["nextCursor"];
nextTemplateCursor: ListResourceTemplatesResult["nextCursor"];
error: string | null;
resourceSubscriptionsSupported: boolean;
resourceSubscriptions: Set<string>;
subscribeToResource: (uri: string) => void;
unsubscribeFromResource: (uri: string) => void;
}) => {
const [selectedTemplate, setSelectedTemplate] =
useState<ResourceTemplate | null>(null);
const [templateValues, setTemplateValues] = useState<Record<string, string>>(
{},
);
const { completions, clearCompletions, requestCompletions } =
useCompletionState(handleCompletion, completionsSupported);
useEffect(() => {
clearCompletions();
}, [clearCompletions]);
const fillTemplate = (
template: string,
values: Record<string, string>,
): string => {
return template.replace(
/{([^}]+)}/g,
(_, key) => values[key] || `{${key}}`,
);
};
const handleTemplateValueChange = async (key: string, value: string) => {
setTemplateValues((prev) => ({ ...prev, [key]: value }));
if (selectedTemplate?.uriTemplate) {
requestCompletions(
{
type: "ref/resource",
uri: selectedTemplate.uriTemplate,
},
key,
value,
);
}
};
const handleReadTemplateResource = () => {
if (selectedTemplate) {
const uri = fillTemplate(selectedTemplate.uriTemplate, templateValues);
readResource(uri);
// We don't have the full Resource object here, so we create a partial one
setSelectedResource({ uri, name: uri } as Resource);
}
};
return (
<TabsContent value="resources">
<div className="grid grid-cols-3 gap-4">
}) => (
<TabsContent value="resources" className="grid grid-cols-2 gap-4">
<ListPane
items={resources}
listItems={listResources}
clearItems={() => {
clearResources();
// Condition to check if selected resource is not resource template's resource
if (!selectedTemplate) {
setSelectedResource(null);
}
}}
setSelectedItem={(resource) => {
setSelectedResource(resource);
readResource(resource.uri);
setSelectedTemplate(null);
}}
renderItem={(resource) => (
<div className="flex items-center w-full">
@@ -137,78 +40,15 @@ const ResourcesTab = ({
</div>
)}
title="Resources"
buttonText={nextCursor ? "List More Resources" : "List Resources"}
isButtonDisabled={!nextCursor && resources.length > 0}
buttonText="List Resources"
/>
<ListPane
items={resourceTemplates}
listItems={listResourceTemplates}
clearItems={() => {
clearResourceTemplates();
// Condition to check if selected resource is resource template's resource
if (selectedTemplate) {
setSelectedResource(null);
}
setSelectedTemplate(null);
}}
setSelectedItem={(template) => {
setSelectedTemplate(template);
setSelectedResource(null);
setTemplateValues({});
}}
renderItem={(template) => (
<div className="flex items-center w-full">
<FileText className="w-4 h-4 mr-2 flex-shrink-0 text-gray-500" />
<span className="flex-1 truncate" title={template.uriTemplate}>
{template.name}
</span>
<ChevronRight className="w-4 h-4 flex-shrink-0 text-gray-400" />
</div>
)}
title="Resource Templates"
buttonText={
nextTemplateCursor ? "List More Templates" : "List Templates"
}
isButtonDisabled={!nextTemplateCursor && resourceTemplates.length > 0}
/>
<div className="bg-card rounded-lg shadow">
<div className="p-4 border-b border-gray-200 dark:border-gray-800 flex justify-between items-center">
<h3
className="font-semibold truncate"
title={selectedResource?.name || selectedTemplate?.name}
>
{selectedResource
? selectedResource.name
: selectedTemplate
? selectedTemplate.name
: "Select a resource or template"}
<div className="bg-white rounded-lg shadow">
<div className="p-4 border-b border-gray-200 flex justify-between items-center">
<h3 className="font-semibold truncate" title={selectedResource?.name}>
{selectedResource ? selectedResource.name : "Select a resource"}
</h3>
{selectedResource && (
<div className="flex row-auto gap-1 justify-end w-2/5">
{resourceSubscriptionsSupported &&
!resourceSubscriptions.has(selectedResource.uri) && (
<Button
variant="outline"
size="sm"
onClick={() => subscribeToResource(selectedResource.uri)}
>
Subscribe
</Button>
)}
{resourceSubscriptionsSupported &&
resourceSubscriptions.has(selectedResource.uri) && (
<Button
variant="outline"
size="sm"
onClick={() =>
unsubscribeFromResource(selectedResource.uri)
}
>
Unsubscribe
</Button>
)}
<Button
variant="outline"
size="sm"
@@ -217,7 +57,6 @@ const ResourcesTab = ({
<RefreshCw className="w-4 h-4 mr-2" />
Refresh
</Button>
</div>
)}
</div>
<div className="p-4">
@@ -228,57 +67,19 @@ const ResourcesTab = ({
<AlertDescription>{error}</AlertDescription>
</Alert>
) : selectedResource ? (
<JsonView
data={resourceContent}
className="bg-gray-50 dark:bg-gray-800 p-4 rounded text-sm overflow-auto max-h-96 text-gray-900 dark:text-gray-100"
/>
) : selectedTemplate ? (
<div className="space-y-4">
<p className="text-sm text-gray-600">
{selectedTemplate.description}
</p>
{selectedTemplate.uriTemplate
.match(/{([^}]+)}/g)
?.map((param) => {
const key = param.slice(1, -1);
return (
<div key={key}>
<Label htmlFor={key}>{key}</Label>
<Combobox
id={key}
placeholder={`Enter ${key}`}
value={templateValues[key] || ""}
onChange={(value) =>
handleTemplateValueChange(key, value)
}
onInputChange={(value) =>
handleTemplateValueChange(key, value)
}
options={completions[key] || []}
/>
</div>
);
})}
<Button
onClick={handleReadTemplateResource}
disabled={Object.keys(templateValues).length === 0}
>
Read Resource
</Button>
</div>
<pre className="bg-gray-50 p-4 rounded text-sm overflow-auto max-h-96 whitespace-pre-wrap break-words">
{resourceContent}
</pre>
) : (
<Alert>
<AlertDescription>
Select a resource or template from the list to view its
contents
Select a resource from the list to view its contents
</AlertDescription>
</Alert>
)}
</div>
</div>
</div>
</TabsContent>
);
};
);
export default ResourcesTab;

View File

@@ -1,79 +0,0 @@
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { TabsContent } from "@/components/ui/tabs";
import { Root } from "@modelcontextprotocol/sdk/types.js";
import { Plus, Minus, Save } from "lucide-react";
const RootsTab = ({
roots,
setRoots,
onRootsChange,
}: {
roots: Root[];
setRoots: React.Dispatch<React.SetStateAction<Root[]>>;
onRootsChange: () => void;
}) => {
const addRoot = () => {
setRoots((currentRoots) => [...currentRoots, { uri: "file://", name: "" }]);
};
const removeRoot = (index: number) => {
setRoots((currentRoots) => currentRoots.filter((_, i) => i !== index));
};
const updateRoot = (index: number, field: keyof Root, value: string) => {
setRoots((currentRoots) =>
currentRoots.map((root, i) =>
i === index ? { ...root, [field]: value } : root,
),
);
};
const handleSave = () => {
onRootsChange();
};
return (
<TabsContent value="roots">
<div className="space-y-4">
<Alert>
<AlertDescription>
Configure the root directories that the server can access
</AlertDescription>
</Alert>
{roots.map((root, index) => (
<div key={index} className="flex gap-2 items-center">
<Input
placeholder="file:// URI"
value={root.uri}
onChange={(e) => updateRoot(index, "uri", e.target.value)}
className="flex-1"
/>
<Button
variant="destructive"
size="sm"
onClick={() => removeRoot(index)}
>
<Minus className="h-4 w-4" />
</Button>
</div>
))}
<div className="flex gap-2">
<Button variant="outline" onClick={addRoot}>
<Plus className="h-4 w-4 mr-2" />
Add Root
</Button>
<Button onClick={handleSave}>
<Save className="h-4 w-4 mr-2" />
Save Changes
</Button>
</div>
</div>
</TabsContent>
);
};
export default RootsTab;

View File

@@ -1,167 +0,0 @@
import { Button } from "@/components/ui/button";
import JsonView from "./JsonView";
import { useMemo, useState } from "react";
import {
CreateMessageResult,
CreateMessageResultSchema,
} from "@modelcontextprotocol/sdk/types.js";
import { PendingRequest } from "./SamplingTab";
import DynamicJsonForm from "./DynamicJsonForm";
import { useToast } from "@/lib/hooks/useToast";
import { JsonSchemaType, JsonValue } from "@/utils/jsonUtils";
export type SamplingRequestProps = {
request: PendingRequest;
onApprove: (id: number, result: CreateMessageResult) => void;
onReject: (id: number) => void;
};
const SamplingRequest = ({
onApprove,
request,
onReject,
}: SamplingRequestProps) => {
const { toast } = useToast();
const [messageResult, setMessageResult] = useState<JsonValue>({
model: "stub-model",
stopReason: "endTurn",
role: "assistant",
content: {
type: "text",
text: "",
},
});
const contentType = (
(messageResult as { [key: string]: JsonValue })?.content as {
[key: string]: JsonValue;
}
)?.type;
const schema = useMemo(() => {
const s: JsonSchemaType = {
type: "object",
description: "Message result",
properties: {
model: {
type: "string",
default: "stub-model",
description: "model name",
},
stopReason: {
type: "string",
default: "endTurn",
description: "Stop reason",
},
role: {
type: "string",
default: "endTurn",
description: "Role of the model",
},
content: {
type: "object",
properties: {
type: {
type: "string",
default: "text",
description: "Type of content",
},
},
},
},
};
if (contentType === "text" && s.properties) {
s.properties.content.properties = {
...s.properties.content.properties,
text: {
type: "string",
default: "",
description: "text content",
},
};
setMessageResult((prev) => ({
...(prev as { [key: string]: JsonValue }),
content: {
type: contentType,
text: "",
},
}));
} else if (contentType === "image" && s.properties) {
s.properties.content.properties = {
...s.properties.content.properties,
data: {
type: "string",
default: "",
description: "Base64 encoded image data",
},
mimeType: {
type: "string",
default: "",
description: "Mime type of the image",
},
};
setMessageResult((prev) => ({
...(prev as { [key: string]: JsonValue }),
content: {
type: contentType,
data: "",
mimeType: "",
},
}));
}
return s;
}, [contentType]);
const handleApprove = (id: number) => {
const validationResult = CreateMessageResultSchema.safeParse(messageResult);
if (!validationResult.success) {
toast({
title: "Error",
description: `There was an error validating the message result: ${validationResult.error.message}`,
variant: "destructive",
});
return;
}
onApprove(id, validationResult.data);
};
return (
<div
data-testid="sampling-request"
className="flex gap-4 p-4 border rounded-lg space-y-4"
>
<div className="flex-1 bg-gray-50 dark:bg-gray-800 dark:text-gray-100 p-2 rounded">
<JsonView data={JSON.stringify(request.request)} />
</div>
<form className="flex-1 space-y-4">
<div className="space-y-2">
<DynamicJsonForm
schema={schema}
value={messageResult}
onChange={(newValue: JsonValue) => {
setMessageResult(newValue);
}}
/>
</div>
<div className="flex space-x-2 mt-1">
<Button type="button" onClick={() => handleApprove(request.id)}>
Approve
</Button>
<Button
type="button"
variant="outline"
onClick={() => onReject(request.id)}
>
Reject
</Button>
</div>
</form>
</div>
);
};
export default SamplingRequest;

View File

@@ -1,49 +0,0 @@
import { Alert, AlertDescription } from "@/components/ui/alert";
import { TabsContent } from "@/components/ui/tabs";
import {
CreateMessageRequest,
CreateMessageResult,
} from "@modelcontextprotocol/sdk/types.js";
import SamplingRequest from "./SamplingRequest";
export type PendingRequest = {
id: number;
request: CreateMessageRequest;
};
export type Props = {
pendingRequests: PendingRequest[];
onApprove: (id: number, result: CreateMessageResult) => void;
onReject: (id: number) => void;
};
const SamplingTab = ({ pendingRequests, onApprove, onReject }: Props) => {
return (
<TabsContent value="sampling">
<div className="h-96">
<Alert>
<AlertDescription>
When the server requests LLM sampling, requests will appear here for
approval.
</AlertDescription>
</Alert>
<div className="mt-4 space-y-4">
<h3 className="text-lg font-semibold">Recent Requests</h3>
{pendingRequests.map((request) => (
<SamplingRequest
key={request.id}
request={request}
onApprove={onApprove}
onReject={onReject}
/>
))}
{pendingRequests.length === 0 && (
<p className="text-gray-500">No pending requests</p>
)}
</div>
</div>
</TabsContent>
);
};
export default SamplingTab;

View File

@@ -1,860 +1,39 @@
import { useState, useCallback } from "react";
import {
Play,
ChevronDown,
ChevronRight,
CircleHelp,
Bug,
Github,
Eye,
EyeOff,
RotateCcw,
Settings,
HelpCircle,
RefreshCwOff,
Copy,
CheckCheck,
} from "lucide-react";
import { Menu, Settings } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { StdErrNotification } from "@/lib/notificationTypes";
import {
LoggingLevel,
LoggingLevelSchema,
} from "@modelcontextprotocol/sdk/types.js";
import { InspectorConfig } from "@/lib/configurationTypes";
import { ConnectionStatus } from "@/lib/constants";
import useTheme from "../lib/hooks/useTheme";
import { version } from "../../../package.json";
import {
Tooltip,
TooltipTrigger,
TooltipContent,
} from "@/components/ui/tooltip";
import { useToast } from "../lib/hooks/useToast";
export interface SidebarProps {
connectionStatus: ConnectionStatus;
transportType: "stdio" | "sse" | "streamable-http";
setTransportType: (type: "stdio" | "sse" | "streamable-http") => void;
command: string;
setCommand: (command: string) => void;
args: string;
setArgs: (args: string) => void;
sseUrl: string;
setSseUrl: (url: string) => void;
env: Record<string, string>;
setEnv: (env: Record<string, string>) => void;
bearerToken: string;
setBearerToken: (token: string) => void;
headerName?: string;
setHeaderName?: (name: string) => void;
customHeaders: [string, string][];
setCustomHeaders: (headers: [string, string][]) => void;
onConnect: () => void;
onDisconnect: () => void;
stdErrNotifications: StdErrNotification[];
clearStdErrNotifications: () => void;
logLevel: LoggingLevel;
sendLogLevelRequest: (level: LoggingLevel) => void;
loggingSupported: boolean;
config: InspectorConfig;
setConfig: (config: InspectorConfig) => void;
}
const Sidebar = ({
connectionStatus,
transportType,
setTransportType,
command,
setCommand,
args,
setArgs,
sseUrl,
setSseUrl,
env,
setEnv,
bearerToken,
setBearerToken,
headerName,
setHeaderName,
customHeaders,
setCustomHeaders,
onConnect,
onDisconnect,
stdErrNotifications,
clearStdErrNotifications,
logLevel,
sendLogLevelRequest,
loggingSupported,
config,
setConfig,
}: SidebarProps) => {
const [theme, setTheme] = useTheme();
const [showEnvVars, setShowEnvVars] = useState(false);
const [showBearerToken, setShowBearerToken] = useState(false);
const [showConfig, setShowConfig] = useState(false);
const [shownEnvVars, setShownEnvVars] = useState<Set<string>>(new Set());
const [copiedServerEntry, setCopiedServerEntry] = useState(false);
const [copiedServerFile, setCopiedServerFile] = useState(false);
const { toast } = useToast();
const [showCustomHeaders, setShowCustomHeaders] = useState(false);
// Reusable error reporter for copy actions
const reportError = useCallback(
(error: unknown) => {
toast({
title: "Error",
description: `Failed to copy config: ${error instanceof Error ? error.message : String(error)}`,
variant: "destructive",
});
},
[toast],
);
// Shared utility function to generate server config
const generateServerConfig = useCallback(() => {
if (transportType === "stdio") {
return {
command,
args: args.trim() ? args.split(/\s+/) : [],
env: { ...env },
};
}
if (transportType === "sse") {
return {
type: "sse",
url: sseUrl,
note: "For SSE connections, add this URL directly in Client",
};
}
if (transportType === "streamable-http") {
return {
type: "streamable-http",
url: sseUrl,
note: "For Streamable HTTP connections, add this URL directly in Client",
};
}
return {};
}, [transportType, command, args, env, sseUrl]);
// Memoized config entry generator
const generateMCPServerEntry = useCallback(() => {
return JSON.stringify(generateServerConfig(), null, 4);
}, [generateServerConfig]);
// Memoized config file generator
const generateMCPServerFile = useCallback(() => {
return JSON.stringify(
{
mcpServers: {
"default-server": generateServerConfig(),
},
},
null,
4,
);
}, [generateServerConfig]);
// Memoized copy handlers
const handleCopyServerEntry = useCallback(() => {
try {
const configJson = generateMCPServerEntry();
navigator.clipboard
.writeText(configJson)
.then(() => {
setCopiedServerEntry(true);
toast({
title: "Config entry copied",
description:
transportType === "stdio"
? "Server configuration has been copied to clipboard. Add this to your mcp.json inside the 'mcpServers' object with your preferred server name."
: "SSE URL has been copied. Use this URL in Cursor directly.",
});
setTimeout(() => {
setCopiedServerEntry(false);
}, 2000);
})
.catch((error) => {
reportError(error);
});
} catch (error) {
reportError(error);
}
}, [generateMCPServerEntry, transportType, toast, reportError]);
const handleCopyServerFile = useCallback(() => {
try {
const configJson = generateMCPServerFile();
navigator.clipboard
.writeText(configJson)
.then(() => {
setCopiedServerFile(true);
toast({
title: "Servers file copied",
description:
"Servers configuration has been copied to clipboard. Add this to your mcp.json file. Current testing server will be added as 'default-server'",
});
setTimeout(() => {
setCopiedServerFile(false);
}, 2000);
})
.catch((error) => {
reportError(error);
});
} catch (error) {
reportError(error);
}
}, [generateMCPServerFile, toast, reportError]);
const removeCustomHeader = (index: number) => {
const newHeaders = [...customHeaders];
newHeaders.splice(index, 1);
setCustomHeaders(newHeaders);
};
const updateCustomHeader = (index: number, field: 'key' | 'value', value: string) => {
const newArr = [...customHeaders];
const [oldKey, oldValue] = newArr[index];
const newTuple: [string, string] = field === 'key'
? [value, oldValue]
: [oldKey, value];
newArr[index] = newTuple;
setCustomHeaders(newArr);
};
return (
<div className="w-80 bg-card border-r border-border flex flex-col h-full">
<div className="flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-800">
<div className="flex items-center">
<h1 className="ml-2 text-lg font-semibold">
MCP Inspector v{version}
</h1>
</div>
const Sidebar = ({ connectionStatus }: { connectionStatus: string }) => (
<div className="w-64 bg-white border-r border-gray-200">
<div className="flex items-center p-4 border-b border-gray-200">
<Menu className="w-6 h-6 text-gray-500" />
<h1 className="ml-2 text-lg font-semibold">MCP Inspector</h1>
</div>
<div className="p-4 flex-1 overflow-auto">
<div className="space-y-4">
<div className="space-y-2">
<label
className="text-sm font-medium"
htmlFor="transport-type-select"
>
Transport Type
</label>
<Select
value={transportType}
onValueChange={(value: "stdio" | "sse" | "streamable-http") =>
setTransportType(value)
}
>
<SelectTrigger id="transport-type-select">
<SelectValue placeholder="Select transport type" />
</SelectTrigger>
<SelectContent>
<SelectItem value="stdio">STDIO</SelectItem>
<SelectItem value="sse">SSE</SelectItem>
<SelectItem value="streamable-http">Streamable HTTP</SelectItem>
</SelectContent>
</Select>
</div>
{transportType === "stdio" ? (
<>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="command-input">
Command
</label>
<Input
id="command-input"
placeholder="Command"
value={command}
onChange={(e) => setCommand(e.target.value)}
className="font-mono"
/>
</div>
<div className="space-y-2">
<label
className="text-sm font-medium"
htmlFor="arguments-input"
>
Arguments
</label>
<Input
id="arguments-input"
placeholder="Arguments (space-separated)"
value={args}
onChange={(e) => setArgs(e.target.value)}
className="font-mono"
/>
</div>
</>
) : (
<>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="sse-url-input">
URL
</label>
<Input
id="sse-url-input"
placeholder="URL"
value={sseUrl}
onChange={(e) => setSseUrl(e.target.value)}
className="font-mono"
/>
</div>
<div className="space-y-2">
<Button
variant="outline"
onClick={() => setShowBearerToken(!showBearerToken)}
className="flex items-center w-full"
data-testid="auth-button"
aria-expanded={showBearerToken}
>
{showBearerToken ? (
<ChevronDown className="w-4 h-4 mr-2" />
) : (
<ChevronRight className="w-4 h-4 mr-2" />
)}
Authentication
</Button>
{showBearerToken && (
<div className="space-y-2">
<label className="text-sm font-medium">Header Name</label>
<Input
placeholder="Authorization"
onChange={(e) =>
setHeaderName && setHeaderName(e.target.value)
}
data-testid="header-input"
className="font-mono"
value={headerName}
/>
<label
className="text-sm font-medium"
htmlFor="bearer-token-input"
>
Bearer Token
</label>
<Input
id="bearer-token-input"
placeholder="Bearer Token"
value={bearerToken}
onChange={(e) => setBearerToken(e.target.value)}
data-testid="bearer-token-input"
className="font-mono"
type="password"
/>
</div>
)}
</div>
</>
)}
{transportType === "stdio" && (
<div className="space-y-2">
<Button
variant="outline"
onClick={() => setShowEnvVars(!showEnvVars)}
className="flex items-center w-full"
data-testid="env-vars-button"
aria-expanded={showEnvVars}
>
{showEnvVars ? (
<ChevronDown className="w-4 h-4 mr-2" />
) : (
<ChevronRight className="w-4 h-4 mr-2" />
)}
Environment Variables
</Button>
{showEnvVars && (
<div className="space-y-2">
{Object.entries(env).map(([key, value], idx) => (
<div key={idx} className="space-y-2 pb-4">
<div className="flex gap-2">
<Input
aria-label={`Environment variable key ${idx + 1}`}
placeholder="Key"
value={key}
onChange={(e) => {
const newKey = e.target.value;
const newEnv = Object.entries(env).reduce(
(acc, [k, v]) => {
if (k === key) {
acc[newKey] = value;
} else {
acc[k] = v;
}
return acc;
},
{} as Record<string, string>,
);
setEnv(newEnv);
setShownEnvVars((prev) => {
const next = new Set(prev);
if (next.has(key)) {
next.delete(key);
next.add(newKey);
}
return next;
});
}}
className="font-mono"
/>
<Button
variant="destructive"
size="icon"
className="h-9 w-9 p-0 shrink-0"
onClick={() => {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { [key]: _removed, ...rest } = env;
setEnv(rest);
}}
>
×
</Button>
</div>
<div className="flex gap-2">
<Input
aria-label={`Environment variable value ${idx + 1}`}
type={shownEnvVars.has(key) ? "text" : "password"}
placeholder="Value"
value={value}
onChange={(e) => {
const newEnv = { ...env };
newEnv[key] = e.target.value;
setEnv(newEnv);
}}
className="font-mono"
/>
<Button
variant="outline"
size="icon"
className="h-9 w-9 p-0 shrink-0"
onClick={() => {
setShownEnvVars((prev) => {
const next = new Set(prev);
if (next.has(key)) {
next.delete(key);
} else {
next.add(key);
}
return next;
});
}}
aria-label={
shownEnvVars.has(key) ? "Hide value" : "Show value"
}
aria-pressed={shownEnvVars.has(key)}
title={
shownEnvVars.has(key) ? "Hide value" : "Show value"
}
>
{shownEnvVars.has(key) ? (
<Eye className="h-4 w-4" aria-hidden="true" />
) : (
<EyeOff className="h-4 w-4" aria-hidden="true" />
)}
</Button>
</div>
</div>
))}
<Button
variant="outline"
className="w-full mt-2"
onClick={() => {
const key = "";
const newEnv = { ...env };
newEnv[key] = "";
setEnv(newEnv);
}}
>
Add Environment Variable
</Button>
</div>
)}
</div>
)}
{/* Always show both copy buttons for all transport types */}
<div className="grid grid-cols-2 gap-2 mt-2">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="sm"
onClick={handleCopyServerEntry}
className="w-full"
>
{copiedServerEntry ? (
<CheckCheck className="h-4 w-4 mr-2" />
) : (
<Copy className="h-4 w-4 mr-2" />
)}
Server Entry
</Button>
</TooltipTrigger>
<TooltipContent>Copy Server Entry</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="sm"
onClick={handleCopyServerFile}
className="w-full"
>
{copiedServerFile ? (
<CheckCheck className="h-4 w-4 mr-2" />
) : (
<Copy className="h-4 w-4 mr-2" />
)}
Servers File
</Button>
</TooltipTrigger>
<TooltipContent>Copy Servers File</TooltipContent>
</Tooltip>
</div>
{/* Configuration */}
<div className="space-y-2">
<Button
variant="outline"
onClick={() => setShowConfig(!showConfig)}
className="flex items-center w-full"
data-testid="config-button"
aria-expanded={showConfig}
>
{showConfig ? (
<ChevronDown className="w-4 h-4 mr-2" />
) : (
<ChevronRight className="w-4 h-4 mr-2" />
)}
<Settings className="w-4 h-4 mr-2" />
Configuration
</Button>
{showConfig && (
<div className="space-y-2">
{Object.entries(config).map(([key, configItem]) => {
const configKey = key as keyof InspectorConfig;
return (
<div key={key} className="space-y-2">
<div className="flex items-center gap-1">
<label
className="text-sm font-medium text-green-600 break-all"
htmlFor={`${configKey}-input`}
>
{configItem.label}
</label>
<Tooltip>
<TooltipTrigger asChild>
<HelpCircle className="h-4 w-4 text-muted-foreground" />
</TooltipTrigger>
<TooltipContent>
{configItem.description}
</TooltipContent>
</Tooltip>
</div>
{typeof configItem.value === "number" ? (
<Input
id={`${configKey}-input`}
type="number"
data-testid={`${configKey}-input`}
value={configItem.value}
onChange={(e) => {
const newConfig = { ...config };
newConfig[configKey] = {
...configItem,
value: Number(e.target.value),
};
setConfig(newConfig);
}}
className="font-mono"
/>
) : typeof configItem.value === "boolean" ? (
<Select
data-testid={`${configKey}-select`}
value={configItem.value.toString()}
onValueChange={(val) => {
const newConfig = { ...config };
newConfig[configKey] = {
...configItem,
value: val === "true",
};
setConfig(newConfig);
}}
>
<SelectTrigger id={`${configKey}-input`}>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="true">True</SelectItem>
<SelectItem value="false">False</SelectItem>
</SelectContent>
</Select>
) : (
<Input
id={`${configKey}-input`}
data-testid={`${configKey}-input`}
value={configItem.value}
onChange={(e) => {
const newConfig = { ...config };
newConfig[configKey] = {
...configItem,
value: e.target.value,
};
setConfig(newConfig);
}}
className="font-mono"
/>
)}
</div>
);
})}
</div>
)}
</div>
<div className="space-y-2">
{connectionStatus === "connected" && (
<div className="grid grid-cols-2 gap-4">
<Button
data-testid="connect-button"
onClick={() => {
onDisconnect();
onConnect();
}}
>
<RotateCcw className="w-4 h-4 mr-2" />
{transportType === "stdio" ? "Restart" : "Reconnect"}
</Button>
<Button onClick={onDisconnect}>
<RefreshCwOff className="w-4 h-4 mr-2" />
Disconnect
</Button>
</div>
)}
{connectionStatus !== "connected" && (
<Button className="w-full" onClick={onConnect}>
<Play className="w-4 h-4 mr-2" />
Connect
</Button>
)}
<div className="flex items-center justify-center space-x-2 mb-4">
<div className="p-4">
<div className="flex items-center space-x-2 mb-4">
<div
className={`w-2 h-2 rounded-full ${(() => {
switch (connectionStatus) {
case "connected":
return "bg-green-500";
case "error":
return "bg-red-500";
case "error-connecting-to-proxy":
return "bg-red-500";
default:
return "bg-gray-500";
}
})()}`}
className={`w-2 h-2 rounded-full ${
connectionStatus === "connected"
? "bg-green-500"
: connectionStatus === "error"
? "bg-red-500"
: "bg-gray-500"
}`}
/>
<span className="text-sm text-gray-600">
{(() => {
switch (connectionStatus) {
case "connected":
return "Connected";
case "error":
return "Connection Error, is your MCP server running?";
case "error-connecting-to-proxy":
return "Error Connecting to MCP Inspector Proxy - Check Console logs";
default:
return "Disconnected";
}
})()}
{connectionStatus === "connected"
? "Connected"
: connectionStatus === "error"
? "Connection Error"
: "Disconnected"}
</span>
</div>
{loggingSupported && connectionStatus === "connected" && (
<div className="space-y-2">
<label
className="text-sm font-medium"
htmlFor="logging-level-select"
>
Logging Level
</label>
<Select
value={logLevel}
onValueChange={(value: LoggingLevel) =>
sendLogLevelRequest(value)
}
>
<SelectTrigger id="logging-level-select">
<SelectValue placeholder="Select logging level" />
</SelectTrigger>
<SelectContent>
{Object.values(LoggingLevelSchema.enum).map((level) => (
<SelectItem key={level} value={level}>
{level}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{stdErrNotifications.length > 0 && (
<>
<div className="mt-4 border-t border-gray-200 pt-4">
<div className="flex justify-between items-center">
<h3 className="text-sm font-medium">
Error output from MCP server
</h3>
<Button
variant="outline"
size="sm"
onClick={clearStdErrNotifications}
className="h-8 px-2"
>
Clear
</Button>
</div>
<div className="mt-2 max-h-80 overflow-y-auto">
{stdErrNotifications.map((notification, index) => (
<div
key={index}
className="text-sm text-red-500 font-mono py-2 border-b border-gray-200 last:border-b-0"
>
{notification.params.content}
</div>
))}
</div>
</div>
</>
)}
</div>
<div className="space-y-2">
<Button
variant="outline"
onClick={() => setShowCustomHeaders(!showCustomHeaders)}
className="flex items-center w-full"
data-testid="custom-headers-button"
aria-expanded={showCustomHeaders}
>
{showCustomHeaders ? (
<ChevronDown className="w-4 h-4 mr-2" />
) : (
<ChevronRight className="w-4 h-4 mr-2" />
)}
Custom Headers
</Button>
{showCustomHeaders && (
<div className="space-y-2">
{customHeaders.map((header, index) => (
<div key={index} className="space-y-2">
<label className="text-sm font-medium">Header Name</label>
<Input
placeholder="Header Name"
value={header[0]}
onChange={(e) => updateCustomHeader(index, 'key', e.target.value)}
className="font-mono"
/>
<label className="text-sm font-medium">Header Value</label>
<Input
placeholder="Header Value"
value={header[1]}
onChange={(e) => updateCustomHeader(index, 'value', e.target.value)}
className="font-mono"
/>
<Button
variant="destructive"
size="sm"
onClick={() => removeCustomHeader(index)}
className="w-full"
>
Remove Header
</Button>
</div>
))}
<Button
variant="outline"
className="w-full mt-2"
onClick={() => {
setCustomHeaders([ ...customHeaders, ["", ""]]);
}}
>
Add Custom Header
</Button>
</div>
)}
</div>
</div>
</div>
<div className="p-4 border-t">
<div className="flex items-center justify-between">
<Select
value={theme}
onValueChange={(value: string) =>
setTheme(value as "system" | "light" | "dark")
}
>
<SelectTrigger className="w-[100px]" id="theme-select">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="system">System</SelectItem>
<SelectItem value="light">Light</SelectItem>
<SelectItem value="dark">Dark</SelectItem>
</SelectContent>
</Select>
<div className="flex items-center space-x-2">
<Button variant="ghost" title="Inspector Documentation" asChild>
<a
href="https://modelcontextprotocol.io/docs/tools/inspector"
target="_blank"
rel="noopener noreferrer"
>
<CircleHelp className="w-4 h-4 text-foreground" />
</a>
</Button>
<Button variant="ghost" title="Debugging Guide" asChild>
<a
href="https://modelcontextprotocol.io/docs/tools/debugging"
target="_blank"
rel="noopener noreferrer"
>
<Bug className="w-4 h-4 text-foreground" />
</a>
</Button>
<Button
variant="ghost"
title="Report bugs or contribute on GitHub"
asChild
>
<a
href="https://github.com/modelcontextprotocol/inspector"
target="_blank"
rel="noopener noreferrer"
>
<Github className="w-4 h-4 text-foreground" />
</a>
<Button variant="outline" className="w-full justify-start">
<Settings className="w-4 h-4 mr-2" />
Connection Settings
</Button>
</div>
</div>
</div>
</div>
);
};
);
export default Sidebar;

View File

@@ -1,166 +1,68 @@
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { TabsContent } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea";
import DynamicJsonForm from "./DynamicJsonForm";
import type { JsonValue, JsonSchemaType } from "@/utils/jsonUtils";
import { generateDefaultValue } from "@/utils/schemaUtils";
import {
CallToolResultSchema,
CompatibilityCallToolResult,
ListToolsResult,
Tool,
} from "@modelcontextprotocol/sdk/types.js";
import { Loader2, Send } from "lucide-react";
import { useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Send, AlertCircle } from "lucide-react";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Tool } from "mcp-typescript/types.js";
import { useState } from "react";
import { Label } from "@/components/ui/label";
import ListPane from "./ListPane";
import JsonView from "./JsonView";
const ToolsTab = ({
tools,
listTools,
clearTools,
callTool,
selectedTool,
setSelectedTool,
toolResult,
nextCursor,
error,
}: {
tools: Tool[];
listTools: () => void;
clearTools: () => void;
callTool: (name: string, params: Record<string, unknown>) => Promise<void>;
callTool: (name: string, params: Record<string, unknown>) => void;
selectedTool: Tool | null;
setSelectedTool: (tool: Tool | null) => void;
toolResult: CompatibilityCallToolResult | null;
nextCursor: ListToolsResult["nextCursor"];
setSelectedTool: (tool: Tool) => void;
toolResult: string;
error: string | null;
}) => {
const [params, setParams] = useState<Record<string, unknown>>({});
const [isToolRunning, setIsToolRunning] = useState(false);
useEffect(() => {
const params = Object.entries(
selectedTool?.inputSchema.properties ?? [],
).map(([key, value]) => [
key,
generateDefaultValue(value as JsonSchemaType),
]);
setParams(Object.fromEntries(params));
}, [selectedTool]);
const renderToolResult = () => {
if (!toolResult) return null;
if ("content" in toolResult) {
const parsedResult = CallToolResultSchema.safeParse(toolResult);
if (!parsedResult.success) {
return (
<>
<h4 className="font-semibold mb-2">Invalid Tool Result:</h4>
<JsonView data={toolResult} />
<h4 className="font-semibold mb-2">Errors:</h4>
{parsedResult.error.errors.map((error, idx) => (
<JsonView data={error} key={idx} />
))}
</>
);
}
const structuredResult = parsedResult.data;
const isError = structuredResult.isError ?? false;
return (
<>
<h4 className="font-semibold mb-2">
Tool Result:{" "}
{isError ? (
<span className="text-red-600 font-semibold">Error</span>
) : (
<span className="text-green-600 font-semibold">Success</span>
)}
</h4>
{structuredResult.content.map((item, index) => (
<div key={index} className="mb-2">
{item.type === "text" && (
<JsonView data={item.text} isError={isError} />
)}
{item.type === "image" && (
<img
src={`data:${item.mimeType};base64,${item.data}`}
alt="Tool result image"
className="max-w-full h-auto"
/>
)}
{item.type === "resource" &&
(item.resource?.mimeType?.startsWith("audio/") ? (
<audio
controls
src={`data:${item.resource.mimeType};base64,${item.resource.blob}`}
className="w-full"
>
<p>Your browser does not support audio playback</p>
</audio>
) : (
<JsonView data={item.resource} />
))}
</div>
))}
</>
);
} else if ("toolResult" in toolResult) {
return (
<>
<h4 className="font-semibold mb-2">Tool Result (Legacy):</h4>
<JsonView data={toolResult.toolResult} />
</>
);
}
};
return (
<TabsContent value="tools">
<div className="grid grid-cols-2 gap-4">
<TabsContent value="tools" className="grid grid-cols-2 gap-4">
<ListPane
items={tools}
listItems={listTools}
clearItems={() => {
clearTools();
setSelectedTool(null);
}}
setSelectedItem={setSelectedTool}
renderItem={(tool) => (
<div className="flex flex-col items-start">
<>
<span className="flex-1">{tool.name}</span>
<span className="text-sm text-gray-500 text-left">
{tool.description}
</span>
</div>
<span className="text-sm text-gray-500">{tool.description}</span>
</>
)}
title="Tools"
buttonText={nextCursor ? "List More Tools" : "List Tools"}
isButtonDisabled={!nextCursor && tools.length > 0}
buttonText="List Tools"
/>
<div className="bg-card rounded-lg shadow">
<div className="p-4 border-b border-gray-200 dark:border-gray-800">
<div className="bg-white rounded-lg shadow">
<div className="p-4 border-b border-gray-200">
<h3 className="font-semibold">
{selectedTool ? selectedTool.name : "Select a tool"}
</h3>
</div>
<div className="p-4">
{selectedTool ? (
{error ? (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertTitle>Error</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
) : selectedTool ? (
<div className="space-y-4">
<p className="text-sm text-gray-600">
{selectedTool.description}
</p>
{Object.entries(selectedTool.inputSchema.properties ?? []).map(
([key, value]) => {
const prop = value as JsonSchemaType;
return (
{Object.entries(selectedTool.inputSchema.properties).map(
([key, value]) => (
<div key={key}>
<Label
htmlFor={key}
@@ -168,124 +70,36 @@ const ToolsTab = ({
>
{key}
</Label>
{prop.type === "boolean" ? (
<div className="flex items-center space-x-2 mt-2">
<Checkbox
id={key}
name={key}
checked={!!params[key]}
onCheckedChange={(checked: boolean) =>
setParams({
...params,
[key]: checked,
})
}
/>
<label
htmlFor={key}
className="text-sm font-medium text-gray-700 dark:text-gray-300"
>
{prop.description || "Toggle this option"}
</label>
</div>
) : prop.type === "string" ? (
<Textarea
id={key}
name={key}
placeholder={prop.description}
value={(params[key] as string) ?? ""}
onChange={(e) =>
setParams({
...params,
[key]: e.target.value,
})
}
className="mt-1"
/>
) : prop.type === "object" || prop.type === "array" ? (
<div className="mt-1">
<DynamicJsonForm
schema={{
type: prop.type,
properties: prop.properties,
description: prop.description,
items: prop.items,
}}
value={
(params[key] as JsonValue) ??
generateDefaultValue(prop)
}
onChange={(newValue: JsonValue) => {
setParams({
...params,
[key]: newValue,
});
}}
/>
</div>
) : prop.type === "number" ||
prop.type === "integer" ? (
<Input
type="number"
type={value.type === "number" ? "number" : "text"}
id={key}
name={key}
placeholder={prop.description}
value={(params[key] as string) ?? ""}
placeholder={value.description}
onChange={(e) =>
setParams({
...params,
[key]: Number(e.target.value),
[key]:
value.type === "number"
? Number(e.target.value)
: e.target.value,
})
}
className="mt-1"
/>
) : (
<div className="mt-1">
<DynamicJsonForm
schema={{
type: prop.type,
properties: prop.properties,
description: prop.description,
items: prop.items,
}}
value={params[key] as JsonValue}
onChange={(newValue: JsonValue) => {
setParams({
...params,
[key]: newValue,
});
}}
/>
</div>
),
)}
</div>
);
},
)}
<Button
onClick={async () => {
try {
setIsToolRunning(true);
await callTool(selectedTool.name, params);
} finally {
setIsToolRunning(false);
}
}}
disabled={isToolRunning}
>
{isToolRunning ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Running...
</>
) : (
<>
<Button onClick={() => callTool(selectedTool.name, params)}>
<Send className="w-4 h-4 mr-2" />
Run Tool
</Button>
{toolResult && (
<>
<h4 className="font-semibold mb-2">Tool Result:</h4>
<pre className="bg-gray-50 p-4 rounded text-sm overflow-auto max-h-64">
{toolResult}
</pre>
</>
)}
</Button>
{toolResult && renderToolResult()}
</div>
) : (
<Alert>
@@ -296,7 +110,6 @@ const ToolsTab = ({
)}
</div>
</div>
</div>
</TabsContent>
);
};

View File

@@ -1,382 +0,0 @@
import {
render,
screen,
fireEvent,
waitFor,
act,
} from "@testing-library/react";
import "@testing-library/jest-dom";
import { describe, it, beforeEach, jest } from "@jest/globals";
import AuthDebugger, { AuthDebuggerProps } from "../AuthDebugger";
import { TooltipProvider } from "@/components/ui/tooltip";
import { SESSION_KEYS } from "@/lib/constants";
const mockOAuthTokens = {
access_token: "test_access_token",
token_type: "Bearer",
expires_in: 3600,
refresh_token: "test_refresh_token",
scope: "test_scope",
};
const mockOAuthMetadata = {
issuer: "https://oauth.example.com",
authorization_endpoint: "https://oauth.example.com/authorize",
token_endpoint: "https://oauth.example.com/token",
response_types_supported: ["code"],
grant_types_supported: ["authorization_code"],
};
const mockOAuthClientInfo = {
client_id: "test_client_id",
client_secret: "test_client_secret",
redirect_uris: ["http://localhost:3000/oauth/callback/debug"],
};
// Mock MCP SDK functions - must be before imports
jest.mock("@modelcontextprotocol/sdk/client/auth.js", () => ({
auth: jest.fn(),
discoverOAuthMetadata: jest.fn(),
registerClient: jest.fn(),
startAuthorization: jest.fn(),
exchangeAuthorization: jest.fn(),
}));
// Import the functions to get their types
import {
discoverOAuthMetadata,
registerClient,
startAuthorization,
exchangeAuthorization,
} from "@modelcontextprotocol/sdk/client/auth.js";
import { OAuthMetadata } from "@modelcontextprotocol/sdk/shared/auth.js";
// Type the mocked functions properly
const mockDiscoverOAuthMetadata = discoverOAuthMetadata as jest.MockedFunction<
typeof discoverOAuthMetadata
>;
const mockRegisterClient = registerClient as jest.MockedFunction<
typeof registerClient
>;
const mockStartAuthorization = startAuthorization as jest.MockedFunction<
typeof startAuthorization
>;
const mockExchangeAuthorization = exchangeAuthorization as jest.MockedFunction<
typeof exchangeAuthorization
>;
const sessionStorageMock = {
getItem: jest.fn(),
setItem: jest.fn(),
removeItem: jest.fn(),
clear: jest.fn(),
};
Object.defineProperty(window, "sessionStorage", {
value: sessionStorageMock,
});
Object.defineProperty(window, "location", {
value: {
origin: "http://localhost:3000",
},
});
describe("AuthDebugger", () => {
const defaultAuthState = {
isInitiatingAuth: false,
oauthTokens: null,
loading: false,
oauthStep: "metadata_discovery" as const,
oauthMetadata: null,
oauthClientInfo: null,
authorizationUrl: null,
authorizationCode: "",
latestError: null,
statusMessage: null,
validationError: null,
};
const defaultProps = {
serverUrl: "https://example.com",
onBack: jest.fn(),
authState: defaultAuthState,
updateAuthState: jest.fn(),
};
beforeEach(() => {
jest.clearAllMocks();
sessionStorageMock.getItem.mockReturnValue(null);
mockDiscoverOAuthMetadata.mockResolvedValue(mockOAuthMetadata);
mockRegisterClient.mockResolvedValue(mockOAuthClientInfo);
mockStartAuthorization.mockImplementation(async (_sseUrl, options) => {
const authUrl = new URL("https://oauth.example.com/authorize");
if (options.scope) {
authUrl.searchParams.set("scope", options.scope);
}
return {
authorizationUrl: authUrl,
codeVerifier: "test_verifier",
};
});
mockExchangeAuthorization.mockResolvedValue(mockOAuthTokens);
});
const renderAuthDebugger = (props: Partial<AuthDebuggerProps> = {}) => {
const mergedProps = {
...defaultProps,
...props,
authState: { ...defaultAuthState, ...(props.authState || {}) },
};
return render(
<TooltipProvider>
<AuthDebugger {...mergedProps} />
</TooltipProvider>,
);
};
describe("Initial Rendering", () => {
it("should render the component with correct title", async () => {
await act(async () => {
renderAuthDebugger();
});
expect(screen.getByText("Authentication Settings")).toBeInTheDocument();
});
it("should call onBack when Back button is clicked", async () => {
const onBack = jest.fn();
await act(async () => {
renderAuthDebugger({ onBack });
});
fireEvent.click(screen.getByText("Back to Connect"));
expect(onBack).toHaveBeenCalled();
});
});
describe("OAuth Flow", () => {
it("should start OAuth flow when 'Guided OAuth Flow' is clicked", async () => {
await act(async () => {
renderAuthDebugger();
});
await act(async () => {
fireEvent.click(screen.getByText("Guided OAuth Flow"));
});
expect(screen.getByText("OAuth Flow Progress")).toBeInTheDocument();
});
it("should show error when OAuth flow is started without sseUrl", async () => {
const updateAuthState = jest.fn();
await act(async () => {
renderAuthDebugger({ serverUrl: "", updateAuthState });
});
await act(async () => {
fireEvent.click(screen.getByText("Guided OAuth Flow"));
});
expect(updateAuthState).toHaveBeenCalledWith({
statusMessage: {
type: "error",
message:
"Please enter a server URL in the sidebar before authenticating",
},
});
});
});
describe("Session Storage Integration", () => {
it("should load OAuth tokens from session storage", async () => {
// Mock the specific key for tokens with server URL
sessionStorageMock.getItem.mockImplementation((key) => {
if (key === "[https://example.com] mcp_tokens") {
return JSON.stringify(mockOAuthTokens);
}
return null;
});
await act(async () => {
renderAuthDebugger({
authState: {
...defaultAuthState,
oauthTokens: mockOAuthTokens,
},
});
});
await waitFor(() => {
expect(screen.getByText(/Access Token:/)).toBeInTheDocument();
});
});
it("should handle errors loading OAuth tokens from session storage", async () => {
// Mock console to avoid cluttering test output
const originalError = console.error;
console.error = jest.fn();
// Mock getItem to return invalid JSON for tokens
sessionStorageMock.getItem.mockImplementation((key) => {
if (key === "[https://example.com] mcp_tokens") {
return "invalid json";
}
return null;
});
await act(async () => {
renderAuthDebugger();
});
// Component should still render despite the error
expect(screen.getByText("Authentication Settings")).toBeInTheDocument();
// Restore console.error
console.error = originalError;
});
});
describe("OAuth State Management", () => {
it("should clear OAuth state when Clear button is clicked", async () => {
const updateAuthState = jest.fn();
// Mock the session storage to return tokens for the specific key
sessionStorageMock.getItem.mockImplementation((key) => {
if (key === "[https://example.com] mcp_tokens") {
return JSON.stringify(mockOAuthTokens);
}
return null;
});
await act(async () => {
renderAuthDebugger({
authState: {
...defaultAuthState,
oauthTokens: mockOAuthTokens,
},
updateAuthState,
});
});
await act(async () => {
fireEvent.click(screen.getByText("Clear OAuth State"));
});
expect(updateAuthState).toHaveBeenCalledWith({
oauthTokens: null,
oauthStep: "metadata_discovery",
latestError: null,
oauthClientInfo: null,
oauthMetadata: null,
authorizationCode: "",
validationError: null,
statusMessage: {
type: "success",
message: "OAuth tokens cleared successfully",
},
});
// Verify session storage was cleared
expect(sessionStorageMock.removeItem).toHaveBeenCalled();
});
});
describe("OAuth Flow Steps", () => {
it("should handle OAuth flow step progression", async () => {
const updateAuthState = jest.fn();
await act(async () => {
renderAuthDebugger({
updateAuthState,
authState: {
...defaultAuthState,
isInitiatingAuth: false, // Changed to false so button is enabled
oauthStep: "metadata_discovery",
},
});
});
// Verify metadata discovery step
expect(screen.getByText("Metadata Discovery")).toBeInTheDocument();
// Click Continue - this should trigger metadata discovery
await act(async () => {
fireEvent.click(screen.getByText("Continue"));
});
expect(mockDiscoverOAuthMetadata).toHaveBeenCalledWith(
"https://example.com",
);
});
// Setup helper for OAuth authorization tests
const setupAuthorizationUrlTest = async (metadata: OAuthMetadata) => {
const updateAuthState = jest.fn();
// Mock the session storage to return metadata
sessionStorageMock.getItem.mockImplementation((key) => {
if (key === `[https://example.com] ${SESSION_KEYS.SERVER_METADATA}`) {
return JSON.stringify(metadata);
}
if (
key === `[https://example.com] ${SESSION_KEYS.CLIENT_INFORMATION}`
) {
return JSON.stringify(mockOAuthClientInfo);
}
return null;
});
await act(async () => {
renderAuthDebugger({
updateAuthState,
authState: {
...defaultAuthState,
isInitiatingAuth: false,
oauthStep: "authorization_redirect",
oauthMetadata: metadata,
oauthClientInfo: mockOAuthClientInfo,
},
});
});
// Click Continue to trigger authorization
await act(async () => {
fireEvent.click(screen.getByText("Continue"));
});
return updateAuthState;
};
it("should include scope in authorization URL when scopes_supported is present", async () => {
const metadataWithScopes = {
...mockOAuthMetadata,
scopes_supported: ["read", "write", "admin"],
};
const updateAuthState =
await setupAuthorizationUrlTest(metadataWithScopes);
// Wait for the updateAuthState to be called
await waitFor(() => {
expect(updateAuthState).toHaveBeenCalledWith(
expect.objectContaining({
authorizationUrl: expect.stringContaining("scope="),
}),
);
});
});
it("should not include scope in authorization URL when scopes_supported is not present", async () => {
const updateAuthState =
await setupAuthorizationUrlTest(mockOAuthMetadata);
// Wait for the updateAuthState to be called
await waitFor(() => {
expect(updateAuthState).toHaveBeenCalledWith(
expect.objectContaining({
authorizationUrl: expect.not.stringContaining("scope="),
}),
);
});
});
});
});

View File

@@ -1,139 +0,0 @@
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { describe, it, expect, jest } from "@jest/globals";
import DynamicJsonForm from "../DynamicJsonForm";
import type { JsonSchemaType } from "@/utils/jsonUtils";
describe("DynamicJsonForm String Fields", () => {
const renderForm = (props = {}) => {
const defaultProps = {
schema: {
type: "string" as const,
description: "Test string field",
} satisfies JsonSchemaType,
value: undefined,
onChange: jest.fn(),
};
return render(<DynamicJsonForm {...defaultProps} {...props} />);
};
describe("Type Validation", () => {
it("should handle numeric input as string type", () => {
const onChange = jest.fn();
renderForm({ onChange });
const input = screen.getByRole("textbox");
fireEvent.change(input, { target: { value: "123321" } });
expect(onChange).toHaveBeenCalledWith("123321");
// Verify the value is a string, not a number
expect(typeof onChange.mock.calls[0][0]).toBe("string");
});
it("should render as text input, not number input", () => {
renderForm();
const input = screen.getByRole("textbox");
expect(input).toHaveProperty("type", "text");
});
});
});
describe("DynamicJsonForm Integer Fields", () => {
const renderForm = (props = {}) => {
const defaultProps = {
schema: {
type: "integer" as const,
description: "Test integer field",
} satisfies JsonSchemaType,
value: undefined,
onChange: jest.fn(),
};
return render(<DynamicJsonForm {...defaultProps} {...props} />);
};
describe("Basic Operations", () => {
it("should render number input with step=1", () => {
renderForm();
const input = screen.getByRole("spinbutton");
expect(input).toHaveProperty("type", "number");
expect(input).toHaveProperty("step", "1");
});
it("should pass integer values to onChange", () => {
const onChange = jest.fn();
renderForm({ onChange });
const input = screen.getByRole("spinbutton");
fireEvent.change(input, { target: { value: "42" } });
expect(onChange).toHaveBeenCalledWith(42);
// Verify the value is a number, not a string
expect(typeof onChange.mock.calls[0][0]).toBe("number");
});
it("should not pass string values to onChange", () => {
const onChange = jest.fn();
renderForm({ onChange });
const input = screen.getByRole("spinbutton");
fireEvent.change(input, { target: { value: "abc" } });
expect(onChange).not.toHaveBeenCalled();
});
});
describe("Edge Cases", () => {
it("should handle non-numeric input by not calling onChange", () => {
const onChange = jest.fn();
renderForm({ onChange });
const input = screen.getByRole("spinbutton");
fireEvent.change(input, { target: { value: "abc" } });
expect(onChange).not.toHaveBeenCalled();
});
});
});
describe("DynamicJsonForm Complex Fields", () => {
const renderForm = (props = {}) => {
const defaultProps = {
schema: {
type: "object",
properties: {
// The simplified JsonSchemaType does not accept oneOf fields
// But they exist in the more-complete JsonSchema7Type
nested: { oneOf: [{ type: "string" }, { type: "integer" }] },
},
} as unknown as JsonSchemaType,
value: undefined,
onChange: jest.fn(),
};
return render(<DynamicJsonForm {...defaultProps} {...props} />);
};
describe("Basic Operations", () => {
it("should render textbox and autoformat button, but no switch-to-form button", () => {
renderForm();
const input = screen.getByRole("textbox");
expect(input).toHaveProperty("type", "textarea");
const buttons = screen.getAllByRole("button");
expect(buttons).toHaveLength(1);
expect(buttons[0]).toHaveProperty("textContent", "Format JSON");
});
it("should pass changed values to onChange", () => {
const onChange = jest.fn();
renderForm({ onChange });
const input = screen.getByRole("textbox");
fireEvent.change(input, {
target: { value: `{ "nested": "i am string" }` },
});
// The onChange handler is debounced when using the JSON view, so we need to wait a little bit
waitFor(() => {
expect(onChange).toHaveBeenCalledWith(`{ "nested": "i am string" }`);
});
});
});
});

View File

@@ -1,952 +0,0 @@
import { render, screen, fireEvent, act } from "@testing-library/react";
import "@testing-library/jest-dom";
import { describe, it, beforeEach, jest } from "@jest/globals";
import Sidebar from "../Sidebar";
import { DEFAULT_INSPECTOR_CONFIG } from "@/lib/constants";
import { InspectorConfig } from "@/lib/configurationTypes";
import { TooltipProvider } from "@/components/ui/tooltip";
// Mock theme hook
jest.mock("../../lib/hooks/useTheme", () => ({
__esModule: true,
default: () => ["light", jest.fn()],
}));
// Mock toast hook
const mockToast = jest.fn();
jest.mock("@/lib/hooks/useToast", () => ({
useToast: () => ({
toast: mockToast,
}),
}));
// Mock navigator clipboard
const mockClipboardWrite = jest.fn(() => Promise.resolve());
Object.defineProperty(navigator, "clipboard", {
value: {
writeText: mockClipboardWrite,
},
});
// Setup fake timers
jest.useFakeTimers();
const defaultProps = {
connectionStatus: "disconnected" as const,
transportType: "stdio" as const,
setTransportType: jest.fn(),
command: "",
setCommand: jest.fn(),
args: "",
setArgs: jest.fn(),
sseUrl: "",
setSseUrl: jest.fn(),
env: {},
setEnv: jest.fn(),
bearerToken: "",
setBearerToken: jest.fn(),
headerName: "",
setHeaderName: jest.fn(),
customHeaders: [],
setCustomHeaders: jest.fn(),
onConnect: jest.fn(),
onDisconnect: jest.fn(),
stdErrNotifications: [],
clearStdErrNotifications: jest.fn(),
logLevel: "debug" as const,
sendLogLevelRequest: jest.fn(),
loggingSupported: false,
config: DEFAULT_INSPECTOR_CONFIG,
setConfig: jest.fn(),
};
describe("Sidebar Environment Variables", () => {
const renderSidebar = (props = {}) => {
return render(
<TooltipProvider>
<Sidebar {...defaultProps} {...props} />
</TooltipProvider>,
);
};
const openEnvVarsSection = () => {
const button = screen.getByTestId("env-vars-button");
fireEvent.click(button);
};
beforeEach(() => {
jest.clearAllMocks();
jest.clearAllTimers();
});
describe("Basic Operations", () => {
it("should add a new environment variable", () => {
const setEnv = jest.fn();
renderSidebar({ env: {}, setEnv });
openEnvVarsSection();
const addButton = screen.getByText("Add Environment Variable");
fireEvent.click(addButton);
expect(setEnv).toHaveBeenCalledWith({ "": "" });
});
it("should remove an environment variable", () => {
const setEnv = jest.fn();
const initialEnv = { TEST_KEY: "test_value" };
renderSidebar({ env: initialEnv, setEnv });
openEnvVarsSection();
const removeButton = screen.getByRole("button", { name: "×" });
fireEvent.click(removeButton);
expect(setEnv).toHaveBeenCalledWith({});
});
it("should update environment variable value", () => {
const setEnv = jest.fn();
const initialEnv = { TEST_KEY: "test_value" };
renderSidebar({ env: initialEnv, setEnv });
openEnvVarsSection();
const valueInput = screen.getByDisplayValue("test_value");
fireEvent.change(valueInput, { target: { value: "new_value" } });
expect(setEnv).toHaveBeenCalledWith({ TEST_KEY: "new_value" });
});
it("should toggle value visibility", () => {
const initialEnv = { TEST_KEY: "test_value" };
renderSidebar({ env: initialEnv });
openEnvVarsSection();
const valueInput = screen.getByDisplayValue("test_value");
expect(valueInput).toHaveProperty("type", "password");
const toggleButton = screen.getByRole("button", { name: /show value/i });
fireEvent.click(toggleButton);
expect(valueInput).toHaveProperty("type", "text");
});
});
describe("Authentication", () => {
const openAuthSection = () => {
const button = screen.getByTestId("auth-button");
fireEvent.click(button);
};
it("should update bearer token", () => {
const setBearerToken = jest.fn();
renderSidebar({
bearerToken: "",
setBearerToken,
transportType: "sse", // Set transport type to SSE
});
openAuthSection();
const tokenInput = screen.getByTestId("bearer-token-input");
fireEvent.change(tokenInput, { target: { value: "new_token" } });
expect(setBearerToken).toHaveBeenCalledWith("new_token");
});
it("should update header name", () => {
const setHeaderName = jest.fn();
renderSidebar({
headerName: "Authorization",
setHeaderName,
transportType: "sse",
});
openAuthSection();
const headerInput = screen.getByTestId("header-input");
fireEvent.change(headerInput, { target: { value: "X-Custom-Auth" } });
expect(setHeaderName).toHaveBeenCalledWith("X-Custom-Auth");
});
it("should clear bearer token", () => {
const setBearerToken = jest.fn();
renderSidebar({
bearerToken: "existing_token",
setBearerToken,
transportType: "sse", // Set transport type to SSE
});
openAuthSection();
const tokenInput = screen.getByTestId("bearer-token-input");
fireEvent.change(tokenInput, { target: { value: "" } });
expect(setBearerToken).toHaveBeenCalledWith("");
});
it("should properly render bearer token input", () => {
const { rerender } = renderSidebar({
bearerToken: "existing_token",
transportType: "sse", // Set transport type to SSE
});
openAuthSection();
// Token input should be a password field
const tokenInput = screen.getByTestId("bearer-token-input");
expect(tokenInput).toHaveProperty("type", "password");
// Update the token
fireEvent.change(tokenInput, { target: { value: "new_token" } });
// Rerender with updated token
rerender(
<TooltipProvider>
<Sidebar
{...defaultProps}
bearerToken="new_token"
transportType="sse"
/>
</TooltipProvider>,
);
// Token input should still exist after update
expect(screen.getByTestId("bearer-token-input")).toBeInTheDocument();
});
it("should maintain token visibility state after update", () => {
const { rerender } = renderSidebar({
bearerToken: "existing_token",
transportType: "sse", // Set transport type to SSE
});
openAuthSection();
// Token input should be a password field
const tokenInput = screen.getByTestId("bearer-token-input");
expect(tokenInput).toHaveProperty("type", "password");
// Update the token
fireEvent.change(tokenInput, { target: { value: "new_token" } });
// Rerender with updated token
rerender(
<TooltipProvider>
<Sidebar
{...defaultProps}
bearerToken="new_token"
transportType="sse"
/>
</TooltipProvider>,
);
// Token input should still exist after update
expect(screen.getByTestId("bearer-token-input")).toBeInTheDocument();
});
it("should maintain header name when toggling auth section", () => {
renderSidebar({
headerName: "X-API-Key",
transportType: "sse",
});
// Open auth section
openAuthSection();
// Verify header name is displayed
const headerInput = screen.getByTestId("header-input");
expect(headerInput).toHaveValue("X-API-Key");
// Close auth section
const authButton = screen.getByTestId("auth-button");
fireEvent.click(authButton);
// Reopen auth section
fireEvent.click(authButton);
// Verify header name is still preserved
expect(screen.getByTestId("header-input")).toHaveValue("X-API-Key");
});
it("should display default header name when not specified", () => {
renderSidebar({
headerName: undefined,
transportType: "sse",
});
openAuthSection();
const headerInput = screen.getByTestId("header-input");
expect(headerInput).toHaveAttribute("placeholder", "Authorization");
});
});
describe("Key Editing", () => {
it("should maintain order when editing first key", () => {
const setEnv = jest.fn();
const initialEnv = {
FIRST_KEY: "first_value",
SECOND_KEY: "second_value",
THIRD_KEY: "third_value",
};
renderSidebar({ env: initialEnv, setEnv });
openEnvVarsSection();
const firstKeyInput = screen.getByDisplayValue("FIRST_KEY");
fireEvent.change(firstKeyInput, { target: { value: "NEW_FIRST_KEY" } });
expect(setEnv).toHaveBeenCalledWith({
NEW_FIRST_KEY: "first_value",
SECOND_KEY: "second_value",
THIRD_KEY: "third_value",
});
});
it("should maintain order when editing middle key", () => {
const setEnv = jest.fn();
const initialEnv = {
FIRST_KEY: "first_value",
SECOND_KEY: "second_value",
THIRD_KEY: "third_value",
};
renderSidebar({ env: initialEnv, setEnv });
openEnvVarsSection();
const middleKeyInput = screen.getByDisplayValue("SECOND_KEY");
fireEvent.change(middleKeyInput, { target: { value: "NEW_SECOND_KEY" } });
expect(setEnv).toHaveBeenCalledWith({
FIRST_KEY: "first_value",
NEW_SECOND_KEY: "second_value",
THIRD_KEY: "third_value",
});
});
it("should maintain order when editing last key", () => {
const setEnv = jest.fn();
const initialEnv = {
FIRST_KEY: "first_value",
SECOND_KEY: "second_value",
THIRD_KEY: "third_value",
};
renderSidebar({ env: initialEnv, setEnv });
openEnvVarsSection();
const lastKeyInput = screen.getByDisplayValue("THIRD_KEY");
fireEvent.change(lastKeyInput, { target: { value: "NEW_THIRD_KEY" } });
expect(setEnv).toHaveBeenCalledWith({
FIRST_KEY: "first_value",
SECOND_KEY: "second_value",
NEW_THIRD_KEY: "third_value",
});
});
it("should maintain order during key editing", () => {
const setEnv = jest.fn();
const initialEnv = {
KEY1: "value1",
KEY2: "value2",
};
renderSidebar({ env: initialEnv, setEnv });
openEnvVarsSection();
// Type "NEW_" one character at a time
const key1Input = screen.getByDisplayValue("KEY1");
"NEW_".split("").forEach((char) => {
fireEvent.change(key1Input, {
target: { value: char + "KEY1".slice(1) },
});
});
// Verify the last setEnv call maintains the order
const lastCall = setEnv.mock.calls[
setEnv.mock.calls.length - 1
][0] as Record<string, string>;
const entries = Object.entries(lastCall);
// The values should stay with their original keys
expect(entries[0][1]).toBe("value1"); // First entry should still have value1
expect(entries[1][1]).toBe("value2"); // Second entry should still have value2
});
});
describe("Multiple Operations", () => {
it("should maintain state after multiple key edits", () => {
const setEnv = jest.fn();
const initialEnv = {
FIRST_KEY: "first_value",
SECOND_KEY: "second_value",
};
const { rerender } = renderSidebar({ env: initialEnv, setEnv });
openEnvVarsSection();
// First key edit
const firstKeyInput = screen.getByDisplayValue("FIRST_KEY");
fireEvent.change(firstKeyInput, { target: { value: "NEW_FIRST_KEY" } });
// Get the updated env from the first setEnv call
const updatedEnv = setEnv.mock.calls[0][0] as Record<string, string>;
// Rerender with the updated env
rerender(
<TooltipProvider>
<Sidebar {...defaultProps} env={updatedEnv} setEnv={setEnv} />
</TooltipProvider>,
);
// Second key edit
const secondKeyInput = screen.getByDisplayValue("SECOND_KEY");
fireEvent.change(secondKeyInput, { target: { value: "NEW_SECOND_KEY" } });
// Verify the final state matches what we expect
expect(setEnv).toHaveBeenLastCalledWith({
NEW_FIRST_KEY: "first_value",
NEW_SECOND_KEY: "second_value",
});
});
it("should maintain visibility state after key edit", () => {
const initialEnv = { TEST_KEY: "test_value" };
const { rerender } = renderSidebar({ env: initialEnv });
openEnvVarsSection();
// Show the value
const toggleButton = screen.getByRole("button", { name: /show value/i });
fireEvent.click(toggleButton);
const valueInput = screen.getByDisplayValue("test_value");
expect(valueInput).toHaveProperty("type", "text");
// Edit the key
const keyInput = screen.getByDisplayValue("TEST_KEY");
fireEvent.change(keyInput, { target: { value: "NEW_KEY" } });
// Rerender with updated env
rerender(
<TooltipProvider>
<Sidebar {...defaultProps} env={{ NEW_KEY: "test_value" }} />
</TooltipProvider>,
);
// Value should still be visible
const updatedValueInput = screen.getByDisplayValue("test_value");
expect(updatedValueInput).toHaveProperty("type", "text");
});
});
describe("Edge Cases", () => {
it("should handle empty key", () => {
const setEnv = jest.fn();
const initialEnv = { TEST_KEY: "test_value" };
renderSidebar({ env: initialEnv, setEnv });
openEnvVarsSection();
const keyInput = screen.getByDisplayValue("TEST_KEY");
fireEvent.change(keyInput, { target: { value: "" } });
expect(setEnv).toHaveBeenCalledWith({ "": "test_value" });
});
it("should handle special characters in key", () => {
const setEnv = jest.fn();
const initialEnv = { TEST_KEY: "test_value" };
renderSidebar({ env: initialEnv, setEnv });
openEnvVarsSection();
const keyInput = screen.getByDisplayValue("TEST_KEY");
fireEvent.change(keyInput, { target: { value: "TEST-KEY@123" } });
expect(setEnv).toHaveBeenCalledWith({ "TEST-KEY@123": "test_value" });
});
it("should handle unicode characters", () => {
const setEnv = jest.fn();
const initialEnv = { TEST_KEY: "test_value" };
renderSidebar({ env: initialEnv, setEnv });
openEnvVarsSection();
const keyInput = screen.getByDisplayValue("TEST_KEY");
fireEvent.change(keyInput, { target: { value: "TEST_🔑" } });
expect(setEnv).toHaveBeenCalledWith({ "TEST_🔑": "test_value" });
});
it("should handle very long key names", () => {
const setEnv = jest.fn();
const initialEnv = { TEST_KEY: "test_value" };
renderSidebar({ env: initialEnv, setEnv });
openEnvVarsSection();
const keyInput = screen.getByDisplayValue("TEST_KEY");
const longKey = "A".repeat(100);
fireEvent.change(keyInput, { target: { value: longKey } });
expect(setEnv).toHaveBeenCalledWith({ [longKey]: "test_value" });
});
});
describe("Configuration Operations", () => {
const openConfigSection = () => {
const button = screen.getByTestId("config-button");
fireEvent.click(button);
};
it("should update MCP server request timeout", () => {
const setConfig = jest.fn();
renderSidebar({ config: DEFAULT_INSPECTOR_CONFIG, setConfig });
openConfigSection();
const timeoutInput = screen.getByTestId(
"MCP_SERVER_REQUEST_TIMEOUT-input",
);
fireEvent.change(timeoutInput, { target: { value: "5000" } });
expect(setConfig).toHaveBeenCalledWith(
expect.objectContaining({
MCP_SERVER_REQUEST_TIMEOUT: {
label: "Request Timeout",
description: "Timeout for requests to the MCP server (ms)",
value: 5000,
},
}),
);
});
it("should update MCP server proxy address", () => {
const setConfig = jest.fn();
renderSidebar({ config: DEFAULT_INSPECTOR_CONFIG, setConfig });
openConfigSection();
const proxyAddressInput = screen.getByTestId(
"MCP_PROXY_FULL_ADDRESS-input",
);
fireEvent.change(proxyAddressInput, {
target: { value: "http://localhost:8080" },
});
expect(setConfig).toHaveBeenCalledWith(
expect.objectContaining({
MCP_PROXY_FULL_ADDRESS: {
label: "Inspector Proxy Address",
description:
"Set this if you are running the MCP Inspector Proxy on a non-default address. Example: http://10.1.1.22:5577",
value: "http://localhost:8080",
},
}),
);
});
it("should update max total timeout", () => {
const setConfig = jest.fn();
renderSidebar({ config: DEFAULT_INSPECTOR_CONFIG, setConfig });
openConfigSection();
const maxTotalTimeoutInput = screen.getByTestId(
"MCP_REQUEST_MAX_TOTAL_TIMEOUT-input",
);
fireEvent.change(maxTotalTimeoutInput, {
target: { value: "10000" },
});
expect(setConfig).toHaveBeenCalledWith(
expect.objectContaining({
MCP_REQUEST_MAX_TOTAL_TIMEOUT: {
label: "Maximum Total Timeout",
description:
"Maximum total timeout for requests sent to the MCP server (ms) (Use with progress notifications)",
value: 10000,
},
}),
);
});
it("should handle invalid timeout values entered by user", () => {
const setConfig = jest.fn();
renderSidebar({ config: DEFAULT_INSPECTOR_CONFIG, setConfig });
openConfigSection();
const timeoutInput = screen.getByTestId(
"MCP_SERVER_REQUEST_TIMEOUT-input",
);
fireEvent.change(timeoutInput, { target: { value: "abc1" } });
expect(setConfig).toHaveBeenCalledWith(
expect.objectContaining({
MCP_SERVER_REQUEST_TIMEOUT: {
label: "Request Timeout",
description: "Timeout for requests to the MCP server (ms)",
value: 0,
},
}),
);
});
it("should maintain configuration state after multiple updates", () => {
const setConfig = jest.fn();
const { rerender } = renderSidebar({
config: DEFAULT_INSPECTOR_CONFIG,
setConfig,
});
openConfigSection();
// First update
const timeoutInput = screen.getByTestId(
"MCP_SERVER_REQUEST_TIMEOUT-input",
);
fireEvent.change(timeoutInput, { target: { value: "5000" } });
// Get the updated config from the first setConfig call
const updatedConfig = setConfig.mock.calls[0][0] as InspectorConfig;
// Rerender with the updated config
rerender(
<TooltipProvider>
<Sidebar
{...defaultProps}
config={updatedConfig}
setConfig={setConfig}
/>
</TooltipProvider>,
);
// Second update
const updatedTimeoutInput = screen.getByTestId(
"MCP_SERVER_REQUEST_TIMEOUT-input",
);
fireEvent.change(updatedTimeoutInput, { target: { value: "3000" } });
// Verify the final state matches what we expect
expect(setConfig).toHaveBeenLastCalledWith(
expect.objectContaining({
MCP_SERVER_REQUEST_TIMEOUT: {
label: "Request Timeout",
description: "Timeout for requests to the MCP server (ms)",
value: 3000,
},
}),
);
});
});
describe("Copy Configuration Features", () => {
beforeEach(() => {
jest.clearAllMocks();
jest.clearAllTimers();
});
const getCopyButtons = () => {
return {
serverEntry: screen.getByRole("button", { name: /server entry/i }),
serversFile: screen.getByRole("button", { name: /servers file/i }),
};
};
it("should render both copy buttons for all transport types", () => {
["stdio", "sse", "streamable-http"].forEach((transportType) => {
renderSidebar({ transportType });
// There should be exactly one Server Entry and one Servers File button per render
const serverEntryButtons = screen.getAllByRole("button", {
name: /server entry/i,
});
const serversFileButtons = screen.getAllByRole("button", {
name: /servers file/i,
});
expect(serverEntryButtons).toHaveLength(1);
expect(serversFileButtons).toHaveLength(1);
// Clean up DOM for next iteration
// (Testing Library's render does not auto-unmount in a loop)
document.body.innerHTML = "";
});
});
it("should copy server entry configuration to clipboard for STDIO transport", async () => {
const command = "node";
const args = "--inspect server.js";
const env = { API_KEY: "test-key", DEBUG: "true" };
renderSidebar({
transportType: "stdio",
command,
args,
env,
});
await act(async () => {
const { serverEntry } = getCopyButtons();
fireEvent.click(serverEntry);
jest.runAllTimers();
});
expect(mockClipboardWrite).toHaveBeenCalledTimes(1);
const expectedConfig = JSON.stringify(
{
command,
args: ["--inspect", "server.js"],
env,
},
null,
4,
);
expect(mockClipboardWrite).toHaveBeenCalledWith(expectedConfig);
});
it("should copy servers file configuration to clipboard for STDIO transport", async () => {
const command = "node";
const args = "--inspect server.js";
const env = { API_KEY: "test-key", DEBUG: "true" };
renderSidebar({
transportType: "stdio",
command,
args,
env,
});
await act(async () => {
const { serversFile } = getCopyButtons();
fireEvent.click(serversFile);
jest.runAllTimers();
});
expect(mockClipboardWrite).toHaveBeenCalledTimes(1);
const expectedConfig = JSON.stringify(
{
mcpServers: {
"default-server": {
command,
args: ["--inspect", "server.js"],
env,
},
},
},
null,
4,
);
expect(mockClipboardWrite).toHaveBeenCalledWith(expectedConfig);
});
it("should copy server entry configuration to clipboard for SSE transport", async () => {
const sseUrl = "http://localhost:3000/events";
renderSidebar({ transportType: "sse", sseUrl });
await act(async () => {
const { serverEntry } = getCopyButtons();
fireEvent.click(serverEntry);
jest.runAllTimers();
});
expect(mockClipboardWrite).toHaveBeenCalledTimes(1);
const expectedConfig = JSON.stringify(
{
type: "sse",
url: sseUrl,
note: "For SSE connections, add this URL directly in Client",
},
null,
4,
);
expect(mockClipboardWrite).toHaveBeenCalledWith(expectedConfig);
});
it("should copy servers file configuration to clipboard for SSE transport", async () => {
const sseUrl = "http://localhost:3000/events";
renderSidebar({ transportType: "sse", sseUrl });
await act(async () => {
const { serversFile } = getCopyButtons();
fireEvent.click(serversFile);
jest.runAllTimers();
});
expect(mockClipboardWrite).toHaveBeenCalledTimes(1);
const expectedConfig = JSON.stringify(
{
mcpServers: {
"default-server": {
type: "sse",
url: sseUrl,
note: "For SSE connections, add this URL directly in Client",
},
},
},
null,
4,
);
expect(mockClipboardWrite).toHaveBeenCalledWith(expectedConfig);
});
it("should copy server entry configuration to clipboard for streamable-http transport", async () => {
const sseUrl = "http://localhost:3001/sse";
renderSidebar({ transportType: "streamable-http", sseUrl });
await act(async () => {
const { serverEntry } = getCopyButtons();
fireEvent.click(serverEntry);
jest.runAllTimers();
});
expect(mockClipboardWrite).toHaveBeenCalledTimes(1);
const expectedConfig = JSON.stringify(
{
type: "streamable-http",
url: sseUrl,
note: "For Streamable HTTP connections, add this URL directly in Client",
},
null,
4,
);
expect(mockClipboardWrite).toHaveBeenCalledWith(expectedConfig);
});
it("should copy servers file configuration to clipboard for streamable-http transport", async () => {
const sseUrl = "http://localhost:3001/sse";
renderSidebar({ transportType: "streamable-http", sseUrl });
await act(async () => {
const { serversFile } = getCopyButtons();
fireEvent.click(serversFile);
jest.runAllTimers();
});
expect(mockClipboardWrite).toHaveBeenCalledTimes(1);
const expectedConfig = JSON.stringify(
{
mcpServers: {
"default-server": {
type: "streamable-http",
url: sseUrl,
note: "For Streamable HTTP connections, add this URL directly in Client",
},
},
},
null,
4,
);
expect(mockClipboardWrite).toHaveBeenCalledWith(expectedConfig);
});
it("should handle empty args in STDIO transport", async () => {
const command = "python";
const args = "";
renderSidebar({
transportType: "stdio",
command,
args,
});
await act(async () => {
const { serverEntry } = getCopyButtons();
fireEvent.click(serverEntry);
jest.runAllTimers();
});
expect(mockClipboardWrite).toHaveBeenCalledTimes(1);
const expectedConfig = JSON.stringify(
{
command,
args: [],
env: {},
},
null,
4,
);
expect(mockClipboardWrite).toHaveBeenCalledWith(expectedConfig);
});
});
});
describe("Sidebar", () => {
it("renders", () => {
render(<Sidebar {...defaultProps} />);
expect(screen.getByText("MCP Inspector")).toBeInTheDocument();
});
it("shows connect button when disconnected", () => {
render(<Sidebar {...defaultProps} />);
expect(screen.getByText("Connect")).toBeInTheDocument();
});
it("shows disconnect button when connected", () => {
render(
<Sidebar
{...defaultProps}
connectionStatus="connected"
customHeaders={[]}
setCustomHeaders={jest.fn()}
/>,
);
expect(screen.getByText("Disconnect")).toBeInTheDocument();
});
it("shows reconnect button when connected", () => {
render(
<Sidebar
{...defaultProps}
connectionStatus="connected"
transportType="sse"
customHeaders={[]}
setCustomHeaders={jest.fn()}
/>,
);
expect(screen.getByText("Reconnect")).toBeInTheDocument();
});
it("shows restart button when connected with stdio transport", () => {
render(
<Sidebar
{...defaultProps}
connectionStatus="connected"
transportType="stdio"
customHeaders={[]}
setCustomHeaders={jest.fn()}
/>,
);
expect(screen.getByText("Restart")).toBeInTheDocument();
});
it("shows environment variables section when stdio transport is selected", () => {
render(
<Sidebar
{...defaultProps}
env={{ NEW_KEY: "new_value" }}
customHeaders={[]}
setCustomHeaders={jest.fn()}
/>,
);
const envButton = screen.getByText("Environment Variables");
expect(envButton).toBeInTheDocument();
});
it("shows configuration section", () => {
render(
<Sidebar
{...defaultProps}
config={DEFAULT_INSPECTOR_CONFIG}
customHeaders={[]}
setCustomHeaders={jest.fn()}
/>,
);
const configButton = screen.getByText("Configuration");
expect(configButton).toBeInTheDocument();
});
});

View File

@@ -1,144 +0,0 @@
import { render, screen, fireEvent, act } from "@testing-library/react";
import { describe, it, expect, jest } from "@jest/globals";
import "@testing-library/jest-dom";
import ToolsTab from "../ToolsTab";
import { Tool } from "@modelcontextprotocol/sdk/types.js";
import { Tabs } from "@/components/ui/tabs";
describe("ToolsTab", () => {
const mockTools: Tool[] = [
{
name: "tool1",
description: "First tool",
inputSchema: {
type: "object" as const,
properties: {
num: { type: "number" as const },
},
},
},
{
name: "tool3",
description: "Integer tool",
inputSchema: {
type: "object" as const,
properties: {
count: { type: "integer" as const },
},
},
},
{
name: "tool2",
description: "Second tool",
inputSchema: {
type: "object" as const,
properties: {
num: { type: "number" as const },
},
},
},
];
const defaultProps = {
tools: mockTools,
listTools: jest.fn(),
clearTools: jest.fn(),
callTool: jest.fn(async () => {}),
selectedTool: null,
setSelectedTool: jest.fn(),
toolResult: null,
nextCursor: "",
error: null,
};
const renderToolsTab = (props = {}) => {
return render(
<Tabs defaultValue="tools">
<ToolsTab {...defaultProps} {...props} />
</Tabs>,
);
};
it("should reset input values when switching tools", async () => {
const { rerender } = renderToolsTab({
selectedTool: mockTools[0],
});
// Enter a value in the first tool's input
const input = screen.getByRole("spinbutton") as HTMLInputElement;
await act(async () => {
fireEvent.change(input, { target: { value: "42" } });
});
expect(input.value).toBe("42");
// Switch to second tool
rerender(
<Tabs defaultValue="tools">
<ToolsTab {...defaultProps} selectedTool={mockTools[2]} />
</Tabs>,
);
// Verify input is reset
const newInput = screen.getByRole("spinbutton") as HTMLInputElement;
expect(newInput.value).toBe("");
});
it("should handle integer type inputs", async () => {
renderToolsTab({
selectedTool: mockTools[1], // Use the tool with integer type
});
const input = screen.getByRole("spinbutton", {
name: /count/i,
}) as HTMLInputElement;
expect(input).toHaveProperty("type", "number");
fireEvent.change(input, { target: { value: "42" } });
expect(input.value).toBe("42");
const submitButton = screen.getByRole("button", { name: /run tool/i });
await act(async () => {
fireEvent.click(submitButton);
});
expect(defaultProps.callTool).toHaveBeenCalledWith(mockTools[1].name, {
count: 42,
});
});
it("should disable button and change text while tool is running", async () => {
// Create a promise that we can resolve later
let resolvePromise: ((value: unknown) => void) | undefined;
const mockPromise = new Promise((resolve) => {
resolvePromise = resolve;
});
// Mock callTool to return our promise
const mockCallTool = jest.fn().mockReturnValue(mockPromise);
renderToolsTab({
selectedTool: mockTools[0],
callTool: mockCallTool,
});
const submitButton = screen.getByRole("button", { name: /run tool/i });
expect(submitButton.getAttribute("disabled")).toBeNull();
// Click the button and verify immediate state changes
await act(async () => {
fireEvent.click(submitButton);
});
// Verify button is disabled and text changed
expect(submitButton.getAttribute("disabled")).not.toBeNull();
expect(submitButton.textContent).toBe("Running...");
// Resolve the promise to simulate tool completion
await act(async () => {
if (resolvePromise) {
await resolvePromise({});
}
});
expect(submitButton.getAttribute("disabled")).toBeNull();
});
});

View File

@@ -1,73 +0,0 @@
import { render, screen, fireEvent } from "@testing-library/react";
import SamplingRequest from "../SamplingRequest";
import { PendingRequest } from "../SamplingTab";
const mockRequest: PendingRequest = {
id: 1,
request: {
method: "sampling/createMessage",
params: {
messages: [
{
role: "user",
content: {
type: "text",
text: "What files are in the current directory?",
},
},
],
systemPrompt: "You are a helpful file system assistant.",
includeContext: "thisServer",
maxTokens: 100,
},
},
};
describe("Form to handle sampling response", () => {
const mockOnApprove = jest.fn();
const mockOnReject = jest.fn();
afterEach(() => {
jest.clearAllMocks();
});
it("should call onApprove with correct text content when Approve button is clicked", () => {
render(
<SamplingRequest
request={mockRequest}
onApprove={mockOnApprove}
onReject={mockOnReject}
/>,
);
// Click the Approve button
fireEvent.click(screen.getByRole("button", { name: /approve/i }));
// Assert that onApprove is called with the correct arguments
expect(mockOnApprove).toHaveBeenCalledWith(mockRequest.id, {
model: "stub-model",
stopReason: "endTurn",
role: "assistant",
content: {
type: "text",
text: "",
},
});
});
it("should call onReject with correct request id when Reject button is clicked", () => {
render(
<SamplingRequest
request={mockRequest}
onApprove={mockOnApprove}
onReject={mockOnReject}
/>,
);
// Click the Approve button
fireEvent.click(screen.getByRole("button", { name: /Reject/i }));
// Assert that onApprove is called with the correct arguments
expect(mockOnReject).toHaveBeenCalledWith(mockRequest.id);
});
});

View File

@@ -1,55 +0,0 @@
import { render, screen } from "@testing-library/react";
import { Tabs } from "@/components/ui/tabs";
import SamplingTab, { PendingRequest } from "../SamplingTab";
describe("Sampling tab", () => {
const mockOnApprove = jest.fn();
const mockOnReject = jest.fn();
const renderSamplingTab = (pendingRequests: PendingRequest[]) =>
render(
<Tabs defaultValue="sampling">
<SamplingTab
pendingRequests={pendingRequests}
onApprove={mockOnApprove}
onReject={mockOnReject}
/>
</Tabs>,
);
it("should render 'No pending requests' when there are no pending requests", () => {
renderSamplingTab([]);
expect(
screen.getByText(
"When the server requests LLM sampling, requests will appear here for approval.",
),
).toBeTruthy();
expect(screen.findByText("No pending requests")).toBeTruthy();
});
it("should render the correct number of requests", () => {
renderSamplingTab(
Array.from({ length: 5 }, (_, i) => ({
id: i,
request: {
method: "sampling/createMessage",
params: {
messages: [
{
role: "user",
content: {
type: "text",
text: "What files are in the current directory?",
},
},
],
systemPrompt: "You are a helpful file system assistant.",
includeContext: "thisServer",
maxTokens: 100,
},
},
})),
);
expect(screen.getAllByTestId("sampling-request").length).toBe(5);
});
});

View File

@@ -1,7 +1,7 @@
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils";
import { cn } from "@/lib/utils"
const alertVariants = cva(
"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",
@@ -16,8 +16,8 @@ const alertVariants = cva(
defaultVariants: {
variant: "default",
},
},
);
}
)
const Alert = React.forwardRef<
HTMLDivElement,
@@ -29,8 +29,8 @@ const Alert = React.forwardRef<
className={cn(alertVariants({ variant }), className)}
{...props}
/>
));
Alert.displayName = "Alert";
))
Alert.displayName = "Alert"
const AlertTitle = React.forwardRef<
HTMLParagraphElement,
@@ -41,8 +41,8 @@ const AlertTitle = React.forwardRef<
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
{...props}
/>
));
AlertTitle.displayName = "AlertTitle";
))
AlertTitle.displayName = "AlertTitle"
const AlertDescription = React.forwardRef<
HTMLParagraphElement,
@@ -53,7 +53,7 @@ const AlertDescription = React.forwardRef<
className={cn("text-sm [&_p]:leading-relaxed", className)}
{...props}
/>
));
AlertDescription.displayName = "AlertDescription";
))
AlertDescription.displayName = "AlertDescription"
export { Alert, AlertTitle, AlertDescription };
export { Alert, AlertTitle, AlertDescription }

View File

@@ -1,8 +1,8 @@
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils";
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"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",
@@ -10,7 +10,7 @@ const buttonVariants = cva(
variants: {
variant: {
default:
"bg-primary text-primary-foreground shadow hover:bg-primary/90 dark:bg-gray-800 dark:text-white dark:hover:bg-gray-700",
"bg-primary text-primary-foreground shadow hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
outline:
@@ -31,27 +31,27 @@ const buttonVariants = cva(
variant: "default",
size: "default",
},
},
);
}
)
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
asChild?: boolean
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
const Comp = asChild ? Slot : "button"
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
);
},
);
Button.displayName = "Button";
)
}
)
Button.displayName = "Button"
export { Button };
export { Button, buttonVariants }

View File

@@ -1,30 +0,0 @@
"use client";
import * as React from "react";
import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
import { Check } from "lucide-react";
import { cn } from "@/lib/utils";
const Checkbox = React.forwardRef<
React.ElementRef<typeof CheckboxPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
>(({ className, ...props }, ref) => (
<CheckboxPrimitive.Root
ref={ref}
className={cn(
"peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
className,
)}
{...props}
>
<CheckboxPrimitive.Indicator
className={cn("flex items-center justify-center text-current")}
>
<Check className="h-4 w-4" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
));
Checkbox.displayName = CheckboxPrimitive.Root.displayName;
export { Checkbox };

View File

@@ -1,97 +0,0 @@
import React from "react";
import { Check, ChevronsUpDown } from "lucide-react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
} from "@/components/ui/command";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
interface ComboboxProps {
value: string;
onChange: (value: string) => void;
onInputChange: (value: string) => void;
options: string[];
placeholder?: string;
emptyMessage?: string;
id?: string;
}
export function Combobox({
value,
onChange,
onInputChange,
options = [],
placeholder = "Select...",
emptyMessage = "No results found.",
id,
}: ComboboxProps) {
const [open, setOpen] = React.useState(false);
const handleSelect = React.useCallback(
(option: string) => {
onChange(option);
setOpen(false);
},
[onChange],
);
const handleInputChange = React.useCallback(
(value: string) => {
onInputChange(value);
},
[onInputChange],
);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
aria-controls={id}
className="w-full justify-between"
>
{value || placeholder}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-full p-0" align="start">
<Command shouldFilter={false} id={id}>
<CommandInput
placeholder={placeholder}
value={value}
onValueChange={handleInputChange}
/>
<CommandEmpty>{emptyMessage}</CommandEmpty>
<CommandGroup>
{options.map((option) => (
<CommandItem
key={option}
value={option}
onSelect={() => handleSelect(option)}
>
<Check
className={cn(
"mr-2 h-4 w-4",
value === option ? "opacity-100" : "opacity-0",
)}
/>
{option}
</CommandItem>
))}
</CommandGroup>
</Command>
</PopoverContent>
</Popover>
);
}

View File

@@ -1,150 +0,0 @@
import * as React from "react";
import { type DialogProps } from "@radix-ui/react-dialog";
import { Command as CommandPrimitive } from "cmdk";
import { cn } from "@/lib/utils";
import { Dialog, DialogContent } from "@/components/ui/dialog";
import { MagnifyingGlassIcon } from "@radix-ui/react-icons";
const Command = React.forwardRef<
React.ElementRef<typeof CommandPrimitive>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive>
>(({ className, ...props }, ref) => (
<CommandPrimitive
ref={ref}
className={cn(
"flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground",
className,
)}
{...props}
/>
));
Command.displayName = CommandPrimitive.displayName;
const CommandDialog = ({ children, ...props }: DialogProps) => {
return (
<Dialog {...props}>
<DialogContent className="overflow-hidden p-0">
<Command className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
{children}
</Command>
</DialogContent>
</Dialog>
);
};
const CommandInput = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Input>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
>(({ className, ...props }, ref) => (
<div className="flex items-center border-b px-3" cmdk-input-wrapper="">
<MagnifyingGlassIcon className="mr-2 h-4 w-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
ref={ref}
className={cn(
"flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
{...props}
/>
</div>
));
CommandInput.displayName = CommandPrimitive.Input.displayName;
const CommandList = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.List>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
>(({ className, ...props }, ref) => (
<CommandPrimitive.List
ref={ref}
className={cn("max-h-[300px] overflow-y-auto overflow-x-hidden", className)}
{...props}
/>
));
CommandList.displayName = CommandPrimitive.List.displayName;
const CommandEmpty = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Empty>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
>((props, ref) => (
<CommandPrimitive.Empty
ref={ref}
className="py-6 text-center text-sm"
{...props}
/>
));
CommandEmpty.displayName = CommandPrimitive.Empty.displayName;
const CommandGroup = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Group>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Group
ref={ref}
className={cn(
"overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground",
className,
)}
{...props}
/>
));
CommandGroup.displayName = CommandPrimitive.Group.displayName;
const CommandSeparator = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Separator
ref={ref}
className={cn("-mx-1 h-px bg-border", className)}
{...props}
/>
));
CommandSeparator.displayName = CommandPrimitive.Separator.displayName;
const CommandItem = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default gap-2 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled=true]:pointer-events-none data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
className,
)}
{...props}
/>
));
CommandItem.displayName = CommandPrimitive.Item.displayName;
const CommandShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground",
className,
)}
{...props}
/>
);
};
CommandShortcut.displayName = "CommandShortcut";
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator,
};

View File

@@ -1,121 +0,0 @@
"use client";
import * as React from "react";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { cn } from "@/lib/utils";
import { Cross2Icon } from "@radix-ui/react-icons";
const Dialog = DialogPrimitive.Root;
const DialogTrigger = DialogPrimitive.Trigger;
const DialogPortal = DialogPrimitive.Portal;
const DialogClose = DialogPrimitive.Close;
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className,
)}
{...props}
/>
));
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className,
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<Cross2Icon className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
));
DialogContent.displayName = DialogPrimitive.Content.displayName;
const DialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-1.5 text-center sm:text-left",
className,
)}
{...props}
/>
);
DialogHeader.displayName = "DialogHeader";
const DialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className,
)}
{...props}
/>
);
DialogFooter.displayName = "DialogFooter";
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight",
className,
)}
{...props}
/>
));
DialogTitle.displayName = DialogPrimitive.Title.displayName;
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
));
DialogDescription.displayName = DialogPrimitive.Description.displayName;
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogTrigger,
DialogClose,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
};

View File

@@ -1,8 +1,9 @@
import * as React from "react";
import * as React from "react"
import { cn } from "@/lib/utils";
import { cn } from "@/lib/utils"
export type InputProps = React.InputHTMLAttributes<HTMLInputElement>;
export interface InputProps
extends React.InputHTMLAttributes<HTMLInputElement> {}
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, ...props }, ref) => {
@@ -11,14 +12,14 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
type={type}
className={cn(
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
className,
className
)}
ref={ref}
{...props}
/>
);
},
);
Input.displayName = "Input";
)
}
)
Input.displayName = "Input"
export { Input };
export { Input }

View File

@@ -1,12 +1,12 @@
import * as React from "react";
import * as LabelPrimitive from "@radix-ui/react-label";
import { cva, type VariantProps } from "class-variance-authority";
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils";
import { cn } from "@/lib/utils"
const labelVariants = cva(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
);
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
)
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
@@ -18,7 +18,7 @@ const Label = React.forwardRef<
className={cn(labelVariants(), className)}
{...props}
/>
));
Label.displayName = LabelPrimitive.Root.displayName;
))
Label.displayName = LabelPrimitive.Root.displayName
export { Label };
export { Label }

View File

@@ -1,31 +0,0 @@
import * as React from "react";
import * as PopoverPrimitive from "@radix-ui/react-popover";
import { cn } from "@/lib/utils";
const Popover = PopoverPrimitive.Root;
const PopoverTrigger = PopoverPrimitive.Trigger;
const PopoverAnchor = PopoverPrimitive.Anchor;
const PopoverContent = React.forwardRef<
React.ElementRef<typeof PopoverPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className,
)}
{...props}
/>
</PopoverPrimitive.Portal>
));
PopoverContent.displayName = PopoverPrimitive.Content.displayName;
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor };

View File

@@ -1,19 +1,19 @@
import * as React from "react";
import * as React from "react"
import {
CaretSortIcon,
CheckIcon,
ChevronDownIcon,
ChevronUpIcon,
} from "@radix-ui/react-icons";
import * as SelectPrimitive from "@radix-ui/react-select";
} from "@radix-ui/react-icons"
import * as SelectPrimitive from "@radix-ui/react-select"
import { cn } from "@/lib/utils";
import { cn } from "@/lib/utils"
const Select = SelectPrimitive.Root;
const Select = SelectPrimitive.Root
const SelectGroup = SelectPrimitive.Group;
const SelectGroup = SelectPrimitive.Group
const SelectValue = SelectPrimitive.Value;
const SelectValue = SelectPrimitive.Value
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
@@ -22,8 +22,8 @@ const SelectTrigger = React.forwardRef<
<SelectPrimitive.Trigger
ref={ref}
className={cn(
"flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1 hover:border-[#646cff] hover:border-1",
className,
"flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
className
)}
{...props}
>
@@ -32,8 +32,8 @@ const SelectTrigger = React.forwardRef<
<CaretSortIcon className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
));
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
))
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
const SelectScrollUpButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
@@ -43,14 +43,14 @@ const SelectScrollUpButton = React.forwardRef<
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className,
className
)}
{...props}
>
<ChevronUpIcon />
</SelectPrimitive.ScrollUpButton>
));
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
))
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
const SelectScrollDownButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
@@ -60,15 +60,15 @@ const SelectScrollDownButton = React.forwardRef<
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className,
className
)}
{...props}
>
<ChevronDownIcon />
</SelectPrimitive.ScrollDownButton>
));
))
SelectScrollDownButton.displayName =
SelectPrimitive.ScrollDownButton.displayName;
SelectPrimitive.ScrollDownButton.displayName
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
@@ -81,7 +81,7 @@ const SelectContent = React.forwardRef<
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className,
className
)}
position={position}
{...props}
@@ -91,7 +91,7 @@ const SelectContent = React.forwardRef<
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]",
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
)}
>
{children}
@@ -99,8 +99,8 @@ const SelectContent = React.forwardRef<
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
));
SelectContent.displayName = SelectPrimitive.Content.displayName;
))
SelectContent.displayName = SelectPrimitive.Content.displayName
const SelectLabel = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Label>,
@@ -111,8 +111,8 @@ const SelectLabel = React.forwardRef<
className={cn("px-2 py-1.5 text-sm font-semibold", className)}
{...props}
/>
));
SelectLabel.displayName = SelectPrimitive.Label.displayName;
))
SelectLabel.displayName = SelectPrimitive.Label.displayName
const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
@@ -122,7 +122,7 @@ const SelectItem = React.forwardRef<
ref={ref}
className={cn(
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className,
className
)}
{...props}
>
@@ -133,8 +133,8 @@ const SelectItem = React.forwardRef<
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
));
SelectItem.displayName = SelectPrimitive.Item.displayName;
))
SelectItem.displayName = SelectPrimitive.Item.displayName
const SelectSeparator = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Separator>,
@@ -145,8 +145,8 @@ const SelectSeparator = React.forwardRef<
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
));
SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
))
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
export {
Select,
@@ -159,4 +159,4 @@ export {
SelectSeparator,
SelectScrollUpButton,
SelectScrollDownButton,
};
}

View File

@@ -1,9 +1,9 @@
import * as React from "react";
import * as TabsPrimitive from "@radix-ui/react-tabs";
import * as React from "react"
import * as TabsPrimitive from "@radix-ui/react-tabs"
import { cn } from "@/lib/utils";
import { cn } from "@/lib/utils"
const Tabs = TabsPrimitive.Root;
const Tabs = TabsPrimitive.Root
const TabsList = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.List>,
@@ -13,12 +13,12 @@ const TabsList = React.forwardRef<
ref={ref}
className={cn(
"inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground",
className,
className
)}
{...props}
/>
));
TabsList.displayName = TabsPrimitive.List.displayName;
))
TabsList.displayName = TabsPrimitive.List.displayName
const TabsTrigger = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Trigger>,
@@ -27,13 +27,13 @@ const TabsTrigger = React.forwardRef<
<TabsPrimitive.Trigger
ref={ref}
className={cn(
"inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 bg-muted data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow",
className,
"inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow",
className
)}
{...props}
/>
));
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
))
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
const TabsContent = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Content>,
@@ -43,11 +43,11 @@ const TabsContent = React.forwardRef<
ref={ref}
className={cn(
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
className,
className
)}
{...props}
/>
));
TabsContent.displayName = TabsPrimitive.Content.displayName;
))
TabsContent.displayName = TabsPrimitive.Content.displayName
export { Tabs, TabsList, TabsTrigger, TabsContent };
export { Tabs, TabsList, TabsTrigger, TabsContent }

View File

@@ -1,8 +1,9 @@
import * as React from "react";
import * as React from "react"
import { cn } from "@/lib/utils";
import { cn } from "@/lib/utils"
export type TextareaProps = React.TextareaHTMLAttributes<HTMLTextAreaElement>;
export interface TextareaProps
extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
({ className, ...props }, ref) => {
@@ -10,14 +11,14 @@ const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
<textarea
className={cn(
"flex min-h-[60px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
className,
className
)}
ref={ref}
{...props}
/>
);
},
);
Textarea.displayName = "Textarea";
)
}
)
Textarea.displayName = "Textarea"
export { Textarea };
export { Textarea }

View File

@@ -1,126 +0,0 @@
import * as React from "react";
import * as ToastPrimitives from "@radix-ui/react-toast";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
import { Cross2Icon } from "@radix-ui/react-icons";
const ToastProvider = ToastPrimitives.Provider;
const ToastViewport = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Viewport>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Viewport
ref={ref}
className={cn(
"fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]",
className,
)}
{...props}
/>
));
ToastViewport.displayName = ToastPrimitives.Viewport.displayName;
const toastVariants = cva(
"group pointer-events-auto relative flex w-full items-center justify-between space-x-2 overflow-hidden rounded-md border p-4 pr-6 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full",
{
variants: {
variant: {
default: "border bg-background text-foreground",
destructive:
"destructive group border-destructive bg-destructive text-destructive-foreground",
},
},
defaultVariants: {
variant: "default",
},
},
);
const Toast = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> &
VariantProps<typeof toastVariants>
>(({ className, variant, ...props }, ref) => {
return (
<ToastPrimitives.Root
ref={ref}
className={cn(toastVariants({ variant }), className)}
{...props}
/>
);
});
Toast.displayName = ToastPrimitives.Root.displayName;
const ToastAction = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Action>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Action
ref={ref}
className={cn(
"inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium transition-colors hover:bg-secondary focus:outline-none focus:ring-1 focus:ring-ring disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive",
className,
)}
{...props}
/>
));
ToastAction.displayName = ToastPrimitives.Action.displayName;
const ToastClose = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Close>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Close
ref={ref}
className={cn(
"absolute right-1 top-1 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-1 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600",
className,
)}
toast-close=""
{...props}
>
<Cross2Icon className="h-4 w-4" />
</ToastPrimitives.Close>
));
ToastClose.displayName = ToastPrimitives.Close.displayName;
const ToastTitle = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Title>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Title
ref={ref}
className={cn("text-sm font-semibold [&+div]:text-xs", className)}
{...props}
/>
));
ToastTitle.displayName = ToastPrimitives.Title.displayName;
const ToastDescription = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Description>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Description
ref={ref}
className={cn("text-sm opacity-90", className)}
{...props}
/>
));
ToastDescription.displayName = ToastPrimitives.Description.displayName;
type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>;
type ToastActionElement = React.ReactElement<typeof ToastAction>;
export {
type ToastProps,
type ToastActionElement,
ToastProvider,
ToastViewport,
Toast,
ToastTitle,
ToastDescription,
ToastClose,
ToastAction,
};

View File

@@ -1,33 +0,0 @@
import { useToast } from "@/lib/hooks/useToast";
import {
Toast,
ToastClose,
ToastDescription,
ToastProvider,
ToastTitle,
ToastViewport,
} from "@/components/ui/toast";
export function Toaster() {
const { toasts } = useToast();
return (
<ToastProvider>
{toasts.map(function ({ id, title, description, action, ...props }) {
return (
<Toast key={id} {...props}>
<div className="grid gap-1">
{title && <ToastTitle>{title}</ToastTitle>}
{description && (
<ToastDescription>{description}</ToastDescription>
)}
</div>
{action}
<ToastClose />
</Toast>
);
})}
<ToastViewport />
</ToastProvider>
);
}

View File

@@ -1,30 +0,0 @@
"use client";
import * as React from "react";
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
import { cn } from "@/lib/utils";
const TooltipProvider = TooltipPrimitive.Provider;
const Tooltip = TooltipPrimitive.Root;
const TooltipTrigger = TooltipPrimitive.Trigger;
const TooltipContent = React.forwardRef<
React.ElementRef<typeof TooltipPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<TooltipPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-tooltip-content-transform-origin]",
className,
)}
{...props}
/>
));
TooltipContent.displayName = TooltipPrimitive.Content.displayName;
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };

View File

@@ -38,6 +38,25 @@ h1 {
line-height: 1.1;
}
button {
border-radius: 8px;
border: 1px solid transparent;
padding: 0.6em 1.2em;
font-size: 1em;
font-weight: 500;
font-family: inherit;
background-color: #1a1a1a;
cursor: pointer;
transition: border-color 0.25s;
}
button:hover {
border-color: #646cff;
}
button:focus,
button:focus-visible {
outline: 4px auto -webkit-focus-ring-color;
}
@media (prefers-color-scheme: light) {
:root {
color: #213547;
@@ -46,6 +65,9 @@ h1 {
a:hover {
color: #747bff;
}
button {
background-color: #f9f9f9;
}
}
@layer base {

View File

@@ -1,38 +0,0 @@
import {
OAuthMetadata,
OAuthClientInformationFull,
OAuthClientInformation,
OAuthTokens,
} from "@modelcontextprotocol/sdk/shared/auth.js";
// OAuth flow steps
export type OAuthStep =
| "metadata_discovery"
| "client_registration"
| "authorization_redirect"
| "authorization_code"
| "token_request"
| "complete";
// Message types for inline feedback
export type MessageType = "success" | "error" | "info";
export interface StatusMessage {
type: MessageType;
message: string;
}
// Single state interface for OAuth state
export interface AuthDebuggerState {
isInitiatingAuth: boolean;
oauthTokens: OAuthTokens | null;
loading: boolean;
oauthStep: OAuthStep;
oauthMetadata: OAuthMetadata | null;
oauthClientInfo: OAuthClientInformationFull | OAuthClientInformation | null;
authorizationUrl: string | null;
authorizationCode: string;
latestError: Error | null;
statusMessage: StatusMessage | null;
validationError: string | null;
}

View File

@@ -1,140 +0,0 @@
import { OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js";
import {
OAuthClientInformationSchema,
OAuthClientInformation,
OAuthTokens,
OAuthTokensSchema,
OAuthClientMetadata,
OAuthMetadata,
} from "@modelcontextprotocol/sdk/shared/auth.js";
import { SESSION_KEYS, getServerSpecificKey } from "./constants";
export class InspectorOAuthClientProvider implements OAuthClientProvider {
constructor(public serverUrl: string) {
// Save the server URL to session storage
sessionStorage.setItem(SESSION_KEYS.SERVER_URL, serverUrl);
}
get redirectUrl() {
return window.location.origin + "/oauth/callback";
}
get clientMetadata(): OAuthClientMetadata {
return {
redirect_uris: [this.redirectUrl],
token_endpoint_auth_method: "none",
grant_types: ["authorization_code", "refresh_token"],
response_types: ["code"],
client_name: "MCP Inspector",
client_uri: "https://github.com/modelcontextprotocol/inspector",
};
}
async clientInformation() {
const key = getServerSpecificKey(
SESSION_KEYS.CLIENT_INFORMATION,
this.serverUrl,
);
const value = sessionStorage.getItem(key);
if (!value) {
return undefined;
}
return await OAuthClientInformationSchema.parseAsync(JSON.parse(value));
}
saveClientInformation(clientInformation: OAuthClientInformation) {
const key = getServerSpecificKey(
SESSION_KEYS.CLIENT_INFORMATION,
this.serverUrl,
);
sessionStorage.setItem(key, JSON.stringify(clientInformation));
}
async tokens() {
const key = getServerSpecificKey(SESSION_KEYS.TOKENS, this.serverUrl);
const tokens = sessionStorage.getItem(key);
if (!tokens) {
return undefined;
}
return await OAuthTokensSchema.parseAsync(JSON.parse(tokens));
}
saveTokens(tokens: OAuthTokens) {
const key = getServerSpecificKey(SESSION_KEYS.TOKENS, this.serverUrl);
sessionStorage.setItem(key, JSON.stringify(tokens));
}
redirectToAuthorization(authorizationUrl: URL) {
window.location.href = authorizationUrl.href;
}
saveCodeVerifier(codeVerifier: string) {
const key = getServerSpecificKey(
SESSION_KEYS.CODE_VERIFIER,
this.serverUrl,
);
sessionStorage.setItem(key, codeVerifier);
}
codeVerifier() {
const key = getServerSpecificKey(
SESSION_KEYS.CODE_VERIFIER,
this.serverUrl,
);
const verifier = sessionStorage.getItem(key);
if (!verifier) {
throw new Error("No code verifier saved for session");
}
return verifier;
}
clear() {
sessionStorage.removeItem(
getServerSpecificKey(SESSION_KEYS.CLIENT_INFORMATION, this.serverUrl),
);
sessionStorage.removeItem(
getServerSpecificKey(SESSION_KEYS.TOKENS, this.serverUrl),
);
sessionStorage.removeItem(
getServerSpecificKey(SESSION_KEYS.CODE_VERIFIER, this.serverUrl),
);
}
}
// Overrides debug URL and allows saving server OAuth metadata to
// display in debug UI.
export class DebugInspectorOAuthClientProvider extends InspectorOAuthClientProvider {
get redirectUrl(): string {
return `${window.location.origin}/oauth/callback/debug`;
}
saveServerMetadata(metadata: OAuthMetadata) {
const key = getServerSpecificKey(
SESSION_KEYS.SERVER_METADATA,
this.serverUrl,
);
sessionStorage.setItem(key, JSON.stringify(metadata));
}
getServerMetadata(): OAuthMetadata | null {
const key = getServerSpecificKey(
SESSION_KEYS.SERVER_METADATA,
this.serverUrl,
);
const metadata = sessionStorage.getItem(key);
if (!metadata) {
return null;
}
return JSON.parse(metadata);
}
clear() {
super.clear();
sessionStorage.removeItem(
getServerSpecificKey(SESSION_KEYS.SERVER_METADATA, this.serverUrl),
);
}
}

View File

@@ -1,36 +0,0 @@
export type ConfigItem = {
label: string;
description: string;
value: string | number | boolean;
};
/**
* Configuration interface for the MCP Inspector, including settings for the MCP Client,
* Proxy Server, and Inspector UI/UX.
*
* Note: Configuration related to which MCP Server to use or any other MCP Server
* specific settings are outside the scope of this interface as of now.
*/
export type InspectorConfig = {
/**
* Maximum time in milliseconds to wait for a response from the MCP server before timing out.
*/
MCP_SERVER_REQUEST_TIMEOUT: ConfigItem;
/**
* Whether to reset the timeout on progress notifications. Useful for long-running operations that send periodic progress updates.
* Refer: https://spec.modelcontextprotocol.io/specification/2025-03-26/basic/utilities/progress/#progress-flow
*/
MCP_REQUEST_TIMEOUT_RESET_ON_PROGRESS: ConfigItem;
/**
* Maximum total time in milliseconds to wait for a response from the MCP server before timing out. Used in conjunction with MCP_SERVER_REQUEST_TIMEOUT_RESET_ON_PROGRESS.
* Refer: https://spec.modelcontextprotocol.io/specification/2025-03-26/basic/utilities/progress/#progress-flow
*/
MCP_REQUEST_MAX_TOTAL_TIMEOUT: ConfigItem;
/**
* The full address of the MCP Proxy Server, in case it is running on a non-default address. Example: http://10.1.1.22:5577
*/
MCP_PROXY_FULL_ADDRESS: ConfigItem;
};

View File

@@ -1,56 +0,0 @@
import { InspectorConfig } from "./configurationTypes";
// OAuth-related session storage keys
export const SESSION_KEYS = {
CODE_VERIFIER: "mcp_code_verifier",
SERVER_URL: "mcp_server_url",
TOKENS: "mcp_tokens",
CLIENT_INFORMATION: "mcp_client_information",
SERVER_METADATA: "mcp_server_metadata",
} as const;
// Generate server-specific session storage keys
export const getServerSpecificKey = (
baseKey: string,
serverUrl?: string,
): string => {
if (!serverUrl) return baseKey;
return `[${serverUrl}] ${baseKey}`;
};
export type ConnectionStatus =
| "disconnected"
| "connected"
| "error"
| "error-connecting-to-proxy";
export const DEFAULT_MCP_PROXY_LISTEN_PORT = "6277";
/**
* Default configuration for the MCP Inspector, Currently persisted in local_storage in the Browser.
* Future plans: Provide json config file + Browser local_storage to override default values
**/
export const DEFAULT_INSPECTOR_CONFIG: InspectorConfig = {
MCP_SERVER_REQUEST_TIMEOUT: {
label: "Request Timeout",
description: "Timeout for requests to the MCP server (ms)",
value: 10000,
},
MCP_REQUEST_TIMEOUT_RESET_ON_PROGRESS: {
label: "Reset Timeout on Progress",
description: "Reset timeout on progress notifications",
value: true,
},
MCP_REQUEST_MAX_TOTAL_TIMEOUT: {
label: "Maximum Total Timeout",
description:
"Maximum total timeout for requests sent to the MCP server (ms) (Use with progress notifications)",
value: 60000,
},
MCP_PROXY_FULL_ADDRESS: {
label: "Inspector Proxy Address",
description:
"Set this if you are running the MCP Inspector Proxy on a non-default address. Example: http://10.1.1.22:5577",
value: "",
},
} as const;

View File

@@ -1,166 +0,0 @@
import { renderHook, act } from "@testing-library/react";
import { useConnection } from "../useConnection";
import { z } from "zod";
import { ClientRequest } from "@modelcontextprotocol/sdk/types.js";
import { DEFAULT_INSPECTOR_CONFIG } from "../../constants";
// Mock fetch
global.fetch = jest.fn().mockResolvedValue({
json: () => Promise.resolve({ status: "ok" }),
});
// Mock the SDK dependencies
const mockRequest = jest.fn().mockResolvedValue({ test: "response" });
const mockClient = {
request: mockRequest,
notification: jest.fn(),
connect: jest.fn().mockResolvedValue(undefined),
close: jest.fn(),
getServerCapabilities: jest.fn(),
getServerVersion: jest.fn(),
getInstructions: jest.fn(),
setNotificationHandler: jest.fn(),
setRequestHandler: jest.fn(),
};
jest.mock("@modelcontextprotocol/sdk/client/index.js", () => ({
Client: jest.fn().mockImplementation(() => mockClient),
}));
jest.mock("@modelcontextprotocol/sdk/client/sse.js", () => ({
SSEClientTransport: jest.fn(),
SseError: jest.fn(),
}));
jest.mock("@modelcontextprotocol/sdk/client/auth.js", () => ({
auth: jest.fn().mockResolvedValue("AUTHORIZED"),
}));
// Mock the toast hook
jest.mock("@/lib/hooks/useToast", () => ({
useToast: () => ({
toast: jest.fn(),
}),
}));
// Mock the auth provider
jest.mock("../../auth", () => ({
InspectorOAuthClientProvider: jest.fn().mockImplementation(() => ({
tokens: jest.fn().mockResolvedValue({ access_token: "mock-token" }),
})),
}));
describe("useConnection", () => {
const defaultProps = {
transportType: "sse" as const,
command: "",
args: "",
sseUrl: "http://localhost:8080",
env: {},
config: DEFAULT_INSPECTOR_CONFIG,
};
describe("Request Configuration", () => {
beforeEach(() => {
jest.clearAllMocks();
});
test("uses the default config values in makeRequest", async () => {
const { result } = renderHook(() => useConnection(defaultProps));
// Connect the client
await act(async () => {
await result.current.connect();
});
// Wait for state update
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0));
});
const mockRequest: ClientRequest = {
method: "ping",
params: {},
};
const mockSchema = z.object({
test: z.string(),
});
await act(async () => {
await result.current.makeRequest(mockRequest, mockSchema);
});
expect(mockClient.request).toHaveBeenCalledWith(
mockRequest,
mockSchema,
expect.objectContaining({
timeout: DEFAULT_INSPECTOR_CONFIG.MCP_SERVER_REQUEST_TIMEOUT.value,
maxTotalTimeout:
DEFAULT_INSPECTOR_CONFIG.MCP_REQUEST_MAX_TOTAL_TIMEOUT.value,
resetTimeoutOnProgress:
DEFAULT_INSPECTOR_CONFIG.MCP_REQUEST_TIMEOUT_RESET_ON_PROGRESS
.value,
}),
);
});
test("overrides the default config values when passed in options in makeRequest", async () => {
const { result } = renderHook(() => useConnection(defaultProps));
// Connect the client
await act(async () => {
await result.current.connect();
});
// Wait for state update
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0));
});
const mockRequest: ClientRequest = {
method: "ping",
params: {},
};
const mockSchema = z.object({
test: z.string(),
});
await act(async () => {
await result.current.makeRequest(mockRequest, mockSchema, {
timeout: 1000,
maxTotalTimeout: 2000,
resetTimeoutOnProgress: false,
});
});
expect(mockClient.request).toHaveBeenCalledWith(
mockRequest,
mockSchema,
expect.objectContaining({
timeout: 1000,
maxTotalTimeout: 2000,
resetTimeoutOnProgress: false,
}),
);
});
});
test("throws error when mcpClient is not connected", async () => {
const { result } = renderHook(() => useConnection(defaultProps));
const mockRequest: ClientRequest = {
method: "ping",
params: {},
};
const mockSchema = z.object({
test: z.string(),
});
await expect(
result.current.makeRequest(mockRequest, mockSchema),
).rejects.toThrow("MCP client not connected");
});
});

View File

@@ -1,129 +0,0 @@
import { useState, useCallback, useEffect, useRef, useMemo } from "react";
import {
ResourceReference,
PromptReference,
} from "@modelcontextprotocol/sdk/types.js";
interface CompletionState {
completions: Record<string, string[]>;
loading: Record<string, boolean>;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function debounce<T extends (...args: any[]) => PromiseLike<void>>(
func: T,
wait: number,
): (...args: Parameters<T>) => void {
let timeout: ReturnType<typeof setTimeout>;
return (...args: Parameters<T>) => {
clearTimeout(timeout);
timeout = setTimeout(() => {
void func(...args);
}, wait);
};
}
export function useCompletionState(
handleCompletion: (
ref: ResourceReference | PromptReference,
argName: string,
value: string,
signal?: AbortSignal,
) => Promise<string[]>,
completionsSupported: boolean = true,
debounceMs: number = 300,
) {
const [state, setState] = useState<CompletionState>({
completions: {},
loading: {},
});
const abortControllerRef = useRef<AbortController | null>(null);
const cleanup = useCallback(() => {
if (abortControllerRef.current) {
abortControllerRef.current.abort();
abortControllerRef.current = null;
}
}, []);
// Cleanup on unmount
useEffect(() => {
return cleanup;
}, [cleanup]);
const clearCompletions = useCallback(() => {
cleanup();
setState({
completions: {},
loading: {},
});
}, [cleanup]);
const requestCompletions = useMemo(() => {
return debounce(
async (
ref: ResourceReference | PromptReference,
argName: string,
value: string,
) => {
if (!completionsSupported) {
return;
}
cleanup();
const abortController = new AbortController();
abortControllerRef.current = abortController;
setState((prev) => ({
...prev,
loading: { ...prev.loading, [argName]: true },
}));
try {
const values = await handleCompletion(
ref,
argName,
value,
abortController.signal,
);
if (!abortController.signal.aborted) {
setState((prev) => ({
...prev,
completions: { ...prev.completions, [argName]: values },
loading: { ...prev.loading, [argName]: false },
}));
}
} catch {
if (!abortController.signal.aborted) {
setState((prev) => ({
...prev,
loading: { ...prev.loading, [argName]: false },
}));
}
} finally {
if (abortControllerRef.current === abortController) {
abortControllerRef.current = null;
}
}
},
debounceMs,
);
}, [handleCompletion, completionsSupported, cleanup, debounceMs]);
// Clear completions when support status changes
useEffect(() => {
if (!completionsSupported) {
clearCompletions();
}
}, [completionsSupported, clearCompletions]);
return {
...state,
clearCompletions,
requestCompletions,
completionsSupported,
};
}

View File

@@ -1,490 +0,0 @@
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import {
SSEClientTransport,
SseError,
SSEClientTransportOptions,
} from "@modelcontextprotocol/sdk/client/sse.js";
import {
StreamableHTTPClientTransport,
StreamableHTTPClientTransportOptions,
} from "@modelcontextprotocol/sdk/client/streamableHttp.js";
import {
ClientNotification,
ClientRequest,
CreateMessageRequestSchema,
ListRootsRequestSchema,
ResourceUpdatedNotificationSchema,
LoggingMessageNotificationSchema,
Request,
Result,
ServerCapabilities,
PromptReference,
ResourceReference,
McpError,
CompleteResultSchema,
ErrorCode,
CancelledNotificationSchema,
ResourceListChangedNotificationSchema,
ToolListChangedNotificationSchema,
PromptListChangedNotificationSchema,
Progress,
} from "@modelcontextprotocol/sdk/types.js";
import { RequestOptions } from "@modelcontextprotocol/sdk/shared/protocol.js";
import { useState } from "react";
import { useToast } from "@/lib/hooks/useToast";
import { z } from "zod";
import { ConnectionStatus } from "../constants";
import { Notification, StdErrNotificationSchema } from "../notificationTypes";
import { auth } from "@modelcontextprotocol/sdk/client/auth.js";
import { InspectorOAuthClientProvider } from "../auth";
import packageJson from "../../../package.json";
import {
getMCPProxyAddress,
getMCPServerRequestMaxTotalTimeout,
resetRequestTimeoutOnProgress,
} from "@/utils/configUtils";
import { getMCPServerRequestTimeout } from "@/utils/configUtils";
import { InspectorConfig } from "../configurationTypes";
interface UseConnectionOptions {
transportType: "stdio" | "sse" | "streamable-http";
command: string;
args: string;
sseUrl: string;
env: Record<string, string>;
bearerToken?: string;
headerName?: string;
customHeaders?: [string, string][];
config: InspectorConfig;
onNotification?: (notification: Notification) => void;
onStdErrNotification?: (notification: Notification) => void;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
onPendingRequest?: (request: any, resolve: any, reject: any) => void;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
getRoots?: () => any[];
}
export function useConnection({
transportType,
command,
args,
sseUrl,
env,
bearerToken,
headerName,
customHeaders,
config,
onNotification,
onStdErrNotification,
onPendingRequest,
getRoots,
}: UseConnectionOptions) {
const [connectionStatus, setConnectionStatus] =
useState<ConnectionStatus>("disconnected");
const { toast } = useToast();
const [serverCapabilities, setServerCapabilities] =
useState<ServerCapabilities | null>(null);
const [mcpClient, setMcpClient] = useState<Client | null>(null);
const [requestHistory, setRequestHistory] = useState<
{ request: string; response?: string }[]
>([]);
const [completionsSupported, setCompletionsSupported] = useState(true);
const pushHistory = (request: object, response?: object) => {
setRequestHistory((prev) => [
...prev,
{
request: JSON.stringify(request),
response: response !== undefined ? JSON.stringify(response) : undefined,
},
]);
};
const makeRequest = async <T extends z.ZodType>(
request: ClientRequest,
schema: T,
options?: RequestOptions & { suppressToast?: boolean },
): Promise<z.output<T>> => {
if (!mcpClient) {
throw new Error("MCP client not connected");
}
try {
const abortController = new AbortController();
// prepare MCP Client request options
const mcpRequestOptions: RequestOptions = {
signal: options?.signal ?? abortController.signal,
resetTimeoutOnProgress:
options?.resetTimeoutOnProgress ??
resetRequestTimeoutOnProgress(config),
timeout: options?.timeout ?? getMCPServerRequestTimeout(config),
maxTotalTimeout:
options?.maxTotalTimeout ??
getMCPServerRequestMaxTotalTimeout(config),
};
// If progress notifications are enabled, add an onprogress hook to the MCP Client request options
// This is required by SDK to reset the timeout on progress notifications
if (mcpRequestOptions.resetTimeoutOnProgress) {
mcpRequestOptions.onprogress = (params: Progress) => {
// Add progress notification to `Server Notification` window in the UI
if (onNotification) {
onNotification({
method: "notification/progress",
params,
});
}
};
}
let response;
try {
response = await mcpClient.request(request, schema, mcpRequestOptions);
pushHistory(request, response);
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error);
pushHistory(request, { error: errorMessage });
throw error;
}
return response;
} catch (e: unknown) {
if (!options?.suppressToast) {
const errorString = (e as Error).message ?? String(e);
toast({
title: "Error",
description: errorString,
variant: "destructive",
});
}
throw e;
}
};
const handleCompletion = async (
ref: ResourceReference | PromptReference,
argName: string,
value: string,
signal?: AbortSignal,
): Promise<string[]> => {
if (!mcpClient || !completionsSupported) {
return [];
}
const request: ClientRequest = {
method: "completion/complete",
params: {
argument: {
name: argName,
value,
},
ref,
},
};
try {
const response = await makeRequest(request, CompleteResultSchema, {
signal,
suppressToast: true,
});
return response?.completion.values || [];
} catch (e: unknown) {
// Disable completions silently if the server doesn't support them.
// See https://github.com/modelcontextprotocol/specification/discussions/122
if (e instanceof McpError && e.code === ErrorCode.MethodNotFound) {
setCompletionsSupported(false);
return [];
}
// Unexpected errors - show toast and rethrow
toast({
title: "Error",
description: e instanceof Error ? e.message : String(e),
variant: "destructive",
});
throw e;
}
};
const sendNotification = async (notification: ClientNotification) => {
if (!mcpClient) {
const error = new Error("MCP client not connected");
toast({
title: "Error",
description: error.message,
variant: "destructive",
});
throw error;
}
try {
await mcpClient.notification(notification);
// Log successful notifications
pushHistory(notification);
} catch (e: unknown) {
if (e instanceof McpError) {
// Log MCP protocol errors
pushHistory(notification, { error: e.message });
}
toast({
title: "Error",
description: e instanceof Error ? e.message : String(e),
variant: "destructive",
});
throw e;
}
};
const checkProxyHealth = async () => {
try {
const proxyHealthUrl = new URL(`${getMCPProxyAddress(config)}/health`);
const proxyHealthResponse = await fetch(proxyHealthUrl);
const proxyHealth = await proxyHealthResponse.json();
if (proxyHealth?.status !== "ok") {
throw new Error("MCP Proxy Server is not healthy");
}
} catch (e) {
console.error("Couldn't connect to MCP Proxy Server", e);
throw e;
}
};
const handleAuthError = async (error: unknown) => {
if (error instanceof SseError && error.code === 401) {
// Create a new auth provider with the current server URL
const serverAuthProvider = new InspectorOAuthClientProvider(sseUrl);
const result = await auth(serverAuthProvider, { serverUrl: sseUrl });
return result === "AUTHORIZED";
}
return false;
};
const connect = async (_e?: unknown, retryCount: number = 0) => {
const client = new Client<Request, Notification, Result>(
{
name: "mcp-inspector",
version: packageJson.version,
},
{
capabilities: {
sampling: {},
roots: {
listChanged: true,
},
},
},
);
try {
await checkProxyHealth();
} catch {
setConnectionStatus("error-connecting-to-proxy");
return;
}
try {
// Inject auth manually instead of using SSEClientTransport, because we're
// proxying through the inspector server first.
const headers = new Headers(customHeaders||[]);
//const headers: HeadersInit = [ ...customHeaders||[] ];
// Create an auth provider with the current server URL
const serverAuthProvider = new InspectorOAuthClientProvider(sseUrl);
// Use manually provided bearer token if available, otherwise use OAuth tokens
const token =
bearerToken || (await serverAuthProvider.tokens())?.access_token;
if (token) {
const authHeaderName = headerName || "Authorization";
headers.set(authHeaderName, `Bearer ${token}`);
}
// Create appropriate transport
let transportOptions:
| StreamableHTTPClientTransportOptions
| SSEClientTransportOptions;
let mcpProxyServerUrl;
switch (transportType) {
case "stdio":
mcpProxyServerUrl = new URL(`${getMCPProxyAddress(config)}/stdio`);
mcpProxyServerUrl.searchParams.append("command", command);
mcpProxyServerUrl.searchParams.append("args", args);
mcpProxyServerUrl.searchParams.append("env", JSON.stringify(env));
transportOptions = {
authProvider: serverAuthProvider,
eventSourceInit: {
fetch: (
url: string | URL | globalThis.Request,
init: RequestInit | undefined,
) => fetch(url, { ...init, headers }),
},
requestInit: {
headers,
},
};
break;
case "sse":
mcpProxyServerUrl = new URL(`${getMCPProxyAddress(config)}/sse`);
mcpProxyServerUrl.searchParams.append("url", sseUrl);
transportOptions = {
authProvider: serverAuthProvider,
eventSourceInit: {
fetch: (
url: string | URL | globalThis.Request,
init: RequestInit | undefined,
) => fetch(url, { ...init, headers }),
},
requestInit: {
headers,
},
};
break;
case "streamable-http":
mcpProxyServerUrl = new URL(`${getMCPProxyAddress(config)}/mcp`);
mcpProxyServerUrl.searchParams.append("url", sseUrl);
transportOptions = {
authProvider: serverAuthProvider,
eventSourceInit: {
fetch: (
url: string | URL | globalThis.Request,
init: RequestInit | undefined,
) => fetch(url, { ...init, headers }),
},
requestInit: {
headers,
},
// TODO these should be configurable...
reconnectionOptions: {
maxReconnectionDelay: 30000,
initialReconnectionDelay: 1000,
reconnectionDelayGrowFactor: 1.5,
maxRetries: 2,
},
};
break;
}
(mcpProxyServerUrl as URL).searchParams.append(
"transportType",
transportType,
);
const clientTransport =
transportType === "streamable-http"
? new StreamableHTTPClientTransport(mcpProxyServerUrl as URL, {
sessionId: undefined,
...transportOptions,
})
: new SSEClientTransport(mcpProxyServerUrl as URL, transportOptions);
if (onNotification) {
[
CancelledNotificationSchema,
LoggingMessageNotificationSchema,
ResourceUpdatedNotificationSchema,
ResourceListChangedNotificationSchema,
ToolListChangedNotificationSchema,
PromptListChangedNotificationSchema,
].forEach((notificationSchema) => {
client.setNotificationHandler(notificationSchema, onNotification);
});
client.fallbackNotificationHandler = (
notification: Notification,
): Promise<void> => {
onNotification(notification);
return Promise.resolve();
};
}
if (onStdErrNotification) {
client.setNotificationHandler(
StdErrNotificationSchema,
onStdErrNotification,
);
}
let capabilities;
try {
await client.connect(clientTransport);
capabilities = client.getServerCapabilities();
const initializeRequest = {
method: "initialize",
};
pushHistory(initializeRequest, {
capabilities,
serverInfo: client.getServerVersion(),
instructions: client.getInstructions(),
});
} catch (error) {
console.error(
`Failed to connect to MCP Server via the MCP Inspector Proxy: ${mcpProxyServerUrl}:`,
error,
);
const shouldRetry = await handleAuthError(error);
if (shouldRetry) {
return connect(undefined, retryCount + 1);
}
if (error instanceof SseError && error.code === 401) {
// Don't set error state if we're about to redirect for auth
return;
}
throw error;
}
setServerCapabilities(capabilities ?? null);
setCompletionsSupported(true); // Reset completions support on new connection
if (onPendingRequest) {
client.setRequestHandler(CreateMessageRequestSchema, (request) => {
return new Promise((resolve, reject) => {
onPendingRequest(request, resolve, reject);
});
});
}
if (getRoots) {
client.setRequestHandler(ListRootsRequestSchema, async () => {
return { roots: getRoots() };
});
}
setMcpClient(client);
setConnectionStatus("connected");
} catch (e) {
console.error(e);
setConnectionStatus("error");
}
};
const disconnect = async () => {
await mcpClient?.close();
const authProvider = new InspectorOAuthClientProvider(sseUrl);
authProvider.clear();
setMcpClient(null);
setConnectionStatus("disconnected");
setCompletionsSupported(false);
setServerCapabilities(null);
};
return {
connectionStatus,
serverCapabilities,
mcpClient,
requestHistory,
makeRequest,
sendNotification,
handleCompletion,
completionsSupported,
connect,
disconnect,
};
}

View File

@@ -1,53 +0,0 @@
import { useCallback, useEffect, useRef, useState } from "react";
export function useDraggablePane(initialHeight: number) {
const [height, setHeight] = useState(initialHeight);
const [isDragging, setIsDragging] = useState(false);
const dragStartY = useRef<number>(0);
const dragStartHeight = useRef<number>(0);
const handleDragStart = useCallback(
(e: React.MouseEvent) => {
setIsDragging(true);
dragStartY.current = e.clientY;
dragStartHeight.current = height;
document.body.style.userSelect = "none";
},
[height],
);
const handleDragMove = useCallback(
(e: MouseEvent) => {
if (!isDragging) return;
const deltaY = dragStartY.current - e.clientY;
const newHeight = Math.max(
100,
Math.min(800, dragStartHeight.current + deltaY),
);
setHeight(newHeight);
},
[isDragging],
);
const handleDragEnd = useCallback(() => {
setIsDragging(false);
document.body.style.userSelect = "";
}, []);
useEffect(() => {
if (isDragging) {
window.addEventListener("mousemove", handleDragMove);
window.addEventListener("mouseup", handleDragEnd);
return () => {
window.removeEventListener("mousemove", handleDragMove);
window.removeEventListener("mouseup", handleDragEnd);
};
}
}, [isDragging, handleDragMove, handleDragEnd]);
return {
height,
isDragging,
handleDragStart,
};
}

View File

@@ -1,52 +0,0 @@
import { useCallback, useEffect, useMemo, useState } from "react";
type Theme = "light" | "dark" | "system";
const useTheme = (): [Theme, (mode: Theme) => void] => {
const [theme, setTheme] = useState<Theme>(() => {
const savedTheme = localStorage.getItem("theme") as Theme;
return savedTheme || "system";
});
useEffect(() => {
const darkModeMediaQuery = window.matchMedia(
"(prefers-color-scheme: dark)",
);
const handleDarkModeChange = (e: MediaQueryListEvent) => {
if (theme === "system") {
updateDocumentTheme(e.matches ? "dark" : "light");
}
};
const updateDocumentTheme = (newTheme: "light" | "dark") => {
document.documentElement.classList.toggle("dark", newTheme === "dark");
};
// Set initial theme based on current mode
if (theme === "system") {
updateDocumentTheme(darkModeMediaQuery.matches ? "dark" : "light");
} else {
updateDocumentTheme(theme);
}
darkModeMediaQuery.addEventListener("change", handleDarkModeChange);
return () => {
darkModeMediaQuery.removeEventListener("change", handleDarkModeChange);
};
}, [theme]);
const setThemeWithSideEffect = useCallback((newTheme: Theme) => {
setTheme(newTheme);
localStorage.setItem("theme", newTheme);
if (newTheme !== "system") {
document.documentElement.classList.toggle("dark", newTheme === "dark");
}
}, []);
return useMemo(
() => [theme, setThemeWithSideEffect],
[theme, setThemeWithSideEffect],
);
};
export default useTheme;

View File

@@ -1,191 +0,0 @@
"use client";
// Inspired by react-hot-toast library
import * as React from "react";
import type { ToastActionElement, ToastProps } from "@/components/ui/toast";
const TOAST_LIMIT = 1;
const TOAST_REMOVE_DELAY = 1000000;
type ToasterToast = ToastProps & {
id: string;
title?: React.ReactNode;
description?: React.ReactNode;
action?: ToastActionElement;
};
let count = 0;
function genId() {
count = (count + 1) % Number.MAX_SAFE_INTEGER;
return count.toString();
}
const enum ActionType {
ADD_TOAST = "ADD_TOAST",
UPDATE_TOAST = "UPDATE_TOAST",
DISMISS_TOAST = "DISMISS_TOAST",
REMOVE_TOAST = "REMOVE_TOAST",
}
type Action =
| {
type: ActionType.ADD_TOAST;
toast: ToasterToast;
}
| {
type: ActionType.UPDATE_TOAST;
toast: Partial<ToasterToast>;
}
| {
type: ActionType.DISMISS_TOAST;
toastId?: ToasterToast["id"];
}
| {
type: ActionType.REMOVE_TOAST;
toastId?: ToasterToast["id"];
};
interface State {
toasts: ToasterToast[];
}
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>();
const addToRemoveQueue = (toastId: string) => {
if (toastTimeouts.has(toastId)) {
return;
}
const timeout = setTimeout(() => {
toastTimeouts.delete(toastId);
dispatch({
type: ActionType.REMOVE_TOAST,
toastId: toastId,
});
}, TOAST_REMOVE_DELAY);
toastTimeouts.set(toastId, timeout);
};
export const reducer = (state: State, action: Action): State => {
switch (action.type) {
case ActionType.ADD_TOAST:
return {
...state,
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
};
case ActionType.UPDATE_TOAST:
return {
...state,
toasts: state.toasts.map((t) =>
t.id === action.toast.id ? { ...t, ...action.toast } : t,
),
};
case ActionType.DISMISS_TOAST: {
const { toastId } = action;
// ! Side effects ! - This could be extracted into a dismissToast() action,
// but I'll keep it here for simplicity
if (toastId) {
addToRemoveQueue(toastId);
} else {
state.toasts.forEach((toast) => {
addToRemoveQueue(toast.id);
});
}
return {
...state,
toasts: state.toasts.map((t) =>
t.id === toastId || toastId === undefined
? {
...t,
open: false,
}
: t,
),
};
}
case ActionType.REMOVE_TOAST:
if (action.toastId === undefined) {
return {
...state,
toasts: [],
};
}
return {
...state,
toasts: state.toasts.filter((t) => t.id !== action.toastId),
};
}
};
const listeners: Array<(state: State) => void> = [];
let memoryState: State = { toasts: [] };
function dispatch(action: Action) {
memoryState = reducer(memoryState, action);
listeners.forEach((listener) => {
listener(memoryState);
});
}
type Toast = Omit<ToasterToast, "id">;
function toast({ ...props }: Toast) {
const id = genId();
const update = (props: ToasterToast) =>
dispatch({
type: ActionType.UPDATE_TOAST,
toast: { ...props, id },
});
const dismiss = () =>
dispatch({ type: ActionType.DISMISS_TOAST, toastId: id });
dispatch({
type: ActionType.ADD_TOAST,
toast: {
...props,
id,
open: true,
onOpenChange: (open) => {
if (!open) dismiss();
},
},
});
return {
id: id,
dismiss,
update,
};
}
function useToast() {
const [state, setState] = React.useState<State>(memoryState);
React.useEffect(() => {
listeners.push(setState);
return () => {
const index = listeners.indexOf(setState);
if (index > -1) {
listeners.splice(index, 1);
}
};
}, [state]);
return {
...state,
toast,
dismiss: (toastId?: string) =>
dispatch({ type: ActionType.DISMISS_TOAST, toastId }),
};
}
export { useToast, toast };

View File

@@ -1,22 +0,0 @@
import {
NotificationSchema as BaseNotificationSchema,
ClientNotificationSchema,
ServerNotificationSchema,
} from "@modelcontextprotocol/sdk/types.js";
import { z } from "zod";
export const StdErrNotificationSchema = BaseNotificationSchema.extend({
method: z.literal("notifications/stderr"),
params: z.object({
content: z.string(),
}),
});
export const NotificationSchema = ClientNotificationSchema.or(
StdErrNotificationSchema,
)
.or(ServerNotificationSchema)
.or(BaseNotificationSchema);
export type StdErrNotification = z.infer<typeof StdErrNotificationSchema>;
export type Notification = z.infer<typeof NotificationSchema>;

View File

@@ -1,181 +0,0 @@
import { OAuthStep, AuthDebuggerState } from "./auth-types";
import { DebugInspectorOAuthClientProvider } from "./auth";
import {
discoverOAuthMetadata,
registerClient,
startAuthorization,
exchangeAuthorization,
} from "@modelcontextprotocol/sdk/client/auth.js";
import { OAuthMetadataSchema } from "@modelcontextprotocol/sdk/shared/auth.js";
export interface StateMachineContext {
state: AuthDebuggerState;
serverUrl: string;
provider: DebugInspectorOAuthClientProvider;
updateState: (updates: Partial<AuthDebuggerState>) => void;
}
export interface StateTransition {
canTransition: (context: StateMachineContext) => Promise<boolean>;
execute: (context: StateMachineContext) => Promise<void>;
nextStep: OAuthStep;
}
// State machine transitions
export const oauthTransitions: Record<OAuthStep, StateTransition> = {
metadata_discovery: {
canTransition: async () => true,
execute: async (context) => {
const metadata = await discoverOAuthMetadata(context.serverUrl);
if (!metadata) {
throw new Error("Failed to discover OAuth metadata");
}
const parsedMetadata = await OAuthMetadataSchema.parseAsync(metadata);
context.provider.saveServerMetadata(parsedMetadata);
context.updateState({
oauthMetadata: parsedMetadata,
oauthStep: "client_registration",
});
},
nextStep: "client_registration",
},
client_registration: {
canTransition: async (context) => !!context.state.oauthMetadata,
execute: async (context) => {
const metadata = context.state.oauthMetadata!;
const clientMetadata = context.provider.clientMetadata;
// Add all supported scopes to client registration
if (metadata.scopes_supported) {
clientMetadata.scope = metadata.scopes_supported.join(" ");
}
const fullInformation = await registerClient(context.serverUrl, {
metadata,
clientMetadata,
});
context.provider.saveClientInformation(fullInformation);
context.updateState({
oauthClientInfo: fullInformation,
oauthStep: "authorization_redirect",
});
},
nextStep: "authorization_redirect",
},
authorization_redirect: {
canTransition: async (context) =>
!!context.state.oauthMetadata && !!context.state.oauthClientInfo,
execute: async (context) => {
const metadata = context.state.oauthMetadata!;
const clientInformation = context.state.oauthClientInfo!;
let scope: string | undefined = undefined;
if (metadata.scopes_supported) {
scope = metadata.scopes_supported.join(" ");
}
const { authorizationUrl, codeVerifier } = await startAuthorization(
context.serverUrl,
{
metadata,
clientInformation,
redirectUrl: context.provider.redirectUrl,
scope,
},
);
context.provider.saveCodeVerifier(codeVerifier);
context.updateState({
authorizationUrl: authorizationUrl.toString(),
oauthStep: "authorization_code",
});
},
nextStep: "authorization_code",
},
authorization_code: {
canTransition: async () => true,
execute: async (context) => {
if (
!context.state.authorizationCode ||
context.state.authorizationCode.trim() === ""
) {
context.updateState({
validationError: "You need to provide an authorization code",
});
// Don't advance if no code
throw new Error("Authorization code required");
}
context.updateState({
validationError: null,
oauthStep: "token_request",
});
},
nextStep: "token_request",
},
token_request: {
canTransition: async (context) => {
return (
!!context.state.authorizationCode &&
!!context.provider.getServerMetadata() &&
!!(await context.provider.clientInformation())
);
},
execute: async (context) => {
const codeVerifier = context.provider.codeVerifier();
const metadata = context.provider.getServerMetadata()!;
const clientInformation = (await context.provider.clientInformation())!;
const tokens = await exchangeAuthorization(context.serverUrl, {
metadata,
clientInformation,
authorizationCode: context.state.authorizationCode,
codeVerifier,
redirectUri: context.provider.redirectUrl,
});
context.provider.saveTokens(tokens);
context.updateState({
oauthTokens: tokens,
oauthStep: "complete",
});
},
nextStep: "complete",
},
complete: {
canTransition: async () => false,
execute: async () => {
// No-op for complete state
},
nextStep: "complete",
},
};
export class OAuthStateMachine {
constructor(
private serverUrl: string,
private updateState: (updates: Partial<AuthDebuggerState>) => void,
) {}
async executeStep(state: AuthDebuggerState): Promise<void> {
const provider = new DebugInspectorOAuthClientProvider(this.serverUrl);
const context: StateMachineContext = {
state,
serverUrl: this.serverUrl,
provider,
updateState: this.updateState,
};
const transition = oauthTransitions[state.oauthStep];
if (!(await transition.canTransition(context))) {
throw new Error(`Cannot transition from ${state.oauthStep}`);
}
await transition.execute(context);
}
}

View File

@@ -1,6 +1,6 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
return twMerge(clsx(inputs))
}

View File

@@ -1,15 +1,10 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { Toaster } from "@/components/ui/toaster.tsx";
import App from "./App.tsx";
import "./index.css";
import { TooltipProvider } from "./components/ui/tooltip.tsx";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<TooltipProvider>
<App />
</TooltipProvider>
<Toaster />
</StrictMode>,
);

View File

@@ -1,27 +0,0 @@
import { escapeUnicode } from "../escapeUnicode";
describe("escapeUnicode", () => {
it("should escape Unicode characters in a string", () => {
const input = { text: "你好世界" };
const expected = '{\n "text": "\\\\u4f60\\\\u597d\\\\u4e16\\\\u754c"\n}';
expect(escapeUnicode(input)).toBe(expected);
});
it("should handle empty strings", () => {
const input = { text: "" };
const expected = '{\n "text": ""\n}';
expect(escapeUnicode(input)).toBe(expected);
});
it("should handle null and undefined values", () => {
const input = { text: null, value: undefined };
const expected = '{\n "text": null\n}';
expect(escapeUnicode(input)).toBe(expected);
});
it("should handle numbers and booleans", () => {
const input = { number: 123, boolean: true };
const expected = '{\n "number": 123,\n "boolean": true\n}';
expect(escapeUnicode(input)).toBe(expected);
});
});

View File

@@ -1,319 +0,0 @@
import {
getDataType,
tryParseJson,
updateValueAtPath,
getValueAtPath,
} from "../jsonUtils";
import type { JsonValue } from "../jsonUtils";
describe("getDataType", () => {
test("should return 'string' for string values", () => {
expect(getDataType("hello")).toBe("string");
expect(getDataType("")).toBe("string");
});
test("should return 'number' for number values", () => {
expect(getDataType(123)).toBe("number");
expect(getDataType(0)).toBe("number");
expect(getDataType(-10)).toBe("number");
expect(getDataType(1.5)).toBe("number");
expect(getDataType(NaN)).toBe("number");
expect(getDataType(Infinity)).toBe("number");
});
test("should return 'boolean' for boolean values", () => {
expect(getDataType(true)).toBe("boolean");
expect(getDataType(false)).toBe("boolean");
});
test("should return 'undefined' for undefined value", () => {
expect(getDataType(undefined)).toBe("undefined");
});
test("should return 'object' for object values", () => {
expect(getDataType({})).toBe("object");
expect(getDataType({ key: "value" })).toBe("object");
});
test("should return 'array' for array values", () => {
expect(getDataType([])).toBe("array");
expect(getDataType([1, 2, 3])).toBe("array");
expect(getDataType(["a", "b", "c"])).toBe("array");
expect(getDataType([{}, { nested: true }])).toBe("array");
});
test("should return 'null' for null value", () => {
expect(getDataType(null)).toBe("null");
});
});
describe("tryParseJson", () => {
test("should correctly parse valid JSON object", () => {
const jsonString = '{"name":"test","value":123}';
const result = tryParseJson(jsonString);
expect(result.success).toBe(true);
expect(result.data).toEqual({ name: "test", value: 123 });
});
test("should correctly parse valid JSON array", () => {
const jsonString = '[1,2,3,"test"]';
const result = tryParseJson(jsonString);
expect(result.success).toBe(true);
expect(result.data).toEqual([1, 2, 3, "test"]);
});
test("should correctly parse JSON with whitespace", () => {
const jsonString = ' { "name" : "test" } ';
const result = tryParseJson(jsonString);
expect(result.success).toBe(true);
expect(result.data).toEqual({ name: "test" });
});
test("should correctly parse nested JSON structures", () => {
const jsonString =
'{"user":{"name":"test","details":{"age":30}},"items":[1,2,3]}';
const result = tryParseJson(jsonString);
expect(result.success).toBe(true);
expect(result.data).toEqual({
user: {
name: "test",
details: {
age: 30,
},
},
items: [1, 2, 3],
});
});
test("should correctly parse empty objects and arrays", () => {
expect(tryParseJson("{}").success).toBe(true);
expect(tryParseJson("{}").data).toEqual({});
expect(tryParseJson("[]").success).toBe(true);
expect(tryParseJson("[]").data).toEqual([]);
});
test("should return failure for non-JSON strings", () => {
const nonJsonString = "this is not json";
const result = tryParseJson(nonJsonString);
expect(result.success).toBe(false);
expect(result.data).toBe(nonJsonString);
});
test("should return failure for malformed JSON", () => {
const malformedJson = '{"name":"test",}';
const result = tryParseJson(malformedJson);
expect(result.success).toBe(false);
expect(result.data).toBe(malformedJson);
});
test("should return failure for strings with correct delimiters but invalid JSON", () => {
const invalidJson = "{name:test}";
const result = tryParseJson(invalidJson);
expect(result.success).toBe(false);
expect(result.data).toBe(invalidJson);
});
test("should handle edge cases", () => {
expect(tryParseJson("").success).toBe(false);
expect(tryParseJson("").data).toBe("");
expect(tryParseJson(" ").success).toBe(false);
expect(tryParseJson(" ").data).toBe(" ");
expect(tryParseJson("null").success).toBe(false);
expect(tryParseJson("null").data).toBe("null");
expect(tryParseJson('"string"').success).toBe(false);
expect(tryParseJson('"string"').data).toBe('"string"');
expect(tryParseJson("123").success).toBe(false);
expect(tryParseJson("123").data).toBe("123");
expect(tryParseJson("true").success).toBe(false);
expect(tryParseJson("true").data).toBe("true");
});
});
describe("updateValueAtPath", () => {
// Basic functionality tests
test("returns the new value when path is empty", () => {
expect(updateValueAtPath({ foo: "bar" }, [], "newValue")).toBe("newValue");
});
test("initializes an empty object when input is null/undefined and path starts with a string", () => {
expect(updateValueAtPath(null, ["foo"], "bar")).toEqual({
foo: "bar",
});
expect(updateValueAtPath(undefined, ["foo"], "bar")).toEqual({
foo: "bar",
});
});
test("initializes an empty array when input is null/undefined and path starts with a number", () => {
expect(updateValueAtPath(null, ["0"], "bar")).toEqual(["bar"]);
expect(updateValueAtPath(undefined, ["0"], "bar")).toEqual(["bar"]);
});
// Object update tests
test("updates a simple object property", () => {
const obj = { name: "John", age: 30 };
expect(updateValueAtPath(obj, ["age"], 31)).toEqual({
name: "John",
age: 31,
});
});
test("updates a nested object property", () => {
const obj = { user: { name: "John", address: { city: "New York" } } };
expect(
updateValueAtPath(obj, ["user", "address", "city"], "Boston"),
).toEqual({ user: { name: "John", address: { city: "Boston" } } });
});
test("creates missing object properties", () => {
const obj = { user: { name: "John" } };
expect(
updateValueAtPath(obj, ["user", "address", "city"], "Boston"),
).toEqual({ user: { name: "John", address: { city: "Boston" } } });
});
// Array update tests
test("updates an array item", () => {
const arr = [1, 2, 3, 4];
expect(updateValueAtPath(arr, ["2"], 5)).toEqual([1, 2, 5, 4]);
});
test("extends an array when index is out of bounds", () => {
const arr = [1, 2, 3];
const result = updateValueAtPath(arr, ["5"], "new") as JsonValue[];
// Check overall array structure
expect(result).toEqual([1, 2, 3, null, null, "new"]);
// Explicitly verify that indices 3 and 4 contain null, not undefined
expect(result[3]).toBe(null);
expect(result[4]).toBe(null);
// Verify these aren't "holes" in the array (important distinction)
expect(3 in result).toBe(true);
expect(4 in result).toBe(true);
// Verify the array has the correct length
expect(result.length).toBe(6);
// Verify the array doesn't have holes by checking every index exists
expect(result.every((_, index: number) => index in result)).toBe(true);
});
test("updates a nested array item", () => {
const obj = { users: [{ name: "John" }, { name: "Jane" }] };
expect(updateValueAtPath(obj, ["users", "1", "name"], "Janet")).toEqual({
users: [{ name: "John" }, { name: "Janet" }],
});
});
// Error handling tests
test("returns original value when trying to update a primitive with a path", () => {
const spy = jest.spyOn(console, "error").mockImplementation();
const result = updateValueAtPath("string", ["foo"], "bar");
expect(result).toBe("string");
expect(spy).toHaveBeenCalled();
spy.mockRestore();
});
test("returns original array when index is invalid", () => {
const spy = jest.spyOn(console, "error").mockImplementation();
const arr = [1, 2, 3];
expect(updateValueAtPath(arr, ["invalid"], 4)).toEqual(arr);
expect(spy).toHaveBeenCalled();
spy.mockRestore();
});
test("returns original array when index is negative", () => {
const spy = jest.spyOn(console, "error").mockImplementation();
const arr = [1, 2, 3];
expect(updateValueAtPath(arr, ["-1"], 4)).toEqual(arr);
expect(spy).toHaveBeenCalled();
spy.mockRestore();
});
test("handles sparse arrays correctly by filling holes with null", () => {
// Create a sparse array by deleting an element
const sparseArr = [1, 2, 3];
delete sparseArr[1]; // Now sparseArr is [1, <1 empty item>, 3]
// Update a value beyond the array length
const result = updateValueAtPath(sparseArr, ["5"], "new") as JsonValue[];
// Check overall array structure
expect(result).toEqual([1, null, 3, null, null, "new"]);
// Explicitly verify that index 1 (the hole) contains null, not undefined
expect(result[1]).toBe(null);
// Verify this isn't a hole in the array
expect(1 in result).toBe(true);
// Verify all indices contain null (not undefined)
expect(result[1]).not.toBe(undefined);
expect(result[3]).toBe(null);
expect(result[4]).toBe(null);
});
});
describe("getValueAtPath", () => {
test("returns the original value when path is empty", () => {
const obj = { foo: "bar" };
expect(getValueAtPath(obj, [])).toBe(obj);
});
test("returns the value at a simple path", () => {
const obj = { name: "John", age: 30 };
expect(getValueAtPath(obj, ["name"])).toBe("John");
});
test("returns the value at a nested path", () => {
const obj = { user: { name: "John", address: { city: "New York" } } };
expect(getValueAtPath(obj, ["user", "address", "city"])).toBe("New York");
});
test("returns default value when path does not exist", () => {
const obj = { user: { name: "John" } };
expect(getValueAtPath(obj, ["user", "address", "city"], "Unknown")).toBe(
"Unknown",
);
});
test("returns default value when input is null/undefined", () => {
expect(getValueAtPath(null, ["foo"], "default")).toBe("default");
expect(getValueAtPath(undefined, ["foo"], "default")).toBe("default");
});
test("handles array indices correctly", () => {
const arr = ["a", "b", "c"];
expect(getValueAtPath(arr, ["1"])).toBe("b");
});
test("returns default value for out of bounds array indices", () => {
const arr = ["a", "b", "c"];
expect(getValueAtPath(arr, ["5"], "default")).toBe("default");
});
test("returns default value for invalid array indices", () => {
const arr = ["a", "b", "c"];
expect(getValueAtPath(arr, ["invalid"], "default")).toBe("default");
});
test("navigates through mixed object and array paths", () => {
const obj = { users: [{ name: "John" }, { name: "Jane" }] };
expect(getValueAtPath(obj, ["users", "1", "name"])).toBe("Jane");
});
});

View File

@@ -1,78 +0,0 @@
import {
generateOAuthErrorDescription,
parseOAuthCallbackParams,
} from "@/utils/oauthUtils.ts";
describe("parseOAuthCallbackParams", () => {
it("Returns successful: true and code when present", () => {
expect(parseOAuthCallbackParams("?code=fake-code")).toEqual({
successful: true,
code: "fake-code",
});
});
it("Returns successful: false and error when error is present", () => {
expect(parseOAuthCallbackParams("?error=access_denied")).toEqual({
successful: false,
error: "access_denied",
error_description: null,
error_uri: null,
});
});
it("Returns optional error metadata fields when present", () => {
const search =
"?error=access_denied&" +
"error_description=User%20Denied%20Request&" +
"error_uri=https%3A%2F%2Fexample.com%2Ferror-docs";
expect(parseOAuthCallbackParams(search)).toEqual({
successful: false,
error: "access_denied",
error_description: "User Denied Request",
error_uri: "https://example.com/error-docs",
});
});
it("Returns error when nothing present", () => {
expect(parseOAuthCallbackParams("?")).toEqual({
successful: false,
error: "invalid_request",
error_description: "Missing code or error in response",
error_uri: null,
});
});
});
describe("generateOAuthErrorDescription", () => {
it("When only error is present", () => {
expect(
generateOAuthErrorDescription({
successful: false,
error: "invalid_request",
error_description: null,
error_uri: null,
}),
).toBe("Error: invalid_request.");
});
it("When error description is present", () => {
expect(
generateOAuthErrorDescription({
successful: false,
error: "invalid_request",
error_description: "The request could not be completed as dialed",
error_uri: null,
}),
).toEqual(
"Error: invalid_request.\nDetails: The request could not be completed as dialed.",
);
});
it("When all fields present", () => {
expect(
generateOAuthErrorDescription({
successful: false,
error: "invalid_request",
error_description: "The request could not be completed as dialed",
error_uri: "https://example.com/error-docs",
}),
).toEqual(
"Error: invalid_request.\nDetails: The request could not be completed as dialed.\nMore info: https://example.com/error-docs.",
);
});
});

View File

@@ -1,139 +0,0 @@
import { generateDefaultValue, formatFieldLabel } from "../schemaUtils";
import type { JsonSchemaType } from "../jsonUtils";
describe("generateDefaultValue", () => {
test("generates default string", () => {
expect(generateDefaultValue({ type: "string", required: true })).toBe("");
});
test("generates default number", () => {
expect(generateDefaultValue({ type: "number", required: true })).toBe(0);
});
test("generates default integer", () => {
expect(generateDefaultValue({ type: "integer", required: true })).toBe(0);
});
test("generates default boolean", () => {
expect(generateDefaultValue({ type: "boolean", required: true })).toBe(
false,
);
});
test("generates default array", () => {
expect(generateDefaultValue({ type: "array", required: true })).toEqual([]);
});
test("generates default empty object", () => {
expect(generateDefaultValue({ type: "object", required: true })).toEqual(
{},
);
});
test("generates default null for unknown types", () => {
// @ts-expect-error Testing with invalid type
expect(generateDefaultValue({ type: "unknown", required: true })).toBe(
null,
);
});
test("generates empty array for non-required array", () => {
expect(generateDefaultValue({ type: "array", required: false })).toEqual(
[],
);
});
test("generates empty object for non-required object", () => {
expect(generateDefaultValue({ type: "object", required: false })).toEqual(
{},
);
});
test("generates null for non-required primitive types", () => {
expect(generateDefaultValue({ type: "string", required: false })).toBe(
undefined,
);
expect(generateDefaultValue({ type: "number", required: false })).toBe(
undefined,
);
expect(generateDefaultValue({ type: "boolean", required: false })).toBe(
undefined,
);
});
test("generates object with properties", () => {
const schema: JsonSchemaType = {
type: "object",
required: true,
properties: {
name: { type: "string", required: true },
age: { type: "number", required: true },
isActive: { type: "boolean", required: true },
},
};
expect(generateDefaultValue(schema)).toEqual({
name: "",
age: 0,
isActive: false,
});
});
test("handles nested objects", () => {
const schema: JsonSchemaType = {
type: "object",
required: true,
properties: {
user: {
type: "object",
required: true,
properties: {
name: { type: "string", required: true },
address: {
type: "object",
required: true,
properties: {
city: { type: "string", required: true },
},
},
},
},
},
};
expect(generateDefaultValue(schema)).toEqual({
user: {
name: "",
address: {
city: "",
},
},
});
});
test("uses schema default value when provided", () => {
expect(generateDefaultValue({ type: "string", default: "test" })).toBe(
"test",
);
});
});
describe("formatFieldLabel", () => {
test("formats camelCase", () => {
expect(formatFieldLabel("firstName")).toBe("First Name");
});
test("formats snake_case", () => {
expect(formatFieldLabel("first_name")).toBe("First name");
});
test("formats single word", () => {
expect(formatFieldLabel("name")).toBe("Name");
});
test("formats mixed case with underscores", () => {
expect(formatFieldLabel("user_firstName")).toBe("User first Name");
});
test("handles empty string", () => {
expect(formatFieldLabel("")).toBe("");
});
});

View File

@@ -1,126 +0,0 @@
import { InspectorConfig } from "@/lib/configurationTypes";
import {
DEFAULT_MCP_PROXY_LISTEN_PORT,
DEFAULT_INSPECTOR_CONFIG,
} from "@/lib/constants";
export const getMCPProxyAddress = (config: InspectorConfig): string => {
const proxyFullAddress = config.MCP_PROXY_FULL_ADDRESS.value as string;
if (proxyFullAddress) {
return proxyFullAddress;
}
return `${window.location.protocol}//${window.location.hostname}:${DEFAULT_MCP_PROXY_LISTEN_PORT}`;
};
export const getMCPServerRequestTimeout = (config: InspectorConfig): number => {
return config.MCP_SERVER_REQUEST_TIMEOUT.value as number;
};
export const resetRequestTimeoutOnProgress = (
config: InspectorConfig,
): boolean => {
return config.MCP_REQUEST_TIMEOUT_RESET_ON_PROGRESS.value as boolean;
};
export const getMCPServerRequestMaxTotalTimeout = (
config: InspectorConfig,
): number => {
return config.MCP_REQUEST_MAX_TOTAL_TIMEOUT.value as number;
};
const getSearchParam = (key: string): string | null => {
try {
const url = new URL(window.location.href);
return url.searchParams.get(key);
} catch {
return null;
}
};
export const getInitialTransportType = ():
| "stdio"
| "sse"
| "streamable-http" => {
const param = getSearchParam("transport");
if (param === "stdio" || param === "sse" || param === "streamable-http") {
return param;
}
return (
(localStorage.getItem("lastTransportType") as
| "stdio"
| "sse"
| "streamable-http") || "stdio"
);
};
export const getInitialSseUrl = (): string => {
const param = getSearchParam("serverUrl");
if (param) return param;
return localStorage.getItem("lastSseUrl") || "http://localhost:3001/sse";
};
export const getInitialCommand = (): string => {
const param = getSearchParam("serverCommand");
if (param) return param;
return localStorage.getItem("lastCommand") || "mcp-server-everything";
};
export const getInitialArgs = (): string => {
const param = getSearchParam("serverArgs");
if (param) return param;
return localStorage.getItem("lastArgs") || "";
};
// Returns a map of config key -> value from query params if present
export const getConfigOverridesFromQueryParams = (
defaultConfig: InspectorConfig,
): Partial<InspectorConfig> => {
const url = new URL(window.location.href);
const overrides: Partial<InspectorConfig> = {};
for (const key of Object.keys(defaultConfig)) {
const param = url.searchParams.get(key);
if (param !== null) {
// Try to coerce to correct type based on default value
const defaultValue = defaultConfig[key as keyof InspectorConfig].value;
let value: string | number | boolean = param;
if (typeof defaultValue === "number") {
value = Number(param);
} else if (typeof defaultValue === "boolean") {
value = param === "true";
}
overrides[key as keyof InspectorConfig] = {
...defaultConfig[key as keyof InspectorConfig],
value,
};
}
}
return overrides;
};
export const initializeInspectorConfig = (
localStorageKey: string,
): InspectorConfig => {
const savedConfig = localStorage.getItem(localStorageKey);
let baseConfig: InspectorConfig;
if (savedConfig) {
// merge default config with saved config
const mergedConfig = {
...DEFAULT_INSPECTOR_CONFIG,
...JSON.parse(savedConfig),
} as InspectorConfig;
// update description of keys to match the new description (in case of any updates to the default config description)
for (const [key, value] of Object.entries(mergedConfig)) {
mergedConfig[key as keyof InspectorConfig] = {
...value,
label: DEFAULT_INSPECTOR_CONFIG[key as keyof InspectorConfig].label,
};
}
baseConfig = mergedConfig;
} else {
baseConfig = DEFAULT_INSPECTOR_CONFIG;
}
// Apply query param overrides
const overrides = getConfigOverridesFromQueryParams(DEFAULT_INSPECTOR_CONFIG);
return { ...baseConfig, ...overrides };
};

View File

@@ -1,16 +0,0 @@
// Utility function to escape Unicode characters
export function escapeUnicode(obj: unknown): string {
return JSON.stringify(
obj,
(_key: string, value) => {
if (typeof value === "string") {
// Replace non-ASCII characters with their Unicode escape sequences
return value.replace(/[^\0-\x7F]/g, (char) => {
return "\\u" + ("0000" + char.charCodeAt(0).toString(16)).slice(-4);
});
}
return value;
},
2,
);
}

View File

@@ -1,208 +0,0 @@
export type JsonValue =
| string
| number
| boolean
| null
| undefined
| JsonValue[]
| { [key: string]: JsonValue };
export type JsonSchemaType = {
type:
| "string"
| "number"
| "integer"
| "boolean"
| "array"
| "object"
| "null";
description?: string;
required?: boolean;
default?: JsonValue;
properties?: Record<string, JsonSchemaType>;
items?: JsonSchemaType;
};
export type JsonObject = { [key: string]: JsonValue };
export type DataType =
| "string"
| "number"
| "bigint"
| "boolean"
| "symbol"
| "undefined"
| "object"
| "function"
| "array"
| "null";
export function getDataType(value: JsonValue): DataType {
if (Array.isArray(value)) return "array";
if (value === null) return "null";
return typeof value;
}
export function tryParseJson(str: string): {
success: boolean;
data: JsonValue;
} {
const trimmed = str.trim();
if (
!(trimmed.startsWith("{") && trimmed.endsWith("}")) &&
!(trimmed.startsWith("[") && trimmed.endsWith("]"))
) {
return { success: false, data: str };
}
try {
return { success: true, data: JSON.parse(str) };
} catch {
return { success: false, data: str };
}
}
/**
* Updates a value at a specific path in a nested JSON structure
* @param obj The original JSON value
* @param path Array of keys/indices representing the path to the value
* @param value The new value to set
* @returns A new JSON value with the updated path
*/
export function updateValueAtPath(
obj: JsonValue,
path: string[],
value: JsonValue,
): JsonValue {
if (path.length === 0) return value;
if (obj === null || obj === undefined) {
obj = !isNaN(Number(path[0])) ? [] : {};
}
if (Array.isArray(obj)) {
return updateArray(obj, path, value);
} else if (typeof obj === "object" && obj !== null) {
return updateObject(obj as JsonObject, path, value);
} else {
console.error(
`Cannot update path ${path.join(".")} in non-object/array value:`,
obj,
);
return obj;
}
}
/**
* Updates an array at a specific path
*/
function updateArray(
array: JsonValue[],
path: string[],
value: JsonValue,
): JsonValue[] {
const [index, ...restPath] = path;
const arrayIndex = Number(index);
if (isNaN(arrayIndex)) {
console.error(`Invalid array index: ${index}`);
return array;
}
if (arrayIndex < 0) {
console.error(`Array index out of bounds: ${arrayIndex} < 0`);
return array;
}
let newArray: JsonValue[] = [];
for (let i = 0; i < array.length; i++) {
newArray[i] = i in array ? array[i] : null;
}
if (arrayIndex >= newArray.length) {
const extendedArray: JsonValue[] = new Array(arrayIndex).fill(null);
// Copy over the existing elements (now guaranteed to be dense)
for (let i = 0; i < newArray.length; i++) {
extendedArray[i] = newArray[i];
}
newArray = extendedArray;
}
if (restPath.length === 0) {
newArray[arrayIndex] = value;
} else {
newArray[arrayIndex] = updateValueAtPath(
newArray[arrayIndex],
restPath,
value,
);
}
return newArray;
}
/**
* Updates an object at a specific path
*/
function updateObject(
obj: JsonObject,
path: string[],
value: JsonValue,
): JsonObject {
const [key, ...restPath] = path;
// Validate object key
if (typeof key !== "string") {
console.error(`Invalid object key: ${key}`);
return obj;
}
const newObj = { ...obj };
if (restPath.length === 0) {
newObj[key] = value;
} else {
// Ensure key exists
if (!(key in newObj)) {
newObj[key] = {};
}
newObj[key] = updateValueAtPath(newObj[key], restPath, value);
}
return newObj;
}
/**
* Gets a value at a specific path in a nested JSON structure
* @param obj The JSON value to traverse
* @param path Array of keys/indices representing the path to the value
* @param defaultValue Value to return if path doesn't exist
* @returns The value at the path, or defaultValue if not found
*/
export function getValueAtPath(
obj: JsonValue,
path: string[],
defaultValue: JsonValue = null,
): JsonValue {
if (path.length === 0) return obj;
const [first, ...rest] = path;
if (obj === null || obj === undefined) {
return defaultValue;
}
if (Array.isArray(obj)) {
const index = Number(first);
if (isNaN(index) || index < 0 || index >= obj.length) {
return defaultValue;
}
return getValueAtPath(obj[index], rest, defaultValue);
}
if (typeof obj === "object" && obj !== null) {
if (!(first in obj)) {
return defaultValue;
}
return getValueAtPath((obj as JsonObject)[first], rest, defaultValue);
}
return defaultValue;
}

View File

@@ -1,65 +0,0 @@
// The parsed query parameters returned by the Authorization Server
// representing either a valid authorization_code or an error
// ref: https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1-12#section-4.1.2
type CallbackParams =
| {
successful: true;
// The authorization code is generated by the authorization server.
code: string;
}
| {
successful: false;
// The OAuth 2.1 Error Code.
// Usually one of:
// ```
// invalid_request, unauthorized_client, access_denied, unsupported_response_type,
// invalid_scope, server_error, temporarily_unavailable
// ```
error: string;
// Human-readable ASCII text providing additional information, used to assist the
// developer in understanding the error that occurred.
error_description: string | null;
// A URI identifying a human-readable web page with information about the error,
// used to provide the client developer with additional information about the error.
error_uri: string | null;
};
export const parseOAuthCallbackParams = (location: string): CallbackParams => {
const params = new URLSearchParams(location);
const code = params.get("code");
if (code) {
return { successful: true, code };
}
const error = params.get("error");
const error_description = params.get("error_description");
const error_uri = params.get("error_uri");
if (error) {
return { successful: false, error, error_description, error_uri };
}
return {
successful: false,
error: "invalid_request",
error_description: "Missing code or error in response",
error_uri: null,
};
};
export const generateOAuthErrorDescription = (
params: Extract<CallbackParams, { successful: false }>,
): string => {
const error = params.error;
const errorDescription = params.error_description;
const errorUri = params.error_uri;
return [
`Error: ${error}.`,
errorDescription ? `Details: ${errorDescription}.` : "",
errorUri ? `More info: ${errorUri}.` : "",
]
.filter(Boolean)
.join("\n");
};

Some files were not shown because too many files have changed in this diff Show More