Merge branch 'main' into sampling-form

This commit is contained in:
Nathan Arseneau 2025-04-22 22:07:37 -04:00
commit 29b465fe45
33 changed files with 1236 additions and 4042 deletions

View file

@ -58,6 +58,8 @@ jobs:
# - run: npm ci
- run: npm install --no-package-lock
- run: npm run build
# TODO: Add --provenance once the repo is public
- run: npm run publish-all
env:

4
.gitignore vendored
View file

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

View file

@ -6,6 +6,10 @@ The MCP inspector is a developer tool for testing and debugging MCP servers.
## 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`:
@ -99,7 +103,7 @@ Development mode:
npm run dev
```
> **Note for Windows users:**
> **Note for Windows users:**
> On Windows, use the following command instead:
>
> ```bash

View file

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

View file

@ -1,20 +0,0 @@
/**
* Cross-platform script to copy the built file to cli.js
*/
import { promises as fs } from "fs";
import path from "path";
const SOURCE_FILE = path.resolve("build/index.js");
const TARGET_FILE = path.resolve("cli.js");
async function copyFile() {
try {
await fs.copyFile(SOURCE_FILE, TARGET_FILE);
console.log(`Successfully copied ${SOURCE_FILE} to ${TARGET_FILE}`);
} catch (error) {
console.error("Error copying file:", error);
process.exit(1);
}
}
copyFile();

View file

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

View file

@ -1,23 +1,27 @@
{
"name": "@modelcontextprotocol/inspector-cli",
"version": "0.9.0",
"version": "0.10.2",
"description": "CLI for the Model Context Protocol inspector",
"license": "MIT",
"author": "Nicolas Barraud",
"homepage": "https://github.com/nbarraud",
"main": "build/index.js",
"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/index.js"
"mcp-inspector-cli": "build/cli.js"
},
"files": [
"build"
],
"scripts": {
"build": "tsc"
"build": "tsc",
"postbuild": "node scripts/make-executable.js",
"test": "node scripts/cli-tests.js"
},
"devDependencies": {},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.10.0",
"commander": "^13.1.0",
"spawn-rx": "^5.1.2"
}

633
cli/scripts/cli-tests.js Executable file
View file

@ -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);
});

29
cli/scripts/make-executable.js Executable file
View file

@ -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();

114
bin/cli.js → cli/src/cli.ts Executable file → Normal file
View file

@ -1,13 +1,37 @@
#!/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));
function handleError(error) {
let message;
type Args = {
command: string;
args: string[];
envArgs: Record<string, string>;
cli: boolean;
};
type CliOptions = {
e?: Record<string, string>;
config?: string;
server?: string;
cli?: boolean;
};
type ServerConfig = {
command: string;
args?: string[];
env?: Record<string, string>;
};
function handleError(error: unknown): never {
let message: string;
if (error instanceof Error) {
message = error.message;
} else if (typeof error === "string") {
@ -15,39 +39,49 @@ function handleError(error) {
} else {
message = "Unknown error";
}
console.error(message);
process.exit(1);
}
function delay(ms) {
function delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms, true));
}
async function runWebClient(args) {
async function runWebClient(args: Args): Promise<void> {
const inspectorServerPath = resolve(
__dirname,
"..",
"../../",
"server",
"build",
"index.js",
);
// Path to the client entry point
const inspectorClientPath = resolve(
__dirname,
"..",
"../../",
"client",
"bin",
"cli.js",
"client.js",
);
const CLIENT_PORT = process.env.CLIENT_PORT ?? "6274";
const SERVER_PORT = process.env.SERVER_PORT ?? "6277";
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 = false;
let cancelled: boolean = false;
process.on("SIGINT", () => {
cancelled = true;
abort.abort();
});
let server;
let serverOk;
let server: ReturnType<typeof spawnPromise>;
let serverOk: unknown;
try {
server = spawnPromise(
"node",
@ -66,9 +100,11 @@ async function runWebClient(args) {
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], {
@ -81,15 +117,20 @@ async function runWebClient(args) {
}
}
}
async function runCli(args) {
async function runCli(args: Args): Promise<void> {
const projectRoot = resolve(__dirname, "..");
const cliPath = resolve(projectRoot, "cli", "build", "index.js");
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 },
@ -102,16 +143,20 @@ async function runCli(args) {
}
}
}
function loadConfigFile(configPath, serverName) {
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(
", ",
@ -120,35 +165,48 @@ function loadConfigFile(configPath, serverName) {
`Server '${serverName}' not found in config file. Available servers: ${availableServers}`,
);
}
const serverConfig = parsedConfig.mcpServers[serverName];
return serverConfig;
} catch (err) {
} catch (err: unknown) {
if (err instanceof SyntaxError) {
throw new Error(`Invalid JSON in config file: ${err.message}`);
}
throw err;
}
}
function parseKeyValuePair(value, previous = {}) {
function parseKeyValuePair(
value: string,
previous: Record<string, string> = {},
): Record<string, string> {
const parts = value.split("=");
const key = parts[0];
const val = parts.slice(1).join("=");
if (val === undefined || val === "") {
throw new Error(
`Invalid parameter format: ${value}. Use key=value format.`,
);
}
return { ...previous, [key]: val };
return { ...previous, [key as string]: val };
}
function parseArgs() {
function parseArgs(): Args {
const program = new Command();
const argSeparatorIndex = process.argv.indexOf("--");
let preArgs = process.argv;
let postArgs = [];
let postArgs: string[] = [];
if (argSeparatorIndex !== -1) {
preArgs = process.argv.slice(0, argSeparatorIndex);
postArgs = process.argv.slice(argSeparatorIndex + 1);
}
program
.name("inspector-bin")
.allowExcessArguments()
@ -162,12 +220,16 @@ function parseArgs() {
.option("--config <path>", "config file path")
.option("--server <n>", "server name from config file")
.option("--cli", "enable CLI mode");
// Parse only the arguments before --
program.parse(preArgs);
const options = program.opts();
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) ||
@ -177,11 +239,13 @@ function parseArgs() {
"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],
@ -189,9 +253,11 @@ function parseArgs() {
cli: options.cli || false,
};
}
// Otherwise use command line arguments
const command = finalArgs[0] || "";
const args = finalArgs.slice(1);
return {
command,
args,
@ -199,12 +265,15 @@ function parseArgs() {
cli: options.cli || false,
};
}
async function main() {
async function main(): Promise<void> {
process.on("uncaughtException", (error) => {
handleError(error);
});
try {
const args = parseArgs();
if (args.cli) {
runCli(args);
} else {
@ -214,4 +283,5 @@ async function main() {
handleError(error);
}
}
main();

33
client/bin/client.js Executable file
View file

@ -0,0 +1,33 @@
#!/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,
rewrites: [{ source: "/**", destination: "/index.html" }],
});
});
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);

120
client/bin/start.js Executable file
View file

@ -0,0 +1,120 @@
#!/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, 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 {
await spawnPromise("node", [inspectorClientPath], {
env: { ...process.env, PORT: CLIENT_PORT },
signal: abort.signal,
echoOutput: true,
});
} catch (e) {
if (!cancelled || process.env.DEBUG) throw e;
}
}
return 0;
}
main()
.then((_) => process.exit(0))
.catch((e) => {
console.error(e);
process.exit(1);
});

View file

@ -1,6 +1,6 @@
{
"name": "@modelcontextprotocol/inspector-client",
"version": "0.9.0",
"version": "0.10.2",
"description": "Client-side application for the Model Context Protocol inspector",
"license": "MIT",
"author": "Anthropic, PBC (https://anthropic.com)",
@ -8,14 +8,14 @@
"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 --port 6274",
@ -23,7 +23,7 @@
"test:watch": "jest --config jest.config.cjs --watch"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.9.0",
"@modelcontextprotocol/sdk": "^1.10.0",
"@radix-ui/react-checkbox": "^1.1.4",
"@radix-ui/react-dialog": "^1.1.3",
"@radix-ui/react-icons": "^1.3.0",
@ -72,6 +72,6 @@
"ts-jest": "^29.2.6",
"typescript": "^5.5.3",
"typescript-eslint": "^8.7.0",
"vite": "^5.4.8"
"vite": "^6.3.0"
}
}

View file

@ -17,7 +17,13 @@ import {
Tool,
LoggingLevel,
} from "@modelcontextprotocol/sdk/types.js";
import React, { Suspense, useEffect, useRef, useState } from "react";
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";
@ -46,14 +52,10 @@ import ToolsTab from "./components/ToolsTab";
import { DEFAULT_INSPECTOR_CONFIG } from "./lib/constants";
import { InspectorConfig } from "./lib/configurationTypes";
import { getMCPProxyAddress } from "./utils/configUtils";
import { useToast } from "@/hooks/use-toast";
const params = new URLSearchParams(window.location.search);
const CONFIG_LOCAL_STORAGE_KEY = "inspectorConfig_v1";
const App = () => {
const { toast } = useToast();
// Handle OAuth callback route
const [resources, setResources] = useState<Resource[]>([]);
const [resourceTemplates, setResourceTemplates] = useState<
ResourceTemplate[]
@ -79,9 +81,14 @@ const App = () => {
const [sseUrl, setSseUrl] = useState<string>(() => {
return localStorage.getItem("lastSseUrl") || "http://localhost:3001/sse";
});
const [transportType, setTransportType] = useState<"stdio" | "sse">(() => {
const [transportType, setTransportType] = useState<
"stdio" | "sse" | "streamable-http"
>(() => {
return (
(localStorage.getItem("lastTransportType") as "stdio" | "sse") || "stdio"
(localStorage.getItem("lastTransportType") as
| "stdio"
| "sse"
| "streamable-http") || "stdio"
);
});
const [logLevel, setLogLevel] = useState<LoggingLevel>("debug");
@ -221,31 +228,15 @@ const App = () => {
localStorage.setItem(CONFIG_LOCAL_STORAGE_KEY, JSON.stringify(config));
}, [config]);
const hasProcessedRef = useRef(false);
// Auto-connect if serverUrl is provided in URL params (e.g. after OAuth callback)
useEffect(() => {
if (hasProcessedRef.current) {
// Only try to connect once
return;
}
const serverUrl = params.get("serverUrl");
if (serverUrl) {
// Auto-connect to previously saved serverURL after OAuth callback
const onOAuthConnect = useCallback(
(serverUrl: string) => {
setSseUrl(serverUrl);
setTransportType("sse");
// Remove serverUrl from URL without reloading the page
const newUrl = new URL(window.location.href);
newUrl.searchParams.delete("serverUrl");
window.history.replaceState({}, "", newUrl.toString());
// Show success toast for OAuth
toast({
title: "Success",
description: "Successfully authenticated with OAuth",
});
hasProcessedRef.current = true;
// Connect to the server
connectMcpServer();
}
}, [connectMcpServer, toast]);
void connectMcpServer();
},
[connectMcpServer],
);
useEffect(() => {
fetch(`${getMCPProxyAddress(config)}/config`)
@ -486,7 +477,7 @@ const App = () => {
);
return (
<Suspense fallback={<div>Loading...</div>}>
<OAuthCallback />
<OAuthCallback onConnect={onOAuthConnect} />
</Suspense>
);
}

View file

@ -1,28 +1,35 @@
import { useState, useEffect, useCallback, useRef } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import JsonEditor from "./JsonEditor";
import { updateValueAtPath } from "@/utils/jsonUtils";
import { generateDefaultValue, formatFieldLabel } from "@/utils/schemaUtils";
import type { JsonValue, JsonSchemaType, JsonObject } 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;
defaultIsJsonMode?: boolean;
}
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,
defaultIsJsonMode = false,
}: DynamicJsonFormProps) => {
const [isJsonMode, setIsJsonMode] = useState(defaultIsJsonMode);
const isOnlyJSON = !isSimpleObject(schema);
const [isJsonMode, setIsJsonMode] = useState(isOnlyJSON);
const [jsonError, setJsonError] = useState<string>();
// Store the raw JSON string to allow immediate feedback during typing
// while deferring parsing until the user stops typing
@ -209,111 +216,6 @@ const DynamicJsonForm = ({
required={propSchema.required}
/>
);
case "object": {
// Handle case where we have a value but no schema properties
const objectValue = (currentValue as JsonObject) || {};
// If we have schema properties, use them to render fields
if (propSchema.properties) {
return (
<div className="space-y-4 border rounded-md p-4">
{Object.entries(propSchema.properties).map(([key, prop]) => (
<div key={key} className="space-y-2">
<Label>{formatFieldLabel(key)}</Label>
{renderFormFields(
prop,
objectValue[key],
[...path, key],
depth + 1,
)}
</div>
))}
</div>
);
}
// If we have a value but no schema properties, render fields based on the value
else if (Object.keys(objectValue).length > 0) {
return (
<div className="space-y-4 border rounded-md p-4">
{Object.entries(objectValue).map(([key, value]) => (
<div key={key} className="space-y-2">
<Label>{formatFieldLabel(key)}</Label>
<Input
type="text"
value={String(value)}
onChange={(e) =>
handleFieldChange([...path, key], e.target.value)
}
/>
</div>
))}
</div>
);
}
// If we have neither schema properties nor value, return null
return null;
}
case "array": {
const arrayValue = Array.isArray(currentValue) ? currentValue : [];
if (!propSchema.items) return null;
return (
<div className="space-y-4">
{propSchema.description && (
<p className="text-sm text-gray-600">{propSchema.description}</p>
)}
{propSchema.items?.description && (
<p className="text-sm text-gray-500">
Items: {propSchema.items.description}
</p>
)}
<div className="space-y-2">
{arrayValue.map((item, index) => (
<div key={index} className="flex items-center gap-2">
{renderFormFields(
propSchema.items as JsonSchemaType,
item,
[...path, index.toString()],
depth + 1,
)}
<Button
variant="outline"
size="sm"
onClick={() => {
const newArray = [...arrayValue];
newArray.splice(index, 1);
handleFieldChange(path, newArray);
}}
>
Remove
</Button>
</div>
))}
<Button
variant="outline"
size="sm"
onClick={() => {
const defaultValue = generateDefaultValue(
propSchema.items as JsonSchemaType,
);
handleFieldChange(path, [
...arrayValue,
defaultValue ?? null,
]);
}}
title={
propSchema.items?.description
? `Add new ${propSchema.items.description}`
: "Add new item"
}
>
Add Item
</Button>
</div>
</div>
);
}
default:
return null;
}
@ -357,14 +259,11 @@ const DynamicJsonForm = ({
Format JSON
</Button>
)}
<Button
type="button"
variant="outline"
size="sm"
onClick={handleSwitchToFormMode}
>
{isJsonMode ? "Switch to Form" : "Switch to JSON"}
</Button>
{!isOnlyJSON && (
<Button variant="outline" size="sm" onClick={handleSwitchToFormMode}>
{isJsonMode ? "Switch to Form" : "Switch to JSON"}
</Button>
)}
</div>
{isJsonMode ? (

View file

@ -1,9 +1,19 @@
import { useEffect, useRef } from "react";
import { authProvider } from "../lib/auth";
import { InspectorOAuthClientProvider } from "../lib/auth";
import { SESSION_KEYS } from "../lib/constants";
import { auth } from "@modelcontextprotocol/sdk/client/auth.js";
import { useToast } from "@/hooks/use-toast.ts";
import {
generateOAuthErrorDescription,
parseOAuthCallbackParams,
} from "@/utils/oauthUtils.ts";
const OAuthCallback = () => {
interface OAuthCallbackProps {
onConnect: (serverUrl: string) => void;
}
const OAuthCallback = ({ onConnect }: OAuthCallbackProps) => {
const { toast } = useToast();
const hasProcessedRef = useRef(false);
useEffect(() => {
@ -14,37 +24,56 @@ const OAuthCallback = () => {
}
hasProcessedRef.current = true;
const params = new URLSearchParams(window.location.search);
const code = params.get("code");
const serverUrl = sessionStorage.getItem(SESSION_KEYS.SERVER_URL);
const notifyError = (description: string) =>
void toast({
title: "OAuth Authorization Error",
description,
variant: "destructive",
});
if (!code || !serverUrl) {
console.error("Missing code or server URL");
window.location.href = "/";
return;
const params = parseOAuthCallbackParams(window.location.search);
if (!params.successful) {
return notifyError(generateOAuthErrorDescription(params));
}
try {
const result = await auth(authProvider, {
serverUrl,
authorizationCode: code,
});
if (result !== "AUTHORIZED") {
throw new Error(
`Expected to be authorized after providing auth code, got: ${result}`,
);
}
const serverUrl = sessionStorage.getItem(SESSION_KEYS.SERVER_URL);
if (!serverUrl) {
return notifyError("Missing Server URL");
}
// Redirect back to the main app with server URL to trigger auto-connect
window.location.href = `/?serverUrl=${encodeURIComponent(serverUrl)}`;
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);
window.location.href = "/";
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);
};
void handleCallback();
}, []);
handleCallback().finally(() => {
window.history.replaceState({}, document.title, "/");
});
}, [toast, onConnect]);
return (
<div className="flex items-center justify-center h-screen">

View file

@ -43,7 +43,7 @@ const PromptsTab = ({
clearPrompts: () => void;
getPrompt: (name: string, args: Record<string, string>) => void;
selectedPrompt: Prompt | null;
setSelectedPrompt: (prompt: Prompt) => void;
setSelectedPrompt: (prompt: Prompt | null) => void;
handleCompletion: (
ref: PromptReference | ResourceReference,
argName: string,
@ -89,7 +89,10 @@ const PromptsTab = ({
<ListPane
items={prompts}
listItems={listPrompts}
clearItems={clearPrompts}
clearItems={() => {
clearPrompts();
setSelectedPrompt(null);
}}
setSelectedItem={(prompt) => {
setSelectedPrompt(prompt);
setPromptArgs({});

View file

@ -104,7 +104,6 @@ const ResourcesTab = ({
if (selectedTemplate) {
const uri = fillTemplate(selectedTemplate.uriTemplate, templateValues);
readResource(uri);
setSelectedTemplate(null);
// We don't have the full Resource object here, so we create a partial one
setSelectedResource({ uri, name: uri } as Resource);
}
@ -116,7 +115,13 @@ const ResourcesTab = ({
<ListPane
items={resources}
listItems={listResources}
clearItems={clearResources}
clearItems={() => {
clearResources();
// Condition to check if selected resource is not resource template's resource
if (!selectedTemplate) {
setSelectedResource(null);
}
}}
setSelectedItem={(resource) => {
setSelectedResource(resource);
readResource(resource.uri);
@ -139,7 +144,14 @@ const ResourcesTab = ({
<ListPane
items={resourceTemplates}
listItems={listResourceTemplates}
clearItems={clearResourceTemplates}
clearItems={() => {
clearResourceTemplates();
// Condition to check if selected resource is resource template's resource
if (selectedTemplate) {
setSelectedResource(null);
}
setSelectedTemplate(null);
}}
setSelectedItem={(template) => {
setSelectedTemplate(template);
setSelectedResource(null);

View file

@ -46,7 +46,7 @@ const SamplingRequest = ({
properties: {
model: {
type: "string",
default: "GPT-4o",
default: "stub-model",
description: "model name",
},
stopReason: {
@ -140,7 +140,6 @@ const SamplingRequest = ({
<form className="flex-1 space-y-4">
<div className="space-y-2">
<DynamicJsonForm
defaultIsJsonMode={true}
schema={schema}
value={messageResult}
onChange={(newValue: JsonValue) => {

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