Merge branch 'main' into fix766
This commit is contained in:
commit
1778ab70ca
47 changed files with 4072 additions and 1024 deletions
36
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
36
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
---
|
||||||
|
name: Bug report
|
||||||
|
about: Create a report to help us improve
|
||||||
|
title: ""
|
||||||
|
labels: ""
|
||||||
|
assignees: ""
|
||||||
|
---
|
||||||
|
|
||||||
|
**Inspector Version**
|
||||||
|
|
||||||
|
- [e.g. 0.16.5)
|
||||||
|
|
||||||
|
**Describe the bug**
|
||||||
|
A clear and concise description of what the bug is.
|
||||||
|
|
||||||
|
**To Reproduce**
|
||||||
|
Steps to reproduce the behavior:
|
||||||
|
|
||||||
|
1. Go to '...'
|
||||||
|
2. Click on '....'
|
||||||
|
3. Scroll down to '....'
|
||||||
|
4. See error
|
||||||
|
|
||||||
|
**Expected behavior**
|
||||||
|
A clear and concise description of what you expected to happen.
|
||||||
|
|
||||||
|
**Screenshots**
|
||||||
|
If applicable, add screenshots to help explain your problem.
|
||||||
|
|
||||||
|
**Environment (please complete the following information):**
|
||||||
|
|
||||||
|
- OS: [e.g. iOS]
|
||||||
|
- Browser [e.g. chrome, safari]
|
||||||
|
|
||||||
|
**Additional context**
|
||||||
|
Add any other context about the problem here.
|
||||||
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -15,4 +15,6 @@ sdk
|
||||||
client/playwright-report/
|
client/playwright-report/
|
||||||
client/results.json
|
client/results.json
|
||||||
client/test-results/
|
client/test-results/
|
||||||
|
client/e2e/test-results/
|
||||||
mcp.json
|
mcp.json
|
||||||
|
.claude/settings.local.json
|
||||||
|
|
|
||||||
|
|
@ -3,3 +3,4 @@ server/build
|
||||||
CODE_OF_CONDUCT.md
|
CODE_OF_CONDUCT.md
|
||||||
SECURITY.md
|
SECURITY.md
|
||||||
mcp.json
|
mcp.json
|
||||||
|
.claude/settings.local.json
|
||||||
29
README.md
29
README.md
|
|
@ -98,6 +98,16 @@ The MCP Inspector provides convenient buttons to export server launch configurat
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Streamable HTTP transport example:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "streamable-http",
|
||||||
|
"url": "http://localhost:3000/mcp",
|
||||||
|
"note": "For Streamable HTTP connections, add this URL directly in your MCP Client"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
- **Servers File** - Copies a complete MCP configuration file structure to your clipboard, with your current server configuration added as `default-server`. This can be saved directly as `mcp.json`.
|
- **Servers File** - Copies a complete MCP configuration file structure to your clipboard, with your current server configuration added as `default-server`. This can be saved directly as `mcp.json`.
|
||||||
|
|
||||||
**STDIO transport example:**
|
**STDIO transport example:**
|
||||||
|
|
@ -131,9 +141,23 @@ The MCP Inspector provides convenient buttons to export server launch configurat
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Streamable HTTP transport example:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"default-server": {
|
||||||
|
"type": "streamable-http",
|
||||||
|
"url": "http://localhost:3000/mcp",
|
||||||
|
"note": "For Streamable HTTP connections, add this URL directly in your MCP Client"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
These buttons appear in the Inspector UI after you've configured your server settings, making it easy to save and reuse your configurations.
|
These buttons appear in the Inspector UI after you've configured your server settings, making it easy to save and reuse your configurations.
|
||||||
|
|
||||||
For SSE transport connections, the Inspector provides similar functionality for both buttons. The "Server Entry" button copies the SSE URL configuration that can be added to your existing configuration file, while the "Servers File" button creates a complete configuration file containing the SSE URL for direct use in clients.
|
For SSE and Streamable HTTP transport connections, the Inspector provides similar functionality for both buttons. The "Server Entry" button copies the configuration that can be added to your existing configuration file, while the "Servers File" button creates a complete configuration file containing the URL for direct use in clients.
|
||||||
|
|
||||||
You can paste the Server Entry into your existing `mcp.json` file under your chosen server name, or use the complete Servers File payload to create a new configuration file.
|
You can paste the Server Entry into your existing `mcp.json` file under your chosen server name, or use the complete Servers File payload to create a new configuration file.
|
||||||
|
|
||||||
|
|
@ -410,6 +434,9 @@ npx @modelcontextprotocol/inspector --cli https://my-mcp-server.example.com
|
||||||
# Connect to a remote MCP server (with Streamable HTTP transport)
|
# Connect to a remote MCP server (with Streamable HTTP transport)
|
||||||
npx @modelcontextprotocol/inspector --cli https://my-mcp-server.example.com --transport http --method tools/list
|
npx @modelcontextprotocol/inspector --cli https://my-mcp-server.example.com --transport http --method tools/list
|
||||||
|
|
||||||
|
# Connect to a remote MCP server (with custom headers)
|
||||||
|
npx @modelcontextprotocol/inspector --cli https://my-mcp-server.example.com --transport http --method tools/list --header "X-API-Key: your-api-key"
|
||||||
|
|
||||||
# Call a tool on a remote server
|
# 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
|
npx @modelcontextprotocol/inspector --cli https://my-mcp-server.example.com --method tools/call --tool-name remotetool --tool-arg param=value
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@modelcontextprotocol/inspector-cli",
|
"name": "@modelcontextprotocol/inspector-cli",
|
||||||
"version": "0.16.5",
|
"version": "0.17.0",
|
||||||
"description": "CLI for the Model Context Protocol inspector",
|
"description": "CLI for the Model Context Protocol inspector",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"author": "Anthropic, PBC (https://anthropic.com)",
|
"author": "Anthropic, PBC (https://anthropic.com)",
|
||||||
|
|
@ -17,13 +17,14 @@
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsc",
|
"build": "tsc",
|
||||||
"postbuild": "node scripts/make-executable.js",
|
"postbuild": "node scripts/make-executable.js",
|
||||||
"test": "node scripts/cli-tests.js && node scripts/cli-tool-tests.js",
|
"test": "node scripts/cli-tests.js && node scripts/cli-tool-tests.js && node scripts/cli-header-tests.js",
|
||||||
"test:cli": "node scripts/cli-tests.js",
|
"test:cli": "node scripts/cli-tests.js",
|
||||||
"test:cli-tools": "node scripts/cli-tool-tests.js"
|
"test:cli-tools": "node scripts/cli-tool-tests.js",
|
||||||
|
"test:cli-headers": "node scripts/cli-header-tests.js"
|
||||||
},
|
},
|
||||||
"devDependencies": {},
|
"devDependencies": {},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@modelcontextprotocol/sdk": "^1.17.3",
|
"@modelcontextprotocol/sdk": "^1.18.0",
|
||||||
"commander": "^13.1.0",
|
"commander": "^13.1.0",
|
||||||
"spawn-rx": "^5.1.2"
|
"spawn-rx": "^5.1.2"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
252
cli/scripts/cli-header-tests.js
Normal file
252
cli/scripts/cli-header-tests.js
Normal file
|
|
@ -0,0 +1,252 @@
|
||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Integration tests for header functionality
|
||||||
|
* Tests the CLI header parsing end-to-end
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { spawn } from "node:child_process";
|
||||||
|
import { resolve, dirname } from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const CLI_PATH = resolve(__dirname, "..", "build", "index.js");
|
||||||
|
|
||||||
|
// ANSI colors for output
|
||||||
|
const colors = {
|
||||||
|
GREEN: "\x1b[32m",
|
||||||
|
RED: "\x1b[31m",
|
||||||
|
YELLOW: "\x1b[33m",
|
||||||
|
BLUE: "\x1b[34m",
|
||||||
|
NC: "\x1b[0m", // No Color
|
||||||
|
};
|
||||||
|
|
||||||
|
let testsPassed = 0;
|
||||||
|
let testsFailed = 0;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run a CLI test with given arguments and check for expected behavior
|
||||||
|
*/
|
||||||
|
function runHeaderTest(
|
||||||
|
testName,
|
||||||
|
args,
|
||||||
|
expectSuccess = false,
|
||||||
|
expectedInOutput = null,
|
||||||
|
) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
console.log(`\n${colors.BLUE}Testing: ${testName}${colors.NC}`);
|
||||||
|
console.log(
|
||||||
|
`${colors.BLUE}Command: node ${CLI_PATH} ${args.join(" ")}${colors.NC}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const child = spawn("node", [CLI_PATH, ...args], {
|
||||||
|
stdio: ["pipe", "pipe", "pipe"],
|
||||||
|
timeout: 10000,
|
||||||
|
});
|
||||||
|
|
||||||
|
let stdout = "";
|
||||||
|
let stderr = "";
|
||||||
|
|
||||||
|
child.stdout.on("data", (data) => {
|
||||||
|
stdout += data.toString();
|
||||||
|
});
|
||||||
|
|
||||||
|
child.stderr.on("data", (data) => {
|
||||||
|
stderr += data.toString();
|
||||||
|
});
|
||||||
|
|
||||||
|
child.on("close", (code) => {
|
||||||
|
const output = stdout + stderr;
|
||||||
|
let passed = true;
|
||||||
|
let reason = "";
|
||||||
|
|
||||||
|
// Check exit code expectation
|
||||||
|
if (expectSuccess && code !== 0) {
|
||||||
|
passed = false;
|
||||||
|
reason = `Expected success (exit code 0) but got ${code}`;
|
||||||
|
} else if (!expectSuccess && code === 0) {
|
||||||
|
passed = false;
|
||||||
|
reason = `Expected failure (non-zero exit code) but got success`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check expected output
|
||||||
|
if (passed && expectedInOutput && !output.includes(expectedInOutput)) {
|
||||||
|
passed = false;
|
||||||
|
reason = `Expected output to contain "${expectedInOutput}"`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (passed) {
|
||||||
|
console.log(`${colors.GREEN}PASS: ${testName}${colors.NC}`);
|
||||||
|
testsPassed++;
|
||||||
|
} else {
|
||||||
|
console.log(`${colors.RED}FAIL: ${testName}${colors.NC}`);
|
||||||
|
console.log(`${colors.RED}Reason: ${reason}${colors.NC}`);
|
||||||
|
console.log(`${colors.RED}Exit code: ${code}${colors.NC}`);
|
||||||
|
console.log(`${colors.RED}Output: ${output}${colors.NC}`);
|
||||||
|
testsFailed++;
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
child.on("error", (error) => {
|
||||||
|
console.log(
|
||||||
|
`${colors.RED}ERROR: ${testName} - ${error.message}${colors.NC}`,
|
||||||
|
);
|
||||||
|
testsFailed++;
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runHeaderIntegrationTests() {
|
||||||
|
console.log(
|
||||||
|
`${colors.YELLOW}=== MCP Inspector CLI Header Integration Tests ===${colors.NC}`,
|
||||||
|
);
|
||||||
|
console.log(
|
||||||
|
`${colors.BLUE}Testing header parsing and validation${colors.NC}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Test 1: Valid header format should parse successfully (connection will fail)
|
||||||
|
await runHeaderTest(
|
||||||
|
"Valid single header",
|
||||||
|
[
|
||||||
|
"https://example.com",
|
||||||
|
"--method",
|
||||||
|
"tools/list",
|
||||||
|
"--transport",
|
||||||
|
"http",
|
||||||
|
"--header",
|
||||||
|
"Authorization: Bearer token123",
|
||||||
|
],
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Test 2: Multiple headers should parse successfully
|
||||||
|
await runHeaderTest(
|
||||||
|
"Multiple headers",
|
||||||
|
[
|
||||||
|
"https://example.com",
|
||||||
|
"--method",
|
||||||
|
"tools/list",
|
||||||
|
"--transport",
|
||||||
|
"http",
|
||||||
|
"--header",
|
||||||
|
"Authorization: Bearer token123",
|
||||||
|
"--header",
|
||||||
|
"X-API-Key: secret123",
|
||||||
|
],
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Test 3: Invalid header format - no colon
|
||||||
|
await runHeaderTest(
|
||||||
|
"Invalid header format - no colon",
|
||||||
|
[
|
||||||
|
"https://example.com",
|
||||||
|
"--method",
|
||||||
|
"tools/list",
|
||||||
|
"--transport",
|
||||||
|
"http",
|
||||||
|
"--header",
|
||||||
|
"InvalidHeader",
|
||||||
|
],
|
||||||
|
false,
|
||||||
|
"Invalid header format",
|
||||||
|
);
|
||||||
|
|
||||||
|
// Test 4: Invalid header format - empty name
|
||||||
|
await runHeaderTest(
|
||||||
|
"Invalid header format - empty name",
|
||||||
|
[
|
||||||
|
"https://example.com",
|
||||||
|
"--method",
|
||||||
|
"tools/list",
|
||||||
|
"--transport",
|
||||||
|
"http",
|
||||||
|
"--header",
|
||||||
|
": value",
|
||||||
|
],
|
||||||
|
false,
|
||||||
|
"Invalid header format",
|
||||||
|
);
|
||||||
|
|
||||||
|
// Test 5: Invalid header format - empty value
|
||||||
|
await runHeaderTest(
|
||||||
|
"Invalid header format - empty value",
|
||||||
|
[
|
||||||
|
"https://example.com",
|
||||||
|
"--method",
|
||||||
|
"tools/list",
|
||||||
|
"--transport",
|
||||||
|
"http",
|
||||||
|
"--header",
|
||||||
|
"Header:",
|
||||||
|
],
|
||||||
|
false,
|
||||||
|
"Invalid header format",
|
||||||
|
);
|
||||||
|
|
||||||
|
// Test 6: Header with colons in value
|
||||||
|
await runHeaderTest(
|
||||||
|
"Header with colons in value",
|
||||||
|
[
|
||||||
|
"https://example.com",
|
||||||
|
"--method",
|
||||||
|
"tools/list",
|
||||||
|
"--transport",
|
||||||
|
"http",
|
||||||
|
"--header",
|
||||||
|
"X-Time: 2023:12:25:10:30:45",
|
||||||
|
],
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Test 7: Whitespace handling
|
||||||
|
await runHeaderTest(
|
||||||
|
"Whitespace handling in headers",
|
||||||
|
[
|
||||||
|
"https://example.com",
|
||||||
|
"--method",
|
||||||
|
"tools/list",
|
||||||
|
"--transport",
|
||||||
|
"http",
|
||||||
|
"--header",
|
||||||
|
" X-Header : value with spaces ",
|
||||||
|
],
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(`\n${colors.YELLOW}=== Test Results ===${colors.NC}`);
|
||||||
|
console.log(`${colors.GREEN}Tests passed: ${testsPassed}${colors.NC}`);
|
||||||
|
console.log(`${colors.RED}Tests failed: ${testsFailed}${colors.NC}`);
|
||||||
|
|
||||||
|
if (testsFailed === 0) {
|
||||||
|
console.log(
|
||||||
|
`${colors.GREEN}All header integration tests passed!${colors.NC}`,
|
||||||
|
);
|
||||||
|
process.exit(0);
|
||||||
|
} else {
|
||||||
|
console.log(
|
||||||
|
`${colors.RED}Some header integration tests failed.${colors.NC}`,
|
||||||
|
);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle graceful shutdown
|
||||||
|
process.on("SIGINT", () => {
|
||||||
|
console.log(`\n${colors.YELLOW}Test interrupted by user${colors.NC}`);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
process.on("SIGTERM", () => {
|
||||||
|
console.log(`\n${colors.YELLOW}Test terminated${colors.NC}`);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Run the tests
|
||||||
|
runHeaderIntegrationTests().catch((error) => {
|
||||||
|
console.error(`${colors.RED}Test runner error: ${error.message}${colors.NC}`);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
|
@ -16,6 +16,7 @@ type Args = {
|
||||||
cli: boolean;
|
cli: boolean;
|
||||||
transport?: "stdio" | "sse" | "streamable-http";
|
transport?: "stdio" | "sse" | "streamable-http";
|
||||||
serverUrl?: string;
|
serverUrl?: string;
|
||||||
|
headers?: Record<string, string>;
|
||||||
};
|
};
|
||||||
|
|
||||||
type CliOptions = {
|
type CliOptions = {
|
||||||
|
|
@ -25,6 +26,7 @@ type CliOptions = {
|
||||||
cli?: boolean;
|
cli?: boolean;
|
||||||
transport?: string;
|
transport?: string;
|
||||||
serverUrl?: string;
|
serverUrl?: string;
|
||||||
|
header?: Record<string, string>;
|
||||||
};
|
};
|
||||||
|
|
||||||
type ServerConfig =
|
type ServerConfig =
|
||||||
|
|
@ -104,6 +106,10 @@ async function runWebClient(args: Args): Promise<void> {
|
||||||
await spawnPromise("node", [inspectorClientPath, ...startArgs], {
|
await spawnPromise("node", [inspectorClientPath, ...startArgs], {
|
||||||
signal: abort.signal,
|
signal: abort.signal,
|
||||||
echoOutput: true,
|
echoOutput: true,
|
||||||
|
// pipe the stdout through here, prevents issues with buffering and
|
||||||
|
// dropping the end of console.out after 8192 chars due to node
|
||||||
|
// closing the stdout pipe before the output has finished flushing
|
||||||
|
stdio: "inherit",
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (!cancelled || process.env.DEBUG) throw e;
|
if (!cancelled || process.env.DEBUG) throw e;
|
||||||
|
|
@ -127,6 +133,9 @@ async function runCli(args: Args): Promise<void> {
|
||||||
// Build CLI arguments
|
// Build CLI arguments
|
||||||
const cliArgs = [cliPath];
|
const cliArgs = [cliPath];
|
||||||
|
|
||||||
|
// Add target URL/command first
|
||||||
|
cliArgs.push(args.command, ...args.args);
|
||||||
|
|
||||||
// Add transport flag if specified
|
// Add transport flag if specified
|
||||||
if (args.transport && args.transport !== "stdio") {
|
if (args.transport && args.transport !== "stdio") {
|
||||||
// Convert streamable-http back to http for CLI mode
|
// Convert streamable-http back to http for CLI mode
|
||||||
|
|
@ -135,13 +144,21 @@ async function runCli(args: Args): Promise<void> {
|
||||||
cliArgs.push("--transport", cliTransport);
|
cliArgs.push("--transport", cliTransport);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add command and remaining args
|
// Add headers if specified
|
||||||
cliArgs.push(args.command, ...args.args);
|
if (args.headers) {
|
||||||
|
for (const [key, value] of Object.entries(args.headers)) {
|
||||||
|
cliArgs.push("--header", `${key}: ${value}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await spawnPromise("node", cliArgs, {
|
await spawnPromise("node", cliArgs, {
|
||||||
env: { ...process.env, ...args.envArgs },
|
env: { ...process.env, ...args.envArgs },
|
||||||
signal: abort.signal,
|
signal: abort.signal,
|
||||||
echoOutput: true,
|
echoOutput: true,
|
||||||
|
// pipe the stdout through here, prevents issues with buffering and
|
||||||
|
// dropping the end of console.out after 8192 chars due to node
|
||||||
|
// closing the stdout pipe before the output has finished flushing
|
||||||
|
stdio: "inherit",
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (!cancelled || process.env.DEBUG) {
|
if (!cancelled || process.env.DEBUG) {
|
||||||
|
|
@ -201,6 +218,30 @@ function parseKeyValuePair(
|
||||||
return { ...previous, [key as string]: val };
|
return { ...previous, [key as string]: val };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseHeaderPair(
|
||||||
|
value: string,
|
||||||
|
previous: Record<string, string> = {},
|
||||||
|
): Record<string, string> {
|
||||||
|
const colonIndex = value.indexOf(":");
|
||||||
|
|
||||||
|
if (colonIndex === -1) {
|
||||||
|
throw new Error(
|
||||||
|
`Invalid header format: ${value}. Use "HeaderName: Value" format.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = value.slice(0, colonIndex).trim();
|
||||||
|
const val = value.slice(colonIndex + 1).trim();
|
||||||
|
|
||||||
|
if (key === "" || val === "") {
|
||||||
|
throw new Error(
|
||||||
|
`Invalid header format: ${value}. Use "HeaderName: Value" format.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ...previous, [key]: val };
|
||||||
|
}
|
||||||
|
|
||||||
function parseArgs(): Args {
|
function parseArgs(): Args {
|
||||||
const program = new Command();
|
const program = new Command();
|
||||||
|
|
||||||
|
|
@ -227,7 +268,13 @@ function parseArgs(): Args {
|
||||||
.option("--server <n>", "server name from config file")
|
.option("--server <n>", "server name from config file")
|
||||||
.option("--cli", "enable CLI mode")
|
.option("--cli", "enable CLI mode")
|
||||||
.option("--transport <type>", "transport type (stdio, sse, http)")
|
.option("--transport <type>", "transport type (stdio, sse, http)")
|
||||||
.option("--server-url <url>", "server URL for SSE/HTTP transport");
|
.option("--server-url <url>", "server URL for SSE/HTTP transport")
|
||||||
|
.option(
|
||||||
|
"--header <headers...>",
|
||||||
|
'HTTP headers as "HeaderName: Value" pairs (for HTTP/SSE transports)',
|
||||||
|
parseHeaderPair,
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
|
||||||
// Parse only the arguments before --
|
// Parse only the arguments before --
|
||||||
program.parse(preArgs);
|
program.parse(preArgs);
|
||||||
|
|
@ -280,6 +327,7 @@ function parseArgs(): Args {
|
||||||
envArgs: { ...(config.env || {}), ...(options.e || {}) },
|
envArgs: { ...(config.env || {}), ...(options.e || {}) },
|
||||||
cli: options.cli || false,
|
cli: options.cli || false,
|
||||||
transport: "stdio",
|
transport: "stdio",
|
||||||
|
headers: options.header,
|
||||||
};
|
};
|
||||||
} else if (config.type === "sse" || config.type === "streamable-http") {
|
} else if (config.type === "sse" || config.type === "streamable-http") {
|
||||||
return {
|
return {
|
||||||
|
|
@ -289,6 +337,7 @@ function parseArgs(): Args {
|
||||||
cli: options.cli || false,
|
cli: options.cli || false,
|
||||||
transport: config.type,
|
transport: config.type,
|
||||||
serverUrl: config.url,
|
serverUrl: config.url,
|
||||||
|
headers: options.header,
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
// Backwards compatibility: if no type field, assume stdio
|
// Backwards compatibility: if no type field, assume stdio
|
||||||
|
|
@ -298,6 +347,7 @@ function parseArgs(): Args {
|
||||||
envArgs: { ...((config as any).env || {}), ...(options.e || {}) },
|
envArgs: { ...((config as any).env || {}), ...(options.e || {}) },
|
||||||
cli: options.cli || false,
|
cli: options.cli || false,
|
||||||
transport: "stdio",
|
transport: "stdio",
|
||||||
|
headers: options.header,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -319,6 +369,7 @@ function parseArgs(): Args {
|
||||||
cli: options.cli || false,
|
cli: options.cli || false,
|
||||||
transport: transport as "stdio" | "sse" | "streamable-http" | undefined,
|
transport: transport as "stdio" | "sse" | "streamable-http" | undefined,
|
||||||
serverUrl: options.serverUrl,
|
serverUrl: options.serverUrl,
|
||||||
|
headers: options.header,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
import * as fs from "fs";
|
||||||
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
||||||
import { Command } from "commander";
|
import { Command } from "commander";
|
||||||
import {
|
import {
|
||||||
|
|
@ -19,6 +20,7 @@ import {
|
||||||
} from "./client/index.js";
|
} from "./client/index.js";
|
||||||
import { handleError } from "./error-handler.js";
|
import { handleError } from "./error-handler.js";
|
||||||
import { createTransport, TransportOptions } from "./transport.js";
|
import { createTransport, TransportOptions } from "./transport.js";
|
||||||
|
import { awaitableLog } from "./utils/awaitable-log.js";
|
||||||
|
|
||||||
// JSON value type for CLI arguments
|
// JSON value type for CLI arguments
|
||||||
type JsonValue =
|
type JsonValue =
|
||||||
|
|
@ -40,11 +42,13 @@ type Args = {
|
||||||
toolName?: string;
|
toolName?: string;
|
||||||
toolArg?: Record<string, JsonValue>;
|
toolArg?: Record<string, JsonValue>;
|
||||||
transport?: "sse" | "stdio" | "http";
|
transport?: "sse" | "stdio" | "http";
|
||||||
|
headers?: Record<string, string>;
|
||||||
};
|
};
|
||||||
|
|
||||||
function createTransportOptions(
|
function createTransportOptions(
|
||||||
target: string[],
|
target: string[],
|
||||||
transport?: "sse" | "stdio" | "http",
|
transport?: "sse" | "stdio" | "http",
|
||||||
|
headers?: Record<string, string>,
|
||||||
): TransportOptions {
|
): TransportOptions {
|
||||||
if (target.length === 0) {
|
if (target.length === 0) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
|
|
@ -91,16 +95,32 @@ function createTransportOptions(
|
||||||
command: isUrl ? undefined : command,
|
command: isUrl ? undefined : command,
|
||||||
args: isUrl ? undefined : commandArgs,
|
args: isUrl ? undefined : commandArgs,
|
||||||
url: isUrl ? command : undefined,
|
url: isUrl ? command : undefined,
|
||||||
|
headers,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function callMethod(args: Args): Promise<void> {
|
async function callMethod(args: Args): Promise<void> {
|
||||||
const transportOptions = createTransportOptions(args.target, args.transport);
|
// Read package.json to get name and version for client identity
|
||||||
const transport = createTransport(transportOptions);
|
const pathA = "../package.json"; // We're in package @modelcontextprotocol/inspector-cli
|
||||||
const client = new Client({
|
const pathB = "../../package.json"; // We're in package @modelcontextprotocol/inspector
|
||||||
name: "inspector-cli",
|
let packageJson: { name: string; version: string };
|
||||||
version: "0.5.1",
|
let packageJsonData = await import(fs.existsSync(pathA) ? pathA : pathB, {
|
||||||
|
with: { type: "json" },
|
||||||
});
|
});
|
||||||
|
packageJson = packageJsonData.default;
|
||||||
|
|
||||||
|
const transportOptions = createTransportOptions(
|
||||||
|
args.target,
|
||||||
|
args.transport,
|
||||||
|
args.headers,
|
||||||
|
);
|
||||||
|
const transport = createTransport(transportOptions);
|
||||||
|
|
||||||
|
const [, name = packageJson.name] = packageJson.name.split("/");
|
||||||
|
const version = packageJson.version;
|
||||||
|
const clientIdentity = { name, version };
|
||||||
|
|
||||||
|
const client = new Client(clientIdentity);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await connect(client, transport);
|
await connect(client, transport);
|
||||||
|
|
@ -160,7 +180,7 @@ async function callMethod(args: Args): Promise<void> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(JSON.stringify(result, null, 2));
|
await awaitableLog(JSON.stringify(result, null, 2));
|
||||||
} finally {
|
} finally {
|
||||||
try {
|
try {
|
||||||
await disconnect(transport);
|
await disconnect(transport);
|
||||||
|
|
@ -196,6 +216,30 @@ function parseKeyValuePair(
|
||||||
return { ...previous, [key as string]: parsedValue };
|
return { ...previous, [key as string]: parsedValue };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseHeaderPair(
|
||||||
|
value: string,
|
||||||
|
previous: Record<string, string> = {},
|
||||||
|
): Record<string, string> {
|
||||||
|
const colonIndex = value.indexOf(":");
|
||||||
|
|
||||||
|
if (colonIndex === -1) {
|
||||||
|
throw new Error(
|
||||||
|
`Invalid header format: ${value}. Use "HeaderName: Value" format.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = value.slice(0, colonIndex).trim();
|
||||||
|
const val = value.slice(colonIndex + 1).trim();
|
||||||
|
|
||||||
|
if (key === "" || val === "") {
|
||||||
|
throw new Error(
|
||||||
|
`Invalid header format: ${value}. Use "HeaderName: Value" format.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ...previous, [key]: val };
|
||||||
|
}
|
||||||
|
|
||||||
function parseArgs(): Args {
|
function parseArgs(): Args {
|
||||||
const program = new Command();
|
const program = new Command();
|
||||||
|
|
||||||
|
|
@ -275,12 +319,24 @@ function parseArgs(): Args {
|
||||||
}
|
}
|
||||||
return value as "sse" | "http" | "stdio";
|
return value as "sse" | "http" | "stdio";
|
||||||
},
|
},
|
||||||
|
)
|
||||||
|
//
|
||||||
|
// HTTP headers
|
||||||
|
//
|
||||||
|
.option(
|
||||||
|
"--header <headers...>",
|
||||||
|
'HTTP headers as "HeaderName: Value" pairs (for HTTP/SSE transports)',
|
||||||
|
parseHeaderPair,
|
||||||
|
{},
|
||||||
);
|
);
|
||||||
|
|
||||||
// Parse only the arguments before --
|
// Parse only the arguments before --
|
||||||
program.parse(preArgs);
|
program.parse(preArgs);
|
||||||
|
|
||||||
const options = program.opts() as Omit<Args, "target">;
|
const options = program.opts() as Omit<Args, "target"> & {
|
||||||
|
header?: Record<string, string>;
|
||||||
|
};
|
||||||
|
|
||||||
let remainingArgs = program.args;
|
let remainingArgs = program.args;
|
||||||
|
|
||||||
// Add back any arguments that came after --
|
// Add back any arguments that came after --
|
||||||
|
|
@ -295,6 +351,7 @@ function parseArgs(): Args {
|
||||||
return {
|
return {
|
||||||
target: finalArgs,
|
target: finalArgs,
|
||||||
...options,
|
...options,
|
||||||
|
headers: options.header, // commander.js uses 'header' field, map to 'headers'
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -306,6 +363,7 @@ async function main(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const args = parseArgs();
|
const args = parseArgs();
|
||||||
await callMethod(args);
|
await callMethod(args);
|
||||||
|
|
||||||
// Explicitly exit to ensure process terminates in CI
|
// Explicitly exit to ensure process terminates in CI
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ export type TransportOptions = {
|
||||||
command?: string;
|
command?: string;
|
||||||
args?: string[];
|
args?: string[];
|
||||||
url?: string;
|
url?: string;
|
||||||
|
headers?: Record<string, string>;
|
||||||
};
|
};
|
||||||
|
|
||||||
function createStdioTransport(options: TransportOptions): Transport {
|
function createStdioTransport(options: TransportOptions): Transport {
|
||||||
|
|
@ -64,11 +65,25 @@ export function createTransport(options: TransportOptions): Transport {
|
||||||
const url = new URL(options.url);
|
const url = new URL(options.url);
|
||||||
|
|
||||||
if (transportType === "sse") {
|
if (transportType === "sse") {
|
||||||
return new SSEClientTransport(url);
|
const transportOptions = options.headers
|
||||||
|
? {
|
||||||
|
requestInit: {
|
||||||
|
headers: options.headers,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: undefined;
|
||||||
|
return new SSEClientTransport(url, transportOptions);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (transportType === "http") {
|
if (transportType === "http") {
|
||||||
return new StreamableHTTPClientTransport(url);
|
const transportOptions = options.headers
|
||||||
|
? {
|
||||||
|
requestInit: {
|
||||||
|
headers: options.headers,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: undefined;
|
||||||
|
return new StreamableHTTPClientTransport(url, transportOptions);
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new Error(`Unsupported transport type: ${transportType}`);
|
throw new Error(`Unsupported transport type: ${transportType}`);
|
||||||
|
|
|
||||||
7
cli/src/utils/awaitable-log.ts
Normal file
7
cli/src/utils/awaitable-log.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
export function awaitableLog(logValue: string): Promise<void> {
|
||||||
|
return new Promise<void>((resolve) => {
|
||||||
|
process.stdout.write(logValue, () => {
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@modelcontextprotocol/inspector-client",
|
"name": "@modelcontextprotocol/inspector-client",
|
||||||
"version": "0.16.5",
|
"version": "0.17.0",
|
||||||
"description": "Client-side application for the Model Context Protocol inspector",
|
"description": "Client-side application for the Model Context Protocol inspector",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"author": "Anthropic, PBC (https://anthropic.com)",
|
"author": "Anthropic, PBC (https://anthropic.com)",
|
||||||
|
|
@ -25,7 +25,7 @@
|
||||||
"cleanup:e2e": "node e2e/global-teardown.js"
|
"cleanup:e2e": "node e2e/global-teardown.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@modelcontextprotocol/sdk": "^1.17.3",
|
"@modelcontextprotocol/sdk": "^1.18.0",
|
||||||
"@radix-ui/react-checkbox": "^1.1.4",
|
"@radix-ui/react-checkbox": "^1.1.4",
|
||||||
"@radix-ui/react-dialog": "^1.1.3",
|
"@radix-ui/react-dialog": "^1.1.3",
|
||||||
"@radix-ui/react-icons": "^1.3.0",
|
"@radix-ui/react-icons": "^1.3.0",
|
||||||
|
|
@ -33,6 +33,7 @@
|
||||||
"@radix-ui/react-popover": "^1.1.3",
|
"@radix-ui/react-popover": "^1.1.3",
|
||||||
"@radix-ui/react-select": "^2.1.2",
|
"@radix-ui/react-select": "^2.1.2",
|
||||||
"@radix-ui/react-slot": "^1.1.0",
|
"@radix-ui/react-slot": "^1.1.0",
|
||||||
|
"@radix-ui/react-switch": "^1.2.6",
|
||||||
"@radix-ui/react-tabs": "^1.1.1",
|
"@radix-ui/react-tabs": "^1.1.1",
|
||||||
"@radix-ui/react-toast": "^1.2.6",
|
"@radix-ui/react-toast": "^1.2.6",
|
||||||
"@radix-ui/react-tooltip": "^1.1.8",
|
"@radix-ui/react-tooltip": "^1.1.8",
|
||||||
|
|
@ -48,7 +49,6 @@
|
||||||
"react-simple-code-editor": "^0.14.1",
|
"react-simple-code-editor": "^0.14.1",
|
||||||
"serve-handler": "^6.1.6",
|
"serve-handler": "^6.1.6",
|
||||||
"tailwind-merge": "^2.5.3",
|
"tailwind-merge": "^2.5.3",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
|
||||||
"zod": "^3.25.76"
|
"zod": "^3.25.76"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|
@ -70,8 +70,10 @@
|
||||||
"globals": "^15.9.0",
|
"globals": "^15.9.0",
|
||||||
"jest": "^29.7.0",
|
"jest": "^29.7.0",
|
||||||
"jest-environment-jsdom": "^29.7.0",
|
"jest-environment-jsdom": "^29.7.0",
|
||||||
|
"jest-fixed-jsdom": "^0.0.9",
|
||||||
"postcss": "^8.5.6",
|
"postcss": "^8.5.6",
|
||||||
"tailwindcss": "^3.4.13",
|
"tailwindcss": "^3.4.13",
|
||||||
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"ts-jest": "^29.4.0",
|
"ts-jest": "^29.4.0",
|
||||||
"typescript": "^5.5.3",
|
"typescript": "^5.5.3",
|
||||||
"typescript-eslint": "^8.38.0",
|
"typescript-eslint": "^8.38.0",
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,8 @@ import { SESSION_KEYS, getServerSpecificKey } from "./lib/constants";
|
||||||
import { AuthDebuggerState, EMPTY_DEBUGGER_STATE } from "./lib/auth-types";
|
import { AuthDebuggerState, EMPTY_DEBUGGER_STATE } from "./lib/auth-types";
|
||||||
import { OAuthStateMachine } from "./lib/oauth-state-machine";
|
import { OAuthStateMachine } from "./lib/oauth-state-machine";
|
||||||
import { cacheToolOutputSchemas } from "./utils/schemaUtils";
|
import { cacheToolOutputSchemas } from "./utils/schemaUtils";
|
||||||
|
import { cleanParams } from "./utils/paramUtils";
|
||||||
|
import type { JsonSchemaType } from "./utils/jsonUtils";
|
||||||
import React, {
|
import React, {
|
||||||
Suspense,
|
Suspense,
|
||||||
useCallback,
|
useCallback,
|
||||||
|
|
@ -74,6 +76,10 @@ import ElicitationTab, {
|
||||||
PendingElicitationRequest,
|
PendingElicitationRequest,
|
||||||
ElicitationResponse,
|
ElicitationResponse,
|
||||||
} from "./components/ElicitationTab";
|
} from "./components/ElicitationTab";
|
||||||
|
import {
|
||||||
|
CustomHeaders,
|
||||||
|
migrateFromLegacyAuth,
|
||||||
|
} from "./lib/types/customHeaders";
|
||||||
|
|
||||||
const CONFIG_LOCAL_STORAGE_KEY = "inspectorConfig_v1";
|
const CONFIG_LOCAL_STORAGE_KEY = "inspectorConfig_v1";
|
||||||
|
|
||||||
|
|
@ -103,6 +109,14 @@ const App = () => {
|
||||||
const [transportType, setTransportType] = useState<
|
const [transportType, setTransportType] = useState<
|
||||||
"stdio" | "sse" | "streamable-http"
|
"stdio" | "sse" | "streamable-http"
|
||||||
>(getInitialTransportType);
|
>(getInitialTransportType);
|
||||||
|
const [connectionType, setConnectionType] = useState<"direct" | "proxy">(
|
||||||
|
() => {
|
||||||
|
return (
|
||||||
|
(localStorage.getItem("lastConnectionType") as "direct" | "proxy") ||
|
||||||
|
"proxy"
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
const [logLevel, setLogLevel] = useState<LoggingLevel>("debug");
|
const [logLevel, setLogLevel] = useState<LoggingLevel>("debug");
|
||||||
const [notifications, setNotifications] = useState<ServerNotification[]>([]);
|
const [notifications, setNotifications] = useState<ServerNotification[]>([]);
|
||||||
const [roots, setRoots] = useState<Root[]>([]);
|
const [roots, setRoots] = useState<Root[]>([]);
|
||||||
|
|
@ -127,6 +141,39 @@ const App = () => {
|
||||||
return localStorage.getItem("lastOauthScope") || "";
|
return localStorage.getItem("lastOauthScope") || "";
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Custom headers state with migration from legacy auth
|
||||||
|
const [customHeaders, setCustomHeaders] = useState<CustomHeaders>(() => {
|
||||||
|
const savedHeaders = localStorage.getItem("lastCustomHeaders");
|
||||||
|
if (savedHeaders) {
|
||||||
|
try {
|
||||||
|
return JSON.parse(savedHeaders);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(
|
||||||
|
`Failed to parse custom headers: "${savedHeaders}", will try legacy migration`,
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
// Fall back to migration if JSON parsing fails
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Migrate from legacy auth if available
|
||||||
|
const legacyToken = localStorage.getItem("lastBearerToken") || "";
|
||||||
|
const legacyHeaderName = localStorage.getItem("lastHeaderName") || "";
|
||||||
|
|
||||||
|
if (legacyToken) {
|
||||||
|
return migrateFromLegacyAuth(legacyToken, legacyHeaderName);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default to empty array
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
name: "Authorization",
|
||||||
|
value: "Bearer ",
|
||||||
|
enabled: false,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
const [pendingSampleRequests, setPendingSampleRequests] = useState<
|
const [pendingSampleRequests, setPendingSampleRequests] = useState<
|
||||||
Array<
|
Array<
|
||||||
PendingRequest & {
|
PendingRequest & {
|
||||||
|
|
@ -200,6 +247,7 @@ const App = () => {
|
||||||
serverCapabilities,
|
serverCapabilities,
|
||||||
mcpClient,
|
mcpClient,
|
||||||
requestHistory,
|
requestHistory,
|
||||||
|
clearRequestHistory,
|
||||||
makeRequest,
|
makeRequest,
|
||||||
sendNotification,
|
sendNotification,
|
||||||
handleCompletion,
|
handleCompletion,
|
||||||
|
|
@ -212,11 +260,11 @@ const App = () => {
|
||||||
args,
|
args,
|
||||||
sseUrl,
|
sseUrl,
|
||||||
env,
|
env,
|
||||||
bearerToken,
|
customHeaders,
|
||||||
headerName,
|
|
||||||
oauthClientId,
|
oauthClientId,
|
||||||
oauthScope,
|
oauthScope,
|
||||||
config,
|
config,
|
||||||
|
connectionType,
|
||||||
onNotification: (notification) => {
|
onNotification: (notification) => {
|
||||||
setNotifications((prev) => [...prev, notification as ServerNotification]);
|
setNotifications((prev) => [...prev, notification as ServerNotification]);
|
||||||
},
|
},
|
||||||
|
|
@ -302,13 +350,42 @@ const App = () => {
|
||||||
}, [transportType]);
|
}, [transportType]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
localStorage.setItem("lastBearerToken", bearerToken);
|
localStorage.setItem("lastConnectionType", connectionType);
|
||||||
|
}, [connectionType]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (bearerToken) {
|
||||||
|
localStorage.setItem("lastBearerToken", bearerToken);
|
||||||
|
} else {
|
||||||
|
localStorage.removeItem("lastBearerToken");
|
||||||
|
}
|
||||||
}, [bearerToken]);
|
}, [bearerToken]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
localStorage.setItem("lastHeaderName", headerName);
|
if (headerName) {
|
||||||
|
localStorage.setItem("lastHeaderName", headerName);
|
||||||
|
} else {
|
||||||
|
localStorage.removeItem("lastHeaderName");
|
||||||
|
}
|
||||||
}, [headerName]);
|
}, [headerName]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
localStorage.setItem("lastCustomHeaders", JSON.stringify(customHeaders));
|
||||||
|
}, [customHeaders]);
|
||||||
|
|
||||||
|
// Auto-migrate from legacy auth when custom headers are empty but legacy auth exists
|
||||||
|
useEffect(() => {
|
||||||
|
if (customHeaders.length === 0 && (bearerToken || headerName)) {
|
||||||
|
const migratedHeaders = migrateFromLegacyAuth(bearerToken, headerName);
|
||||||
|
if (migratedHeaders.length > 0) {
|
||||||
|
setCustomHeaders(migratedHeaders);
|
||||||
|
// Clear legacy auth after migration
|
||||||
|
setBearerToken("");
|
||||||
|
setHeaderName("");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [bearerToken, headerName, customHeaders, setCustomHeaders]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
localStorage.setItem("lastOauthClientId", oauthClientId);
|
localStorage.setItem("lastOauthClientId", oauthClientId);
|
||||||
}, [oauthClientId]);
|
}, [oauthClientId]);
|
||||||
|
|
@ -702,12 +779,18 @@ const App = () => {
|
||||||
lastToolCallOriginTabRef.current = currentTabRef.current;
|
lastToolCallOriginTabRef.current = currentTabRef.current;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// Find the tool schema to clean parameters properly
|
||||||
|
const tool = tools.find((t) => t.name === name);
|
||||||
|
const cleanedParams = tool?.inputSchema
|
||||||
|
? cleanParams(params, tool.inputSchema as JsonSchemaType)
|
||||||
|
: params;
|
||||||
|
|
||||||
const response = await sendMCPRequest(
|
const response = await sendMCPRequest(
|
||||||
{
|
{
|
||||||
method: "tools/call" as const,
|
method: "tools/call" as const,
|
||||||
params: {
|
params: {
|
||||||
name,
|
name,
|
||||||
arguments: params,
|
arguments: cleanedParams,
|
||||||
_meta: {
|
_meta: {
|
||||||
progressToken: progressTokenRef.current++,
|
progressToken: progressTokenRef.current++,
|
||||||
},
|
},
|
||||||
|
|
@ -718,6 +801,8 @@ const App = () => {
|
||||||
);
|
);
|
||||||
|
|
||||||
setToolResult(response);
|
setToolResult(response);
|
||||||
|
// Clear any validation errors since tool execution completed
|
||||||
|
setErrors((prev) => ({ ...prev, tools: null }));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const toolResult: CompatibilityCallToolResult = {
|
const toolResult: CompatibilityCallToolResult = {
|
||||||
content: [
|
content: [
|
||||||
|
|
@ -729,6 +814,8 @@ const App = () => {
|
||||||
isError: true,
|
isError: true,
|
||||||
};
|
};
|
||||||
setToolResult(toolResult);
|
setToolResult(toolResult);
|
||||||
|
// Clear validation errors - tool execution errors are shown in ToolResults
|
||||||
|
setErrors((prev) => ({ ...prev, tools: null }));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -736,6 +823,10 @@ const App = () => {
|
||||||
await sendNotification({ method: "notifications/roots/list_changed" });
|
await sendNotification({ method: "notifications/roots/list_changed" });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleClearNotifications = () => {
|
||||||
|
setNotifications([]);
|
||||||
|
};
|
||||||
|
|
||||||
const sendLogLevelRequest = async (level: LoggingLevel) => {
|
const sendLogLevelRequest = async (level: LoggingLevel) => {
|
||||||
await sendMCPRequest(
|
await sendMCPRequest(
|
||||||
{
|
{
|
||||||
|
|
@ -805,10 +896,8 @@ const App = () => {
|
||||||
setEnv={setEnv}
|
setEnv={setEnv}
|
||||||
config={config}
|
config={config}
|
||||||
setConfig={setConfig}
|
setConfig={setConfig}
|
||||||
bearerToken={bearerToken}
|
customHeaders={customHeaders}
|
||||||
setBearerToken={setBearerToken}
|
setCustomHeaders={setCustomHeaders}
|
||||||
headerName={headerName}
|
|
||||||
setHeaderName={setHeaderName}
|
|
||||||
oauthClientId={oauthClientId}
|
oauthClientId={oauthClientId}
|
||||||
setOauthClientId={setOauthClientId}
|
setOauthClientId={setOauthClientId}
|
||||||
oauthScope={oauthScope}
|
oauthScope={oauthScope}
|
||||||
|
|
@ -818,6 +907,8 @@ const App = () => {
|
||||||
logLevel={logLevel}
|
logLevel={logLevel}
|
||||||
sendLogLevelRequest={sendLogLevelRequest}
|
sendLogLevelRequest={sendLogLevelRequest}
|
||||||
loggingSupported={!!serverCapabilities?.logging || false}
|
loggingSupported={!!serverCapabilities?.logging || false}
|
||||||
|
connectionType={connectionType}
|
||||||
|
setConnectionType={setConnectionType}
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
onMouseDown={handleSidebarDragStart}
|
onMouseDown={handleSidebarDragStart}
|
||||||
|
|
@ -1102,6 +1193,8 @@ const App = () => {
|
||||||
<HistoryAndNotifications
|
<HistoryAndNotifications
|
||||||
requestHistory={requestHistory}
|
requestHistory={requestHistory}
|
||||||
serverNotifications={notifications}
|
serverNotifications={notifications}
|
||||||
|
onClearHistory={clearRequestHistory}
|
||||||
|
onClearNotifications={handleClearNotifications}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,7 @@ jest.mock("../lib/hooks/useConnection", () => ({
|
||||||
serverCapabilities: null,
|
serverCapabilities: null,
|
||||||
mcpClient: null,
|
mcpClient: null,
|
||||||
requestHistory: [],
|
requestHistory: [],
|
||||||
|
clearRequestHistory: jest.fn(),
|
||||||
makeRequest: jest.fn(),
|
makeRequest: jest.fn(),
|
||||||
sendNotification: jest.fn(),
|
sendNotification: jest.fn(),
|
||||||
handleCompletion: jest.fn(),
|
handleCompletion: jest.fn(),
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,7 @@ const disconnectedConnectionState = {
|
||||||
serverCapabilities: null,
|
serverCapabilities: null,
|
||||||
mcpClient: null,
|
mcpClient: null,
|
||||||
requestHistory: [],
|
requestHistory: [],
|
||||||
|
clearRequestHistory: jest.fn(),
|
||||||
makeRequest: jest.fn(),
|
makeRequest: jest.fn(),
|
||||||
sendNotification: jest.fn(),
|
sendNotification: jest.fn(),
|
||||||
handleCompletion: jest.fn(),
|
handleCompletion: jest.fn(),
|
||||||
|
|
|
||||||
|
|
@ -187,7 +187,7 @@ const AuthDebugger = ({
|
||||||
JSON.stringify(currentState),
|
JSON.stringify(currentState),
|
||||||
);
|
);
|
||||||
// Open the authorization URL automatically
|
// Open the authorization URL automatically
|
||||||
window.location.href = currentState.authorizationUrl;
|
window.location.href = currentState.authorizationUrl.toString();
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
241
client/src/components/CustomHeaders.tsx
Normal file
241
client/src/components/CustomHeaders.tsx
Normal file
|
|
@ -0,0 +1,241 @@
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import { Plus, Trash2, Eye, EyeOff } from "lucide-react";
|
||||||
|
import {
|
||||||
|
CustomHeaders as CustomHeadersType,
|
||||||
|
CustomHeader,
|
||||||
|
createEmptyHeader,
|
||||||
|
} from "@/lib/types/customHeaders";
|
||||||
|
|
||||||
|
interface CustomHeadersProps {
|
||||||
|
headers: CustomHeadersType;
|
||||||
|
onChange: (headers: CustomHeadersType) => void;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CustomHeaders = ({
|
||||||
|
headers,
|
||||||
|
onChange,
|
||||||
|
className,
|
||||||
|
}: CustomHeadersProps) => {
|
||||||
|
const [isJsonMode, setIsJsonMode] = useState(false);
|
||||||
|
const [jsonValue, setJsonValue] = useState("");
|
||||||
|
const [jsonError, setJsonError] = useState<string | null>(null);
|
||||||
|
const [visibleValues, setVisibleValues] = useState<Set<number>>(new Set());
|
||||||
|
|
||||||
|
const updateHeader = (
|
||||||
|
index: number,
|
||||||
|
field: keyof CustomHeader,
|
||||||
|
value: string | boolean,
|
||||||
|
) => {
|
||||||
|
const newHeaders = [...headers];
|
||||||
|
newHeaders[index] = { ...newHeaders[index], [field]: value };
|
||||||
|
onChange(newHeaders);
|
||||||
|
};
|
||||||
|
|
||||||
|
const addHeader = () => {
|
||||||
|
onChange([...headers, createEmptyHeader()]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeHeader = (index: number) => {
|
||||||
|
const newHeaders = headers.filter((_, i) => i !== index);
|
||||||
|
onChange(newHeaders);
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleValueVisibility = (index: number) => {
|
||||||
|
const newVisible = new Set(visibleValues);
|
||||||
|
if (newVisible.has(index)) {
|
||||||
|
newVisible.delete(index);
|
||||||
|
} else {
|
||||||
|
newVisible.add(index);
|
||||||
|
}
|
||||||
|
setVisibleValues(newVisible);
|
||||||
|
};
|
||||||
|
|
||||||
|
const switchToJsonMode = () => {
|
||||||
|
const jsonObject: Record<string, string> = {};
|
||||||
|
headers.forEach((header) => {
|
||||||
|
if (header.enabled && header.name.trim() && header.value.trim()) {
|
||||||
|
jsonObject[header.name.trim()] = header.value.trim();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
setJsonValue(JSON.stringify(jsonObject, null, 2));
|
||||||
|
setJsonError(null);
|
||||||
|
setIsJsonMode(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const switchToFormMode = () => {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(jsonValue);
|
||||||
|
if (
|
||||||
|
typeof parsed !== "object" ||
|
||||||
|
parsed === null ||
|
||||||
|
Array.isArray(parsed)
|
||||||
|
) {
|
||||||
|
setJsonError("JSON must be an object with string key-value pairs");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newHeaders: CustomHeadersType = Object.entries(parsed).map(
|
||||||
|
([name, value]) => ({
|
||||||
|
name,
|
||||||
|
value: String(value),
|
||||||
|
enabled: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
onChange(newHeaders);
|
||||||
|
setJsonError(null);
|
||||||
|
setIsJsonMode(false);
|
||||||
|
} catch {
|
||||||
|
setJsonError("Invalid JSON format");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleJsonChange = (value: string) => {
|
||||||
|
setJsonValue(value);
|
||||||
|
setJsonError(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isJsonMode) {
|
||||||
|
return (
|
||||||
|
<div className={`space-y-3 ${className}`}>
|
||||||
|
<div className="flex justify-between items-center gap-2">
|
||||||
|
<h4 className="text-sm font-semibold flex-shrink-0">
|
||||||
|
Custom Headers (JSON)
|
||||||
|
</h4>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={switchToFormMode}
|
||||||
|
className="flex-shrink-0"
|
||||||
|
>
|
||||||
|
Switch to Form
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Textarea
|
||||||
|
value={jsonValue}
|
||||||
|
onChange={(e) => handleJsonChange(e.target.value)}
|
||||||
|
placeholder='{\n "Authorization": "Bearer token123",\n "X-Tenant-ID": "acme-inc",\n "X-Environment": "staging"\n}'
|
||||||
|
className="font-mono text-sm min-h-[100px] resize-none"
|
||||||
|
/>
|
||||||
|
{jsonError && <p className="text-sm text-red-600">{jsonError}</p>}
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Enter headers as a JSON object with string key-value pairs.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`space-y-3 ${className}`}>
|
||||||
|
<div className="flex justify-between items-center gap-2">
|
||||||
|
<h4 className="text-sm font-semibold flex-shrink-0">Custom Headers</h4>
|
||||||
|
<div className="flex gap-1 flex-shrink-0">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={switchToJsonMode}
|
||||||
|
className="text-xs px-2"
|
||||||
|
>
|
||||||
|
JSON
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={addHeader}
|
||||||
|
className="text-xs px-2"
|
||||||
|
data-testid="add-header-button"
|
||||||
|
>
|
||||||
|
<Plus className="w-3 h-3 mr-1" />
|
||||||
|
Add
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{headers.length === 0 ? (
|
||||||
|
<div className="text-center py-4 text-muted-foreground">
|
||||||
|
<p className="text-sm">No custom headers configured</p>
|
||||||
|
<p className="text-xs mt-1">Click "Add" to get started</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2 max-h-[300px] overflow-y-auto">
|
||||||
|
{headers.map((header, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className="flex items-start gap-2 p-2 border rounded-md"
|
||||||
|
>
|
||||||
|
<Switch
|
||||||
|
checked={header.enabled}
|
||||||
|
onCheckedChange={(enabled) =>
|
||||||
|
updateHeader(index, "enabled", enabled)
|
||||||
|
}
|
||||||
|
className="shrink-0 mt-2"
|
||||||
|
/>
|
||||||
|
<div className="flex-1 min-w-0 space-y-2">
|
||||||
|
<Input
|
||||||
|
placeholder="Header Name"
|
||||||
|
value={header.name}
|
||||||
|
onChange={(e) => updateHeader(index, "name", e.target.value)}
|
||||||
|
className="font-mono text-xs"
|
||||||
|
data-testid={`header-name-input-${index}`}
|
||||||
|
/>
|
||||||
|
<div className="relative">
|
||||||
|
<Input
|
||||||
|
placeholder="Header Value"
|
||||||
|
value={header.value}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateHeader(index, "value", e.target.value)
|
||||||
|
}
|
||||||
|
type={visibleValues.has(index) ? "text" : "password"}
|
||||||
|
className="font-mono text-xs pr-8"
|
||||||
|
data-testid={`header-value-input-${index}`}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => toggleValueVisibility(index)}
|
||||||
|
className="absolute right-1 top-1/2 -translate-y-1/2 h-6 w-6 p-0"
|
||||||
|
>
|
||||||
|
{visibleValues.has(index) ? (
|
||||||
|
<EyeOff className="w-3 h-3" />
|
||||||
|
) : (
|
||||||
|
<Eye className="w-3 h-3" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => removeHeader(index)}
|
||||||
|
className="shrink-0 text-red-600 hover:text-red-700 hover:bg-red-50 h-6 w-6 p-0 mt-2"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-3 h-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{headers.length > 0 && (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Use the toggle to enable/disable headers. Only enabled headers with
|
||||||
|
both name and value will be sent.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CustomHeaders;
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,13 +1,18 @@
|
||||||
import { ServerNotification } from "@modelcontextprotocol/sdk/types.js";
|
import { ServerNotification } from "@modelcontextprotocol/sdk/types.js";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import JsonView from "./JsonView";
|
import JsonView from "./JsonView";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
const HistoryAndNotifications = ({
|
const HistoryAndNotifications = ({
|
||||||
requestHistory,
|
requestHistory,
|
||||||
serverNotifications,
|
serverNotifications,
|
||||||
|
onClearHistory,
|
||||||
|
onClearNotifications,
|
||||||
}: {
|
}: {
|
||||||
requestHistory: Array<{ request: string; response?: string }>;
|
requestHistory: Array<{ request: string; response?: string }>;
|
||||||
serverNotifications: ServerNotification[];
|
serverNotifications: ServerNotification[];
|
||||||
|
onClearHistory?: () => void;
|
||||||
|
onClearNotifications?: () => void;
|
||||||
}) => {
|
}) => {
|
||||||
const [expandedRequests, setExpandedRequests] = useState<{
|
const [expandedRequests, setExpandedRequests] = useState<{
|
||||||
[key: number]: boolean;
|
[key: number]: boolean;
|
||||||
|
|
@ -27,7 +32,17 @@ const HistoryAndNotifications = ({
|
||||||
return (
|
return (
|
||||||
<div className="bg-card overflow-hidden flex h-full">
|
<div className="bg-card overflow-hidden flex h-full">
|
||||||
<div className="flex-1 overflow-y-auto p-4 border-r">
|
<div className="flex-1 overflow-y-auto p-4 border-r">
|
||||||
<h2 className="text-lg font-semibold mb-4">History</h2>
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h2 className="text-lg font-semibold">History</h2>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={onClearHistory}
|
||||||
|
disabled={requestHistory.length === 0}
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
{requestHistory.length === 0 ? (
|
{requestHistory.length === 0 ? (
|
||||||
<p className="text-sm text-gray-500 dark:text-gray-400 italic">
|
<p className="text-sm text-gray-500 dark:text-gray-400 italic">
|
||||||
No history yet
|
No history yet
|
||||||
|
|
@ -93,7 +108,17 @@ const HistoryAndNotifications = ({
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 overflow-y-auto p-4">
|
<div className="flex-1 overflow-y-auto p-4">
|
||||||
<h2 className="text-lg font-semibold mb-4">Server Notifications</h2>
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h2 className="text-lg font-semibold">Server Notifications</h2>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={onClearNotifications}
|
||||||
|
disabled={serverNotifications.length === 0}
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
{serverNotifications.length === 0 ? (
|
{serverNotifications.length === 0 ? (
|
||||||
<p className="text-sm text-gray-500 dark:text-gray-400 italic">
|
<p className="text-sm text-gray-500 dark:text-gray-400 italic">
|
||||||
No notifications yet
|
No notifications yet
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,11 @@
|
||||||
import { useState, memo, useMemo, useCallback, useEffect } from "react";
|
import { useState, memo, useMemo, useCallback } from "react";
|
||||||
import type { JsonValue } from "@/utils/jsonUtils";
|
import type { JsonValue } from "@/utils/jsonUtils";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import { Copy, CheckCheck } from "lucide-react";
|
import { Copy, CheckCheck } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { useToast } from "@/lib/hooks/useToast";
|
import { useToast } from "@/lib/hooks/useToast";
|
||||||
import { getDataType, tryParseJson } from "@/utils/jsonUtils";
|
import { getDataType, tryParseJson } from "@/utils/jsonUtils";
|
||||||
|
import useCopy from "@/lib/hooks/useCopy";
|
||||||
|
|
||||||
interface JsonViewProps {
|
interface JsonViewProps {
|
||||||
data: unknown;
|
data: unknown;
|
||||||
|
|
@ -25,21 +26,7 @@ const JsonView = memo(
|
||||||
isError = false,
|
isError = false,
|
||||||
}: JsonViewProps) => {
|
}: JsonViewProps) => {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const [copied, setCopied] = useState(false);
|
const { copied, setCopied } = useCopy();
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let timeoutId: NodeJS.Timeout;
|
|
||||||
if (copied) {
|
|
||||||
timeoutId = setTimeout(() => {
|
|
||||||
setCopied(false);
|
|
||||||
}, 500);
|
|
||||||
}
|
|
||||||
return () => {
|
|
||||||
if (timeoutId) {
|
|
||||||
clearTimeout(timeoutId);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, [copied]);
|
|
||||||
|
|
||||||
const normalizedData = useMemo(() => {
|
const normalizedData = useMemo(() => {
|
||||||
return typeof data === "string"
|
return typeof data === "string"
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,7 @@
|
||||||
|
import { Search } from "lucide-react";
|
||||||
import { Button } from "./ui/button";
|
import { Button } from "./ui/button";
|
||||||
|
import { Input } from "./ui/input";
|
||||||
|
import { useState, useMemo, useRef } from "react";
|
||||||
|
|
||||||
type ListPaneProps<T> = {
|
type ListPaneProps<T> = {
|
||||||
items: T[];
|
items: T[];
|
||||||
|
|
@ -20,41 +23,111 @@ const ListPane = <T extends object>({
|
||||||
title,
|
title,
|
||||||
buttonText,
|
buttonText,
|
||||||
isButtonDisabled,
|
isButtonDisabled,
|
||||||
}: ListPaneProps<T>) => (
|
}: ListPaneProps<T>) => {
|
||||||
<div className="bg-card border border-border rounded-lg shadow">
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
<div className="p-4 border-b border-gray-200 dark:border-border">
|
const [isSearchExpanded, setIsSearchExpanded] = useState(false);
|
||||||
<h3 className="font-semibold dark:text-white">{title}</h3>
|
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||||
</div>
|
|
||||||
<div className="p-4">
|
const filteredItems = useMemo(() => {
|
||||||
<Button
|
if (!searchQuery.trim()) return items;
|
||||||
variant="outline"
|
|
||||||
className="w-full mb-4"
|
return items.filter((item) => {
|
||||||
onClick={listItems}
|
const searchableText = [
|
||||||
disabled={isButtonDisabled}
|
(item as { name?: string }).name || "",
|
||||||
>
|
(item as { description?: string }).description || "",
|
||||||
{buttonText}
|
]
|
||||||
</Button>
|
.join(" ")
|
||||||
<Button
|
.toLowerCase();
|
||||||
variant="outline"
|
return searchableText.includes(searchQuery.toLowerCase());
|
||||||
className="w-full mb-4"
|
});
|
||||||
onClick={clearItems}
|
}, [items, searchQuery]);
|
||||||
disabled={items.length === 0}
|
|
||||||
>
|
const handleSearchClick = () => {
|
||||||
Clear
|
setIsSearchExpanded(true);
|
||||||
</Button>
|
setTimeout(() => {
|
||||||
<div className="space-y-2 overflow-y-auto max-h-96">
|
searchInputRef.current?.focus();
|
||||||
{items.map((item, index) => (
|
}, 100);
|
||||||
<div
|
};
|
||||||
key={index}
|
|
||||||
className="flex items-center py-2 px-4 rounded hover:bg-gray-50 dark:hover:bg-secondary cursor-pointer"
|
const handleSearchBlur = () => {
|
||||||
onClick={() => setSelectedItem(item)}
|
if (!searchQuery.trim()) {
|
||||||
>
|
setIsSearchExpanded(false);
|
||||||
{renderItem(item)}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-card border border-border rounded-lg shadow">
|
||||||
|
<div className="p-4 border-b border-gray-200 dark:border-border">
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<h3 className="font-semibold dark:text-white flex-shrink-0">
|
||||||
|
{title}
|
||||||
|
</h3>
|
||||||
|
<div className="flex items-center justify-end min-w-0 flex-1">
|
||||||
|
{!isSearchExpanded ? (
|
||||||
|
<button
|
||||||
|
name="search"
|
||||||
|
aria-label="Search"
|
||||||
|
onClick={handleSearchClick}
|
||||||
|
className="p-2 hover:bg-gray-100 dark:hover:bg-secondary rounded-md transition-all duration-300 ease-in-out"
|
||||||
|
>
|
||||||
|
<Search className="w-4 h-4 text-muted-foreground" />
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center w-full max-w-xs">
|
||||||
|
<div className="relative w-full">
|
||||||
|
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-muted-foreground pointer-events-none z-10" />
|
||||||
|
<Input
|
||||||
|
ref={searchInputRef}
|
||||||
|
name="search"
|
||||||
|
type="text"
|
||||||
|
placeholder="Search..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
onBlur={handleSearchBlur}
|
||||||
|
className="pl-10 w-full transition-all duration-300 ease-in-out"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-4">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="w-full mb-4"
|
||||||
|
onClick={listItems}
|
||||||
|
disabled={isButtonDisabled}
|
||||||
|
>
|
||||||
|
{buttonText}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="w-full mb-4"
|
||||||
|
onClick={clearItems}
|
||||||
|
disabled={items.length === 0}
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</Button>
|
||||||
|
<div className="space-y-2 overflow-y-auto max-h-96">
|
||||||
|
{filteredItems.map((item, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className="flex items-center py-2 px-4 rounded hover:bg-gray-50 dark:hover:bg-secondary cursor-pointer"
|
||||||
|
onClick={() => setSelectedItem(item)}
|
||||||
|
>
|
||||||
|
{renderItem(item)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{filteredItems.length === 0 && searchQuery && items.length > 0 && (
|
||||||
|
<div className="text-center py-4 text-muted-foreground">
|
||||||
|
No items found matching "{searchQuery}"
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
);
|
||||||
);
|
};
|
||||||
|
|
||||||
export default ListPane;
|
export default ListPane;
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue