diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 2665e9d9..8efb6773 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -14,6 +14,9 @@ jobs: steps: - uses: actions/checkout@v4 + - name: Check formatting + run: npx prettier --check . + - uses: actions/setup-node@v4 with: node-version: 18 @@ -22,6 +25,15 @@ jobs: # Working around https://github.com/npm/cli/issues/4828 # - run: npm ci - run: npm install --no-package-lock + + - name: Check linting + working-directory: ./client + run: npm run lint + + - name: Run client tests + working-directory: ./client + run: npm test + - run: npm run build publish: diff --git a/.gitignore b/.gitignore index 0f4928e8..60faf653 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,11 @@ .DS_Store -node_modules +.vscode +.idea +node_modules/ +*-workspace/ server/build client/dist client/tsconfig.app.tsbuildinfo client/tsconfig.node.tsbuildinfo +cli/build +test-output diff --git a/.prettierignore b/.prettierignore index b7c83c0e..c8824c9a 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,2 +1,4 @@ packages server/build +CODE_OF_CONDUCT.md +SECURITY.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..e62fd188 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,33 @@ +# 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 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9ad5699f..72502f96 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -7,13 +7,13 @@ Thanks for your interest in contributing! This guide explains how to get involve 1. Fork the repository and clone it locally 2. Install dependencies with `npm install` 3. Run `npm run dev` to start both client and server in development mode -4. Use the web UI at http://localhost:5173 to interact with the inspector +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 -3. Test changes locally +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 diff --git a/README.md b/README.md index c5e6b2e4..d7c34b97 100644 --- a/README.md +++ b/README.md @@ -2,31 +2,183 @@ The MCP inspector is a developer tool for testing and debugging MCP servers. -![MCP Inspector Screenshot](mcp-inspector.png) +![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`: ```bash -npx @modelcontextprotocol/inspector build/index.js +npx @modelcontextprotocol/inspector node build/index.js ``` -You can also pass arguments along which will get passed as arguments to your MCP server: - -``` -npx @modelcontextprotocol/inspector build/index.js arg1 arg2 ... -``` - -The inspector runs both a client UI (default port 5173) and an MCP proxy server (default port 3000). Open the client UI in your browser to use the inspector. You can customize the ports if needed: +You can pass both arguments and environment variables to your MCP server. Arguments are passed directly to your server, while environment variables can be set using the `-e` flag: ```bash -CLIENT_PORT=8080 SERVER_PORT=9000 npx @modelcontextprotocol/inspector build/index.js +# 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 ``` -For more details on ways to use the inspector, see the [Inspector section of the MCP docs site](https://modelcontextprotocol.io/docs/tools/inspector). +The inspector runs both an MCP Inspector (MCPI) client UI (default port 6274) and an MCP Proxy (MCPP) server (default port 6277). Open the MCPI client UI in your browser to use the inspector. (These ports are derived from the T9 dialpad mapping of MCPI and MCPP respectively, as a mnemonic). You can customize the ports if needed: + +```bash +CLIENT_PORT=8080 SERVER_PORT=9000 npx @modelcontextprotocol/inspector node build/index.js +``` + +For more details on ways to use the inspector, see the [Inspector section of the MCP docs site](https://modelcontextprotocol.io/docs/tools/inspector). For help with debugging, see the [Debugging guide](https://modelcontextprotocol.io/docs/tools/debugging). + +### Servers File Export + +The MCP Inspector provides convenient buttons to export server launch configurations for use in clients such as Cursor, Claude Code, or the Inspector's CLI. The file is usually called `mcp.json`. + +- **Server Entry** - Copies a single server configuration entry to your clipboard. This can be added to your `mcp.json` file inside the `mcpServers` object with your preferred server name. + + **STDIO transport example:** + + ```json + { + "command": "node", + "args": ["build/index.js", "--debug"], + "env": { + "API_KEY": "your-api-key", + "DEBUG": "true" + } + } + ``` + + **SSE transport example:** + + ```json + { + "type": "sse", + "url": "http://localhost:3000/events", + "note": "For SSE connections, add this URL directly in Client" + } + ``` + +- **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 @@ -38,6 +190,13 @@ Development mode: npm run dev ``` +> **Note for Windows users:** +> On Windows, use the following command instead: +> +> ```bash +> npm run dev:windows +> ``` + Production mode: ```bash @@ -45,6 +204,57 @@ 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. diff --git a/bin/cli.js b/bin/cli.js deleted file mode 100755 index 2dcc6134..00000000 --- a/bin/cli.js +++ /dev/null @@ -1,88 +0,0 @@ -#!/usr/bin/env node - -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)); -} - -async function main() { - // Get command line arguments - const [, , command, ...mcpServerArgs] = process.argv; - - const inspectorServerPath = resolve( - __dirname, - "..", - "server", - "build", - "index.js", - ); - - // Path to the client entry point - const inspectorClientPath = resolve( - __dirname, - "..", - "client", - "bin", - "cli.js", - ); - - const CLIENT_PORT = process.env.CLIENT_PORT ?? "5173"; - const SERVER_PORT = process.env.SERVER_PORT ?? "3000"; - - console.log("Starting MCP inspector..."); - - const abort = new AbortController(); - - let cancelled = false; - process.on("SIGINT", () => { - cancelled = true; - abort.abort(); - }); - - const server = spawnPromise( - "node", - [ - inspectorServerPath, - ...(command ? [`--env`, command] : []), - ...(mcpServerArgs ? [`--args=${mcpServerArgs.join(" ")}`] : []), - ], - { - env: { ...process.env, PORT: SERVER_PORT }, - signal: abort.signal, - echoOutput: true, - }, - ); - - const client = spawnPromise("node", [inspectorClientPath], { - env: { ...process.env, PORT: CLIENT_PORT }, - signal: abort.signal, - echoOutput: true, - }); - - // Make sure our server/client didn't immediately fail - await Promise.any([server, client, delay(2 * 1000)]); - const portParam = SERVER_PORT === "3000" ? "" : `?proxyPort=${SERVER_PORT}`; - console.log( - `\nπŸ” MCP Inspector is up and running at http://localhost:${CLIENT_PORT}${portParam} πŸš€`, - ); - - try { - await Promise.any([server, client]); - } catch (e) { - if (!cancelled || process.env.DEBUG) throw e; - } - - return 0; -} - -main() - .then((_) => process.exit(0)) - .catch((e) => { - console.error(e); - process.exit(1); - }); diff --git a/cli/package.json b/cli/package.json new file mode 100644 index 00000000..55f12e15 --- /dev/null +++ b/cli/package.json @@ -0,0 +1,28 @@ +{ + "name": "@modelcontextprotocol/inspector-cli", + "version": "0.13.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.5", + "commander": "^13.1.0", + "spawn-rx": "^5.1.2" + } +} diff --git a/cli/scripts/cli-tests.js b/cli/scripts/cli-tests.js new file mode 100755 index 00000000..6e6b56f9 --- /dev/null +++ b/cli/scripts/cli-tests.js @@ -0,0 +1,633 @@ +#!/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); +}); diff --git a/cli/scripts/make-executable.js b/cli/scripts/make-executable.js new file mode 100755 index 00000000..f3b8c902 --- /dev/null +++ b/cli/scripts/make-executable.js @@ -0,0 +1,29 @@ +/** + * Cross-platform script to make a file executable + */ +import { promises as fs } from "fs"; +import { platform } from "os"; +import { execSync } from "child_process"; +import path from "path"; + +const TARGET_FILE = path.resolve("build/cli.js"); + +async function makeExecutable() { + try { + // On Unix-like systems (Linux, macOS), use chmod + if (platform() !== "win32") { + execSync(`chmod +x "${TARGET_FILE}"`); + console.log("Made file executable with chmod"); + } else { + // On Windows, no need to make files "executable" in the Unix sense + // Just ensure the file exists + await fs.access(TARGET_FILE); + console.log("File exists and is accessible on Windows"); + } + } catch (error) { + console.error("Error making file executable:", error); + process.exit(1); + } +} + +makeExecutable(); diff --git a/cli/src/cli.ts b/cli/src/cli.ts new file mode 100644 index 00000000..1f87b516 --- /dev/null +++ b/cli/src/cli.ts @@ -0,0 +1,287 @@ +#!/usr/bin/env node + +import { Command } from "commander"; +import fs from "node:fs"; +import path from "node:path"; +import { dirname, resolve } from "path"; +import { spawnPromise } from "spawn-rx"; +import { fileURLToPath } from "url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +type Args = { + command: string; + args: string[]; + envArgs: Record; + cli: boolean; +}; + +type CliOptions = { + e?: Record; + config?: string; + server?: string; + cli?: boolean; +}; + +type ServerConfig = { + command: string; + args?: string[]; + env?: Record; +}; + +function handleError(error: unknown): never { + let message: string; + + if (error instanceof Error) { + message = error.message; + } else if (typeof error === "string") { + message = error; + } else { + message = "Unknown error"; + } + + console.error(message); + + process.exit(1); +} + +function delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms, true)); +} + +async function runWebClient(args: Args): Promise { + 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; + 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 { + 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 = {}, +): Record { + const parts = value.split("="); + const key = parts[0]; + const val = parts.slice(1).join("="); + + if (val === undefined || val === "") { + throw new Error( + `Invalid parameter format: ${value}. Use key=value format.`, + ); + } + + return { ...previous, [key as string]: val }; +} + +function parseArgs(): Args { + const program = new Command(); + + const argSeparatorIndex = process.argv.indexOf("--"); + let preArgs = process.argv; + let postArgs: string[] = []; + + if (argSeparatorIndex !== -1) { + preArgs = process.argv.slice(0, argSeparatorIndex); + postArgs = process.argv.slice(argSeparatorIndex + 1); + } + + program + .name("inspector-bin") + .allowExcessArguments() + .allowUnknownOption() + .option( + "-e ", + "environment variables in KEY=VALUE format", + parseKeyValuePair, + {}, + ) + .option("--config ", "config file path") + .option("--server ", "server name from config file") + .option("--cli", "enable CLI mode"); + + // 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 { + process.on("uncaughtException", (error) => { + handleError(error); + }); + + try { + const args = parseArgs(); + + if (args.cli) { + runCli(args); + } else { + await runWebClient(args); + } + } catch (error) { + handleError(error); + } +} + +main(); diff --git a/cli/src/client/connection.ts b/cli/src/client/connection.ts new file mode 100644 index 00000000..931f803d --- /dev/null +++ b/cli/src/client/connection.ts @@ -0,0 +1,51 @@ +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js"; +import { McpResponse } from "./types.js"; + +export const validLogLevels = [ + "trace", + "debug", + "info", + "warn", + "error", +] as const; + +export type LogLevel = (typeof validLogLevels)[number]; + +export async function connect( + client: Client, + transport: Transport, +): Promise { + try { + await client.connect(transport); + } catch (error) { + throw new Error( + `Failed to connect to MCP server: ${error instanceof Error ? error.message : String(error)}`, + ); + } +} + +export async function disconnect(transport: Transport): Promise { + try { + await transport.close(); + } catch (error) { + throw new Error( + `Failed to disconnect from MCP server: ${error instanceof Error ? error.message : String(error)}`, + ); + } +} + +// Set logging level +export async function setLoggingLevel( + client: Client, + level: LogLevel, +): Promise { + try { + const response = await client.setLoggingLevel(level as any); + return response; + } catch (error) { + throw new Error( + `Failed to set logging level: ${error instanceof Error ? error.message : String(error)}`, + ); + } +} diff --git a/cli/src/client/index.ts b/cli/src/client/index.ts new file mode 100644 index 00000000..095d716b --- /dev/null +++ b/cli/src/client/index.ts @@ -0,0 +1,6 @@ +// Re-export everything from the client modules +export * from "./connection.js"; +export * from "./prompts.js"; +export * from "./resources.js"; +export * from "./tools.js"; +export * from "./types.js"; diff --git a/cli/src/client/prompts.ts b/cli/src/client/prompts.ts new file mode 100644 index 00000000..0b237496 --- /dev/null +++ b/cli/src/client/prompts.ts @@ -0,0 +1,34 @@ +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { McpResponse } from "./types.js"; + +// List available prompts +export async function listPrompts(client: Client): Promise { + 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, +): Promise { + 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)}`, + ); + } +} diff --git a/cli/src/client/resources.ts b/cli/src/client/resources.ts new file mode 100644 index 00000000..bf33d64d --- /dev/null +++ b/cli/src/client/resources.ts @@ -0,0 +1,43 @@ +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { McpResponse } from "./types.js"; + +// List available resources +export async function listResources(client: Client): Promise { + 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 { + 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 { + 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)}`, + ); + } +} diff --git a/cli/src/client/tools.ts b/cli/src/client/tools.ts new file mode 100644 index 00000000..acdb4871 --- /dev/null +++ b/cli/src/client/tools.ts @@ -0,0 +1,95 @@ +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; + items?: JsonSchemaType; +}; + +export async function listTools(client: Client): Promise { + 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, +): Record { + const result: Record = {}; + const properties = tool.inputSchema.properties || {}; + + for (const [key, value] of Object.entries(params)) { + const paramSchema = properties[key] as JsonSchemaType | undefined; + + if (paramSchema) { + result[key] = convertParameterValue(value, paramSchema); + } else { + // If no schema is found for this parameter, keep it as string + result[key] = value; + } + } + + return result; +} + +export async function callTool( + client: Client, + name: string, + args: Record, +): Promise { + try { + const toolsResponse = await listTools(client); + const tools = toolsResponse.tools as Tool[]; + const tool = tools.find((t) => t.name === name); + + let convertedArgs: Record = args; + + if (tool) { + // Convert parameters based on the tool's schema + 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)}`, + ); + } +} diff --git a/cli/src/client/types.ts b/cli/src/client/types.ts new file mode 100644 index 00000000..bbbe1bf4 --- /dev/null +++ b/cli/src/client/types.ts @@ -0,0 +1 @@ +export type McpResponse = Record; diff --git a/cli/src/error-handler.ts b/cli/src/error-handler.ts new file mode 100644 index 00000000..97257745 --- /dev/null +++ b/cli/src/error-handler.ts @@ -0,0 +1,20 @@ +function formatError(error: unknown): string { + let message: string; + + if (error instanceof Error) { + message = error.message; + } else if (typeof error === "string") { + message = error; + } else { + message = "Unknown error"; + } + + return message; +} + +export function handleError(error: unknown): never { + const errorMessage = formatError(error); + console.error(errorMessage); + + process.exit(1); +} diff --git a/cli/src/index.ts b/cli/src/index.ts new file mode 100644 index 00000000..bda1e8f7 --- /dev/null +++ b/cli/src/index.ts @@ -0,0 +1,253 @@ +#!/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; + uri?: string; + logLevel?: LogLevel; + toolName?: string; + toolArg?: Record; +}; + +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 { + 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 = {}, +): Record { + const parts = value.split("="); + const key = parts[0]; + const val = parts.slice(1).join("="); + + if (val === undefined || val === "") { + throw new Error( + `Invalid parameter format: ${value}. Use key=value format.`, + ); + } + + return { ...previous, [key as string]: val }; +} + +function parseArgs(): Args { + const program = new Command(); + + // Find if there's a -- in the arguments and split them + const argSeparatorIndex = process.argv.indexOf("--"); + let preArgs = process.argv; + let postArgs: string[] = []; + + if (argSeparatorIndex !== -1) { + preArgs = process.argv.slice(0, argSeparatorIndex); + postArgs = process.argv.slice(argSeparatorIndex + 1); + } + + program + .name("inspector-cli") + .allowUnknownOption() + .argument("", "Command and arguments or URL of the MCP server") + // + // Method selection + // + .option("--method ", "Method to invoke") + // + // Tool-related options + // + .option("--tool-name ", "Tool name (for tools/call method)") + .option( + "--tool-arg ", + "Tool argument as key=value pair", + parseKeyValuePair, + {}, + ) + // + // Resource-related options + // + .option("--uri ", "URI of the resource (for resources/read method)") + // + // Prompt-related options + // + .option( + "--prompt-name ", + "Name of the prompt (for prompts/get method)", + ) + .option( + "--prompt-args ", + "Prompt arguments as key=value pairs", + parseKeyValuePair, + {}, + ) + // + // Logging options + // + .option( + "--log-level ", + "Logging level (for logging/setLevel method)", + (value: string) => { + if (!validLogLevels.includes(value as any)) { + throw new Error( + `Invalid log level: ${value}. Valid levels are: ${validLogLevels.join(", ")}`, + ); + } + + return value as LogLevel; + }, + ); + + // Parse only the arguments before -- + program.parse(preArgs); + + const options = program.opts() as Omit; + 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 { + process.on("uncaughtException", (error) => { + handleError(error); + }); + + try { + const args = parseArgs(); + await callMethod(args); + } catch (error) { + handleError(error); + } +} + +main(); diff --git a/cli/src/transport.ts b/cli/src/transport.ts new file mode 100644 index 00000000..e693f246 --- /dev/null +++ b/cli/src/transport.ts @@ -0,0 +1,76 @@ +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 = {}; + + for (const [key, value] of Object.entries(process.env)) { + if (value !== undefined) { + processEnv[key] = value; + } + } + + const defaultEnv = getDefaultEnvironment(); + + const env: Record = { + ...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)}`, + ); + } +} diff --git a/cli/tsconfig.json b/cli/tsconfig.json new file mode 100644 index 00000000..effa34f2 --- /dev/null +++ b/cli/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "outDir": "./build", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "noUncheckedIndexedAccess": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "packages", "**/*.spec.ts", "build"] +} diff --git a/client/bin/cli.js b/client/bin/cli.js deleted file mode 100755 index ae4967fc..00000000 --- a/client/bin/cli.js +++ /dev/null @@ -1,16 +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) => { - return handler(request, response, { public: distPath }); -}); - -const port = process.env.PORT || 5173; -server.listen(port, () => {}); diff --git a/client/bin/client.js b/client/bin/client.js new file mode 100755 index 00000000..7179e19e --- /dev/null +++ b/client/bin/client.js @@ -0,0 +1,57 @@ +#!/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); diff --git a/client/bin/start.js b/client/bin/start.js new file mode 100755 index 00000000..8e383b38 --- /dev/null +++ b/client/bin/start.js @@ -0,0 +1,124 @@ +#!/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); + }); diff --git a/client/jest.config.cjs b/client/jest.config.cjs new file mode 100644 index 00000000..b4d81cfc --- /dev/null +++ b/client/jest.config.cjs @@ -0,0 +1,33 @@ +module.exports = { + preset: "ts-jest", + testEnvironment: "jest-fixed-jsdom", + moduleNameMapper: { + "^@/(.*)$": "/src/$1", + "\\.css$": "/src/__mocks__/styleMock.js", + }, + transform: { + "^.+\\.tsx?$": [ + "ts-jest", + { + jsx: "react-jsx", + tsconfig: "tsconfig.jest.json", + }, + ], + }, + 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)$", + ], +}; diff --git a/client/package.json b/client/package.json index 888495c5..5b5ec19a 100644 --- a/client/package.json +++ b/client/package.json @@ -1,6 +1,6 @@ { "name": "@modelcontextprotocol/inspector-client", - "version": "0.3.0", + "version": "0.13.0", "description": "Client-side application for the Model Context Protocol inspector", "license": "MIT", "author": "Anthropic, PBC (https://anthropic.com)", @@ -8,31 +8,41 @@ "bugs": "https://github.com/modelcontextprotocol/inspector/issues", "type": "module", "bin": { - "mcp-inspector-client": "./bin/cli.js" + "mcp-inspector-client": "./bin/start.js" }, "files": [ "bin", "dist" ], "scripts": { - "dev": "vite", + "dev": "vite --port 6274", "build": "tsc -b && vite build", "lint": "eslint .", - "preview": "vite preview" + "preview": "vite preview --port 6274", + "test": "jest --config jest.config.cjs", + "test:watch": "jest --config jest.config.cjs --watch" }, "dependencies": { - "@modelcontextprotocol/sdk": "^1.0.3", + "@modelcontextprotocol/sdk": "^1.11.5", + "@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", "react": "^18.3.1", "react-dom": "^18.3.1", - "react-toastify": "^10.0.6", + "react-simple-code-editor": "^0.14.1", "serve-handler": "^6.1.6", "tailwind-merge": "^2.5.3", "tailwindcss-animate": "^1.0.7", @@ -40,20 +50,28 @@ }, "devDependencies": { "@eslint/js": "^9.11.1", + "@testing-library/jest-dom": "^6.6.3", + "@testing-library/react": "^16.2.0", + "@types/jest": "^29.5.14", "@types/node": "^22.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": "^5.4.8" + "vite": "^6.3.0" } } diff --git a/client/src/App.tsx b/client/src/App.tsx index 2c84377f..68065e24 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -1,50 +1,51 @@ -import { Client } from "@modelcontextprotocol/sdk/client/index.js"; -import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js"; import { - ClientNotification, ClientRequest, CompatibilityCallToolResult, CompatibilityCallToolResultSchema, - CreateMessageRequestSchema, CreateMessageResult, EmptyResultSchema, GetPromptResultSchema, ListPromptsResultSchema, ListResourcesResultSchema, ListResourceTemplatesResultSchema, - ListRootsRequestSchema, ListToolsResultSchema, - ProgressNotificationSchema, ReadResourceResultSchema, - Request, Resource, ResourceTemplate, - Result, Root, ServerNotification, Tool, + LoggingLevel, } from "@modelcontextprotocol/sdk/types.js"; -import { useCallback, useEffect, useRef, useState } from "react"; +import { OAuthTokensSchema } from "@modelcontextprotocol/sdk/shared/auth.js"; +import { SESSION_KEYS, getServerSpecificKey } from "./lib/constants"; +import { AuthDebuggerState } from "./lib/auth-types"; +import React, { + Suspense, + useCallback, + useEffect, + useRef, + useState, +} from "react"; +import { useConnection } from "./lib/hooks/useConnection"; +import { useDraggablePane } from "./lib/hooks/useDraggablePane"; +import { StdErrNotification } from "./lib/notificationTypes"; -import { - Notification, - StdErrNotification, - StdErrNotificationSchema, -} from "./lib/notificationTypes"; - -import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Button } from "@/components/ui/button"; import { Bell, Files, FolderTree, Hammer, Hash, + Key, MessageSquare, } from "lucide-react"; -import { toast } from "react-toastify"; -import { ZodType } from "zod"; +import { z } from "zod"; import "./App.css"; +import AuthDebugger from "./components/AuthDebugger"; import ConsoleTab from "./components/ConsoleTab"; import HistoryAndNotifications from "./components/History"; import PingTab from "./components/PingTab"; @@ -54,18 +55,19 @@ import RootsTab from "./components/RootsTab"; import SamplingTab, { PendingRequest } from "./components/SamplingTab"; import Sidebar from "./components/Sidebar"; import ToolsTab from "./components/ToolsTab"; +import { InspectorConfig } from "./lib/configurationTypes"; +import { + getMCPProxyAddress, + getInitialSseUrl, + getInitialTransportType, + getInitialCommand, + getInitialArgs, + initializeInspectorConfig, +} from "./utils/configUtils"; -const DEFAULT_REQUEST_TIMEOUT_MSEC = 10000; - -const params = new URLSearchParams(window.location.search); -const PROXY_PORT = params.get("proxyPort") ?? "3000"; -const REQUEST_TIMEOUT = parseInt(params.get("timeout") ?? "") || DEFAULT_REQUEST_TIMEOUT_MSEC; -const PROXY_SERVER_URL = `http://localhost:${PROXY_PORT}`; +const CONFIG_LOCAL_STORAGE_KEY = "inspectorConfig_v1"; const App = () => { - const [connectionStatus, setConnectionStatus] = useState< - "disconnected" | "connected" | "error" - >("disconnected"); const [resources, setResources] = useState([]); const [resourceTemplates, setResourceTemplates] = useState< ResourceTemplate[] @@ -81,19 +83,14 @@ const App = () => { prompts: null, tools: null, }); - const [command, setCommand] = useState(() => { - return localStorage.getItem("lastCommand") || "mcp-server-everything"; - }); - const [args, setArgs] = useState(() => { - return localStorage.getItem("lastArgs") || ""; - }); + const [command, setCommand] = useState(getInitialCommand); + const [args, setArgs] = useState(getInitialArgs); - const [sseUrl, setSseUrl] = useState("http://localhost:3001/sse"); - const [transportType, setTransportType] = useState<"stdio" | "sse">("stdio"); - const [requestHistory, setRequestHistory] = useState< - { request: string; response?: string }[] - >([]); - const [mcpClient, setMcpClient] = useState(null); + const [sseUrl, setSseUrl] = useState(getInitialSseUrl); + const [transportType, setTransportType] = useState< + "stdio" | "sse" | "streamable-http" + >(getInitialTransportType); + const [logLevel, setLogLevel] = useState("debug"); const [notifications, setNotifications] = useState([]); const [stdErrNotifications, setStdErrNotifications] = useState< StdErrNotification[] @@ -101,6 +98,17 @@ const App = () => { const [roots, setRoots] = useState([]); const [env, setEnv] = useState>({}); + const [config, setConfig] = useState(() => + initializeInspectorConfig(CONFIG_LOCAL_STORAGE_KEY), + ); + const [bearerToken, setBearerToken] = useState(() => { + return localStorage.getItem("lastBearerToken") || ""; + }); + + const [headerName, setHeaderName] = useState(() => { + return localStorage.getItem("lastHeaderName") || ""; + }); + const [pendingSampleRequests, setPendingSampleRequests] = useState< Array< PendingRequest & { @@ -109,9 +117,209 @@ const App = () => { } > >([]); + const [isAuthDebuggerVisible, setIsAuthDebuggerVisible] = useState(false); + + // Auth debugger state + const [authState, setAuthState] = useState({ + isInitiatingAuth: false, + oauthTokens: null, + loading: true, + oauthStep: "metadata_discovery", + oauthMetadata: null, + oauthClientInfo: null, + authorizationUrl: null, + authorizationCode: "", + latestError: null, + statusMessage: null, + validationError: null, + }); + + // Helper function to update specific auth state properties + const updateAuthState = (updates: Partial) => { + setAuthState((prev) => ({ ...prev, ...updates })); + }; const nextRequestId = useRef(0); const rootsRef = useRef([]); + const [selectedResource, setSelectedResource] = useState( + null, + ); + const [resourceSubscriptions, setResourceSubscriptions] = useState< + Set + >(new Set()); + + const [selectedPrompt, setSelectedPrompt] = useState(null); + const [selectedTool, setSelectedTool] = useState(null); + const [nextResourceCursor, setNextResourceCursor] = useState< + string | undefined + >(); + const [nextResourceTemplateCursor, setNextResourceTemplateCursor] = useState< + string | undefined + >(); + const [nextPromptCursor, setNextPromptCursor] = useState< + string | undefined + >(); + const [nextToolCursor, setNextToolCursor] = useState(); + const progressTokenRef = useRef(0); + + const { height: historyPaneHeight, handleDragStart } = useDraggablePane(300); + + const { + connectionStatus, + serverCapabilities, + mcpClient, + requestHistory, + makeRequest, + sendNotification, + handleCompletion, + completionsSupported, + connect: connectMcpServer, + disconnect: disconnectMcpServer, + } = useConnection({ + transportType, + command, + args, + sseUrl, + env, + bearerToken, + headerName, + config, + onNotification: (notification) => { + setNotifications((prev) => [...prev, notification as ServerNotification]); + }, + onStdErrNotification: (notification) => { + setStdErrNotifications((prev) => [ + ...prev, + notification as StdErrNotification, + ]); + }, + onPendingRequest: (request, resolve, reject) => { + setPendingSampleRequests((prev) => [ + ...prev, + { id: nextRequestId.current++, request, resolve, reject }, + ]); + }, + getRoots: () => rootsRef.current, + }); + + useEffect(() => { + localStorage.setItem("lastCommand", command); + }, [command]); + + useEffect(() => { + localStorage.setItem("lastArgs", args); + }, [args]); + + useEffect(() => { + localStorage.setItem("lastSseUrl", sseUrl); + }, [sseUrl]); + + useEffect(() => { + localStorage.setItem("lastTransportType", transportType); + }, [transportType]); + + useEffect(() => { + localStorage.setItem("lastBearerToken", bearerToken); + }, [bearerToken]); + + useEffect(() => { + localStorage.setItem("lastHeaderName", headerName); + }, [headerName]); + + useEffect(() => { + localStorage.setItem(CONFIG_LOCAL_STORAGE_KEY, JSON.stringify(config)); + }, [config]); + + // Auto-connect to previously saved serverURL after OAuth callback + const onOAuthConnect = useCallback( + (serverUrl: string) => { + setSseUrl(serverUrl); + setIsAuthDebuggerVisible(false); + void connectMcpServer(); + }, + [connectMcpServer], + ); + + // Update OAuth debug state during debug callback + const onOAuthDebugConnect = useCallback( + ({ + authorizationCode, + errorMsg, + }: { + authorizationCode?: string; + errorMsg?: string; + }) => { + setIsAuthDebuggerVisible(true); + if (authorizationCode) { + updateAuthState({ + authorizationCode, + oauthStep: "token_request", + }); + } + if (errorMsg) { + updateAuthState({ + latestError: new Error(errorMsg), + }); + } + }, + [], + ); + + // Load OAuth tokens when sseUrl changes + useEffect(() => { + const loadOAuthTokens = async () => { + try { + if (sseUrl) { + const key = getServerSpecificKey(SESSION_KEYS.TOKENS, sseUrl); + const tokens = sessionStorage.getItem(key); + if (tokens) { + const parsedTokens = await OAuthTokensSchema.parseAsync( + JSON.parse(tokens), + ); + updateAuthState({ + oauthTokens: parsedTokens, + oauthStep: "complete", + }); + } + } + } catch (error) { + console.error("Error loading OAuth tokens:", error); + } finally { + updateAuthState({ loading: false }); + } + }; + + loadOAuthTokens(); + }, [sseUrl]); + + useEffect(() => { + fetch(`${getMCPProxyAddress(config)}/config`) + .then((response) => response.json()) + .then((data) => { + setEnv(data.defaultEnvironment); + if (data.defaultCommand) { + setCommand(data.defaultCommand); + } + if (data.defaultArgs) { + setArgs(data.defaultArgs); + } + }) + .catch((error) => + console.error("Error fetching default environment:", error), + ); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + useEffect(() => { + rootsRef.current = roots; + }, [roots]); + + useEffect(() => { + if (!window.location.hash) { + window.location.hash = "resources"; + } + }, []); + const handleApproveSampling = (id: number, result: CreateMessageResult) => { setPendingSampleRequests((prev) => { const request = prev.find((r) => r.id === id); @@ -128,170 +336,35 @@ const App = () => { }); }; - const [selectedResource, setSelectedResource] = useState( - null, - ); - const [selectedPrompt, setSelectedPrompt] = useState(null); - const [selectedTool, setSelectedTool] = useState(null); - const [nextResourceCursor, setNextResourceCursor] = useState< - string | undefined - >(); - const [nextResourceTemplateCursor, setNextResourceTemplateCursor] = useState< - string | undefined - >(); - const [nextPromptCursor, setNextPromptCursor] = useState< - string | undefined - >(); - const [nextToolCursor, setNextToolCursor] = useState(); - const progressTokenRef = useRef(0); - const [historyPaneHeight, setHistoryPaneHeight] = useState(300); - const [isDragging, setIsDragging] = useState(false); - const dragStartY = useRef(0); - const dragStartHeight = useRef(0); - - const handleDragStart = useCallback( - (e: React.MouseEvent) => { - setIsDragging(true); - dragStartY.current = e.clientY; - dragStartHeight.current = historyPaneHeight; - document.body.style.userSelect = "none"; - }, - [historyPaneHeight], - ); - - 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), - ); - setHistoryPaneHeight(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]); - - useEffect(() => { - localStorage.setItem("lastCommand", command); - }, [command]); - - useEffect(() => { - localStorage.setItem("lastArgs", args); - }, [args]); - - useEffect(() => { - fetch(`${PROXY_SERVER_URL}/config`) - .then((response) => response.json()) - .then((data) => { - setEnv(data.defaultEnvironment); - if (data.defaultCommand) { - setCommand(data.defaultCommand); - } - if (data.defaultArgs) { - setArgs(data.defaultArgs); - } - }) - .catch((error) => - console.error("Error fetching default environment:", error), - ); - }, []); - - useEffect(() => { - rootsRef.current = roots; - }, [roots]); - - const pushHistory = (request: object, response?: object) => { - setRequestHistory((prev) => [ - ...prev, - { - request: JSON.stringify(request), - response: response !== undefined ? JSON.stringify(response) : undefined, - }, - ]); - }; - const clearError = (tabKey: keyof typeof errors) => { setErrors((prev) => ({ ...prev, [tabKey]: null })); }; - const makeRequest = async >( + const sendMCPRequest = async ( request: ClientRequest, schema: T, tabKey?: keyof typeof errors, ) => { - if (!mcpClient) { - throw new Error("MCP client not connected"); - } - try { - const abortController = new AbortController(); - const timeoutId = setTimeout(() => { - abortController.abort("Request timed out"); - }, REQUEST_TIMEOUT); - - let response; - try { - response = await mcpClient.request(request, schema, { - signal: abortController.signal, - }); - } finally { - clearTimeout(timeoutId); - } - pushHistory(request, response); - + const response = await makeRequest(request, schema); if (tabKey !== undefined) { clearError(tabKey); } - return response; - } catch (e: unknown) { + } catch (e) { const errorString = (e as Error).message ?? String(e); - if (tabKey === undefined) { - toast.error(errorString); - } else { + if (tabKey !== undefined) { setErrors((prev) => ({ ...prev, [tabKey]: errorString, })); } - - throw e; - } - }; - - const sendNotification = async (notification: ClientNotification) => { - if (!mcpClient) { - throw new Error("MCP client not connected"); - } - - try { - await mcpClient.notification(notification); - pushHistory(notification); - } catch (e: unknown) { - toast.error((e as Error).message ?? String(e)); throw e; } }; const listResources = async () => { - const response = await makeRequest( + const response = await sendMCPRequest( { method: "resources/list" as const, params: nextResourceCursor ? { cursor: nextResourceCursor } : {}, @@ -304,7 +377,7 @@ const App = () => { }; const listResourceTemplates = async () => { - const response = await makeRequest( + const response = await sendMCPRequest( { method: "resources/templates/list" as const, params: nextResourceTemplateCursor @@ -321,7 +394,7 @@ const App = () => { }; const readResource = async (uri: string) => { - const response = await makeRequest( + const response = await sendMCPRequest( { method: "resources/read" as const, params: { uri }, @@ -332,8 +405,40 @@ const App = () => { setResourceContent(JSON.stringify(response, null, 2)); }; + const subscribeToResource = async (uri: string) => { + if (!resourceSubscriptions.has(uri)) { + await sendMCPRequest( + { + method: "resources/subscribe" as const, + params: { uri }, + }, + z.object({}), + "resources", + ); + const clone = new Set(resourceSubscriptions); + clone.add(uri); + setResourceSubscriptions(clone); + } + }; + + const unsubscribeFromResource = async (uri: string) => { + if (resourceSubscriptions.has(uri)) { + await sendMCPRequest( + { + method: "resources/unsubscribe" as const, + params: { uri }, + }, + z.object({}), + "resources", + ); + const clone = new Set(resourceSubscriptions); + clone.delete(uri); + setResourceSubscriptions(clone); + } + }; + const listPrompts = async () => { - const response = await makeRequest( + const response = await sendMCPRequest( { method: "prompts/list" as const, params: nextPromptCursor ? { cursor: nextPromptCursor } : {}, @@ -346,7 +451,7 @@ const App = () => { }; const getPrompt = async (name: string, args: Record = {}) => { - const response = await makeRequest( + const response = await sendMCPRequest( { method: "prompts/get" as const, params: { name, arguments: args }, @@ -358,7 +463,7 @@ const App = () => { }; const listTools = async () => { - const response = await makeRequest( + const response = await sendMCPRequest( { method: "tools/list" as const, params: nextToolCursor ? { cursor: nextToolCursor } : {}, @@ -371,100 +476,90 @@ const App = () => { }; const callTool = async (name: string, params: Record) => { - const response = await makeRequest( - { - method: "tools/call" as const, - params: { - name, - arguments: params, - _meta: { - progressToken: progressTokenRef.current++, + try { + const response = await sendMCPRequest( + { + method: "tools/call" as const, + params: { + name, + arguments: params, + _meta: { + progressToken: progressTokenRef.current++, + }, }, }, - }, - CompatibilityCallToolResultSchema, - "tools", - ); - setToolResult(response); + CompatibilityCallToolResultSchema, + "tools", + ); + setToolResult(response); + } catch (e) { + const toolResult: CompatibilityCallToolResult = { + content: [ + { + type: "text", + text: (e as Error).message ?? String(e), + }, + ], + isError: true, + }; + setToolResult(toolResult); + } }; const handleRootsChange = async () => { await sendNotification({ method: "notifications/roots/list_changed" }); }; - const connectMcpServer = async () => { - try { - const client = new Client( - { - name: "mcp-inspector", - version: "0.0.1", - }, - { - capabilities: { - // Support all client capabilities since we're an inspector tool - sampling: {}, - roots: { - listChanged: true, - }, - }, - }, - ); - - const backendUrl = new URL(`${PROXY_SERVER_URL}/sse`); - - backendUrl.searchParams.append("transportType", transportType); - if (transportType === "stdio") { - backendUrl.searchParams.append("command", command); - backendUrl.searchParams.append("args", args); - backendUrl.searchParams.append("env", JSON.stringify(env)); - } else { - backendUrl.searchParams.append("url", sseUrl); - } - - const clientTransport = new SSEClientTransport(backendUrl); - client.setNotificationHandler( - ProgressNotificationSchema, - (notification) => { - setNotifications((prevNotifications) => [ - ...prevNotifications, - notification, - ]); - }, - ); - - client.setNotificationHandler( - StdErrNotificationSchema, - (notification) => { - setStdErrNotifications((prevErrorNotifications) => [ - ...prevErrorNotifications, - notification, - ]); - }, - ); - - await client.connect(clientTransport); - - client.setRequestHandler(CreateMessageRequestSchema, (request) => { - return new Promise((resolve, reject) => { - setPendingSampleRequests((prev) => [ - ...prev, - { id: nextRequestId.current++, request, resolve, reject }, - ]); - }); - }); - - client.setRequestHandler(ListRootsRequestSchema, async () => { - return { roots: rootsRef.current }; - }); - - setMcpClient(client); - setConnectionStatus("connected"); - } catch (e) { - console.error(e); - setConnectionStatus("error"); - } + const sendLogLevelRequest = async (level: LoggingLevel) => { + await sendMCPRequest( + { + method: "logging/setLevel" as const, + params: { level }, + }, + z.object({}), + ); + setLogLevel(level); }; + const clearStdErrNotifications = () => { + setStdErrNotifications([]); + }; + + // Helper component for rendering the AuthDebugger + const AuthDebuggerWrapper = () => ( + + setIsAuthDebuggerVisible(false)} + authState={authState} + updateAuthState={updateAuthState} + /> + + ); + + // Helper function to render OAuth callback components + if (window.location.pathname === "/oauth/callback") { + const OAuthCallback = React.lazy( + () => import("./components/OAuthCallback"), + ); + return ( + Loading...}> + + + ); + } + + if (window.location.pathname === "/oauth/callback/debug") { + const OAuthDebugCallback = React.lazy( + () => import("./components/OAuthDebugCallback"), + ); + return ( + Loading...}> + + + ); + } + return (
{ setSseUrl={setSseUrl} env={env} setEnv={setEnv} + config={config} + setConfig={setConfig} + bearerToken={bearerToken} + setBearerToken={setBearerToken} + headerName={headerName} + setHeaderName={setHeaderName} onConnect={connectMcpServer} + onDisconnect={disconnectMcpServer} stdErrNotifications={stdErrNotifications} + logLevel={logLevel} + sendLogLevelRequest={sendLogLevelRequest} + loggingSupported={!!serverCapabilities?.logging || false} + clearStdErrNotifications={clearStdErrNotifications} />
{mcpClient ? ( - + (window.location.hash = value)} + > - + Resources - + Prompts - + Tools @@ -516,117 +647,185 @@ const App = () => { Roots + + + Auth +
- { - clearError("resources"); - listResources(); - }} - clearResources={() => { - setResources([]); - setNextResourceCursor(undefined); - }} - listResourceTemplates={() => { - clearError("resources"); - listResourceTemplates(); - }} - clearResourceTemplates={() => { - setResourceTemplates([]); - setNextResourceTemplateCursor(undefined); - }} - readResource={(uri) => { - clearError("resources"); - readResource(uri); - }} - selectedResource={selectedResource} - setSelectedResource={(resource) => { - clearError("resources"); - setSelectedResource(resource); - }} - resourceContent={resourceContent} - nextCursor={nextResourceCursor} - nextTemplateCursor={nextResourceTemplateCursor} - error={errors.resources} - /> - { - clearError("prompts"); - listPrompts(); - }} - clearPrompts={() => { - setPrompts([]); - setNextPromptCursor(undefined); - }} - getPrompt={(name, args) => { - clearError("prompts"); - getPrompt(name, args); - }} - selectedPrompt={selectedPrompt} - setSelectedPrompt={(prompt) => { - clearError("prompts"); - setSelectedPrompt(prompt); - }} - promptContent={promptContent} - nextCursor={nextPromptCursor} - error={errors.prompts} - /> - { - clearError("tools"); - listTools(); - }} - clearTools={() => { - setTools([]); - setNextToolCursor(undefined); - }} - callTool={(name, params) => { - clearError("tools"); - callTool(name, params); - }} - selectedTool={selectedTool} - setSelectedTool={(tool) => { - clearError("tools"); - setSelectedTool(tool); - setToolResult(null); - }} - toolResult={toolResult} - nextCursor={nextToolCursor} - error={errors.tools} - /> - - { - void makeRequest( - { - method: "ping" as const, - }, - EmptyResultSchema, - ); - }} - /> - - + {!serverCapabilities?.resources && + !serverCapabilities?.prompts && + !serverCapabilities?.tools ? ( + <> +
+

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

+
+ { + void sendMCPRequest( + { + method: "ping" as const, + }, + EmptyResultSchema, + ); + }} + /> + + ) : ( + <> + { + clearError("resources"); + listResources(); + }} + clearResources={() => { + setResources([]); + setNextResourceCursor(undefined); + }} + listResourceTemplates={() => { + clearError("resources"); + listResourceTemplates(); + }} + clearResourceTemplates={() => { + setResourceTemplates([]); + setNextResourceTemplateCursor(undefined); + }} + readResource={(uri) => { + clearError("resources"); + readResource(uri); + }} + selectedResource={selectedResource} + setSelectedResource={(resource) => { + clearError("resources"); + setSelectedResource(resource); + }} + resourceSubscriptionsSupported={ + serverCapabilities?.resources?.subscribe || false + } + resourceSubscriptions={resourceSubscriptions} + subscribeToResource={(uri) => { + clearError("resources"); + subscribeToResource(uri); + }} + unsubscribeFromResource={(uri) => { + clearError("resources"); + unsubscribeFromResource(uri); + }} + handleCompletion={handleCompletion} + completionsSupported={completionsSupported} + resourceContent={resourceContent} + nextCursor={nextResourceCursor} + nextTemplateCursor={nextResourceTemplateCursor} + error={errors.resources} + /> + { + clearError("prompts"); + listPrompts(); + }} + clearPrompts={() => { + setPrompts([]); + setNextPromptCursor(undefined); + }} + getPrompt={(name, args) => { + clearError("prompts"); + getPrompt(name, args); + }} + selectedPrompt={selectedPrompt} + setSelectedPrompt={(prompt) => { + clearError("prompts"); + setSelectedPrompt(prompt); + setPromptContent(""); + }} + handleCompletion={handleCompletion} + completionsSupported={completionsSupported} + promptContent={promptContent} + nextCursor={nextPromptCursor} + error={errors.prompts} + /> + { + clearError("tools"); + listTools(); + }} + clearTools={() => { + setTools([]); + setNextToolCursor(undefined); + }} + callTool={async (name, params) => { + clearError("tools"); + setToolResult(null); + await callTool(name, params); + }} + selectedTool={selectedTool} + setSelectedTool={(tool) => { + clearError("tools"); + setSelectedTool(tool); + setToolResult(null); + }} + toolResult={toolResult} + nextCursor={nextToolCursor} + error={errors.tools} + /> + + { + void sendMCPRequest( + { + method: "ping" as const, + }, + EmptyResultSchema, + ); + }} + /> + + + + + )}
+ ) : isAuthDebuggerVisible ? ( + (window.location.hash = value)} + > + + ) : ( -
+

Connect to an MCP server to start inspecting

+
+

+ Need to configure authentication? +

+ +
)}
diff --git a/client/src/__mocks__/styleMock.js b/client/src/__mocks__/styleMock.js new file mode 100644 index 00000000..f053ebf7 --- /dev/null +++ b/client/src/__mocks__/styleMock.js @@ -0,0 +1 @@ +module.exports = {}; diff --git a/client/src/components/AuthDebugger.tsx b/client/src/components/AuthDebugger.tsx new file mode 100644 index 00000000..a2f20e26 --- /dev/null +++ b/client/src/components/AuthDebugger.tsx @@ -0,0 +1,272 @@ +import { useCallback, useMemo } from "react"; +import { Button } from "@/components/ui/button"; +import { DebugInspectorOAuthClientProvider } from "../lib/auth"; +import { + auth, + discoverOAuthMetadata, +} from "@modelcontextprotocol/sdk/client/auth.js"; +import { OAuthMetadataSchema } from "@modelcontextprotocol/sdk/shared/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) => 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 ( +
+
+ +

{message.message}

+
+
+ ); +}; + +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, + ); + // First discover OAuth metadata separately so we can save it + const metadata = await discoverOAuthMetadata(serverUrl); + if (!metadata) { + throw new Error("Failed to discover OAuth metadata"); + } + const parsedMetadata = await OAuthMetadataSchema.parseAsync(metadata); + serverAuthProvider.saveServerMetadata(parsedMetadata); + + 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 ( +
+
+

Authentication Settings

+ +
+ +
+
+
+

+ Configure authentication settings for your MCP server connection. +

+ +
+

OAuth Authentication

+

+ Use OAuth to securely authenticate with the MCP server. +

+ + {authState.statusMessage && ( + + )} + + {authState.loading ? ( +

Loading authentication status...

+ ) : ( +
+ {authState.oauthTokens && ( +
+

Access Token:

+
+ {authState.oauthTokens.access_token.substring(0, 25)}... +
+
+ )} + +
+ + + + + +
+ +

+ Choose "Guided" for step-by-step instructions or "Quick" for + the standard automatic flow. +

+
+ )} +
+ + +
+
+
+
+ ); +}; + +export default AuthDebugger; diff --git a/client/src/components/DynamicJsonForm.tsx b/client/src/components/DynamicJsonForm.tsx new file mode 100644 index 00000000..6a5993c3 --- /dev/null +++ b/client/src/components/DynamicJsonForm.tsx @@ -0,0 +1,308 @@ +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(); + // Store the raw JSON string to allow immediate feedback during typing + // while deferring parsing until the user stops typing + const [rawJsonValue, setRawJsonValue] = useState( + 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>(); + + // 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 ( + { + 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 ( + { + 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 ( + { + 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 ( + { + 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 ( + 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 ( +
+
+ {isJsonMode && ( + + )} + {!isOnlyJSON && ( + + )} +
+ + {isJsonMode ? ( + { + // 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 !== "{}" ? ( +
+

+ Form view not available for this JSON structure. Using simplified + view: +

+
+            {rawJsonValue}
+          
+

+ Use JSON mode for full editing capabilities. +

+
+ ) : ( + renderFormFields(schema, value) + )} +
+ ); +}; + +export default DynamicJsonForm; diff --git a/client/src/components/History.tsx b/client/src/components/History.tsx index 532d3b1b..0b05b55d 100644 --- a/client/src/components/History.tsx +++ b/client/src/components/History.tsx @@ -1,6 +1,6 @@ import { ServerNotification } from "@modelcontextprotocol/sdk/types.js"; -import { Copy } from "lucide-react"; import { useState } from "react"; +import JsonView from "./JsonView"; const HistoryAndNotifications = ({ requestHistory, @@ -24,10 +24,6 @@ const HistoryAndNotifications = ({ setExpandedNotifications((prev) => ({ ...prev, [index]: !prev[index] })); }; - const copyToClipboard = (text: string) => { - navigator.clipboard.writeText(text); - }; - return (
@@ -67,16 +63,12 @@ const HistoryAndNotifications = ({ Request: -
-
-                          {JSON.stringify(JSON.parse(request.request), null, 2)}
-                        
+ +
{request.response && (
@@ -84,20 +76,11 @@ const HistoryAndNotifications = ({ Response: -
-
-                            {JSON.stringify(
-                              JSON.parse(request.response),
-                              null,
-                              2,
-                            )}
-                          
+
)} @@ -137,18 +120,11 @@ const HistoryAndNotifications = ({ Details: -
-
-                        {JSON.stringify(notification, null, 2)}
-                      
+
)} diff --git a/client/src/components/JsonEditor.tsx b/client/src/components/JsonEditor.tsx new file mode 100644 index 00000000..215d66b7 --- /dev/null +++ b/client/src/components/JsonEditor.tsx @@ -0,0 +1,67 @@ +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( + undefined, + ); + + useEffect(() => { + setEditorContent(value || ""); + }, [value]); + + const handleEditorChange = (newContent: string) => { + setEditorContent(newContent); + setInternalError(undefined); + onChange(newContent); + }; + + const displayError = internalError || externalError; + + return ( +
+
+ + 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" + /> +
+ {displayError && ( +

{displayError}

+ )} +
+ ); +}; + +export default JsonEditor; diff --git a/client/src/components/JsonView.tsx b/client/src/components/JsonView.tsx new file mode 100644 index 00000000..e9ef0d2d --- /dev/null +++ b/client/src/components/JsonView.tsx @@ -0,0 +1,286 @@ +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 ( +
+ {withCopyButton && ( + + )} +
+ +
+
+ ); + }, +); + +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>({ + 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); + const itemCount = items.length; + const isEmpty = itemCount === 0; + + const symbolMap = { + open: isArray ? "[" : "{", + close: isArray ? "]" : "}", + collapsed: isArray ? "[ ... ]" : "{ ... }", + empty: isArray ? "[]" : "{}", + }; + + if (isEmpty) { + return ( +
+ {name && ( + + {name}: + + )} + {symbolMap.empty} +
+ ); + } + + return ( +
+
setIsExpanded(!isExpanded)} + > + {name && ( + + {name}: + + )} + {isExpanded ? ( + + {symbolMap.open} + + ) : ( + <> + + {symbolMap.collapsed} + + + {itemCount} {itemCount === 1 ? "item" : "items"} + + + )} +
+ {isExpanded && ( + <> +
+ {isArray + ? (items as JsonValue[]).map((item, index) => ( +
+ +
+ )) + : (items as [string, JsonValue][]).map(([key, value]) => ( +
+ +
+ ))} +
+
+ {symbolMap.close} +
+ + )} +
+ ); + }; + + const renderString = (value: string) => { + const maxLength = 100; + const isTooLong = value.length > maxLength; + + if (!isTooLong) { + return ( +
+ {name && ( + + {name}: + + )} +
+              "{value}"
+            
+
+ ); + } + + return ( +
+ {name && ( + + {name}: + + )} +
 setIsExpanded(!isExpanded)}
+            title={isExpanded ? "Click to collapse" : "Click to expand"}
+          >
+            {isExpanded ? `"${value}"` : `"${value.slice(0, maxLength)}..."`}
+          
+
+ ); + }; + + switch (dataType) { + case "object": + case "array": + return renderCollapsible(dataType === "array"); + case "string": + return renderString(data as string); + default: + return ( +
+ {name && ( + + {name}: + + )} + + {data === null ? "null" : String(data)} + +
+ ); + } + }, +); + +JsonNode.displayName = "JsonNode"; + +export default JsonView; diff --git a/client/src/components/ListPane.tsx b/client/src/components/ListPane.tsx index 90693dd2..6f9a8cf7 100644 --- a/client/src/components/ListPane.tsx +++ b/client/src/components/ListPane.tsx @@ -22,7 +22,7 @@ const ListPane = ({ isButtonDisabled, }: ListPaneProps) => (
-
+

{title}

diff --git a/client/src/components/OAuthCallback.tsx b/client/src/components/OAuthCallback.tsx new file mode 100644 index 00000000..ccfd6d92 --- /dev/null +++ b/client/src/components/OAuthCallback.tsx @@ -0,0 +1,85 @@ +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 ( +
+

Processing OAuth callback...

+
+ ); +}; + +export default OAuthCallback; diff --git a/client/src/components/OAuthDebugCallback.tsx b/client/src/components/OAuthDebugCallback.tsx new file mode 100644 index 00000000..88d931c0 --- /dev/null +++ b/client/src/components/OAuthDebugCallback.tsx @@ -0,0 +1,92 @@ +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 ( +
+
+

+ Please copy this authorization code and return to the Auth Debugger: +

+ + {callbackParams.successful && "code" in callbackParams + ? callbackParams.code + : `No code found: ${callbackParams.error}, ${callbackParams.error_description}`} + +

+ Close this tab and paste the code in the OAuth flow to complete + authentication. +

+
+
+ ); +}; + +export default OAuthDebugCallback; diff --git a/client/src/components/OAuthFlowProgress.tsx b/client/src/components/OAuthFlowProgress.tsx new file mode 100644 index 00000000..f604fc73 --- /dev/null +++ b/client/src/components/OAuthFlowProgress.tsx @@ -0,0 +1,259 @@ +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 ( +
+
+ {isComplete ? ( + + ) : ( + + )} + {label} +
+ + {/* Show children if current step or complete and children exist */} + {(isCurrent || isComplete) && children && ( +
{children}
+ )} + + {/* Display error if current step and an error exists */} + {isCurrent && error && ( +
+

Error:

+

{error.message}

+
+ )} +
+ ); +}; + +interface OAuthFlowProgressProps { + serverUrl: string; + authState: AuthDebuggerState; + updateAuthState: (updates: Partial) => void; + proceedToNextStep: () => Promise; +} + +export const OAuthFlowProgress = ({ + serverUrl, + authState, + updateAuthState, + proceedToNextStep, +}: OAuthFlowProgressProps) => { + const provider = new DebugInspectorOAuthClientProvider(serverUrl); + + const steps: Array = [ + "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 ( +
+

OAuth Flow Progress

+

+ Follow these steps to complete OAuth authentication with the server. +

+ +
+ + {provider.getServerMetadata() && ( +
+ + Retrieved OAuth Metadata from {serverUrl} + /.well-known/oauth-authorization-server + +
+                {JSON.stringify(provider.getServerMetadata(), null, 2)}
+              
+
+ )} +
+ + + {authState.oauthClientInfo && ( +
+ + Registered Client Information + +
+                {JSON.stringify(authState.oauthClientInfo, null, 2)}
+              
+
+ )} +
+ + + {authState.authorizationUrl && ( +
+

Authorization URL:

+
+

+ {authState.authorizationUrl} +

+ + + +
+

+ Click the link to authorize in your browser. After + authorization, you'll be redirected back to continue the flow. +

+
+ )} +
+ + +
+ +
+ { + 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" + }`} + /> +
+ {authState.validationError && ( +

+ {authState.validationError} +

+ )} +

+ Once you've completed authorization in the link, paste the code + here. +

+
+
+ + + {authState.oauthMetadata && ( +
+ + Token Request Details + +
+

Token Endpoint:

+ + {authState.oauthMetadata.token_endpoint} + +
+
+ )} +
+ + + {authState.oauthTokens && ( +
+ + Access Tokens + +

+ Authentication successful! You can now use the authenticated + connection. These tokens will be used automatically for server + requests. +

+
+                {JSON.stringify(authState.oauthTokens, null, 2)}
+              
+
+ )} +
+
+ +
+ {authState.oauthStep !== "complete" && ( + <> + + + )} + + {authState.oauthStep === "authorization_redirect" && + authState.authorizationUrl && ( + + )} +
+
+ ); +}; diff --git a/client/src/components/PingTab.tsx b/client/src/components/PingTab.tsx index e963a267..65469016 100644 --- a/client/src/components/PingTab.tsx +++ b/client/src/components/PingTab.tsx @@ -3,16 +3,16 @@ import { Button } from "@/components/ui/button"; const PingTab = ({ onPingClick }: { onPingClick: () => void }) => { return ( - -
- + +
+
+ +
); diff --git a/client/src/components/PromptsTab.tsx b/client/src/components/PromptsTab.tsx index df8b8a50..5fdad823 100644 --- a/client/src/components/PromptsTab.tsx +++ b/client/src/components/PromptsTab.tsx @@ -1,13 +1,19 @@ import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; +import { Combobox } from "@/components/ui/combobox"; import { Label } from "@/components/ui/label"; import { TabsContent } from "@/components/ui/tabs"; -import { Textarea } from "@/components/ui/textarea"; -import { ListPromptsResult } from "@modelcontextprotocol/sdk/types.js"; + +import { + ListPromptsResult, + PromptReference, + ResourceReference, +} from "@modelcontextprotocol/sdk/types.js"; import { AlertCircle } from "lucide-react"; -import { useState } from "react"; +import { useEffect, useState } from "react"; import ListPane from "./ListPane"; +import { useCompletionState } from "@/lib/hooks/useCompletionState"; +import JsonView from "./JsonView"; export type Prompt = { name: string; @@ -26,6 +32,8 @@ const PromptsTab = ({ getPrompt, selectedPrompt, setSelectedPrompt, + handleCompletion, + completionsSupported, promptContent, nextCursor, error, @@ -35,15 +43,38 @@ const PromptsTab = ({ clearPrompts: () => void; getPrompt: (name: string, args: Record) => void; selectedPrompt: Prompt | null; - setSelectedPrompt: (prompt: Prompt) => void; + setSelectedPrompt: (prompt: Prompt | null) => void; + handleCompletion: ( + ref: PromptReference | ResourceReference, + argName: string, + value: string, + ) => Promise; + completionsSupported: boolean; promptContent: string; nextCursor: ListPromptsResult["nextCursor"]; error: string | null; }) => { const [promptArgs, setPromptArgs] = useState>({}); + const { completions, clearCompletions, requestCompletions } = + useCompletionState(handleCompletion, completionsSupported); - const handleInputChange = (argName: string, value: string) => { + useEffect(() => { + clearCompletions(); + }, [clearCompletions, selectedPrompt]); + + const handleInputChange = async (argName: string, value: string) => { setPromptArgs((prev) => ({ ...prev, [argName]: value })); + + if (selectedPrompt) { + requestCompletions( + { + type: "ref/prompt", + name: selectedPrompt.name, + }, + argName, + value, + ); + } }; const handleGetPrompt = () => { @@ -53,85 +84,91 @@ const PromptsTab = ({ }; return ( - - { - setSelectedPrompt(prompt); - setPromptArgs({}); - }} - renderItem={(prompt) => ( - <> - {prompt.name} - {prompt.description} - - )} - title="Prompts" - buttonText={nextCursor ? "List More Prompts" : "List Prompts"} - isButtonDisabled={!nextCursor && prompts.length > 0} - /> - -
-
-

- {selectedPrompt ? selectedPrompt.name : "Select a prompt"} -

-
-
- {error ? ( - - - Error - {error} - - ) : selectedPrompt ? ( -
- {selectedPrompt.description && ( -

- {selectedPrompt.description} -

- )} - {selectedPrompt.arguments?.map((arg) => ( -
- - - handleInputChange(arg.name, e.target.value) - } - /> - {arg.description && ( -

- {arg.description} - {arg.required && ( - (Required) - )} -

- )} -
- ))} - - {promptContent && ( -