Merge branch 'main' into fix/validate-json-on-tool-execution

This commit is contained in:
Nandha Reddy 2025-09-23 20:10:59 +10:00 • committed by GitHub
commit 119922b8b5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
60 changed files with 6549 additions and 1285 deletions

36
.github/ISSUE_TEMPLATE/bug_report.md vendored Normal file
View file

@ -0,0 +1,36 @@
---
name: Bug report
about: Create a report to help us improve
title: ""
labels: ""
assignees: ""
---
**Inspector Version**
- [e.g. 0.16.5)
**Describe the bug**
A clear and concise description of what the bug is.
**To Reproduce**
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
**Expected behavior**
A clear and concise description of what you expected to happen.
**Screenshots**
If applicable, add screenshots to help explain your problem.
**Environment (please complete the following information):**
- OS: [e.g. iOS]
- Browser [e.g. chrome, safari]
**Additional context**
Add any other context about the problem here.

View file

@ -18,10 +18,6 @@ jobs:
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
(github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
) &&
(
github.actor == 'ihrpr' ||
github.actor == 'olaservo'
)
runs-on: ubuntu-latest
permissions:
@ -29,6 +25,7 @@ jobs:
pull-requests: read
issues: read
id-token: write
actions: read
steps:
- name: Checkout repository
uses: actions/checkout@v4
@ -41,24 +38,16 @@ jobs:
with:
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
# Optional: Specify model (defaults to Claude Sonnet 4, uncomment for Claude Opus 4)
# model: "claude-opus-4-20250514"
# Allow Claude to read CI results on PRs
additional_permissions: |
actions: read
# Optional: Customize the trigger phrase (default: @claude)
# trigger_phrase: "/claude"
# Trigger when assigned to an issue
assignee_trigger: "claude"
# Optional: Trigger when specific user is assigned to an issue
# assignee_trigger: "claude-bot"
# Allow Claude to run bash
# This should be safe given the repo is already public
allowed_tools: "Bash"
# Optional: Allow Claude to run specific commands
# allowed_tools: "Bash(npm install),Bash(npm run build),Bash(npm run test:*),Bash(npm run lint:*)"
# Optional: Add custom instructions for Claude to customize its behavior for your project
# custom_instructions: |
# Follow our coding standards
# Ensure all new code has tests
# Use TypeScript for new files
# Optional: Custom environment variables for Claude
# claude_env: |
# NODE_ENV: test
custom_instructions: |
If posting a comment to GitHub, give a concise summary of the comment at the top and put all the details in a <details> block.

4
.gitignore vendored
View file

@ -9,8 +9,12 @@ client/tsconfig.app.tsbuildinfo
client/tsconfig.node.tsbuildinfo
cli/build
test-output
tool-test-output
# symlinked by `npm run link:sdk`:
sdk
client/playwright-report/
client/results.json
client/test-results/
client/e2e/test-results/
mcp.json
.claude/settings.local.json

2
.husky/pre-commit Normal file
View file

@ -0,0 +1,2 @@
npx lint-staged
git update-index --again

View file

@ -2,3 +2,5 @@ packages
server/build
CODE_OF_CONDUCT.md
SECURITY.md
mcp.json
.claude/settings.local.json

View file

@ -1,5 +1,5 @@
# Build stage
FROM node:24-slim AS builder
FROM node:current-alpine3.22 AS builder
# Set working directory
WORKDIR /app
@ -49,4 +49,4 @@ ENV SERVER_PORT=6277
EXPOSE ${CLIENT_PORT} ${SERVER_PORT}
# Use ENTRYPOINT with CMD for arguments
ENTRYPOINT ["npm", "start"]
ENTRYPOINT ["npm", "start"]

122
README.md
View file

@ -29,6 +29,14 @@ npx @modelcontextprotocol/inspector
The server will start up and the UI will be accessible at `http://localhost:6274`.
### Docker Container
You can also start it in a Docker container with the following command:
```bash
docker run --rm --network host -p 6274:6274 -p 6277:6277 ghcr.io/modelcontextprotocol/inspector:latest
```
### 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`:
@ -90,6 +98,16 @@ The MCP Inspector provides convenient buttons to export server launch configurat
}
```
**Streamable HTTP transport example:**
```json
{
"type": "streamable-http",
"url": "http://localhost:3000/mcp",
"note": "For Streamable HTTP connections, add this URL directly in your MCP 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:**
@ -123,9 +141,23 @@ The MCP Inspector provides convenient buttons to export server launch configurat
}
```
**Streamable HTTP transport example:**
```json
{
"mcpServers": {
"default-server": {
"type": "streamable-http",
"url": "http://localhost:3000/mcp",
"note": "For Streamable HTTP connections, add this URL directly in your MCP 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.
For SSE and Streamable HTTP transport connections, the Inspector provides similar functionality for both buttons. The "Server Entry" button copies the configuration that can be added to your existing configuration file, while the "Servers File" button creates a complete configuration file containing the 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.
@ -166,6 +198,16 @@ If you need to disable authentication (NOT RECOMMENDED), you can set the `DANGER
DANGEROUSLY_OMIT_AUTH=true npm start
```
---
**🚨 WARNING 🚨**
Disabling authentication with `DANGEROUSLY_OMIT_AUTH` is incredibly dangerous! Disabling auth leaves your machine open to attack not just when exposed to the public internet, but also **via your web browser**. Meaning, visiting a malicious website OR viewing a malicious advertizement could allow an attacker to remotely compromise your computer. Do not disable this feature unless you truly understand the risks.
Read more about the risks of this vulnerability on Oligo's blog: [Critical RCE Vulnerability in Anthropic MCP Inspector - CVE-2025-49596](https://www.oligo.security/blog/critical-rce-vulnerability-in-anthropic-mcp-inspector-cve-2025-49596)
---
You can also set the token via the `MCP_PROXY_AUTH_TOKEN` environment variable when starting the server:
```bash
@ -234,6 +276,78 @@ Example server configuration file:
}
```
#### Transport Types in Config Files
The inspector automatically detects the transport type from your config file. You can specify different transport types:
**STDIO (default):**
```json
{
"mcpServers": {
"my-stdio-server": {
"type": "stdio",
"command": "npx",
"args": ["@modelcontextprotocol/server-everything"]
}
}
}
```
**SSE (Server-Sent Events):**
```json
{
"mcpServers": {
"my-sse-server": {
"type": "sse",
"url": "http://localhost:3000/sse"
}
}
}
```
**Streamable HTTP:**
```json
{
"mcpServers": {
"my-http-server": {
"type": "streamable-http",
"url": "http://localhost:3000/mcp"
}
}
}
```
#### Default Server Selection
You can launch the inspector without specifying a server name if your config has:
1. **A single server** - automatically selected:
```bash
# Automatically uses "my-server" if it's the only one
npx @modelcontextprotocol/inspector --config mcp.json
```
2. **A server named "default-server"** - automatically selected:
```json
{
"mcpServers": {
"default-server": {
"command": "npx",
"args": ["@modelcontextprotocol/server-everything"]
},
"other-server": {
"command": "node",
"args": ["other.js"]
}
}
}
```
> **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:
@ -305,6 +419,9 @@ npx @modelcontextprotocol/inspector --cli node build/index.js --method tools/lis
# 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
# Call a tool with JSON arguments
npx @modelcontextprotocol/inspector --cli node build/index.js --method tools/call --tool-name mytool --tool-arg 'options={"format": "json", "max_tokens": 100}'
# List available resources
npx @modelcontextprotocol/inspector --cli node build/index.js --method resources/list
@ -317,6 +434,9 @@ npx @modelcontextprotocol/inspector --cli https://my-mcp-server.example.com
# Connect to a remote MCP server (with Streamable HTTP transport)
npx @modelcontextprotocol/inspector --cli https://my-mcp-server.example.com --transport http --method tools/list
# Connect to a remote MCP server (with custom headers)
npx @modelcontextprotocol/inspector --cli https://my-mcp-server.example.com --transport http --method tools/list --header "X-API-Key: your-api-key"
# 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

View file

@ -1,6 +1,6 @@
{
"name": "@modelcontextprotocol/inspector-cli",
"version": "0.16.2",
"version": "0.16.8",
"description": "CLI for the Model Context Protocol inspector",
"license": "MIT",
"author": "Anthropic, PBC (https://anthropic.com)",
@ -17,11 +17,14 @@
"scripts": {
"build": "tsc",
"postbuild": "node scripts/make-executable.js",
"test": "node scripts/cli-tests.js"
"test": "node scripts/cli-tests.js && node scripts/cli-tool-tests.js && node scripts/cli-header-tests.js",
"test:cli": "node scripts/cli-tests.js",
"test:cli-tools": "node scripts/cli-tool-tests.js",
"test:cli-headers": "node scripts/cli-header-tests.js"
},
"devDependencies": {},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.17.0",
"@modelcontextprotocol/sdk": "^1.18.0",
"commander": "^13.1.0",
"spawn-rx": "^5.1.2"
}

View file

@ -0,0 +1,252 @@
#!/usr/bin/env node
/**
* Integration tests for header functionality
* Tests the CLI header parsing end-to-end
*/
import { spawn } from "node:child_process";
import { resolve, dirname } from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = dirname(fileURLToPath(import.meta.url));
const CLI_PATH = resolve(__dirname, "..", "build", "index.js");
// ANSI colors for output
const colors = {
GREEN: "\x1b[32m",
RED: "\x1b[31m",
YELLOW: "\x1b[33m",
BLUE: "\x1b[34m",
NC: "\x1b[0m", // No Color
};
let testsPassed = 0;
let testsFailed = 0;
/**
* Run a CLI test with given arguments and check for expected behavior
*/
function runHeaderTest(
testName,
args,
expectSuccess = false,
expectedInOutput = null,
) {
return new Promise((resolve) => {
console.log(`\n${colors.BLUE}Testing: ${testName}${colors.NC}`);
console.log(
`${colors.BLUE}Command: node ${CLI_PATH} ${args.join(" ")}${colors.NC}`,
);
const child = spawn("node", [CLI_PATH, ...args], {
stdio: ["pipe", "pipe", "pipe"],
timeout: 10000,
});
let stdout = "";
let stderr = "";
child.stdout.on("data", (data) => {
stdout += data.toString();
});
child.stderr.on("data", (data) => {
stderr += data.toString();
});
child.on("close", (code) => {
const output = stdout + stderr;
let passed = true;
let reason = "";
// Check exit code expectation
if (expectSuccess && code !== 0) {
passed = false;
reason = `Expected success (exit code 0) but got ${code}`;
} else if (!expectSuccess && code === 0) {
passed = false;
reason = `Expected failure (non-zero exit code) but got success`;
}
// Check expected output
if (passed && expectedInOutput && !output.includes(expectedInOutput)) {
passed = false;
reason = `Expected output to contain "${expectedInOutput}"`;
}
if (passed) {
console.log(`${colors.GREEN}PASS: ${testName}${colors.NC}`);
testsPassed++;
} else {
console.log(`${colors.RED}FAIL: ${testName}${colors.NC}`);
console.log(`${colors.RED}Reason: ${reason}${colors.NC}`);
console.log(`${colors.RED}Exit code: ${code}${colors.NC}`);
console.log(`${colors.RED}Output: ${output}${colors.NC}`);
testsFailed++;
}
resolve();
});
child.on("error", (error) => {
console.log(
`${colors.RED}ERROR: ${testName} - ${error.message}${colors.NC}`,
);
testsFailed++;
resolve();
});
});
}
async function runHeaderIntegrationTests() {
console.log(
`${colors.YELLOW}=== MCP Inspector CLI Header Integration Tests ===${colors.NC}`,
);
console.log(
`${colors.BLUE}Testing header parsing and validation${colors.NC}`,
);
// Test 1: Valid header format should parse successfully (connection will fail)
await runHeaderTest(
"Valid single header",
[
"https://example.com",
"--method",
"tools/list",
"--transport",
"http",
"--header",
"Authorization: Bearer token123",
],
false,
);
// Test 2: Multiple headers should parse successfully
await runHeaderTest(
"Multiple headers",
[
"https://example.com",
"--method",
"tools/list",
"--transport",
"http",
"--header",
"Authorization: Bearer token123",
"--header",
"X-API-Key: secret123",
],
false,
);
// Test 3: Invalid header format - no colon
await runHeaderTest(
"Invalid header format - no colon",
[
"https://example.com",
"--method",
"tools/list",
"--transport",
"http",
"--header",
"InvalidHeader",
],
false,
"Invalid header format",
);
// Test 4: Invalid header format - empty name
await runHeaderTest(
"Invalid header format - empty name",
[
"https://example.com",
"--method",
"tools/list",
"--transport",
"http",
"--header",
": value",
],
false,
"Invalid header format",
);
// Test 5: Invalid header format - empty value
await runHeaderTest(
"Invalid header format - empty value",
[
"https://example.com",
"--method",
"tools/list",
"--transport",
"http",
"--header",
"Header:",
],
false,
"Invalid header format",
);
// Test 6: Header with colons in value
await runHeaderTest(
"Header with colons in value",
[
"https://example.com",
"--method",
"tools/list",
"--transport",
"http",
"--header",
"X-Time: 2023:12:25:10:30:45",
],
false,
);
// Test 7: Whitespace handling
await runHeaderTest(
"Whitespace handling in headers",
[
"https://example.com",
"--method",
"tools/list",
"--transport",
"http",
"--header",
" X-Header : value with spaces ",
],
false,
);
console.log(`\n${colors.YELLOW}=== Test Results ===${colors.NC}`);
console.log(`${colors.GREEN}Tests passed: ${testsPassed}${colors.NC}`);
console.log(`${colors.RED}Tests failed: ${testsFailed}${colors.NC}`);
if (testsFailed === 0) {
console.log(
`${colors.GREEN}All header integration tests passed!${colors.NC}`,
);
process.exit(0);
} else {
console.log(
`${colors.RED}Some header integration tests failed.${colors.NC}`,
);
process.exit(1);
}
}
// Handle graceful shutdown
process.on("SIGINT", () => {
console.log(`\n${colors.YELLOW}Test interrupted by user${colors.NC}`);
process.exit(1);
});
process.on("SIGTERM", () => {
console.log(`\n${colors.YELLOW}Test terminated${colors.NC}`);
process.exit(1);
});
// Run the tests
runHeaderIntegrationTests().catch((error) => {
console.error(`${colors.RED}Test runner error: ${error.message}${colors.NC}`);
process.exit(1);
});

View file

@ -37,9 +37,6 @@ 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}`,
@ -120,6 +117,85 @@ try {
const invalidConfigPath = path.join(TEMP_DIR, "invalid-config.json");
fs.writeFileSync(invalidConfigPath, '{\n "mcpServers": {\n "invalid": {');
// Create config files with different transport types for testing
const sseConfigPath = path.join(TEMP_DIR, "sse-config.json");
fs.writeFileSync(
sseConfigPath,
JSON.stringify(
{
mcpServers: {
"test-sse": {
type: "sse",
url: "http://localhost:3000/sse",
note: "Test SSE server",
},
},
},
null,
2,
),
);
const httpConfigPath = path.join(TEMP_DIR, "http-config.json");
fs.writeFileSync(
httpConfigPath,
JSON.stringify(
{
mcpServers: {
"test-http": {
type: "streamable-http",
url: "http://localhost:3000/mcp",
note: "Test HTTP server",
},
},
},
null,
2,
),
);
const stdioConfigPath = path.join(TEMP_DIR, "stdio-config.json");
fs.writeFileSync(
stdioConfigPath,
JSON.stringify(
{
mcpServers: {
"test-stdio": {
type: "stdio",
command: "npx",
args: ["@modelcontextprotocol/server-everything"],
env: {
TEST_ENV: "test-value",
},
},
},
},
null,
2,
),
);
// Config without type field (backward compatibility)
const legacyConfigPath = path.join(TEMP_DIR, "legacy-config.json");
fs.writeFileSync(
legacyConfigPath,
JSON.stringify(
{
mcpServers: {
"test-legacy": {
command: "npx",
args: ["@modelcontextprotocol/server-everything"],
env: {
LEGACY_ENV: "legacy-value",
},
},
},
},
null,
2,
),
);
// Function to run a basic test
async function runBasicTest(testName, ...args) {
const outputFile = path.join(
@ -454,65 +530,6 @@ async function runTests() {
"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}`,
);
@ -649,6 +666,160 @@ async function runTests() {
"debug",
);
console.log(
`\n${colors.YELLOW}=== Running Config Transport Type Tests ===${colors.NC}`,
);
// Test 25: Config with stdio transport type
await runBasicTest(
"config_stdio_type",
"--config",
stdioConfigPath,
"--server",
"test-stdio",
"--cli",
"--method",
"tools/list",
);
// Test 26: Config with SSE transport type (CLI mode) - expects connection error
await runErrorTest(
"config_sse_type_cli",
"--config",
sseConfigPath,
"--server",
"test-sse",
"--cli",
"--method",
"tools/list",
);
// Test 27: Config with streamable-http transport type (CLI mode) - expects connection error
await runErrorTest(
"config_http_type_cli",
"--config",
httpConfigPath,
"--server",
"test-http",
"--cli",
"--method",
"tools/list",
);
// Test 28: Legacy config without type field (backward compatibility)
await runBasicTest(
"config_legacy_no_type",
"--config",
legacyConfigPath,
"--server",
"test-legacy",
"--cli",
"--method",
"tools/list",
);
console.log(
`\n${colors.YELLOW}=== Running Default Server Tests ===${colors.NC}`,
);
// Create config with single server for auto-selection
const singleServerConfigPath = path.join(
TEMP_DIR,
"single-server-config.json",
);
fs.writeFileSync(
singleServerConfigPath,
JSON.stringify(
{
mcpServers: {
"only-server": {
command: "npx",
args: ["@modelcontextprotocol/server-everything"],
},
},
},
null,
2,
),
);
// Create config with default-server
const defaultServerConfigPath = path.join(
TEMP_DIR,
"default-server-config.json",
);
fs.writeFileSync(
defaultServerConfigPath,
JSON.stringify(
{
mcpServers: {
"default-server": {
command: "npx",
args: ["@modelcontextprotocol/server-everything"],
},
"other-server": {
command: "node",
args: ["other.js"],
},
},
},
null,
2,
),
);
// Create config with multiple servers (no default)
const multiServerConfigPath = path.join(TEMP_DIR, "multi-server-config.json");
fs.writeFileSync(
multiServerConfigPath,
JSON.stringify(
{
mcpServers: {
server1: {
command: "npx",
args: ["@modelcontextprotocol/server-everything"],
},
server2: {
command: "node",
args: ["other.js"],
},
},
},
null,
2,
),
);
// Test 29: Config with single server auto-selection
await runBasicTest(
"single_server_auto_select",
"--config",
singleServerConfigPath,
"--cli",
"--method",
"tools/list",
);
// Test 30: Config with default-server should now require explicit selection (multiple servers)
await runErrorTest(
"default_server_requires_explicit_selection",
"--config",
defaultServerConfigPath,
"--cli",
"--method",
"tools/list",
);
// Test 31: Config with multiple servers and no default (should fail)
await runErrorTest(
"multi_server_no_default",
"--config",
multiServerConfigPath,
"--cli",
"--method",
"tools/list",
);
console.log(
`\n${colors.YELLOW}=== Running HTTP Transport Tests ===${colors.NC}`,
);
@ -668,7 +839,7 @@ async function runTests() {
await new Promise((resolve) => setTimeout(resolve, 3000));
// Test 25: HTTP transport inferred from URL ending with /mcp
// Test 32: HTTP transport inferred from URL ending with /mcp
await runBasicTest(
"http_transport_inferred",
"http://127.0.0.1:3001/mcp",
@ -677,7 +848,7 @@ async function runTests() {
"tools/list",
);
// Test 26: HTTP transport with explicit --transport http flag
// Test 33: HTTP transport with explicit --transport http flag
await runBasicTest(
"http_transport_with_explicit_flag",
"http://127.0.0.1:3001/mcp",
@ -688,7 +859,7 @@ async function runTests() {
"tools/list",
);
// Test 27: HTTP transport with suffix and --transport http flag
// Test 34: HTTP transport with suffix and --transport http flag
await runBasicTest(
"http_transport_with_explicit_flag_and_suffix",
"http://127.0.0.1:3001/mcp",
@ -699,7 +870,7 @@ async function runTests() {
"tools/list",
);
// Test 28: SSE transport given to HTTP server (should fail)
// Test 35: SSE transport given to HTTP server (should fail)
await runErrorTest(
"sse_transport_given_to_http_server",
"http://127.0.0.1:3001",
@ -710,7 +881,7 @@ async function runTests() {
"tools/list",
);
// Test 29: HTTP transport without URL (should fail)
// Test 36: HTTP transport without URL (should fail)
await runErrorTest(
"http_transport_without_url",
"--transport",
@ -720,7 +891,7 @@ async function runTests() {
"tools/list",
);
// Test 30: SSE transport without URL (should fail)
// Test 37: SSE transport without URL (should fail)
await runErrorTest(
"sse_transport_without_url",
"--transport",

View file

@ -0,0 +1,614 @@
#!/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 { 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 Tool Tests ===${colors.NC}`);
console.log(
`${colors.BLUE}This script tests the MCP Inspector CLI's tool-related functionality:${colors.NC}`,
);
console.log(`${colors.BLUE}- Tool discovery and listing${colors.NC}`);
console.log(
`${colors.BLUE}- JSON argument parsing (strings, numbers, booleans, objects, arrays)${colors.NC}`,
);
console.log(`${colors.BLUE}- Tool schema validation${colors.NC}`);
console.log(
`${colors.BLUE}- Tool execution with various argument types${colors.NC}`,
);
console.log(
`${colors.BLUE}- Error handling for invalid tools and arguments${colors.NC}`,
);
console.log(`\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, "tool-test-output");
if (!fs.existsSync(OUTPUT_DIR)) {
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
}
// Create a temporary directory for test files
const TEMP_DIR = path.join(os.tmpdir(), "mcp-inspector-tool-tests");
fs.mkdirSync(TEMP_DIR, { recursive: true });
// Track servers for cleanup
let runningServers = [];
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}`,
);
}
runningServers.forEach((server) => {
try {
process.kill(-server.pid);
} catch (e) {}
});
});
process.on("SIGINT", () => {
runningServers.forEach((server) => {
try {
process.kill(-server.pid);
} catch (e) {}
});
process.exit(1);
});
// 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"],
});
const timeout = setTimeout(() => {
console.log(`${colors.YELLOW}Test timed out: ${testName}${colors.NC}`);
child.kill();
}, 10000);
// 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) => {
clearTimeout(timeout);
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"],
});
const timeout = setTimeout(() => {
console.log(
`${colors.YELLOW}Error test timed out: ${testName}${colors.NC}`,
);
child.kill();
}, 10000);
// 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) => {
clearTimeout(timeout);
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 Tool Discovery Tests ===${colors.NC}`,
);
// Test 1: List available tools
await runBasicTest(
"tool_discovery_list",
TEST_CMD,
...TEST_ARGS,
"--cli",
"--method",
"tools/list",
);
console.log(
`\n${colors.YELLOW}=== Running JSON Argument Parsing Tests ===${colors.NC}`,
);
// Test 2: String arguments (backward compatibility)
await runBasicTest(
"json_args_string",
TEST_CMD,
...TEST_ARGS,
"--cli",
"--method",
"tools/call",
"--tool-name",
"echo",
"--tool-arg",
"message=hello world",
);
// Test 3: Number arguments
await runBasicTest(
"json_args_number_integer",
TEST_CMD,
...TEST_ARGS,
"--cli",
"--method",
"tools/call",
"--tool-name",
"add",
"--tool-arg",
"a=42",
"b=58",
);
// Test 4: Number arguments with decimals (using add tool with decimal numbers)
await runBasicTest(
"json_args_number_decimal",
TEST_CMD,
...TEST_ARGS,
"--cli",
"--method",
"tools/call",
"--tool-name",
"add",
"--tool-arg",
"a=19.99",
"b=20.01",
);
// Test 5: Boolean arguments - true
await runBasicTest(
"json_args_boolean_true",
TEST_CMD,
...TEST_ARGS,
"--cli",
"--method",
"tools/call",
"--tool-name",
"annotatedMessage",
"--tool-arg",
"messageType=success",
"includeImage=true",
);
// Test 6: Boolean arguments - false
await runBasicTest(
"json_args_boolean_false",
TEST_CMD,
...TEST_ARGS,
"--cli",
"--method",
"tools/call",
"--tool-name",
"annotatedMessage",
"--tool-arg",
"messageType=error",
"includeImage=false",
);
// Test 7: Null arguments (using echo with string "null")
await runBasicTest(
"json_args_null",
TEST_CMD,
...TEST_ARGS,
"--cli",
"--method",
"tools/call",
"--tool-name",
"echo",
"--tool-arg",
'message="null"',
);
// Test 14: Multiple arguments with mixed types (using add tool)
await runBasicTest(
"json_args_multiple_mixed",
TEST_CMD,
...TEST_ARGS,
"--cli",
"--method",
"tools/call",
"--tool-name",
"add",
"--tool-arg",
"a=42.5",
"b=57.5",
);
console.log(
`\n${colors.YELLOW}=== Running JSON Parsing Edge Cases ===${colors.NC}`,
);
// Test 15: Invalid JSON should fall back to string
await runBasicTest(
"json_args_invalid_fallback",
TEST_CMD,
...TEST_ARGS,
"--cli",
"--method",
"tools/call",
"--tool-name",
"echo",
"--tool-arg",
"message={invalid json}",
);
// Test 16: Empty string value
await runBasicTest(
"json_args_empty_value",
TEST_CMD,
...TEST_ARGS,
"--cli",
"--method",
"tools/call",
"--tool-name",
"echo",
"--tool-arg",
'message=""',
);
// Test 17: Special characters in strings
await runBasicTest(
"json_args_special_chars",
TEST_CMD,
...TEST_ARGS,
"--cli",
"--method",
"tools/call",
"--tool-name",
"echo",
"--tool-arg",
'message="C:\\\\Users\\\\test"',
);
// Test 18: Unicode characters
await runBasicTest(
"json_args_unicode",
TEST_CMD,
...TEST_ARGS,
"--cli",
"--method",
"tools/call",
"--tool-name",
"echo",
"--tool-arg",
'message="🚀🎉✨"',
);
// Test 19: Arguments with equals signs in values
await runBasicTest(
"json_args_equals_in_value",
TEST_CMD,
...TEST_ARGS,
"--cli",
"--method",
"tools/call",
"--tool-name",
"echo",
"--tool-arg",
"message=2+2=4",
);
// Test 20: Base64-like strings
await runBasicTest(
"json_args_base64_like",
TEST_CMD,
...TEST_ARGS,
"--cli",
"--method",
"tools/call",
"--tool-name",
"echo",
"--tool-arg",
"message=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0=",
);
console.log(
`\n${colors.YELLOW}=== Running Tool Error Handling Tests ===${colors.NC}`,
);
// Test 21: Non-existent tool
await runErrorTest(
"tool_error_nonexistent",
TEST_CMD,
...TEST_ARGS,
"--cli",
"--method",
"tools/call",
"--tool-name",
"nonexistent_tool",
"--tool-arg",
"message=test",
);
// Test 22: Missing tool name
await runErrorTest(
"tool_error_missing_name",
TEST_CMD,
...TEST_ARGS,
"--cli",
"--method",
"tools/call",
"--tool-arg",
"message=test",
);
// Test 23: Invalid tool argument format
await runErrorTest(
"tool_error_invalid_arg_format",
TEST_CMD,
...TEST_ARGS,
"--cli",
"--method",
"tools/call",
"--tool-name",
"echo",
"--tool-arg",
"invalid_format_no_equals",
);
console.log(
`\n${colors.YELLOW}=== Running Prompt JSON Argument Tests ===${colors.NC}`,
);
// Test 24: Prompt with JSON arguments
await runBasicTest(
"prompt_json_args_mixed",
TEST_CMD,
...TEST_ARGS,
"--cli",
"--method",
"prompts/get",
"--prompt-name",
"complex_prompt",
"--prompt-args",
"temperature=0.7",
'style="concise"',
'options={"format":"json","max_tokens":100}',
);
// Test 25: Prompt with simple arguments
await runBasicTest(
"prompt_json_args_simple",
TEST_CMD,
...TEST_ARGS,
"--cli",
"--method",
"prompts/get",
"--prompt-name",
"simple_prompt",
"--prompt-args",
"name=test",
"count=5",
);
console.log(
`\n${colors.YELLOW}=== Running Backward Compatibility Tests ===${colors.NC}`,
);
// Test 26: Ensure existing string-only usage still works
await runBasicTest(
"backward_compatibility_strings",
TEST_CMD,
...TEST_ARGS,
"--cli",
"--method",
"tools/call",
"--tool-name",
"echo",
"--tool-arg",
"message=hello",
);
// Test 27: Multiple string arguments (existing pattern) - using add tool
await runBasicTest(
"backward_compatibility_multiple_strings",
TEST_CMD,
...TEST_ARGS,
"--cli",
"--method",
"tools/call",
"--tool-name",
"add",
"--tool-arg",
"a=10",
"b=20",
);
// 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 tool tests completed!${colors.NC}`);
}
// Run all tests
runTests().catch((error) => {
console.error(
`${colors.RED}Tests failed with error: ${error.message}${colors.NC}`,
);
process.exit(1);
});

View file

@ -14,6 +14,9 @@ type Args = {
args: string[];
envArgs: Record<string, string>;
cli: boolean;
transport?: "stdio" | "sse" | "streamable-http";
serverUrl?: string;
headers?: Record<string, string>;
};
type CliOptions = {
@ -21,13 +24,23 @@ type CliOptions = {
config?: string;
server?: string;
cli?: boolean;
transport?: string;
serverUrl?: string;
header?: Record<string, string>;
};
type ServerConfig = {
command: string;
args?: string[];
env?: Record<string, string>;
};
type ServerConfig =
| {
type: "stdio";
command: string;
args?: string[];
env?: Record<string, string>;
}
| {
type: "sse" | "streamable-http";
url: string;
note?: string;
};
function handleError(error: unknown): never {
let message: string;
@ -74,6 +87,16 @@ async function runWebClient(args: Args): Promise<void> {
startArgs.push("-e", `${key}=${value}`);
}
// Pass transport type if specified
if (args.transport) {
startArgs.push("--transport", args.transport);
}
// Pass server URL if specified
if (args.serverUrl) {
startArgs.push("--server-url", args.serverUrl);
}
// Pass command and args (using -- to separate them)
if (args.command) {
startArgs.push("--", args.command, ...args.args);
@ -83,6 +106,10 @@ async function runWebClient(args: Args): Promise<void> {
await spawnPromise("node", [inspectorClientPath, ...startArgs], {
signal: abort.signal,
echoOutput: true,
// pipe the stdout through here, prevents issues with buffering and
// dropping the end of console.out after 8192 chars due to node
// closing the stdout pipe before the output has finished flushing
stdio: "inherit",
});
} catch (e) {
if (!cancelled || process.env.DEBUG) throw e;
@ -103,10 +130,35 @@ async function runCli(args: Args): Promise<void> {
});
try {
await spawnPromise("node", [cliPath, args.command, ...args.args], {
// Build CLI arguments
const cliArgs = [cliPath];
// Add target URL/command first
cliArgs.push(args.command, ...args.args);
// Add transport flag if specified
if (args.transport && args.transport !== "stdio") {
// Convert streamable-http back to http for CLI mode
const cliTransport =
args.transport === "streamable-http" ? "http" : args.transport;
cliArgs.push("--transport", cliTransport);
}
// Add headers if specified
if (args.headers) {
for (const [key, value] of Object.entries(args.headers)) {
cliArgs.push("--header", `${key}: ${value}`);
}
}
await spawnPromise("node", cliArgs, {
env: { ...process.env, ...args.envArgs },
signal: abort.signal,
echoOutput: true,
// pipe the stdout through here, prevents issues with buffering and
// dropping the end of console.out after 8192 chars due to node
// closing the stdout pipe before the output has finished flushing
stdio: "inherit",
});
} catch (e) {
if (!cancelled || process.env.DEBUG) {
@ -166,6 +218,30 @@ function parseKeyValuePair(
return { ...previous, [key as string]: val };
}
function parseHeaderPair(
value: string,
previous: Record<string, string> = {},
): Record<string, string> {
const colonIndex = value.indexOf(":");
if (colonIndex === -1) {
throw new Error(
`Invalid header format: ${value}. Use "HeaderName: Value" format.`,
);
}
const key = value.slice(0, colonIndex).trim();
const val = value.slice(colonIndex + 1).trim();
if (key === "" || val === "") {
throw new Error(
`Invalid header format: ${value}. Use "HeaderName: Value" format.`,
);
}
return { ...previous, [key]: val };
}
function parseArgs(): Args {
const program = new Command();
@ -190,7 +266,15 @@ function parseArgs(): Args {
)
.option("--config <path>", "config file path")
.option("--server <n>", "server name from config file")
.option("--cli", "enable CLI mode");
.option("--cli", "enable CLI mode")
.option("--transport <type>", "transport type (stdio, sse, http)")
.option("--server-url <url>", "server URL for SSE/HTTP transport")
.option(
"--header <headers...>",
'HTTP headers as "HeaderName: Value" pairs (for HTTP/SSE transports)',
parseHeaderPair,
{},
);
// Parse only the arguments before --
program.parse(preArgs);
@ -201,14 +285,33 @@ function parseArgs(): 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.",
// Validate config and server options
if (!options.config && options.server) {
throw new Error("--server requires --config to be specified");
}
// If config is provided without server, try to auto-select
if (options.config && !options.server) {
const configContent = fs.readFileSync(
path.isAbsolute(options.config)
? options.config
: path.resolve(process.cwd(), options.config),
"utf8",
);
const parsedConfig = JSON.parse(configContent);
const servers = Object.keys(parsedConfig.mcpServers || {});
if (servers.length === 1) {
// Use the only server if there's just one
options.server = servers[0];
} else if (servers.length === 0) {
throw new Error("No servers found in config file");
} else {
// Multiple servers, require explicit selection
throw new Error(
`Multiple servers found in config file. Please specify one with --server.\nAvailable servers: ${servers.join(", ")}`,
);
}
}
// If config file is specified, load and use the options from the file. We must merge the args
@ -217,23 +320,56 @@ function parseArgs(): Args {
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,
};
if (config.type === "stdio") {
return {
command: config.command,
args: [...(config.args || []), ...finalArgs],
envArgs: { ...(config.env || {}), ...(options.e || {}) },
cli: options.cli || false,
transport: "stdio",
headers: options.header,
};
} else if (config.type === "sse" || config.type === "streamable-http") {
return {
command: config.url,
args: finalArgs,
envArgs: options.e || {},
cli: options.cli || false,
transport: config.type,
serverUrl: config.url,
headers: options.header,
};
} else {
// Backwards compatibility: if no type field, assume stdio
return {
command: (config as any).command || "",
args: [...((config as any).args || []), ...finalArgs],
envArgs: { ...((config as any).env || {}), ...(options.e || {}) },
cli: options.cli || false,
transport: "stdio",
headers: options.header,
};
}
}
// Otherwise use command line arguments
const command = finalArgs[0] || "";
const args = finalArgs.slice(1);
// Map "http" shorthand to "streamable-http"
let transport = options.transport;
if (transport === "http") {
transport = "streamable-http";
}
return {
command,
args,
envArgs: options.e || {},
cli: options.cli || false,
transport: transport as "stdio" | "sse" | "streamable-http" | undefined,
serverUrl: options.serverUrl,
headers: options.header,
};
}

View file

@ -1,6 +1,16 @@
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { McpResponse } from "./types.js";
// JSON value type matching the client utils
type JsonValue =
| string
| number
| boolean
| null
| undefined
| JsonValue[]
| { [key: string]: JsonValue };
// List available prompts
export async function listPrompts(client: Client): Promise<McpResponse> {
try {
@ -17,12 +27,26 @@ export async function listPrompts(client: Client): Promise<McpResponse> {
export async function getPrompt(
client: Client,
name: string,
args?: Record<string, string>,
args?: Record<string, JsonValue>,
): Promise<McpResponse> {
try {
// Convert all arguments to strings for prompt arguments
const stringArgs: Record<string, string> = {};
if (args) {
for (const [key, value] of Object.entries(args)) {
if (typeof value === "string") {
stringArgs[key] = value;
} else if (value === null || value === undefined) {
stringArgs[key] = String(value);
} else {
stringArgs[key] = JSON.stringify(value);
}
}
}
const response = await client.getPrompt({
name,
arguments: args || {},
arguments: stringArgs,
});
return response;

View file

@ -2,6 +2,16 @@ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { Tool } from "@modelcontextprotocol/sdk/types.js";
import { McpResponse } from "./types.js";
// JSON value type matching the client utils
type JsonValue =
| string
| number
| boolean
| null
| undefined
| JsonValue[]
| { [key: string]: JsonValue };
type JsonSchemaType = {
type: "string" | "number" | "integer" | "boolean" | "array" | "object";
description?: string;
@ -20,7 +30,10 @@ export async function listTools(client: Client): Promise<McpResponse> {
}
}
function convertParameterValue(value: string, schema: JsonSchemaType): unknown {
function convertParameterValue(
value: string,
schema: JsonSchemaType,
): JsonValue {
if (!value) {
return value;
}
@ -35,7 +48,7 @@ function convertParameterValue(value: string, schema: JsonSchemaType): unknown {
if (schema.type === "object" || schema.type === "array") {
try {
return JSON.parse(value);
return JSON.parse(value) as JsonValue;
} catch (error) {
return value;
}
@ -47,8 +60,8 @@ function convertParameterValue(value: string, schema: JsonSchemaType): unknown {
function convertParameters(
tool: Tool,
params: Record<string, string>,
): Record<string, unknown> {
const result: Record<string, unknown> = {};
): Record<string, JsonValue> {
const result: Record<string, JsonValue> = {};
const properties = tool.inputSchema.properties || {};
for (const [key, value] of Object.entries(params)) {
@ -68,18 +81,29 @@ function convertParameters(
export async function callTool(
client: Client,
name: string,
args: Record<string, string>,
args: Record<string, JsonValue>,
): Promise<McpResponse> {
try {
const toolsResponse = await listTools(client);
const tools = toolsResponse.tools as Tool[];
const tool = tools.find((t) => t.name === name);
let convertedArgs: Record<string, unknown> = args;
let convertedArgs: Record<string, JsonValue> = args;
if (tool) {
// Convert parameters based on the tool's schema
convertedArgs = convertParameters(tool, args);
// Convert parameters based on the tool's schema, but only for string values
// since we now accept pre-parsed values from the CLI
const stringArgs: Record<string, string> = {};
for (const [key, value] of Object.entries(args)) {
if (typeof value === "string") {
stringArgs[key] = value;
}
}
if (Object.keys(stringArgs).length > 0) {
const convertedStringArgs = convertParameters(tool, stringArgs);
convertedArgs = { ...args, ...convertedStringArgs };
}
}
const response = await client.callTool({

View file

@ -19,22 +19,37 @@ import {
} from "./client/index.js";
import { handleError } from "./error-handler.js";
import { createTransport, TransportOptions } from "./transport.js";
import { awaitableLog } from "./utils/awaitable-log.js";
// JSON value type for CLI arguments
type JsonValue =
| string
| number
| boolean
| null
| undefined
| JsonValue[]
| { [key: string]: JsonValue };
import packageJson from "../package.json" with { type: "json" };
type Args = {
target: string[];
method?: string;
promptName?: string;
promptArgs?: Record<string, string>;
promptArgs?: Record<string, JsonValue>;
uri?: string;
logLevel?: LogLevel;
toolName?: string;
toolArg?: Record<string, string>;
toolArg?: Record<string, JsonValue>;
transport?: "sse" | "stdio" | "http";
headers?: Record<string, string>;
};
function createTransportOptions(
target: string[],
transport?: "sse" | "stdio" | "http",
headers?: Record<string, string>,
): TransportOptions {
if (target.length === 0) {
throw new Error(
@ -81,16 +96,23 @@ function createTransportOptions(
command: isUrl ? undefined : command,
args: isUrl ? undefined : commandArgs,
url: isUrl ? command : undefined,
headers,
};
}
async function callMethod(args: Args): Promise<void> {
const transportOptions = createTransportOptions(args.target, args.transport);
const transportOptions = createTransportOptions(
args.target,
args.transport,
args.headers,
);
const transport = createTransport(transportOptions);
const client = new Client({
name: "inspector-cli",
version: "0.5.1",
});
const [, name = packageJson.name] = packageJson.name.split("/");
const version = packageJson.version;
const clientIdentity = { name, version };
const client = new Client(clientIdentity);
try {
await connect(client, transport);
@ -150,7 +172,7 @@ async function callMethod(args: Args): Promise<void> {
);
}
console.log(JSON.stringify(result, null, 2));
await awaitableLog(JSON.stringify(result, null, 2));
} finally {
try {
await disconnect(transport);
@ -162,8 +184,8 @@ async function callMethod(args: Args): Promise<void> {
function parseKeyValuePair(
value: string,
previous: Record<string, string> = {},
): Record<string, string> {
previous: Record<string, JsonValue> = {},
): Record<string, JsonValue> {
const parts = value.split("=");
const key = parts[0];
const val = parts.slice(1).join("=");
@ -174,7 +196,40 @@ function parseKeyValuePair(
);
}
return { ...previous, [key as string]: val };
// Try to parse as JSON first
let parsedValue: JsonValue;
try {
parsedValue = JSON.parse(val) as JsonValue;
} catch {
// If JSON parsing fails, keep as string
parsedValue = val;
}
return { ...previous, [key as string]: parsedValue };
}
function parseHeaderPair(
value: string,
previous: Record<string, string> = {},
): Record<string, string> {
const colonIndex = value.indexOf(":");
if (colonIndex === -1) {
throw new Error(
`Invalid header format: ${value}. Use "HeaderName: Value" format.`,
);
}
const key = value.slice(0, colonIndex).trim();
const val = value.slice(colonIndex + 1).trim();
if (key === "" || val === "") {
throw new Error(
`Invalid header format: ${value}. Use "HeaderName: Value" format.`,
);
}
return { ...previous, [key]: val };
}
function parseArgs(): Args {
@ -256,12 +311,24 @@ function parseArgs(): Args {
}
return value as "sse" | "http" | "stdio";
},
)
//
// HTTP headers
//
.option(
"--header <headers...>",
'HTTP headers as "HeaderName: Value" pairs (for HTTP/SSE transports)',
parseHeaderPair,
{},
);
// Parse only the arguments before --
program.parse(preArgs);
const options = program.opts() as Omit<Args, "target">;
const options = program.opts() as Omit<Args, "target"> & {
header?: Record<string, string>;
};
let remainingArgs = program.args;
// Add back any arguments that came after --
@ -276,6 +343,7 @@ function parseArgs(): Args {
return {
target: finalArgs,
...options,
headers: options.header, // commander.js uses 'header' field, map to 'headers'
};
}
@ -287,6 +355,7 @@ async function main(): Promise<void> {
try {
const args = parseArgs();
await callMethod(args);
// Explicitly exit to ensure process terminates in CI
process.exit(0);
} catch (error) {

View file

@ -12,6 +12,7 @@ export type TransportOptions = {
command?: string;
args?: string[];
url?: string;
headers?: Record<string, string>;
};
function createStdioTransport(options: TransportOptions): Transport {
@ -32,8 +33,8 @@ function createStdioTransport(options: TransportOptions): Transport {
const defaultEnv = getDefaultEnvironment();
const env: Record<string, string> = {
...processEnv,
...defaultEnv,
...processEnv,
};
const { cmd: actualCommand, args: actualArgs } = findActualExecutable(
@ -64,11 +65,25 @@ export function createTransport(options: TransportOptions): Transport {
const url = new URL(options.url);
if (transportType === "sse") {
return new SSEClientTransport(url);
const transportOptions = options.headers
? {
requestInit: {
headers: options.headers,
},
}
: undefined;
return new SSEClientTransport(url, transportOptions);
}
if (transportType === "http") {
return new StreamableHTTPClientTransport(url);
const transportOptions = options.headers
? {
requestInit: {
headers: options.headers,
},
}
: undefined;
return new StreamableHTTPClientTransport(url, transportOptions);
}
throw new Error(`Unsupported transport type: ${transportType}`);

View file

@ -0,0 +1,7 @@
export function awaitableLog(logValue: string): Promise<void> {
return new Promise<void>((resolve) => {
process.stdout.write(logValue, () => {
resolve();
});
});
}

View file

@ -28,8 +28,15 @@ function getClientUrl(port, authDisabled, sessionToken, serverPort) {
}
async function startDevServer(serverOptions) {
const { SERVER_PORT, CLIENT_PORT, sessionToken, envVars, abort } =
serverOptions;
const {
SERVER_PORT,
CLIENT_PORT,
sessionToken,
envVars,
abort,
transport,
serverUrl,
} = serverOptions;
const serverCommand = "npx";
const serverArgs = ["tsx", "watch", "--clear-screen=false", "src/index.ts"];
const isWindows = process.platform === "win32";
@ -42,6 +49,8 @@ async function startDevServer(serverOptions) {
CLIENT_PORT,
MCP_PROXY_AUTH_TOKEN: sessionToken,
MCP_ENV_VARS: JSON.stringify(envVars),
...(transport ? { MCP_TRANSPORT: transport } : {}),
...(serverUrl ? { MCP_SERVER_URL: serverUrl } : {}),
},
signal: abort.signal,
echoOutput: true,
@ -78,6 +87,8 @@ async function startProdServer(serverOptions) {
abort,
command,
mcpServerArgs,
transport,
serverUrl,
} = serverOptions;
const inspectorServerPath = resolve(
__dirname,
@ -95,6 +106,8 @@ async function startProdServer(serverOptions) {
...(mcpServerArgs && mcpServerArgs.length > 0
? [`--args=${mcpServerArgs.join(" ")}`]
: []),
...(transport ? [`--transport=${transport}`] : []),
...(serverUrl ? [`--server-url=${serverUrl}`] : []),
],
{
env: {
@ -208,6 +221,8 @@ async function main() {
let command = null;
let parsingFlags = true;
let isDev = false;
let transport = null;
let serverUrl = null;
for (let i = 0; i < args.length; i++) {
const arg = args[i];
@ -222,6 +237,16 @@ async function main() {
continue;
}
if (parsingFlags && arg === "--transport" && i + 1 < args.length) {
transport = args[++i];
continue;
}
if (parsingFlags && arg === "--server-url" && i + 1 < args.length) {
serverUrl = args[++i];
continue;
}
if (parsingFlags && arg === "-e" && i + 1 < args.length) {
const envVar = args[++i];
const equalsIndex = envVar.indexOf("=");
@ -273,6 +298,8 @@ async function main() {
abort,
command,
mcpServerArgs,
transport,
serverUrl,
};
const result = isDev

View file

@ -0,0 +1,65 @@
import { test, expect } from "@playwright/test";
// These tests verify that CLI arguments correctly set URL parameters
// The CLI should parse config files and pass transport/serverUrl as URL params
test.describe("CLI Arguments @cli", () => {
test("should pass transport parameter from command line", async ({
page,
}) => {
// Simulate: npx . --transport sse --server-url http://localhost:3000/sse
await page.goto(
"http://localhost:6274/?transport=sse&serverUrl=http://localhost:3000/sse",
);
// Wait for the Transport Type dropdown to be visible
const selectTrigger = page.getByLabel("Transport Type");
await expect(selectTrigger).toBeVisible();
// Verify transport dropdown shows SSE
await expect(selectTrigger).toContainText("SSE");
// Verify URL field is visible and populated
const urlInput = page.locator("#sse-url-input");
await expect(urlInput).toBeVisible();
await expect(urlInput).toHaveValue("http://localhost:3000/sse");
});
test("should pass transport parameter for streamable-http", async ({
page,
}) => {
// Simulate config with streamable-http transport
await page.goto(
"http://localhost:6274/?transport=streamable-http&serverUrl=http://localhost:3000/mcp",
);
// Wait for the Transport Type dropdown to be visible
const selectTrigger = page.getByLabel("Transport Type");
await expect(selectTrigger).toBeVisible();
// Verify transport dropdown shows Streamable HTTP
await expect(selectTrigger).toContainText("Streamable HTTP");
// Verify URL field is visible and populated
const urlInput = page.locator("#sse-url-input");
await expect(urlInput).toBeVisible();
await expect(urlInput).toHaveValue("http://localhost:3000/mcp");
});
test("should not pass transport parameter for stdio config", async ({
page,
}) => {
// Simulate stdio config (no transport param needed)
await page.goto("http://localhost:6274/");
// Wait for the Transport Type dropdown to be visible
const selectTrigger = page.getByLabel("Transport Type");
await expect(selectTrigger).toBeVisible();
// Verify transport dropdown defaults to STDIO
await expect(selectTrigger).toContainText("STDIO");
// Verify command/args fields are visible
await expect(page.locator("#command-input")).toBeVisible();
await expect(page.locator("#arguments-input")).toBeVisible();
});
});

View file

@ -1,6 +1,6 @@
{
"name": "@modelcontextprotocol/inspector-client",
"version": "0.16.2",
"version": "0.16.8",
"description": "Client-side application for the Model Context Protocol inspector",
"license": "MIT",
"author": "Anthropic, PBC (https://anthropic.com)",
@ -25,7 +25,7 @@
"cleanup:e2e": "node e2e/global-teardown.js"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.17.0",
"@modelcontextprotocol/sdk": "^1.18.0",
"@radix-ui/react-checkbox": "^1.1.4",
"@radix-ui/react-dialog": "^1.1.3",
"@radix-ui/react-icons": "^1.3.0",
@ -33,6 +33,7 @@
"@radix-ui/react-popover": "^1.1.3",
"@radix-ui/react-select": "^2.1.2",
"@radix-ui/react-slot": "^1.1.0",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.1",
"@radix-ui/react-toast": "^1.2.6",
"@radix-ui/react-tooltip": "^1.1.8",
@ -48,7 +49,6 @@
"react-simple-code-editor": "^0.14.1",
"serve-handler": "^6.1.6",
"tailwind-merge": "^2.5.3",
"tailwindcss-animate": "^1.0.7",
"zod": "^3.25.76"
},
"devDependencies": {
@ -70,8 +70,10 @@
"globals": "^15.9.0",
"jest": "^29.7.0",
"jest-environment-jsdom": "^29.7.0",
"jest-fixed-jsdom": "^0.0.9",
"postcss": "^8.5.6",
"tailwindcss": "^3.4.13",
"tailwindcss-animate": "^1.0.7",
"ts-jest": "^29.4.0",
"typescript": "^5.5.3",
"typescript-eslint": "^8.38.0",

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