Compare commits
102 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bbe4924c88 | ||
|
|
d90940ab8f | ||
|
|
8ed2651ffd | ||
|
|
0f33c87ed4 | ||
|
|
2720d68417 | ||
|
|
15fb99b644 | ||
|
|
958c9a36f1 | ||
|
|
92bf9abb40 | ||
|
|
bb7834543b | ||
|
|
d7dff30cdb | ||
|
|
214e5e4c25 | ||
|
|
d45d8c9125 | ||
|
|
7cad9a018c | ||
|
|
7c8f2926a0 | ||
|
|
3aa8753f7c | ||
|
|
eba153847e | ||
|
|
1345a50011 | ||
|
|
15960f5aa4 | ||
|
|
0dc6df57d6 | ||
|
|
c8e9a772e4 | ||
|
|
cd28370f2c | ||
|
|
4adf4b1e51 | ||
|
|
880cb4a1de | ||
|
|
78cd701dc9 | ||
|
|
e31ee74842 | ||
|
|
9996be166f | ||
|
|
0b1c00baab | ||
|
|
8b0daef4ce | ||
|
|
49dddfb12b | ||
|
|
e878538d05 | ||
|
|
8901beb73e | ||
|
|
befa209935 | ||
|
|
8e8a5bb866 | ||
|
|
33309f351c | ||
|
|
d9100c8ed4 | ||
|
|
e4b4039e90 | ||
|
|
e798504867 | ||
|
|
61614052e5 | ||
|
|
fa7eba23b7 | ||
|
|
df0b526a41 | ||
|
|
0229aed948 | ||
|
|
a16a94537a | ||
|
|
9854409ece | ||
|
|
b19c78bc0a | ||
|
|
6a99feaf33 | ||
|
|
f7272d8d8c | ||
|
|
fd7dbba7a8 | ||
|
|
5fa76a7e02 | ||
|
|
fe02efd017 | ||
|
|
24418bc2cd | ||
|
|
22eb81350a | ||
|
|
0b37722ad1 | ||
|
|
485e703043 | ||
|
|
6420605d30 | ||
|
|
25cc0f69fd | ||
|
|
f1d5824a25 | ||
|
|
d332352968 | ||
|
|
d0622d3eb5 | ||
|
|
33aad8a271 | ||
|
|
352f5af7f8 | ||
|
|
3784816374 | ||
|
|
de233c9b30 | ||
|
|
dcef9dd068 | ||
|
|
204a90b1d1 | ||
|
|
f461f29f18 | ||
|
|
87fad79e7d | ||
|
|
cd1bcfb15f | ||
|
|
e4bfc058b2 | ||
|
|
bbff5c5883 | ||
|
|
8423776873 | ||
|
|
1175af1074 | ||
|
|
c4cc4144d9 | ||
|
|
80854d9183 | ||
|
|
2e8cc56744 | ||
|
|
3e95d9d42a | ||
|
|
a010f10c26 | ||
|
|
d798d1a132 | ||
|
|
53152e3fb1 | ||
|
|
aeac3ac914 | ||
|
|
a98db777c5 | ||
|
|
a15df913fe | ||
|
|
f6ed09e9fb | ||
|
|
4bc44c4d19 | ||
|
|
6a16e7cd24 | ||
|
|
3f9500f954 | ||
|
|
eb9b2dd027 | ||
|
|
91633de80f | ||
|
|
73d4cecdb1 | ||
|
|
952e13edc1 | ||
|
|
6eb6b3f82e | ||
|
|
3fc63017df | ||
|
|
cf86040df9 | ||
|
|
86a1adefd9 | ||
|
|
1832be2f84 | ||
|
|
1dfe10bf42 | ||
|
|
65c589c193 | ||
|
|
5b22143c85 | ||
|
|
a63de622f8 | ||
|
|
dae389034a | ||
|
|
f4e6f4d4ea | ||
|
|
9f42629b34 | ||
|
|
4c4c8a0884 |
2
.github/workflows/main.yml
vendored
2
.github/workflows/main.yml
vendored
@@ -54,6 +54,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:
|
||||
|
||||
8
.gitignore
vendored
8
.gitignore
vendored
@@ -1,7 +1,11 @@
|
||||
.DS_Store
|
||||
node_modules
|
||||
.vscode
|
||||
.idea
|
||||
node_modules/
|
||||
*-workspace/
|
||||
server/build
|
||||
client/dist
|
||||
client/tsconfig.app.tsbuildinfo
|
||||
client/tsconfig.node.tsbuildinfo
|
||||
.vscode
|
||||
cli/build
|
||||
test-output
|
||||
|
||||
97
README.md
97
README.md
@@ -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`:
|
||||
@@ -18,16 +22,16 @@ You can pass both arguments and environment variables to your MCP server. Argume
|
||||
|
||||
```bash
|
||||
# Pass arguments only
|
||||
npx @modelcontextprotocol/inspector build/index.js arg1 arg2
|
||||
npx @modelcontextprotocol/inspector node build/index.js arg1 arg2
|
||||
|
||||
# Pass environment variables only
|
||||
npx @modelcontextprotocol/inspector -e KEY=value -e KEY2=$VALUE2 node build/index.js
|
||||
npx @modelcontextprotocol/inspector -e key=value -e key2=$VALUE2 node build/index.js
|
||||
|
||||
# Pass both environment variables and arguments
|
||||
npx @modelcontextprotocol/inspector -e KEY=value -e KEY2=$VALUE2 node build/index.js arg1 arg2
|
||||
npx @modelcontextprotocol/inspector -e key=value -e key2=$VALUE2 node build/index.js arg1 arg2
|
||||
|
||||
# Use -- to separate inspector flags from server arguments
|
||||
npx @modelcontextprotocol/inspector -e KEY=$VALUE -- node build/index.js -e server-flag
|
||||
npx @modelcontextprotocol/inspector -e key=$VALUE -- node build/index.js -e server-flag
|
||||
```
|
||||
|
||||
The inspector runs both an MCP Inspector (MCPI) client UI (default port 6274) and an MCP Proxy (MCPP) server (default port 6277). Open the MCPI client UI in your browser to use the inspector. (These ports are derived from the T9 dialpad mapping of MCPI and MCPP respectively, as a mnemonic). You can customize the ports if needed:
|
||||
@@ -40,7 +44,7 @@ For more details on ways to use the inspector, see the [Inspector section of the
|
||||
|
||||
### Authentication
|
||||
|
||||
The inspector supports bearer token authentication for SSE connections. Enter your token in the UI when connecting to an MCP server, and it will be sent in the Authorization header.
|
||||
The inspector supports bearer token authentication for SSE connections. Enter your token in the UI when connecting to an MCP server, and it will be sent in the Authorization header. You can override the header name using the input field in the sidebar.
|
||||
|
||||
### Security Considerations
|
||||
|
||||
@@ -59,6 +63,36 @@ The MCP Inspector supports the following configuration settings. To change them,
|
||||
|
||||
These settings can be adjusted in real-time through the UI and will persist across sessions.
|
||||
|
||||
The inspector also supports configuration files to store settings for different MCP servers. This is useful when working with multiple servers or complex configurations:
|
||||
|
||||
```bash
|
||||
npx @modelcontextprotocol/inspector --config path/to/config.json --server everything
|
||||
```
|
||||
|
||||
Example server configuration file:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"everything": {
|
||||
"command": "npx",
|
||||
"args": ["@modelcontextprotocol/server-everything"],
|
||||
"env": {
|
||||
"hello": "Hello MCP!"
|
||||
}
|
||||
},
|
||||
"my-server": {
|
||||
"command": "node",
|
||||
"args": ["build/index.js", "arg1", "arg2"],
|
||||
"env": {
|
||||
"key": "value",
|
||||
"key2": "value2"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### From this repository
|
||||
|
||||
If you're working on the inspector itself:
|
||||
@@ -69,7 +103,7 @@ Development mode:
|
||||
npm run dev
|
||||
```
|
||||
|
||||
> **Note for Windows users:**
|
||||
> **Note for Windows users:**
|
||||
> On Windows, use the following command instead:
|
||||
>
|
||||
> ```bash
|
||||
@@ -83,6 +117,57 @@ npm run build
|
||||
npm start
|
||||
```
|
||||
|
||||
### CLI Mode
|
||||
|
||||
CLI mode enables programmatic interaction with MCP servers from the command line, ideal for scripting, automation, and integration with coding assistants. This creates an efficient feedback loop for MCP server development.
|
||||
|
||||
```bash
|
||||
npx @modelcontextprotocol/inspector --cli node build/index.js
|
||||
```
|
||||
|
||||
The CLI mode supports most operations across tools, resources, and prompts. A few examples:
|
||||
|
||||
```bash
|
||||
# Basic usage
|
||||
npx @modelcontextprotocol/inspector --cli node build/index.js
|
||||
|
||||
# With config file
|
||||
npx @modelcontextprotocol/inspector --cli --config path/to/config.json --server myserver
|
||||
|
||||
# List available tools
|
||||
npx @modelcontextprotocol/inspector --cli node build/index.js --method tools/list
|
||||
|
||||
# Call a specific tool
|
||||
npx @modelcontextprotocol/inspector --cli node build/index.js --method tools/call --tool-name mytool --tool-arg key=value --tool-arg another=value2
|
||||
|
||||
# List available resources
|
||||
npx @modelcontextprotocol/inspector --cli node build/index.js --method resources/list
|
||||
|
||||
# List available prompts
|
||||
npx @modelcontextprotocol/inspector --cli node build/index.js --method prompts/list
|
||||
|
||||
# Connect to a remote MCP server
|
||||
npx @modelcontextprotocol/inspector --cli https://my-mcp-server.example.com
|
||||
|
||||
# Call a tool on a remote server
|
||||
npx @modelcontextprotocol/inspector --cli https://my-mcp-server.example.com --method tools/call --tool-name remotetool --tool-arg param=value
|
||||
|
||||
# List resources from a remote server
|
||||
npx @modelcontextprotocol/inspector --cli https://my-mcp-server.example.com --method resources/list
|
||||
```
|
||||
|
||||
### UI Mode vs CLI Mode: When to Use Each
|
||||
|
||||
| Use Case | UI Mode | CLI Mode |
|
||||
| ------------------------ | ------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **Server Development** | Visual interface for interactive testing and debugging during development | Scriptable commands for quick testing and continuous integration; creates feedback loops with AI coding assistants like Cursor for rapid development |
|
||||
| **Resource Exploration** | Interactive browser with hierarchical navigation and JSON visualization | Programmatic listing and reading for automation and scripting |
|
||||
| **Tool Testing** | Form-based parameter input with real-time response visualization | Command-line tool execution with JSON output for scripting |
|
||||
| **Prompt Engineering** | Interactive sampling with streaming responses and visual comparison | Batch processing of prompts with machine-readable output |
|
||||
| **Debugging** | Request history, visualized errors, and real-time notifications | Direct JSON output for log analysis and integration with other tools |
|
||||
| **Automation** | N/A | Ideal for CI/CD pipelines, batch processing, and integration with coding assistants |
|
||||
| **Learning MCP** | Rich visual interface helps new users understand server capabilities | Simplified commands for focused learning of specific endpoints |
|
||||
|
||||
## License
|
||||
|
||||
This project is licensed under the MIT License—see the [LICENSE](LICENSE) file for details.
|
||||
|
||||
28
cli/package.json
Normal file
28
cli/package.json
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "@modelcontextprotocol/inspector-cli",
|
||||
"version": "0.10.2",
|
||||
"description": "CLI for the Model Context Protocol inspector",
|
||||
"license": "MIT",
|
||||
"author": "Anthropic, PBC (https://anthropic.com)",
|
||||
"homepage": "https://modelcontextprotocol.io",
|
||||
"bugs": "https://github.com/modelcontextprotocol/inspector/issues",
|
||||
"main": "build/cli.js",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"mcp-inspector-cli": "build/cli.js"
|
||||
},
|
||||
"files": [
|
||||
"build"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"postbuild": "node scripts/make-executable.js",
|
||||
"test": "node scripts/cli-tests.js"
|
||||
},
|
||||
"devDependencies": {},
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.10.0",
|
||||
"commander": "^13.1.0",
|
||||
"spawn-rx": "^5.1.2"
|
||||
}
|
||||
}
|
||||
633
cli/scripts/cli-tests.js
Executable file
633
cli/scripts/cli-tests.js
Executable 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
29
cli/scripts/make-executable.js
Executable 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();
|
||||
287
cli/src/cli.ts
Normal file
287
cli/src/cli.ts
Normal file
@@ -0,0 +1,287 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { Command } from "commander";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { dirname, resolve } from "path";
|
||||
import { spawnPromise } from "spawn-rx";
|
||||
import { fileURLToPath } from "url";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
type Args = {
|
||||
command: string;
|
||||
args: string[];
|
||||
envArgs: Record<string, string>;
|
||||
cli: boolean;
|
||||
};
|
||||
|
||||
type CliOptions = {
|
||||
e?: Record<string, string>;
|
||||
config?: string;
|
||||
server?: string;
|
||||
cli?: boolean;
|
||||
};
|
||||
|
||||
type ServerConfig = {
|
||||
command: string;
|
||||
args?: string[];
|
||||
env?: Record<string, string>;
|
||||
};
|
||||
|
||||
function handleError(error: unknown): never {
|
||||
let message: string;
|
||||
|
||||
if (error instanceof Error) {
|
||||
message = error.message;
|
||||
} else if (typeof error === "string") {
|
||||
message = error;
|
||||
} else {
|
||||
message = "Unknown error";
|
||||
}
|
||||
|
||||
console.error(message);
|
||||
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
function delay(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms, true));
|
||||
}
|
||||
|
||||
async function runWebClient(args: Args): Promise<void> {
|
||||
const inspectorServerPath = resolve(
|
||||
__dirname,
|
||||
"../../",
|
||||
"server",
|
||||
"build",
|
||||
"index.js",
|
||||
);
|
||||
|
||||
// Path to the client entry point
|
||||
const inspectorClientPath = resolve(
|
||||
__dirname,
|
||||
"../../",
|
||||
"client",
|
||||
"bin",
|
||||
"client.js",
|
||||
);
|
||||
|
||||
const CLIENT_PORT: string = process.env.CLIENT_PORT ?? "6274";
|
||||
const SERVER_PORT: string = process.env.SERVER_PORT ?? "6277";
|
||||
|
||||
console.log("Starting MCP inspector...");
|
||||
|
||||
const abort = new AbortController();
|
||||
let cancelled: boolean = false;
|
||||
process.on("SIGINT", () => {
|
||||
cancelled = true;
|
||||
abort.abort();
|
||||
});
|
||||
|
||||
let server: ReturnType<typeof spawnPromise>;
|
||||
let serverOk: unknown;
|
||||
|
||||
try {
|
||||
server = spawnPromise(
|
||||
"node",
|
||||
[
|
||||
inspectorServerPath,
|
||||
...(args.command ? [`--env`, args.command] : []),
|
||||
...(args.args ? [`--args=${args.args.join(" ")}`] : []),
|
||||
],
|
||||
{
|
||||
env: {
|
||||
...process.env,
|
||||
PORT: SERVER_PORT,
|
||||
MCP_ENV_VARS: JSON.stringify(args.envArgs),
|
||||
},
|
||||
signal: abort.signal,
|
||||
echoOutput: true,
|
||||
},
|
||||
);
|
||||
|
||||
// Make sure server started before starting client
|
||||
serverOk = await Promise.race([server, delay(2 * 1000)]);
|
||||
} catch (error) {}
|
||||
|
||||
if (serverOk) {
|
||||
try {
|
||||
await spawnPromise("node", [inspectorClientPath], {
|
||||
env: { ...process.env, PORT: CLIENT_PORT },
|
||||
signal: abort.signal,
|
||||
echoOutput: true,
|
||||
});
|
||||
} catch (e) {
|
||||
if (!cancelled || process.env.DEBUG) throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function runCli(args: Args): Promise<void> {
|
||||
const projectRoot = resolve(__dirname, "..");
|
||||
const cliPath = resolve(projectRoot, "build", "index.js");
|
||||
|
||||
const abort = new AbortController();
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
process.on("SIGINT", () => {
|
||||
cancelled = true;
|
||||
abort.abort();
|
||||
});
|
||||
|
||||
try {
|
||||
await spawnPromise("node", [cliPath, args.command, ...args.args], {
|
||||
env: { ...process.env, ...args.envArgs },
|
||||
signal: abort.signal,
|
||||
echoOutput: true,
|
||||
});
|
||||
} catch (e) {
|
||||
if (!cancelled || process.env.DEBUG) {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function loadConfigFile(configPath: string, serverName: string): ServerConfig {
|
||||
try {
|
||||
const resolvedConfigPath = path.isAbsolute(configPath)
|
||||
? configPath
|
||||
: path.resolve(process.cwd(), configPath);
|
||||
|
||||
if (!fs.existsSync(resolvedConfigPath)) {
|
||||
throw new Error(`Config file not found: ${resolvedConfigPath}`);
|
||||
}
|
||||
|
||||
const configContent = fs.readFileSync(resolvedConfigPath, "utf8");
|
||||
const parsedConfig = JSON.parse(configContent);
|
||||
|
||||
if (!parsedConfig.mcpServers || !parsedConfig.mcpServers[serverName]) {
|
||||
const availableServers = Object.keys(parsedConfig.mcpServers || {}).join(
|
||||
", ",
|
||||
);
|
||||
throw new Error(
|
||||
`Server '${serverName}' not found in config file. Available servers: ${availableServers}`,
|
||||
);
|
||||
}
|
||||
|
||||
const serverConfig = parsedConfig.mcpServers[serverName];
|
||||
|
||||
return serverConfig;
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof SyntaxError) {
|
||||
throw new Error(`Invalid JSON in config file: ${err.message}`);
|
||||
}
|
||||
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
function parseKeyValuePair(
|
||||
value: string,
|
||||
previous: Record<string, string> = {},
|
||||
): Record<string, string> {
|
||||
const parts = value.split("=");
|
||||
const key = parts[0];
|
||||
const val = parts.slice(1).join("=");
|
||||
|
||||
if (val === undefined || val === "") {
|
||||
throw new Error(
|
||||
`Invalid parameter format: ${value}. Use key=value format.`,
|
||||
);
|
||||
}
|
||||
|
||||
return { ...previous, [key as string]: val };
|
||||
}
|
||||
|
||||
function parseArgs(): Args {
|
||||
const program = new Command();
|
||||
|
||||
const argSeparatorIndex = process.argv.indexOf("--");
|
||||
let preArgs = process.argv;
|
||||
let postArgs: string[] = [];
|
||||
|
||||
if (argSeparatorIndex !== -1) {
|
||||
preArgs = process.argv.slice(0, argSeparatorIndex);
|
||||
postArgs = process.argv.slice(argSeparatorIndex + 1);
|
||||
}
|
||||
|
||||
program
|
||||
.name("inspector-bin")
|
||||
.allowExcessArguments()
|
||||
.allowUnknownOption()
|
||||
.option(
|
||||
"-e <env>",
|
||||
"environment variables in KEY=VALUE format",
|
||||
parseKeyValuePair,
|
||||
{},
|
||||
)
|
||||
.option("--config <path>", "config file path")
|
||||
.option("--server <n>", "server name from config file")
|
||||
.option("--cli", "enable CLI mode");
|
||||
|
||||
// Parse only the arguments before --
|
||||
program.parse(preArgs);
|
||||
|
||||
const options = program.opts() as CliOptions;
|
||||
const remainingArgs = program.args;
|
||||
|
||||
// Add back any arguments that came after --
|
||||
const finalArgs = [...remainingArgs, ...postArgs];
|
||||
|
||||
// Validate that config and server are provided together
|
||||
if (
|
||||
(options.config && !options.server) ||
|
||||
(!options.config && options.server)
|
||||
) {
|
||||
throw new Error(
|
||||
"Both --config and --server must be provided together. If you specify one, you must specify the other.",
|
||||
);
|
||||
}
|
||||
|
||||
// If config file is specified, load and use the options from the file. We must merge the args
|
||||
// from the command line and the file together, or we will miss the method options (--method,
|
||||
// etc.)
|
||||
if (options.config && options.server) {
|
||||
const config = loadConfigFile(options.config, options.server);
|
||||
|
||||
return {
|
||||
command: config.command,
|
||||
args: [...(config.args || []), ...finalArgs],
|
||||
envArgs: { ...(config.env || {}), ...(options.e || {}) },
|
||||
cli: options.cli || false,
|
||||
};
|
||||
}
|
||||
|
||||
// Otherwise use command line arguments
|
||||
const command = finalArgs[0] || "";
|
||||
const args = finalArgs.slice(1);
|
||||
|
||||
return {
|
||||
command,
|
||||
args,
|
||||
envArgs: options.e || {},
|
||||
cli: options.cli || false,
|
||||
};
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
process.on("uncaughtException", (error) => {
|
||||
handleError(error);
|
||||
});
|
||||
|
||||
try {
|
||||
const args = parseArgs();
|
||||
|
||||
if (args.cli) {
|
||||
runCli(args);
|
||||
} else {
|
||||
await runWebClient(args);
|
||||
}
|
||||
} catch (error) {
|
||||
handleError(error);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
51
cli/src/client/connection.ts
Normal file
51
cli/src/client/connection.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
||||
import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
|
||||
import { McpResponse } from "./types.js";
|
||||
|
||||
export const validLogLevels = [
|
||||
"trace",
|
||||
"debug",
|
||||
"info",
|
||||
"warn",
|
||||
"error",
|
||||
] as const;
|
||||
|
||||
export type LogLevel = (typeof validLogLevels)[number];
|
||||
|
||||
export async function connect(
|
||||
client: Client,
|
||||
transport: Transport,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await client.connect(transport);
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Failed to connect to MCP server: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function disconnect(transport: Transport): Promise<void> {
|
||||
try {
|
||||
await transport.close();
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Failed to disconnect from MCP server: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Set logging level
|
||||
export async function setLoggingLevel(
|
||||
client: Client,
|
||||
level: LogLevel,
|
||||
): Promise<McpResponse> {
|
||||
try {
|
||||
const response = await client.setLoggingLevel(level as any);
|
||||
return response;
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Failed to set logging level: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
6
cli/src/client/index.ts
Normal file
6
cli/src/client/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
// Re-export everything from the client modules
|
||||
export * from "./connection.js";
|
||||
export * from "./prompts.js";
|
||||
export * from "./resources.js";
|
||||
export * from "./tools.js";
|
||||
export * from "./types.js";
|
||||
34
cli/src/client/prompts.ts
Normal file
34
cli/src/client/prompts.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
||||
import { McpResponse } from "./types.js";
|
||||
|
||||
// List available prompts
|
||||
export async function listPrompts(client: Client): Promise<McpResponse> {
|
||||
try {
|
||||
const response = await client.listPrompts();
|
||||
return response;
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Failed to list prompts: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Get a prompt
|
||||
export async function getPrompt(
|
||||
client: Client,
|
||||
name: string,
|
||||
args?: Record<string, string>,
|
||||
): Promise<McpResponse> {
|
||||
try {
|
||||
const response = await client.getPrompt({
|
||||
name,
|
||||
arguments: args || {},
|
||||
});
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Failed to get prompt: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
43
cli/src/client/resources.ts
Normal file
43
cli/src/client/resources.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
||||
import { McpResponse } from "./types.js";
|
||||
|
||||
// List available resources
|
||||
export async function listResources(client: Client): Promise<McpResponse> {
|
||||
try {
|
||||
const response = await client.listResources();
|
||||
return response;
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Failed to list resources: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Read a resource
|
||||
export async function readResource(
|
||||
client: Client,
|
||||
uri: string,
|
||||
): Promise<McpResponse> {
|
||||
try {
|
||||
const response = await client.readResource({ uri });
|
||||
return response;
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Failed to read resource ${uri}: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// List resource templates
|
||||
export async function listResourceTemplates(
|
||||
client: Client,
|
||||
): Promise<McpResponse> {
|
||||
try {
|
||||
const response = await client.listResourceTemplates();
|
||||
return response;
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Failed to list resource templates: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
95
cli/src/client/tools.ts
Normal file
95
cli/src/client/tools.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
||||
import { Tool } from "@modelcontextprotocol/sdk/types.js";
|
||||
import { McpResponse } from "./types.js";
|
||||
|
||||
type JsonSchemaType = {
|
||||
type: "string" | "number" | "integer" | "boolean" | "array" | "object";
|
||||
description?: string;
|
||||
properties?: Record<string, JsonSchemaType>;
|
||||
items?: JsonSchemaType;
|
||||
};
|
||||
|
||||
export async function listTools(client: Client): Promise<McpResponse> {
|
||||
try {
|
||||
const response = await client.listTools();
|
||||
return response;
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Failed to list tools: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function convertParameterValue(value: string, schema: JsonSchemaType): unknown {
|
||||
if (!value) {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (schema.type === "number" || schema.type === "integer") {
|
||||
return Number(value);
|
||||
}
|
||||
|
||||
if (schema.type === "boolean") {
|
||||
return value.toLowerCase() === "true";
|
||||
}
|
||||
|
||||
if (schema.type === "object" || schema.type === "array") {
|
||||
try {
|
||||
return JSON.parse(value);
|
||||
} catch (error) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
function convertParameters(
|
||||
tool: Tool,
|
||||
params: Record<string, string>,
|
||||
): Record<string, unknown> {
|
||||
const result: Record<string, unknown> = {};
|
||||
const properties = tool.inputSchema.properties || {};
|
||||
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
const paramSchema = properties[key] as JsonSchemaType | undefined;
|
||||
|
||||
if (paramSchema) {
|
||||
result[key] = convertParameterValue(value, paramSchema);
|
||||
} else {
|
||||
// If no schema is found for this parameter, keep it as string
|
||||
result[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function callTool(
|
||||
client: Client,
|
||||
name: string,
|
||||
args: Record<string, string>,
|
||||
): Promise<McpResponse> {
|
||||
try {
|
||||
const toolsResponse = await listTools(client);
|
||||
const tools = toolsResponse.tools as Tool[];
|
||||
const tool = tools.find((t) => t.name === name);
|
||||
|
||||
let convertedArgs: Record<string, unknown> = args;
|
||||
|
||||
if (tool) {
|
||||
// Convert parameters based on the tool's schema
|
||||
convertedArgs = convertParameters(tool, args);
|
||||
}
|
||||
|
||||
const response = await client.callTool({
|
||||
name: name,
|
||||
arguments: convertedArgs,
|
||||
});
|
||||
return response;
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Failed to call tool ${name}: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
1
cli/src/client/types.ts
Normal file
1
cli/src/client/types.ts
Normal file
@@ -0,0 +1 @@
|
||||
export type McpResponse = Record<string, unknown>;
|
||||
20
cli/src/error-handler.ts
Normal file
20
cli/src/error-handler.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
function formatError(error: unknown): string {
|
||||
let message: string;
|
||||
|
||||
if (error instanceof Error) {
|
||||
message = error.message;
|
||||
} else if (typeof error === "string") {
|
||||
message = error;
|
||||
} else {
|
||||
message = "Unknown error";
|
||||
}
|
||||
|
||||
return message;
|
||||
}
|
||||
|
||||
export function handleError(error: unknown): never {
|
||||
const errorMessage = formatError(error);
|
||||
console.error(errorMessage);
|
||||
|
||||
process.exit(1);
|
||||
}
|
||||
253
cli/src/index.ts
Normal file
253
cli/src/index.ts
Normal file
@@ -0,0 +1,253 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
||||
import { Command } from "commander";
|
||||
import {
|
||||
callTool,
|
||||
connect,
|
||||
disconnect,
|
||||
getPrompt,
|
||||
listPrompts,
|
||||
listResources,
|
||||
listResourceTemplates,
|
||||
listTools,
|
||||
LogLevel,
|
||||
McpResponse,
|
||||
readResource,
|
||||
setLoggingLevel,
|
||||
validLogLevels,
|
||||
} from "./client/index.js";
|
||||
import { handleError } from "./error-handler.js";
|
||||
import { createTransport, TransportOptions } from "./transport.js";
|
||||
|
||||
type Args = {
|
||||
target: string[];
|
||||
method?: string;
|
||||
promptName?: string;
|
||||
promptArgs?: Record<string, string>;
|
||||
uri?: string;
|
||||
logLevel?: LogLevel;
|
||||
toolName?: string;
|
||||
toolArg?: Record<string, string>;
|
||||
};
|
||||
|
||||
function createTransportOptions(target: string[]): TransportOptions {
|
||||
if (target.length === 0) {
|
||||
throw new Error(
|
||||
"Target is required. Specify a URL or a command to execute.",
|
||||
);
|
||||
}
|
||||
|
||||
const [command, ...commandArgs] = target;
|
||||
|
||||
if (!command) {
|
||||
throw new Error("Command is required.");
|
||||
}
|
||||
|
||||
const isUrl = command.startsWith("http://") || command.startsWith("https://");
|
||||
|
||||
if (isUrl && commandArgs.length > 0) {
|
||||
throw new Error("Arguments cannot be passed to a URL-based MCP server.");
|
||||
}
|
||||
|
||||
return {
|
||||
transportType: isUrl ? "sse" : "stdio",
|
||||
command: isUrl ? undefined : command,
|
||||
args: isUrl ? undefined : commandArgs,
|
||||
url: isUrl ? command : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
async function callMethod(args: Args): Promise<void> {
|
||||
const transportOptions = createTransportOptions(args.target);
|
||||
const transport = createTransport(transportOptions);
|
||||
const client = new Client({
|
||||
name: "inspector-cli",
|
||||
version: "0.5.1",
|
||||
});
|
||||
|
||||
try {
|
||||
await connect(client, transport);
|
||||
|
||||
let result: McpResponse;
|
||||
|
||||
// Tools methods
|
||||
if (args.method === "tools/list") {
|
||||
result = await listTools(client);
|
||||
} else if (args.method === "tools/call") {
|
||||
if (!args.toolName) {
|
||||
throw new Error(
|
||||
"Tool name is required for tools/call method. Use --tool-name to specify the tool name.",
|
||||
);
|
||||
}
|
||||
|
||||
result = await callTool(client, args.toolName, args.toolArg || {});
|
||||
}
|
||||
// Resources methods
|
||||
else if (args.method === "resources/list") {
|
||||
result = await listResources(client);
|
||||
} else if (args.method === "resources/read") {
|
||||
if (!args.uri) {
|
||||
throw new Error(
|
||||
"URI is required for resources/read method. Use --uri to specify the resource URI.",
|
||||
);
|
||||
}
|
||||
|
||||
result = await readResource(client, args.uri);
|
||||
} else if (args.method === "resources/templates/list") {
|
||||
result = await listResourceTemplates(client);
|
||||
}
|
||||
// Prompts methods
|
||||
else if (args.method === "prompts/list") {
|
||||
result = await listPrompts(client);
|
||||
} else if (args.method === "prompts/get") {
|
||||
if (!args.promptName) {
|
||||
throw new Error(
|
||||
"Prompt name is required for prompts/get method. Use --prompt-name to specify the prompt name.",
|
||||
);
|
||||
}
|
||||
|
||||
result = await getPrompt(client, args.promptName, args.promptArgs || {});
|
||||
}
|
||||
// Logging methods
|
||||
else if (args.method === "logging/setLevel") {
|
||||
if (!args.logLevel) {
|
||||
throw new Error(
|
||||
"Log level is required for logging/setLevel method. Use --log-level to specify the log level.",
|
||||
);
|
||||
}
|
||||
|
||||
result = await setLoggingLevel(client, args.logLevel);
|
||||
} else {
|
||||
throw new Error(
|
||||
`Unsupported method: ${args.method}. Supported methods include: tools/list, tools/call, resources/list, resources/read, resources/templates/list, prompts/list, prompts/get, logging/setLevel`,
|
||||
);
|
||||
}
|
||||
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
} finally {
|
||||
try {
|
||||
await disconnect(transport);
|
||||
} catch (disconnectError) {
|
||||
throw disconnectError;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function parseKeyValuePair(
|
||||
value: string,
|
||||
previous: Record<string, string> = {},
|
||||
): Record<string, string> {
|
||||
const parts = value.split("=");
|
||||
const key = parts[0];
|
||||
const val = parts.slice(1).join("=");
|
||||
|
||||
if (val === undefined || val === "") {
|
||||
throw new Error(
|
||||
`Invalid parameter format: ${value}. Use key=value format.`,
|
||||
);
|
||||
}
|
||||
|
||||
return { ...previous, [key as string]: val };
|
||||
}
|
||||
|
||||
function parseArgs(): Args {
|
||||
const program = new Command();
|
||||
|
||||
// Find if there's a -- in the arguments and split them
|
||||
const argSeparatorIndex = process.argv.indexOf("--");
|
||||
let preArgs = process.argv;
|
||||
let postArgs: string[] = [];
|
||||
|
||||
if (argSeparatorIndex !== -1) {
|
||||
preArgs = process.argv.slice(0, argSeparatorIndex);
|
||||
postArgs = process.argv.slice(argSeparatorIndex + 1);
|
||||
}
|
||||
|
||||
program
|
||||
.name("inspector-cli")
|
||||
.allowUnknownOption()
|
||||
.argument("<target...>", "Command and arguments or URL of the MCP server")
|
||||
//
|
||||
// Method selection
|
||||
//
|
||||
.option("--method <method>", "Method to invoke")
|
||||
//
|
||||
// Tool-related options
|
||||
//
|
||||
.option("--tool-name <toolName>", "Tool name (for tools/call method)")
|
||||
.option(
|
||||
"--tool-arg <pairs...>",
|
||||
"Tool argument as key=value pair",
|
||||
parseKeyValuePair,
|
||||
{},
|
||||
)
|
||||
//
|
||||
// Resource-related options
|
||||
//
|
||||
.option("--uri <uri>", "URI of the resource (for resources/read method)")
|
||||
//
|
||||
// Prompt-related options
|
||||
//
|
||||
.option(
|
||||
"--prompt-name <promptName>",
|
||||
"Name of the prompt (for prompts/get method)",
|
||||
)
|
||||
.option(
|
||||
"--prompt-args <pairs...>",
|
||||
"Prompt arguments as key=value pairs",
|
||||
parseKeyValuePair,
|
||||
{},
|
||||
)
|
||||
//
|
||||
// Logging options
|
||||
//
|
||||
.option(
|
||||
"--log-level <level>",
|
||||
"Logging level (for logging/setLevel method)",
|
||||
(value: string) => {
|
||||
if (!validLogLevels.includes(value as any)) {
|
||||
throw new Error(
|
||||
`Invalid log level: ${value}. Valid levels are: ${validLogLevels.join(", ")}`,
|
||||
);
|
||||
}
|
||||
|
||||
return value as LogLevel;
|
||||
},
|
||||
);
|
||||
|
||||
// Parse only the arguments before --
|
||||
program.parse(preArgs);
|
||||
|
||||
const options = program.opts() as Omit<Args, "target">;
|
||||
let remainingArgs = program.args;
|
||||
|
||||
// Add back any arguments that came after --
|
||||
const finalArgs = [...remainingArgs, ...postArgs];
|
||||
|
||||
if (!options.method) {
|
||||
throw new Error(
|
||||
"Method is required. Use --method to specify the method to invoke.",
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
target: finalArgs,
|
||||
...options,
|
||||
};
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
process.on("uncaughtException", (error) => {
|
||||
handleError(error);
|
||||
});
|
||||
|
||||
try {
|
||||
const args = parseArgs();
|
||||
await callMethod(args);
|
||||
} catch (error) {
|
||||
handleError(error);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
76
cli/src/transport.ts
Normal file
76
cli/src/transport.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
|
||||
import {
|
||||
getDefaultEnvironment,
|
||||
StdioClientTransport,
|
||||
} from "@modelcontextprotocol/sdk/client/stdio.js";
|
||||
import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
|
||||
import { findActualExecutable } from "spawn-rx";
|
||||
|
||||
export type TransportOptions = {
|
||||
transportType: "sse" | "stdio";
|
||||
command?: string;
|
||||
args?: string[];
|
||||
url?: string;
|
||||
};
|
||||
|
||||
function createSSETransport(options: TransportOptions): Transport {
|
||||
const baseUrl = new URL(options.url ?? "");
|
||||
const sseUrl = new URL("/sse", baseUrl);
|
||||
|
||||
return new SSEClientTransport(sseUrl);
|
||||
}
|
||||
|
||||
function createStdioTransport(options: TransportOptions): Transport {
|
||||
let args: string[] = [];
|
||||
|
||||
if (options.args !== undefined) {
|
||||
args = options.args;
|
||||
}
|
||||
|
||||
const processEnv: Record<string, string> = {};
|
||||
|
||||
for (const [key, value] of Object.entries(process.env)) {
|
||||
if (value !== undefined) {
|
||||
processEnv[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
const defaultEnv = getDefaultEnvironment();
|
||||
|
||||
const env: Record<string, string> = {
|
||||
...processEnv,
|
||||
...defaultEnv,
|
||||
};
|
||||
|
||||
const { cmd: actualCommand, args: actualArgs } = findActualExecutable(
|
||||
options.command ?? "",
|
||||
args,
|
||||
);
|
||||
|
||||
return new StdioClientTransport({
|
||||
command: actualCommand,
|
||||
args: actualArgs,
|
||||
env,
|
||||
stderr: "pipe",
|
||||
});
|
||||
}
|
||||
|
||||
export function createTransport(options: TransportOptions): Transport {
|
||||
const { transportType } = options;
|
||||
|
||||
try {
|
||||
if (transportType === "stdio") {
|
||||
return createStdioTransport(options);
|
||||
}
|
||||
|
||||
if (transportType === "sse") {
|
||||
return createSSETransport(options);
|
||||
}
|
||||
|
||||
throw new Error(`Unsupported transport type: ${transportType}`);
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Failed to create transport: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
17
cli/tsconfig.json
Normal file
17
cli/tsconfig.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"outDir": "./build",
|
||||
"rootDir": "./src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"noUncheckedIndexedAccess": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "packages", "**/*.spec.ts", "build"]
|
||||
}
|
||||
@@ -46,7 +46,7 @@ async function main() {
|
||||
|
||||
const inspectorServerPath = resolve(
|
||||
__dirname,
|
||||
"..",
|
||||
"../..",
|
||||
"server",
|
||||
"build",
|
||||
"index.js",
|
||||
@@ -55,10 +55,10 @@ async function main() {
|
||||
// Path to the client entry point
|
||||
const inspectorClientPath = resolve(
|
||||
__dirname,
|
||||
"..",
|
||||
"../..",
|
||||
"client",
|
||||
"bin",
|
||||
"cli.js",
|
||||
"client.js",
|
||||
);
|
||||
|
||||
const CLIENT_PORT = process.env.CLIENT_PORT ?? "6274";
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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[]
|
||||
@@ -117,6 +119,10 @@ const App = () => {
|
||||
return localStorage.getItem("lastBearerToken") || "";
|
||||
});
|
||||
|
||||
const [headerName, setHeaderName] = useState<string>(() => {
|
||||
return localStorage.getItem("lastHeaderName") || "";
|
||||
});
|
||||
|
||||
const [pendingSampleRequests, setPendingSampleRequests] = useState<
|
||||
Array<
|
||||
PendingRequest & {
|
||||
@@ -169,6 +175,7 @@ const App = () => {
|
||||
sseUrl,
|
||||
env,
|
||||
bearerToken,
|
||||
headerName,
|
||||
config,
|
||||
onNotification: (notification) => {
|
||||
setNotifications((prev) => [...prev, notification as ServerNotification]);
|
||||
@@ -208,35 +215,23 @@ const App = () => {
|
||||
localStorage.setItem("lastBearerToken", bearerToken);
|
||||
}, [bearerToken]);
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem("lastHeaderName", headerName);
|
||||
}, [headerName]);
|
||||
|
||||
useEffect(() => {
|
||||
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`)
|
||||
@@ -467,13 +462,17 @@ const App = () => {
|
||||
setLogLevel(level);
|
||||
};
|
||||
|
||||
const clearStdErrNotifications = () => {
|
||||
setStdErrNotifications([]);
|
||||
};
|
||||
|
||||
if (window.location.pathname === "/oauth/callback") {
|
||||
const OAuthCallback = React.lazy(
|
||||
() => import("./components/OAuthCallback"),
|
||||
);
|
||||
return (
|
||||
<Suspense fallback={<div>Loading...</div>}>
|
||||
<OAuthCallback />
|
||||
<OAuthCallback onConnect={onOAuthConnect} />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -496,12 +495,15 @@ const App = () => {
|
||||
setConfig={setConfig}
|
||||
bearerToken={bearerToken}
|
||||
setBearerToken={setBearerToken}
|
||||
headerName={headerName}
|
||||
setHeaderName={setHeaderName}
|
||||
onConnect={connectMcpServer}
|
||||
onDisconnect={disconnectMcpServer}
|
||||
stdErrNotifications={stdErrNotifications}
|
||||
logLevel={logLevel}
|
||||
sendLogLevelRequest={sendLogLevelRequest}
|
||||
loggingSupported={!!serverCapabilities?.logging || false}
|
||||
clearStdErrNotifications={clearStdErrNotifications}
|
||||
/>
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
<div className="flex-1 overflow-auto">
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
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;
|
||||
@@ -14,13 +13,23 @@ interface DynamicJsonFormProps {
|
||||
maxDepth?: number;
|
||||
}
|
||||
|
||||
const isSimpleObject = (schema: JsonSchemaType): boolean => {
|
||||
const supportedTypes = ["string", "number", "integer", "boolean", "null"];
|
||||
if (supportedTypes.includes(schema.type)) return true;
|
||||
if (schema.type !== "object") return false;
|
||||
return Object.values(schema.properties ?? {}).every((prop) =>
|
||||
supportedTypes.includes(prop.type),
|
||||
);
|
||||
};
|
||||
|
||||
const DynamicJsonForm = ({
|
||||
schema,
|
||||
value,
|
||||
onChange,
|
||||
maxDepth = 3,
|
||||
}: DynamicJsonFormProps) => {
|
||||
const [isJsonMode, setIsJsonMode] = useState(false);
|
||||
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
|
||||
@@ -207,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;
|
||||
}
|
||||
@@ -350,9 +254,11 @@ const DynamicJsonForm = ({
|
||||
Format JSON
|
||||
</Button>
|
||||
)}
|
||||
<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 ? (
|
||||
|
||||
@@ -227,7 +227,7 @@ const JsonNode = memo(
|
||||
)}
|
||||
<pre
|
||||
className={clsx(
|
||||
typeStyleMap.string,
|
||||
isError ? typeStyleMap.error : typeStyleMap.string,
|
||||
"break-all whitespace-pre-wrap",
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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({});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -51,9 +51,12 @@ interface SidebarProps {
|
||||
setEnv: (env: Record<string, string>) => void;
|
||||
bearerToken: string;
|
||||
setBearerToken: (token: string) => void;
|
||||
headerName?: string;
|
||||
setHeaderName?: (name: string) => void;
|
||||
onConnect: () => void;
|
||||
onDisconnect: () => void;
|
||||
stdErrNotifications: StdErrNotification[];
|
||||
clearStdErrNotifications: () => void;
|
||||
logLevel: LoggingLevel;
|
||||
sendLogLevelRequest: (level: LoggingLevel) => void;
|
||||
loggingSupported: boolean;
|
||||
@@ -75,9 +78,12 @@ const Sidebar = ({
|
||||
setEnv,
|
||||
bearerToken,
|
||||
setBearerToken,
|
||||
headerName,
|
||||
setHeaderName,
|
||||
onConnect,
|
||||
onDisconnect,
|
||||
stdErrNotifications,
|
||||
clearStdErrNotifications,
|
||||
logLevel,
|
||||
sendLogLevelRequest,
|
||||
loggingSupported,
|
||||
@@ -174,6 +180,7 @@ const Sidebar = ({
|
||||
variant="outline"
|
||||
onClick={() => setShowBearerToken(!showBearerToken)}
|
||||
className="flex items-center w-full"
|
||||
data-testid="auth-button"
|
||||
aria-expanded={showBearerToken}
|
||||
>
|
||||
{showBearerToken ? (
|
||||
@@ -185,6 +192,16 @@ const Sidebar = ({
|
||||
</Button>
|
||||
{showBearerToken && (
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Header Name</label>
|
||||
<Input
|
||||
placeholder="Authorization"
|
||||
onChange={(e) =>
|
||||
setHeaderName && setHeaderName(e.target.value)
|
||||
}
|
||||
data-testid="header-input"
|
||||
className="font-mono"
|
||||
value={headerName}
|
||||
/>
|
||||
<label
|
||||
className="text-sm font-medium"
|
||||
htmlFor="bearer-token-input"
|
||||
@@ -196,6 +213,7 @@ const Sidebar = ({
|
||||
placeholder="Bearer Token"
|
||||
value={bearerToken}
|
||||
onChange={(e) => setBearerToken(e.target.value)}
|
||||
data-testid="bearer-token-input"
|
||||
className="font-mono"
|
||||
type="password"
|
||||
/>
|
||||
@@ -504,7 +522,9 @@ const Sidebar = ({
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.values(LoggingLevelSchema.enum).map((level) => (
|
||||
<SelectItem value={level}>{level}</SelectItem>
|
||||
<SelectItem key={level} value={level}>
|
||||
{level}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
@@ -514,9 +534,19 @@ const Sidebar = ({
|
||||
{stdErrNotifications.length > 0 && (
|
||||
<>
|
||||
<div className="mt-4 border-t border-gray-200 pt-4">
|
||||
<h3 className="text-sm font-medium">
|
||||
Error output from MCP server
|
||||
</h3>
|
||||
<div className="flex justify-between items-center">
|
||||
<h3 className="text-sm font-medium">
|
||||
Error output from MCP server
|
||||
</h3>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={clearStdErrNotifications}
|
||||
className="h-8 px-2"
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
</div>
|
||||
<div className="mt-2 max-h-80 overflow-y-auto">
|
||||
{stdErrNotifications.map((notification, index) => (
|
||||
<div
|
||||
|
||||
@@ -43,7 +43,13 @@ const ToolsTab = ({
|
||||
const [isToolRunning, setIsToolRunning] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setParams({});
|
||||
const params = Object.entries(
|
||||
selectedTool?.inputSchema.properties ?? [],
|
||||
).map(([key, value]) => [
|
||||
key,
|
||||
generateDefaultValue(value as JsonSchemaType),
|
||||
]);
|
||||
setParams(Object.fromEntries(params));
|
||||
}, [selectedTool]);
|
||||
|
||||
const renderToolResult = () => {
|
||||
@@ -217,13 +223,10 @@ const ToolsTab = ({
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
) : prop.type === "number" ||
|
||||
prop.type === "integer" ? (
|
||||
<Input
|
||||
type={
|
||||
prop.type === "number" || prop.type === "integer"
|
||||
? "number"
|
||||
: "text"
|
||||
}
|
||||
type="number"
|
||||
id={key}
|
||||
name={key}
|
||||
placeholder={prop.description}
|
||||
@@ -231,15 +234,29 @@ const ToolsTab = ({
|
||||
onChange={(e) =>
|
||||
setParams({
|
||||
...params,
|
||||
[key]:
|
||||
prop.type === "number" ||
|
||||
prop.type === "integer"
|
||||
? Number(e.target.value)
|
||||
: e.target.value,
|
||||
[key]: Number(e.target.value),
|
||||
})
|
||||
}
|
||||
className="mt-1"
|
||||
/>
|
||||
) : (
|
||||
<div className="mt-1">
|
||||
<DynamicJsonForm
|
||||
schema={{
|
||||
type: prop.type,
|
||||
properties: prop.properties,
|
||||
description: prop.description,
|
||||
items: prop.items,
|
||||
}}
|
||||
value={params[key] as JsonValue}
|
||||
onChange={(newValue: JsonValue) => {
|
||||
setParams({
|
||||
...params,
|
||||
[key]: newValue,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import { describe, it, expect, jest } from "@jest/globals";
|
||||
import DynamicJsonForm from "../DynamicJsonForm";
|
||||
import type { JsonSchemaType } from "@/utils/jsonUtils";
|
||||
@@ -93,3 +93,47 @@ describe("DynamicJsonForm Integer Fields", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("DynamicJsonForm Complex Fields", () => {
|
||||
const renderForm = (props = {}) => {
|
||||
const defaultProps = {
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
// The simplified JsonSchemaType does not accept oneOf fields
|
||||
// But they exist in the more-complete JsonSchema7Type
|
||||
nested: { oneOf: [{ type: "string" }, { type: "integer" }] },
|
||||
},
|
||||
} as unknown as JsonSchemaType,
|
||||
value: undefined,
|
||||
onChange: jest.fn(),
|
||||
};
|
||||
return render(<DynamicJsonForm {...defaultProps} {...props} />);
|
||||
};
|
||||
|
||||
describe("Basic Operations", () => {
|
||||
it("should render textbox and autoformat button, but no switch-to-form button", () => {
|
||||
renderForm();
|
||||
const input = screen.getByRole("textbox");
|
||||
expect(input).toHaveProperty("type", "textarea");
|
||||
const buttons = screen.getAllByRole("button");
|
||||
expect(buttons).toHaveLength(1);
|
||||
expect(buttons[0]).toHaveProperty("textContent", "Format JSON");
|
||||
});
|
||||
|
||||
it("should pass changed values to onChange", () => {
|
||||
const onChange = jest.fn();
|
||||
renderForm({ onChange });
|
||||
|
||||
const input = screen.getByRole("textbox");
|
||||
fireEvent.change(input, {
|
||||
target: { value: `{ "nested": "i am string" }` },
|
||||
});
|
||||
|
||||
// The onChange handler is debounced when using the JSON view, so we need to wait a little bit
|
||||
waitFor(() => {
|
||||
expect(onChange).toHaveBeenCalledWith(`{ "nested": "i am string" }`);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import "@testing-library/jest-dom";
|
||||
import { describe, it, beforeEach, jest } from "@jest/globals";
|
||||
import Sidebar from "../Sidebar";
|
||||
import { DEFAULT_INSPECTOR_CONFIG } from "@/lib/constants";
|
||||
@@ -29,6 +30,7 @@ describe("Sidebar Environment Variables", () => {
|
||||
onConnect: jest.fn(),
|
||||
onDisconnect: jest.fn(),
|
||||
stdErrNotifications: [],
|
||||
clearStdErrNotifications: jest.fn(),
|
||||
logLevel: "info" as const,
|
||||
sendLogLevelRequest: jest.fn(),
|
||||
loggingSupported: true,
|
||||
@@ -108,6 +110,157 @@ describe("Sidebar Environment Variables", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("Authentication", () => {
|
||||
const openAuthSection = () => {
|
||||
const button = screen.getByTestId("auth-button");
|
||||
fireEvent.click(button);
|
||||
};
|
||||
|
||||
it("should update bearer token", () => {
|
||||
const setBearerToken = jest.fn();
|
||||
renderSidebar({
|
||||
bearerToken: "",
|
||||
setBearerToken,
|
||||
transportType: "sse", // Set transport type to SSE
|
||||
});
|
||||
|
||||
openAuthSection();
|
||||
|
||||
const tokenInput = screen.getByTestId("bearer-token-input");
|
||||
fireEvent.change(tokenInput, { target: { value: "new_token" } });
|
||||
|
||||
expect(setBearerToken).toHaveBeenCalledWith("new_token");
|
||||
});
|
||||
|
||||
it("should update header name", () => {
|
||||
const setHeaderName = jest.fn();
|
||||
renderSidebar({
|
||||
headerName: "Authorization",
|
||||
setHeaderName,
|
||||
transportType: "sse",
|
||||
});
|
||||
|
||||
openAuthSection();
|
||||
|
||||
const headerInput = screen.getByTestId("header-input");
|
||||
fireEvent.change(headerInput, { target: { value: "X-Custom-Auth" } });
|
||||
|
||||
expect(setHeaderName).toHaveBeenCalledWith("X-Custom-Auth");
|
||||
});
|
||||
|
||||
it("should clear bearer token", () => {
|
||||
const setBearerToken = jest.fn();
|
||||
renderSidebar({
|
||||
bearerToken: "existing_token",
|
||||
setBearerToken,
|
||||
transportType: "sse", // Set transport type to SSE
|
||||
});
|
||||
|
||||
openAuthSection();
|
||||
|
||||
const tokenInput = screen.getByTestId("bearer-token-input");
|
||||
fireEvent.change(tokenInput, { target: { value: "" } });
|
||||
|
||||
expect(setBearerToken).toHaveBeenCalledWith("");
|
||||
});
|
||||
|
||||
it("should properly render bearer token input", () => {
|
||||
const { rerender } = renderSidebar({
|
||||
bearerToken: "existing_token",
|
||||
transportType: "sse", // Set transport type to SSE
|
||||
});
|
||||
|
||||
openAuthSection();
|
||||
|
||||
// Token input should be a password field
|
||||
const tokenInput = screen.getByTestId("bearer-token-input");
|
||||
expect(tokenInput).toHaveProperty("type", "password");
|
||||
|
||||
// Update the token
|
||||
fireEvent.change(tokenInput, { target: { value: "new_token" } });
|
||||
|
||||
// Rerender with updated token
|
||||
rerender(
|
||||
<TooltipProvider>
|
||||
<Sidebar
|
||||
{...defaultProps}
|
||||
bearerToken="new_token"
|
||||
transportType="sse"
|
||||
/>
|
||||
</TooltipProvider>,
|
||||
);
|
||||
|
||||
// Token input should still exist after update
|
||||
expect(screen.getByTestId("bearer-token-input")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should maintain token visibility state after update", () => {
|
||||
const { rerender } = renderSidebar({
|
||||
bearerToken: "existing_token",
|
||||
transportType: "sse", // Set transport type to SSE
|
||||
});
|
||||
|
||||
openAuthSection();
|
||||
|
||||
// Token input should be a password field
|
||||
const tokenInput = screen.getByTestId("bearer-token-input");
|
||||
expect(tokenInput).toHaveProperty("type", "password");
|
||||
|
||||
// Update the token
|
||||
fireEvent.change(tokenInput, { target: { value: "new_token" } });
|
||||
|
||||
// Rerender with updated token
|
||||
rerender(
|
||||
<TooltipProvider>
|
||||
<Sidebar
|
||||
{...defaultProps}
|
||||
bearerToken="new_token"
|
||||
transportType="sse"
|
||||
/>
|
||||
</TooltipProvider>,
|
||||
);
|
||||
|
||||
// Token input should still exist after update
|
||||
expect(screen.getByTestId("bearer-token-input")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should maintain header name when toggling auth section", () => {
|
||||
renderSidebar({
|
||||
headerName: "X-API-Key",
|
||||
transportType: "sse",
|
||||
});
|
||||
|
||||
// Open auth section
|
||||
openAuthSection();
|
||||
|
||||
// Verify header name is displayed
|
||||
const headerInput = screen.getByTestId("header-input");
|
||||
expect(headerInput).toHaveValue("X-API-Key");
|
||||
|
||||
// Close auth section
|
||||
const authButton = screen.getByTestId("auth-button");
|
||||
fireEvent.click(authButton);
|
||||
|
||||
// Reopen auth section
|
||||
fireEvent.click(authButton);
|
||||
|
||||
// Verify header name is still preserved
|
||||
expect(screen.getByTestId("header-input")).toHaveValue("X-API-Key");
|
||||
});
|
||||
|
||||
it("should display default header name when not specified", () => {
|
||||
renderSidebar({
|
||||
headerName: undefined,
|
||||
transportType: "sse",
|
||||
});
|
||||
|
||||
openAuthSection();
|
||||
|
||||
const headerInput = screen.getByTestId("header-input");
|
||||
expect(headerInput).toHaveAttribute("placeholder", "Authorization");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Key Editing", () => {
|
||||
it("should maintain order when editing first key", () => {
|
||||
const setEnv = jest.fn();
|
||||
|
||||
@@ -5,9 +5,14 @@ import {
|
||||
OAuthTokens,
|
||||
OAuthTokensSchema,
|
||||
} from "@modelcontextprotocol/sdk/shared/auth.js";
|
||||
import { SESSION_KEYS } from "./constants";
|
||||
import { SESSION_KEYS, getServerSpecificKey } from "./constants";
|
||||
|
||||
export class InspectorOAuthClientProvider implements OAuthClientProvider {
|
||||
constructor(private serverUrl: string) {
|
||||
// Save the server URL to session storage
|
||||
sessionStorage.setItem(SESSION_KEYS.SERVER_URL, serverUrl);
|
||||
}
|
||||
|
||||
class InspectorOAuthClientProvider implements OAuthClientProvider {
|
||||
get redirectUrl() {
|
||||
return window.location.origin + "/oauth/callback";
|
||||
}
|
||||
@@ -24,7 +29,11 @@ class InspectorOAuthClientProvider implements OAuthClientProvider {
|
||||
}
|
||||
|
||||
async clientInformation() {
|
||||
const value = sessionStorage.getItem(SESSION_KEYS.CLIENT_INFORMATION);
|
||||
const key = getServerSpecificKey(
|
||||
SESSION_KEYS.CLIENT_INFORMATION,
|
||||
this.serverUrl,
|
||||
);
|
||||
const value = sessionStorage.getItem(key);
|
||||
if (!value) {
|
||||
return undefined;
|
||||
}
|
||||
@@ -33,14 +42,16 @@ class InspectorOAuthClientProvider implements OAuthClientProvider {
|
||||
}
|
||||
|
||||
saveClientInformation(clientInformation: OAuthClientInformation) {
|
||||
sessionStorage.setItem(
|
||||
const key = getServerSpecificKey(
|
||||
SESSION_KEYS.CLIENT_INFORMATION,
|
||||
JSON.stringify(clientInformation),
|
||||
this.serverUrl,
|
||||
);
|
||||
sessionStorage.setItem(key, JSON.stringify(clientInformation));
|
||||
}
|
||||
|
||||
async tokens() {
|
||||
const tokens = sessionStorage.getItem(SESSION_KEYS.TOKENS);
|
||||
const key = getServerSpecificKey(SESSION_KEYS.TOKENS, this.serverUrl);
|
||||
const tokens = sessionStorage.getItem(key);
|
||||
if (!tokens) {
|
||||
return undefined;
|
||||
}
|
||||
@@ -49,7 +60,8 @@ class InspectorOAuthClientProvider implements OAuthClientProvider {
|
||||
}
|
||||
|
||||
saveTokens(tokens: OAuthTokens) {
|
||||
sessionStorage.setItem(SESSION_KEYS.TOKENS, JSON.stringify(tokens));
|
||||
const key = getServerSpecificKey(SESSION_KEYS.TOKENS, this.serverUrl);
|
||||
sessionStorage.setItem(key, JSON.stringify(tokens));
|
||||
}
|
||||
|
||||
redirectToAuthorization(authorizationUrl: URL) {
|
||||
@@ -57,17 +69,35 @@ class InspectorOAuthClientProvider implements OAuthClientProvider {
|
||||
}
|
||||
|
||||
saveCodeVerifier(codeVerifier: string) {
|
||||
sessionStorage.setItem(SESSION_KEYS.CODE_VERIFIER, codeVerifier);
|
||||
const key = getServerSpecificKey(
|
||||
SESSION_KEYS.CODE_VERIFIER,
|
||||
this.serverUrl,
|
||||
);
|
||||
sessionStorage.setItem(key, codeVerifier);
|
||||
}
|
||||
|
||||
codeVerifier() {
|
||||
const verifier = sessionStorage.getItem(SESSION_KEYS.CODE_VERIFIER);
|
||||
const key = getServerSpecificKey(
|
||||
SESSION_KEYS.CODE_VERIFIER,
|
||||
this.serverUrl,
|
||||
);
|
||||
const verifier = sessionStorage.getItem(key);
|
||||
if (!verifier) {
|
||||
throw new Error("No code verifier saved for session");
|
||||
}
|
||||
|
||||
return verifier;
|
||||
}
|
||||
}
|
||||
|
||||
export const authProvider = new InspectorOAuthClientProvider();
|
||||
clear() {
|
||||
sessionStorage.removeItem(
|
||||
getServerSpecificKey(SESSION_KEYS.CLIENT_INFORMATION, this.serverUrl),
|
||||
);
|
||||
sessionStorage.removeItem(
|
||||
getServerSpecificKey(SESSION_KEYS.TOKENS, this.serverUrl),
|
||||
);
|
||||
sessionStorage.removeItem(
|
||||
getServerSpecificKey(SESSION_KEYS.CODE_VERIFIER, this.serverUrl),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,15 @@ export const SESSION_KEYS = {
|
||||
CLIENT_INFORMATION: "mcp_client_information",
|
||||
} as const;
|
||||
|
||||
// Generate server-specific session storage keys
|
||||
export const getServerSpecificKey = (
|
||||
baseKey: string,
|
||||
serverUrl?: string,
|
||||
): string => {
|
||||
if (!serverUrl) return baseKey;
|
||||
return `[${serverUrl}] ${baseKey}`;
|
||||
};
|
||||
|
||||
export type ConnectionStatus =
|
||||
| "disconnected"
|
||||
| "connected"
|
||||
|
||||
@@ -17,6 +17,8 @@ const mockClient = {
|
||||
connect: jest.fn().mockResolvedValue(undefined),
|
||||
close: jest.fn(),
|
||||
getServerCapabilities: jest.fn(),
|
||||
getServerVersion: jest.fn(),
|
||||
getInstructions: jest.fn(),
|
||||
setNotificationHandler: jest.fn(),
|
||||
setRequestHandler: jest.fn(),
|
||||
};
|
||||
@@ -43,9 +45,9 @@ jest.mock("@/hooks/use-toast", () => ({
|
||||
|
||||
// Mock the auth provider
|
||||
jest.mock("../../auth", () => ({
|
||||
authProvider: {
|
||||
InspectorOAuthClientProvider: jest.fn().mockImplementation(() => ({
|
||||
tokens: jest.fn().mockResolvedValue({ access_token: "mock-token" }),
|
||||
},
|
||||
})),
|
||||
}));
|
||||
|
||||
describe("useConnection", () => {
|
||||
|
||||
@@ -28,10 +28,10 @@ import { RequestOptions } from "@modelcontextprotocol/sdk/shared/protocol.js";
|
||||
import { useState } from "react";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { z } from "zod";
|
||||
import { ConnectionStatus, SESSION_KEYS } from "../constants";
|
||||
import { ConnectionStatus } from "../constants";
|
||||
import { Notification, StdErrNotificationSchema } from "../notificationTypes";
|
||||
import { auth } from "@modelcontextprotocol/sdk/client/auth.js";
|
||||
import { authProvider } from "../auth";
|
||||
import { InspectorOAuthClientProvider } from "../auth";
|
||||
import packageJson from "../../../package.json";
|
||||
import {
|
||||
getMCPProxyAddress,
|
||||
@@ -48,6 +48,7 @@ interface UseConnectionOptions {
|
||||
sseUrl: string;
|
||||
env: Record<string, string>;
|
||||
bearerToken?: string;
|
||||
headerName?: string;
|
||||
config: InspectorConfig;
|
||||
onNotification?: (notification: Notification) => void;
|
||||
onStdErrNotification?: (notification: Notification) => void;
|
||||
@@ -64,6 +65,7 @@ export function useConnection({
|
||||
sseUrl,
|
||||
env,
|
||||
bearerToken,
|
||||
headerName,
|
||||
config,
|
||||
onNotification,
|
||||
onStdErrNotification,
|
||||
@@ -244,9 +246,10 @@ export function useConnection({
|
||||
|
||||
const handleAuthError = async (error: unknown) => {
|
||||
if (error instanceof SseError && error.code === 401) {
|
||||
sessionStorage.setItem(SESSION_KEYS.SERVER_URL, sseUrl);
|
||||
// Create a new auth provider with the current server URL
|
||||
const serverAuthProvider = new InspectorOAuthClientProvider(sseUrl);
|
||||
|
||||
const result = await auth(authProvider, { serverUrl: sseUrl });
|
||||
const result = await auth(serverAuthProvider, { serverUrl: sseUrl });
|
||||
return result === "AUTHORIZED";
|
||||
}
|
||||
|
||||
@@ -290,10 +293,15 @@ export function useConnection({
|
||||
// proxying through the inspector server first.
|
||||
const headers: HeadersInit = {};
|
||||
|
||||
// Create an auth provider with the current server URL
|
||||
const serverAuthProvider = new InspectorOAuthClientProvider(sseUrl);
|
||||
|
||||
// Use manually provided bearer token if available, otherwise use OAuth tokens
|
||||
const token = bearerToken || (await authProvider.tokens())?.access_token;
|
||||
const token =
|
||||
bearerToken || (await serverAuthProvider.tokens())?.access_token;
|
||||
if (token) {
|
||||
headers["Authorization"] = `Bearer ${token}`;
|
||||
const authHeaderName = headerName || "Authorization";
|
||||
headers[authHeaderName] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
const clientTransport = new SSEClientTransport(mcpProxyServerUrl, {
|
||||
@@ -332,8 +340,19 @@ export function useConnection({
|
||||
);
|
||||
}
|
||||
|
||||
let capabilities;
|
||||
try {
|
||||
await client.connect(clientTransport);
|
||||
|
||||
capabilities = client.getServerCapabilities();
|
||||
const initializeRequest = {
|
||||
method: "initialize",
|
||||
};
|
||||
pushHistory(initializeRequest, {
|
||||
capabilities,
|
||||
serverInfo: client.getServerVersion(),
|
||||
instructions: client.getInstructions(),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`Failed to connect to MCP Server via the MCP Inspector Proxy: ${mcpProxyServerUrl}:`,
|
||||
@@ -350,8 +369,6 @@ export function useConnection({
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
const capabilities = client.getServerCapabilities();
|
||||
setServerCapabilities(capabilities ?? null);
|
||||
setCompletionsSupported(true); // Reset completions support on new connection
|
||||
|
||||
@@ -379,6 +396,8 @@ export function useConnection({
|
||||
|
||||
const disconnect = async () => {
|
||||
await mcpClient?.close();
|
||||
const authProvider = new InspectorOAuthClientProvider(sseUrl);
|
||||
authProvider.clear();
|
||||
setMcpClient(null);
|
||||
setConnectionStatus("disconnected");
|
||||
setCompletionsSupported(false);
|
||||
|
||||
78
client/src/utils/__tests__/oauthUtils.ts
Normal file
78
client/src/utils/__tests__/oauthUtils.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import {
|
||||
generateOAuthErrorDescription,
|
||||
parseOAuthCallbackParams,
|
||||
} from "@/utils/oauthUtils.ts";
|
||||
|
||||
describe("parseOAuthCallbackParams", () => {
|
||||
it("Returns successful: true and code when present", () => {
|
||||
expect(parseOAuthCallbackParams("?code=fake-code")).toEqual({
|
||||
successful: true,
|
||||
code: "fake-code",
|
||||
});
|
||||
});
|
||||
it("Returns successful: false and error when error is present", () => {
|
||||
expect(parseOAuthCallbackParams("?error=access_denied")).toEqual({
|
||||
successful: false,
|
||||
error: "access_denied",
|
||||
error_description: null,
|
||||
error_uri: null,
|
||||
});
|
||||
});
|
||||
it("Returns optional error metadata fields when present", () => {
|
||||
const search =
|
||||
"?error=access_denied&" +
|
||||
"error_description=User%20Denied%20Request&" +
|
||||
"error_uri=https%3A%2F%2Fexample.com%2Ferror-docs";
|
||||
expect(parseOAuthCallbackParams(search)).toEqual({
|
||||
successful: false,
|
||||
error: "access_denied",
|
||||
error_description: "User Denied Request",
|
||||
error_uri: "https://example.com/error-docs",
|
||||
});
|
||||
});
|
||||
it("Returns error when nothing present", () => {
|
||||
expect(parseOAuthCallbackParams("?")).toEqual({
|
||||
successful: false,
|
||||
error: "invalid_request",
|
||||
error_description: "Missing code or error in response",
|
||||
error_uri: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("generateOAuthErrorDescription", () => {
|
||||
it("When only error is present", () => {
|
||||
expect(
|
||||
generateOAuthErrorDescription({
|
||||
successful: false,
|
||||
error: "invalid_request",
|
||||
error_description: null,
|
||||
error_uri: null,
|
||||
}),
|
||||
).toBe("Error: invalid_request.");
|
||||
});
|
||||
it("When error description is present", () => {
|
||||
expect(
|
||||
generateOAuthErrorDescription({
|
||||
successful: false,
|
||||
error: "invalid_request",
|
||||
error_description: "The request could not be completed as dialed",
|
||||
error_uri: null,
|
||||
}),
|
||||
).toEqual(
|
||||
"Error: invalid_request.\nDetails: The request could not be completed as dialed.",
|
||||
);
|
||||
});
|
||||
it("When all fields present", () => {
|
||||
expect(
|
||||
generateOAuthErrorDescription({
|
||||
successful: false,
|
||||
error: "invalid_request",
|
||||
error_description: "The request could not be completed as dialed",
|
||||
error_uri: "https://example.com/error-docs",
|
||||
}),
|
||||
).toEqual(
|
||||
"Error: invalid_request.\nDetails: The request could not be completed as dialed.\nMore info: https://example.com/error-docs.",
|
||||
);
|
||||
});
|
||||
});
|
||||
65
client/src/utils/oauthUtils.ts
Normal file
65
client/src/utils/oauthUtils.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
// The parsed query parameters returned by the Authorization Server
|
||||
// representing either a valid authorization_code or an error
|
||||
// ref: https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1-12#section-4.1.2
|
||||
type CallbackParams =
|
||||
| {
|
||||
successful: true;
|
||||
// The authorization code is generated by the authorization server.
|
||||
code: string;
|
||||
}
|
||||
| {
|
||||
successful: false;
|
||||
// The OAuth 2.1 Error Code.
|
||||
// Usually one of:
|
||||
// ```
|
||||
// invalid_request, unauthorized_client, access_denied, unsupported_response_type,
|
||||
// invalid_scope, server_error, temporarily_unavailable
|
||||
// ```
|
||||
error: string;
|
||||
// Human-readable ASCII text providing additional information, used to assist the
|
||||
// developer in understanding the error that occurred.
|
||||
error_description: string | null;
|
||||
// A URI identifying a human-readable web page with information about the error,
|
||||
// used to provide the client developer with additional information about the error.
|
||||
error_uri: string | null;
|
||||
};
|
||||
|
||||
export const parseOAuthCallbackParams = (location: string): CallbackParams => {
|
||||
const params = new URLSearchParams(location);
|
||||
|
||||
const code = params.get("code");
|
||||
if (code) {
|
||||
return { successful: true, code };
|
||||
}
|
||||
|
||||
const error = params.get("error");
|
||||
const error_description = params.get("error_description");
|
||||
const error_uri = params.get("error_uri");
|
||||
|
||||
if (error) {
|
||||
return { successful: false, error, error_description, error_uri };
|
||||
}
|
||||
|
||||
return {
|
||||
successful: false,
|
||||
error: "invalid_request",
|
||||
error_description: "Missing code or error in response",
|
||||
error_uri: null,
|
||||
};
|
||||
};
|
||||
|
||||
export const generateOAuthErrorDescription = (
|
||||
params: Extract<CallbackParams, { successful: false }>,
|
||||
): string => {
|
||||
const error = params.error;
|
||||
const errorDescription = params.error_description;
|
||||
const errorUri = params.error_uri;
|
||||
|
||||
return [
|
||||
`Error: ${error}.`,
|
||||
errorDescription ? `Details: ${errorDescription}.` : "",
|
||||
errorUri ? `More info: ${errorUri}.` : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
};
|
||||
3262
package-lock.json
generated
3262
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
36
package.json
36
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@modelcontextprotocol/inspector",
|
||||
"version": "0.9.0",
|
||||
"version": "0.10.2",
|
||||
"description": "Model Context Protocol inspector",
|
||||
"license": "MIT",
|
||||
"author": "Anthropic, PBC (https://anthropic.com)",
|
||||
@@ -8,45 +8,51 @@
|
||||
"bugs": "https://github.com/modelcontextprotocol/inspector/issues",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"mcp-inspector": "./bin/cli.js"
|
||||
"mcp-inspector": "cli/build/cli.js"
|
||||
},
|
||||
"files": [
|
||||
"bin",
|
||||
"client/bin",
|
||||
"client/dist",
|
||||
"server/build"
|
||||
"server/build",
|
||||
"cli/build"
|
||||
],
|
||||
"workspaces": [
|
||||
"client",
|
||||
"server"
|
||||
"server",
|
||||
"cli"
|
||||
],
|
||||
"scripts": {
|
||||
"dev": "concurrently \"cd client && npm run dev\" \"cd server && npm run dev\"",
|
||||
"dev:windows": "concurrently \"cd client && npm run dev\" \"cd server && npm run dev:windows",
|
||||
"test": "npm run prettier-check && cd client && npm test",
|
||||
"build": "npm run build-server && npm run build-client && npm run build-cli",
|
||||
"build-server": "cd server && npm run build",
|
||||
"build-client": "cd client && npm run build",
|
||||
"build": "npm run build-server && npm run build-client",
|
||||
"build-cli": "cd cli && npm run build",
|
||||
"dev": "concurrently \"cd client && npm run dev\" \"cd server && npm run dev\"",
|
||||
"dev:windows": "concurrently \"cd client && npm run dev\" \"cd server && npm run dev:windows\"",
|
||||
"start": "node client/bin/start.js",
|
||||
"start-server": "cd server && npm run start",
|
||||
"start-client": "cd client && npm run preview",
|
||||
"start": "node ./bin/cli.js",
|
||||
"prepare": "npm run build",
|
||||
"test": "npm run prettier-check && cd client && npm test",
|
||||
"test-cli": "cd cli && npm run test",
|
||||
"prettier-fix": "prettier --write .",
|
||||
"prettier-check": "prettier --check .",
|
||||
"publish-all": "npm publish --workspaces --access public && npm publish --access public"
|
||||
},
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/inspector-client": "^0.9.0",
|
||||
"@modelcontextprotocol/inspector-server": "^0.9.0",
|
||||
"@modelcontextprotocol/inspector-cli": "^0.10.2",
|
||||
"@modelcontextprotocol/inspector-client": "^0.10.2",
|
||||
"@modelcontextprotocol/inspector-server": "^0.10.2",
|
||||
"@modelcontextprotocol/sdk": "^1.10.0",
|
||||
"concurrently": "^9.0.1",
|
||||
"shell-quote": "^1.8.2",
|
||||
"spawn-rx": "^5.1.2",
|
||||
"ts-node": "^10.9.2"
|
||||
"ts-node": "^10.9.2",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/jest": "^29.5.14",
|
||||
"@types/node": "^22.7.5",
|
||||
"@types/shell-quote": "^1.7.5",
|
||||
"prettier": "3.3.3"
|
||||
"prettier": "3.3.3",
|
||||
"typescript": "^5.4.2"
|
||||
}
|
||||
}
|
||||
|
||||
19
sample-config.json
Normal file
19
sample-config.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"everything": {
|
||||
"command": "npx",
|
||||
"args": ["@modelcontextprotocol/server-everything"],
|
||||
"env": {
|
||||
"HELLO": "Hello MCP!"
|
||||
}
|
||||
},
|
||||
"myserver": {
|
||||
"command": "node",
|
||||
"args": ["build/index.js", "arg1", "arg2"],
|
||||
"env": {
|
||||
"KEY": "value",
|
||||
"KEY2": "value2"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@modelcontextprotocol/inspector-server",
|
||||
"version": "0.9.0",
|
||||
"version": "0.10.2",
|
||||
"description": "Server-side application for the Model Context Protocol inspector",
|
||||
"license": "MIT",
|
||||
"author": "Anthropic, PBC (https://anthropic.com)",
|
||||
@@ -27,9 +27,9 @@
|
||||
"typescript": "^5.6.2"
|
||||
},
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.9.0",
|
||||
"@modelcontextprotocol/sdk": "^1.10.0",
|
||||
"cors": "^2.8.5",
|
||||
"express": "^4.21.0",
|
||||
"express": "^5.1.0",
|
||||
"ws": "^8.18.0",
|
||||
"zod": "^3.23.8"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user