Merge branch 'main' into con-refused

This commit is contained in:
Cliff Hall 2025-07-03 11:30:12 -04:00 • committed by GitHub
commit f3f06efa7e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
26 changed files with 978 additions and 299 deletions

View file

@ -8,7 +8,8 @@ on:
jobs:
test:
timeout-minutes: 5
# Installing Playright dependencies can take quite awhile, and also depends on GitHub CI load.
timeout-minutes: 15
runs-on: ubuntu-latest
steps:
@ -20,8 +21,10 @@ jobs:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
id: setup_node
with:
node-version: 18
node-version-file: package.json
cache: npm
# Cache Playwright browsers
- name: Cache Playwright browsers
@ -44,11 +47,12 @@ jobs:
if: steps.cache-playwright.outputs.cache-hit != 'true'
- name: Run Playwright tests
id: playwright-tests
run: npm run test:e2e
- name: Upload Playwright Report and Screenshots
uses: actions/upload-artifact@v4
if: always()
if: steps.playwright-tests.conclusion != 'skipped'
with:
name: playwright-report
path: |
@ -59,7 +63,7 @@ jobs:
- name: Publish Playwright Test Summary
uses: daun/playwright-report-summary@v3
if: always()
if: steps.playwright-tests.conclusion != 'skipped'
with:
create-comment: ${{ github.event.pull_request.head.repo.full_name == github.repository }}
report-file: client/results.json
@ -67,7 +71,7 @@ jobs:
job-summary: true
icon-style: "emojis"
custom-info: |
**Test Environment:** Ubuntu Latest, Node.js 18
**Test Environment:** Ubuntu Latest, Node.js ${{ steps.setup_node.outputs.node-version }}
**Browsers:** Chromium, Firefox
📊 [View Detailed HTML Report](https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}) (download artifacts)

View file

@ -19,7 +19,7 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version: 18
node-version-file: package.json
cache: npm
# Working around https://github.com/npm/cli/issues/4828
@ -53,7 +53,7 @@ jobs:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 18
node-version-file: package.json
cache: npm
registry-url: "https://registry.npmjs.org"

1
.node-version Normal file
View file

@ -0,0 +1 @@
22.x.x

View file

@ -7,7 +7,7 @@ Thanks for your interest in contributing! This guide explains how to get involve
1. Fork the repository and clone it locally
2. Install dependencies with `npm install`
3. Run `npm run dev` to start both client and server in development mode
4. Use the web UI at http://127.0.0.1:6274 to interact with the inspector
4. Use the web UI at http://localhost:6274 to interact with the inspector
## Development Process & Pull Requests

View file

@ -168,20 +168,20 @@ DANGEROUSLY_OMIT_AUTH=true npm start
#### Local-only Binding
By default, the MCP Inspector proxy server binds only to `127.0.0.1` (localhost) to prevent network access. This ensures the server is not accessible from other devices on the network. If you need to bind to all interfaces for development purposes, you can override this with the `HOST` environment variable:
By default, both the MCP Inspector proxy server and client bind only to `localhost` to prevent network access. This ensures they are not accessible from other devices on the network. If you need to bind to all interfaces for development purposes, you can override this with the `HOST` environment variable:
```bash
HOST=0.0.0.0 npm start
```
**Warning:** Only bind to all interfaces in trusted network environments, as this exposes the proxy server's ability to execute local processes.
**Warning:** Only bind to all interfaces in trusted network environments, as this exposes the proxy server's ability to execute local processes and both services to network access.
#### DNS Rebinding Protection
To prevent DNS rebinding attacks, the MCP Inspector validates the `Origin` header on incoming requests. By default, only requests from the client origin are allowed (respects `CLIENT_PORT` if set, defaulting to port 6274). You can configure additional allowed origins by setting the `ALLOWED_ORIGINS` environment variable (comma-separated list):
```bash
ALLOWED_ORIGINS=http://localhost:6274,http://127.0.0.1:6274,http://localhost:8000 npm start
ALLOWED_ORIGINS=http://localhost:6274,http://localhost:8000 npm start
```
### Configuration
@ -299,9 +299,12 @@ npx @modelcontextprotocol/inspector --cli node build/index.js --method resources
# List available prompts
npx @modelcontextprotocol/inspector --cli node build/index.js --method prompts/list
# Connect to a remote MCP server
# Connect to a remote MCP server (default is SSE transport)
npx @modelcontextprotocol/inspector --cli https://my-mcp-server.example.com
# Connect to a remote MCP server (with Streamable HTTP transport)
npx @modelcontextprotocol/inspector --cli https://my-mcp-server.example.com --transport http
# Call a tool on a remote server
npx @modelcontextprotocol/inspector --cli https://my-mcp-server.example.com --method tools/call --tool-name remotetool --tool-arg param=value

View file

@ -1,6 +1,6 @@
{
"name": "@modelcontextprotocol/inspector-cli",
"version": "0.14.3",
"version": "0.15.0",
"description": "CLI for the Model Context Protocol inspector",
"license": "MIT",
"author": "Anthropic, PBC (https://anthropic.com)",
@ -21,7 +21,7 @@
},
"devDependencies": {},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.13.0",
"@modelcontextprotocol/sdk": "^1.13.1",
"commander": "^13.1.0",
"spawn-rx": "^5.1.2"
}

View file

@ -44,7 +44,14 @@ 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`);
console.log(`${colors.BLUE}- Logging options (--log-level)${colors.NC}`);
console.log(
`${colors.BLUE}- Transport types (--transport http/sse/stdio)${colors.NC}`,
);
console.log(
`${colors.BLUE}- Transport inference from URL suffixes (/mcp, /sse)${colors.NC}`,
);
console.log(`\n`);
// Get directory paths
const SCRIPTS_DIR = __dirname;
@ -62,9 +69,11 @@ if (!fs.existsSync(OUTPUT_DIR)) {
}
// Create a temporary directory for test files
const TEMP_DIR = fs.mkdirSync(path.join(os.tmpdir(), "mcp-inspector-tests"), {
recursive: true,
});
const TEMP_DIR = path.join(os.tmpdir(), "mcp-inspector-tests");
fs.mkdirSync(TEMP_DIR, { recursive: true });
// Track servers for cleanup
let runningServers = [];
process.on("exit", () => {
try {
@ -74,6 +83,21 @@ process.on("exit", () => {
`${colors.RED}Failed to remove temp directory: ${err.message}${colors.NC}`,
);
}
runningServers.forEach((server) => {
try {
process.kill(-server.pid);
} catch (e) {}
});
});
process.on("SIGINT", () => {
runningServers.forEach((server) => {
try {
process.kill(-server.pid);
} catch (e) {}
});
process.exit(1);
});
// Use the existing sample config file
@ -121,6 +145,11 @@ async function runBasicTest(testName, ...args) {
stdio: ["ignore", "pipe", "pipe"],
});
const timeout = setTimeout(() => {
console.log(`${colors.YELLOW}Test timed out: ${testName}${colors.NC}`);
child.kill();
}, 10000);
// Pipe stdout and stderr to the output file
child.stdout.pipe(outputStream);
child.stderr.pipe(outputStream);
@ -135,6 +164,7 @@ async function runBasicTest(testName, ...args) {
});
child.on("close", (code) => {
clearTimeout(timeout);
outputStream.end();
if (code === 0) {
@ -201,6 +231,13 @@ async function runErrorTest(testName, ...args) {
stdio: ["ignore", "pipe", "pipe"],
});
const timeout = setTimeout(() => {
console.log(
`${colors.YELLOW}Error test timed out: ${testName}${colors.NC}`,
);
child.kill();
}, 10000);
// Pipe stdout and stderr to the output file
child.stdout.pipe(outputStream);
child.stderr.pipe(outputStream);
@ -215,6 +252,7 @@ async function runErrorTest(testName, ...args) {
});
child.on("close", (code) => {
clearTimeout(timeout);
outputStream.end();
// For error tests, we expect a non-zero exit code
@ -611,6 +649,79 @@ async function runTests() {
"debug",
);
console.log(
`\n${colors.YELLOW}=== Running HTTP Transport Tests ===${colors.NC}`,
);
console.log(
`${colors.BLUE}Starting server-everything in streamableHttp mode.${colors.NC}`,
);
const httpServer = spawn(
"npx",
["@modelcontextprotocol/server-everything", "streamableHttp"],
{
detached: true,
stdio: "ignore",
},
);
runningServers.push(httpServer);
await new Promise((resolve) => setTimeout(resolve, 3000));
// Test 25: HTTP transport inferred from URL ending with /mcp
await runBasicTest(
"http_transport_inferred",
"http://127.0.0.1:3001/mcp",
"--cli",
"--method",
"tools/list",
);
// Test 26: HTTP transport with explicit --transport http flag
await runBasicTest(
"http_transport_with_explicit_flag",
"http://127.0.0.1:3001",
"--transport",
"http",
"--cli",
"--method",
"tools/list",
);
// Test 27: HTTP transport with suffix and --transport http flag
await runBasicTest(
"http_transport_with_explicit_flag_and_suffix",
"http://127.0.0.1:3001/mcp",
"--transport",
"http",
"--cli",
"--method",
"tools/list",
);
// Test 28: SSE transport given to HTTP server (should fail)
await runErrorTest(
"sse_transport_given_to_http_server",
"http://127.0.0.1:3001",
"--transport",
"sse",
"--cli",
"--method",
"tools/list",
);
// Kill HTTP server
try {
process.kill(-httpServer.pid);
console.log(
`${colors.BLUE}HTTP server killed, waiting for port to be released...${colors.NC}`,
);
} catch (e) {
console.log(
`${colors.RED}Error killing HTTP server: ${e.message}${colors.NC}`,
);
}
// Print test summary
console.log(`\n${colors.YELLOW}=== Test Summary ===${colors.NC}`);
console.log(`${colors.GREEN}Passed: ${PASSED_TESTS}${colors.NC}`);

View file

@ -29,9 +29,13 @@ type Args = {
logLevel?: LogLevel;
toolName?: string;
toolArg?: Record<string, string>;
transport?: "sse" | "stdio" | "http";
};
function createTransportOptions(target: string[]): TransportOptions {
function createTransportOptions(
target: string[],
transport?: "sse" | "stdio" | "http",
): TransportOptions {
if (target.length === 0) {
throw new Error(
"Target is required. Specify a URL or a command to execute.",
@ -50,8 +54,30 @@ function createTransportOptions(target: string[]): TransportOptions {
throw new Error("Arguments cannot be passed to a URL-based MCP server.");
}
let transportType: "sse" | "stdio" | "http";
if (transport) {
if (!isUrl && transport !== "stdio") {
throw new Error("Only stdio transport can be used with local commands.");
}
if (isUrl && transport === "stdio") {
throw new Error("stdio transport cannot be used with URLs.");
}
transportType = transport;
} else if (isUrl) {
const url = new URL(command);
if (url.pathname.endsWith("/mcp")) {
transportType = "http";
} else if (url.pathname.endsWith("/sse")) {
transportType = "sse";
} else {
transportType = "sse";
}
} else {
transportType = "stdio";
}
return {
transportType: isUrl ? "sse" : "stdio",
transportType,
command: isUrl ? undefined : command,
args: isUrl ? undefined : commandArgs,
url: isUrl ? command : undefined,
@ -59,7 +85,7 @@ function createTransportOptions(target: string[]): TransportOptions {
}
async function callMethod(args: Args): Promise<void> {
const transportOptions = createTransportOptions(args.target);
const transportOptions = createTransportOptions(args.target, args.transport);
const transport = createTransport(transportOptions);
const client = new Client({
name: "inspector-cli",
@ -214,6 +240,22 @@ function parseArgs(): Args {
return value as LogLevel;
},
)
//
// Transport options
//
.option(
"--transport <type>",
"Transport type (sse, http, or stdio). Auto-detected from URL: /mcp → http, /sse → sse, commands → stdio",
(value: string) => {
const validTransports = ["sse", "http", "stdio"];
if (!validTransports.includes(value)) {
throw new Error(
`Invalid transport type: ${value}. Valid types are: ${validTransports.join(", ")}`,
);
}
return value as "sse" | "http" | "stdio";
},
);
// Parse only the arguments before --

View file

@ -3,11 +3,12 @@ import {
getDefaultEnvironment,
StdioClientTransport,
} from "@modelcontextprotocol/sdk/client/stdio.js";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
import { findActualExecutable } from "spawn-rx";
export type TransportOptions = {
transportType: "sse" | "stdio";
transportType: "sse" | "stdio" | "http";
command?: string;
args?: string[];
url?: string;
@ -15,11 +16,22 @@ export type TransportOptions = {
function createSSETransport(options: TransportOptions): Transport {
const baseUrl = new URL(options.url ?? "");
const sseUrl = new URL("/sse", baseUrl);
const sseUrl = baseUrl.pathname.endsWith("/sse")
? baseUrl
: new URL("/sse", baseUrl);
return new SSEClientTransport(sseUrl);
}
function createHTTPTransport(options: TransportOptions): Transport {
const baseUrl = new URL(options.url ?? "");
const mcpUrl = baseUrl.pathname.endsWith("/mcp")
? baseUrl
: new URL("/mcp", baseUrl);
return new StreamableHTTPClientTransport(mcpUrl);
}
function createStdioTransport(options: TransportOptions): Transport {
let args: string[] = [];
@ -67,6 +79,10 @@ export function createTransport(options: TransportOptions): Transport {
return createSSETransport(options);
}
if (transportType === "http") {
return createHTTPTransport(options);
}
throw new Error(`Unsupported transport type: ${transportType}`);
} catch (error) {
throw new Error(

View file

@ -1,5 +1,6 @@
#!/usr/bin/env node
import open from "open";
import { join, dirname } from "path";
import { fileURLToPath } from "url";
import handler from "serve-handler";
@ -39,19 +40,23 @@ const server = http.createServer((request, response) => {
return handler(request, response, handlerOptions);
});
const port = process.env.PORT || 6274;
const port = parseInt(process.env.CLIENT_PORT || "6274", 10);
const host = process.env.HOST || "localhost";
server.on("listening", () => {
console.log(
`🔍 MCP Inspector is up and running at http://127.0.0.1:${port} 🚀`,
);
const url = process.env.INSPECTOR_URL || `http://${host}:${port}`;
console.log(`\n🚀 MCP Inspector is up and running at:\n ${url}\n`);
if (process.env.MCP_AUTO_OPEN_ENABLED !== "false") {
console.log(`🌐 Opening browser...`);
open(url);
}
});
server.on("error", (err) => {
if (err.message.includes(`EADDRINUSE`)) {
console.error(
`❌ MCP Inspector PORT IS IN USE at http://127.0.0.1:${port} ❌ `,
`❌ MCP Inspector PORT IS IN USE at http://${host}:${port} ❌ `,
);
} else {
throw err;
}
});
server.listen(port);
server.listen(port, host);

View file

@ -7,11 +7,26 @@ import { fileURLToPath } from "url";
import { randomBytes } from "crypto";
const __dirname = dirname(fileURLToPath(import.meta.url));
const DEFAULT_MCP_PROXY_LISTEN_PORT = "6277";
function delay(ms) {
return new Promise((resolve) => setTimeout(resolve, ms, true));
}
function getClientUrl(port, authDisabled, sessionToken, serverPort) {
const host = process.env.HOST || "localhost";
const baseUrl = `http://${host}:${port}`;
const params = new URLSearchParams();
if (serverPort && serverPort !== DEFAULT_MCP_PROXY_LISTEN_PORT) {
params.set("MCP_PROXY_PORT", serverPort);
}
if (!authDisabled) {
params.set("MCP_PROXY_AUTH_TOKEN", sessionToken);
}
return params.size > 0 ? `${baseUrl}/?${params.toString()}` : baseUrl;
}
async function startDevServer(serverOptions) {
const { SERVER_PORT, CLIENT_PORT, sessionToken, envVars, abort } =
serverOptions;
@ -23,8 +38,8 @@ async function startDevServer(serverOptions) {
cwd: resolve(__dirname, "../..", "server"),
env: {
...process.env,
PORT: SERVER_PORT,
CLIENT_PORT: CLIENT_PORT,
SERVER_PORT,
CLIENT_PORT,
MCP_PROXY_TOKEN: sessionToken,
MCP_ENV_VARS: JSON.stringify(envVars),
},
@ -82,8 +97,8 @@ async function startProdServer(serverOptions) {
{
env: {
...process.env,
PORT: SERVER_PORT,
CLIENT_PORT: CLIENT_PORT,
SERVER_PORT,
CLIENT_PORT,
MCP_PROXY_TOKEN: sessionToken,
MCP_ENV_VARS: JSON.stringify(envVars),
},
@ -99,30 +114,40 @@ async function startProdServer(serverOptions) {
}
async function startDevClient(clientOptions) {
const { CLIENT_PORT, authDisabled, sessionToken, abort, cancelled } =
clientOptions;
const {
CLIENT_PORT,
SERVER_PORT,
authDisabled,
sessionToken,
abort,
cancelled,
} = clientOptions;
const clientCommand = "npx";
const clientArgs = ["vite", "--port", CLIENT_PORT];
const host = process.env.HOST || "localhost";
const clientArgs = ["vite", "--port", CLIENT_PORT, "--host", host];
const client = spawn(clientCommand, clientArgs, {
cwd: resolve(__dirname, ".."),
env: { ...process.env, PORT: CLIENT_PORT },
env: { ...process.env, CLIENT_PORT },
signal: abort.signal,
echoOutput: true,
});
// Auto-open browser after vite starts
if (process.env.MCP_AUTO_OPEN_ENABLED !== "false") {
const url = authDisabled
? `http://127.0.0.1:${CLIENT_PORT}`
: `http://127.0.0.1:${CLIENT_PORT}/?MCP_PROXY_AUTH_TOKEN=${sessionToken}`;
const url = getClientUrl(
CLIENT_PORT,
authDisabled,
sessionToken,
SERVER_PORT,
);
// Give vite time to start before opening browser
setTimeout(() => {
// Give vite time to start before opening or logging the URL
setTimeout(() => {
console.log(`\n🚀 MCP Inspector is up and running at:\n ${url}\n`);
if (process.env.MCP_AUTO_OPEN_ENABLED !== "false") {
console.log("🌐 Opening browser...");
open(url);
console.log(`\n🔗 Opening browser at: ${url}\n`);
}, 3000);
}
}
}, 3000);
await new Promise((resolve) => {
client.subscribe({
@ -139,7 +164,14 @@ async function startDevClient(clientOptions) {
}
async function startProdClient(clientOptions) {
const { CLIENT_PORT, authDisabled, sessionToken, abort } = clientOptions;
const {
CLIENT_PORT,
SERVER_PORT,
authDisabled,
sessionToken,
abort,
cancelled,
} = clientOptions;
const inspectorClientPath = resolve(
__dirname,
"../..",
@ -148,16 +180,19 @@ async function startProdClient(clientOptions) {
"client.js",
);
// Auto-open browser with token
if (process.env.MCP_AUTO_OPEN_ENABLED !== "false") {
const url = authDisabled
? `http://127.0.0.1:${CLIENT_PORT}`
: `http://127.0.0.1:${CLIENT_PORT}/?MCP_PROXY_AUTH_TOKEN=${sessionToken}`;
open(url);
}
const url = getClientUrl(
CLIENT_PORT,
authDisabled,
sessionToken,
SERVER_PORT,
);
await spawnPromise("node", [inspectorClientPath], {
env: { ...process.env, PORT: CLIENT_PORT },
env: {
...process.env,
CLIENT_PORT,
INSPECTOR_URL: url,
},
signal: abort.signal,
echoOutput: true,
});
@ -204,7 +239,7 @@ async function main() {
}
const CLIENT_PORT = process.env.CLIENT_PORT ?? "6274";
const SERVER_PORT = process.env.SERVER_PORT ?? "6277";
const SERVER_PORT = process.env.SERVER_PORT ?? DEFAULT_MCP_PROXY_LISTEN_PORT;
console.log(
isDev
@ -249,6 +284,7 @@ async function main() {
try {
const clientOptions = {
CLIENT_PORT,
SERVER_PORT,
authDisabled,
sessionToken,
abort,

View file

@ -7,7 +7,7 @@
<title>MCP Inspector</title>
</head>
<body>
<div id="root"></div>
<div id="root" class="w-full"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View file

@ -1,6 +1,6 @@
{
"name": "@modelcontextprotocol/inspector-client",
"version": "0.14.3",
"version": "0.15.0",
"description": "Client-side application for the Model Context Protocol inspector",
"license": "MIT",
"author": "Anthropic, PBC (https://anthropic.com)",
@ -25,7 +25,7 @@
"cleanup:e2e": "node e2e/global-teardown.js"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.13.0",
"@modelcontextprotocol/sdk": "^1.13.1",
"@radix-ui/react-checkbox": "^1.1.4",
"ajv": "^6.12.6",
"@radix-ui/react-dialog": "^1.1.3",
@ -40,7 +40,7 @@
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
"cmdk": "^1.0.4",
"lucide-react": "^0.447.0",
"lucide-react": "^0.523.0",
"pkce-challenge": "^4.1.0",
"prismjs": "^1.30.0",
"react": "^18.3.1",

View file

@ -1,7 +1,3 @@
#root {
margin: 0 auto;
}
.logo {
height: 6em;
padding: 1.5em;

View file

@ -16,10 +16,39 @@ interface DynamicJsonFormProps {
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),
);
if (schema.type === "object") {
return Object.values(schema.properties ?? {}).every((prop) =>
supportedTypes.includes(prop.type),
);
}
if (schema.type === "array") {
return !!schema.items && isSimpleObject(schema.items);
}
return false;
};
const getArrayItemDefault = (schema: JsonSchemaType): JsonValue => {
if ("default" in schema && schema.default !== undefined) {
return schema.default;
}
switch (schema.type) {
case "string":
return "";
case "number":
case "integer":
return 0;
case "boolean":
return false;
case "array":
return [];
case "object":
return {};
case "null":
return null;
default:
return null;
}
};
const DynamicJsonForm = ({
@ -113,6 +142,8 @@ const DynamicJsonForm = ({
currentValue: JsonValue,
path: string[] = [],
depth: number = 0,
parentSchema?: JsonSchemaType,
propertyName?: string,
) => {
if (
depth >= maxDepth &&
@ -122,7 +153,8 @@ const DynamicJsonForm = ({
return (
<JsonEditor
value={JSON.stringify(
currentValue ?? generateDefaultValue(propSchema),
currentValue ??
generateDefaultValue(propSchema, propertyName, parentSchema),
null,
2,
)}
@ -140,6 +172,10 @@ const DynamicJsonForm = ({
);
}
// Check if this property is required in the parent schema
const isRequired =
parentSchema?.required?.includes(propertyName || "") ?? false;
switch (propSchema.type) {
case "string":
return (
@ -148,16 +184,11 @@ const DynamicJsonForm = ({
value={(currentValue as string) ?? ""}
onChange={(e) => {
const val = e.target.value;
// Allow clearing non-required fields by setting undefined
// This preserves the distinction between empty string and unset
if (!val && !propSchema.required) {
handleFieldChange(path, undefined);
} else {
handleFieldChange(path, val);
}
// Always allow setting string values, including empty strings
handleFieldChange(path, val);
}}
placeholder={propSchema.description}
required={propSchema.required}
required={isRequired}
/>
);
case "number":
@ -167,9 +198,7 @@ const DynamicJsonForm = ({
value={(currentValue as number)?.toString() ?? ""}
onChange={(e) => {
const val = e.target.value;
// Allow clearing non-required number fields
// This preserves the distinction between 0 and unset
if (!val && !propSchema.required) {
if (!val && !isRequired) {
handleFieldChange(path, undefined);
} else {
const num = Number(val);
@ -179,7 +208,7 @@ const DynamicJsonForm = ({
}
}}
placeholder={propSchema.description}
required={propSchema.required}
required={isRequired}
/>
);
case "integer":
@ -190,9 +219,7 @@ const DynamicJsonForm = ({
value={(currentValue as number)?.toString() ?? ""}
onChange={(e) => {
const val = e.target.value;
// Allow clearing non-required integer fields
// This preserves the distinction between 0 and unset
if (!val && !propSchema.required) {
if (!val && !isRequired) {
handleFieldChange(path, undefined);
} else {
const num = Number(val);
@ -203,7 +230,7 @@ const DynamicJsonForm = ({
}
}}
placeholder={propSchema.description}
required={propSchema.required}
required={isRequired}
/>
);
case "boolean":
@ -213,9 +240,135 @@ const DynamicJsonForm = ({
checked={(currentValue as boolean) ?? false}
onChange={(e) => handleFieldChange(path, e.target.checked)}
className="w-4 h-4"
required={propSchema.required}
required={isRequired}
/>
);
case "object":
if (!propSchema.properties) {
return (
<JsonEditor
value={JSON.stringify(currentValue ?? {}, null, 2)}
onChange={(newValue) => {
try {
const parsed = JSON.parse(newValue);
handleFieldChange(path, parsed);
setJsonError(undefined);
} catch (err) {
setJsonError(
err instanceof Error ? err.message : "Invalid JSON",
);
}
}}
error={jsonError}
/>
);
}
return (
<div className="space-y-2 border rounded p-3">
{Object.entries(propSchema.properties).map(([key, subSchema]) => (
<div key={key}>
<label className="block text-sm font-medium mb-1">
{key}
{propSchema.required?.includes(key) && (
<span className="text-red-500 ml-1">*</span>
)}
</label>
{renderFormFields(
subSchema as JsonSchemaType,
(currentValue as Record<string, JsonValue>)?.[key],
[...path, key],
depth + 1,
propSchema,
key,
)}
</div>
))}
</div>
);
case "array": {
const arrayValue = Array.isArray(currentValue) ? currentValue : [];
if (!propSchema.items) return null;
// If the array items are simple, render as form fields, otherwise use JSON editor
if (isSimpleObject(propSchema.items)) {
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 = getArrayItemDefault(
propSchema.items as JsonSchemaType,
);
handleFieldChange(path, [...arrayValue, defaultValue]);
}}
title={
propSchema.items?.description
? `Add new ${propSchema.items.description}`
: "Add new item"
}
>
Add Item
</Button>
</div>
</div>
);
}
// For complex arrays, fall back to JSON editor
return (
<JsonEditor
value={JSON.stringify(currentValue ?? [], null, 2)}
onChange={(newValue) => {
try {
const parsed = JSON.parse(newValue);
handleFieldChange(path, parsed);
setJsonError(undefined);
} catch (err) {
setJsonError(
err instanceof Error ? err.message : "Invalid JSON",
);
}
}}
error={jsonError}
/>
);
}
default:
return null;
}

View file

@ -7,7 +7,7 @@ import { TabsContent } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea";
import DynamicJsonForm from "./DynamicJsonForm";
import type { JsonValue, JsonSchemaType } from "@/utils/jsonUtils";
import { generateDefaultValue } from "@/utils/schemaUtils";
import { generateDefaultValue, isPropertyRequired } from "@/utils/schemaUtils";
import {
CompatibilityCallToolResult,
ListToolsResult,
@ -48,7 +48,11 @@ const ToolsTab = ({
selectedTool?.inputSchema.properties ?? [],
).map(([key, value]) => [
key,
generateDefaultValue(value as JsonSchemaType),
generateDefaultValue(
value as JsonSchemaType,
key,
selectedTool?.inputSchema as JsonSchemaType,
),
]);
setParams(Object.fromEntries(params));
}, [selectedTool]);
@ -92,6 +96,9 @@ const ToolsTab = ({
{Object.entries(selectedTool.inputSchema.properties ?? []).map(
([key, value]) => {
const prop = value as JsonSchemaType;
const inputSchema =
selectedTool.inputSchema as JsonSchemaType;
const required = isPropertyRequired(key, inputSchema);
return (
<div key={key}>
<Label
@ -99,6 +106,9 @@ const ToolsTab = ({
className="block text-sm font-medium text-gray-700 dark:text-gray-300"
>
{key}
{required && (
<span className="text-red-500 ml-1">*</span>
)}
</Label>
{prop.type === "boolean" ? (
<div className="flex items-center space-x-2 mt-2">
@ -163,12 +173,13 @@ const ToolsTab = ({
name={key}
placeholder={prop.description}
value={(params[key] as string) ?? ""}
onChange={(e) =>
onChange={(e) => {
const value = e.target.value;
setParams({
...params,
[key]: Number(e.target.value),
})
}
[key]: value === "" ? "" : Number(value),
});
}}
className="mt-1"
/>
) : (

View file

@ -0,0 +1,256 @@
import { render, screen, fireEvent } from "@testing-library/react";
import { describe, it, expect, jest } from "@jest/globals";
import DynamicJsonForm from "../DynamicJsonForm";
import type { JsonSchemaType } from "@/utils/jsonUtils";
describe("DynamicJsonForm Array Fields", () => {
const renderSimpleArrayForm = (props = {}) => {
const defaultProps = {
schema: {
type: "array" as const,
description: "Test array field",
items: {
type: "string" as const,
description: "Array item",
},
} satisfies JsonSchemaType,
value: [],
onChange: jest.fn(),
};
return render(<DynamicJsonForm {...defaultProps} {...props} />);
};
const renderComplexArrayForm = (props = {}) => {
const defaultProps = {
schema: {
type: "array" as const,
description: "Test complex array field",
items: {
type: "object" as const,
properties: {
nested: { type: "object" as const },
},
},
} satisfies JsonSchemaType,
value: [],
onChange: jest.fn(),
};
return render(<DynamicJsonForm {...defaultProps} {...props} />);
};
describe("Simple Array Rendering", () => {
it("should render form fields for simple array items", () => {
renderSimpleArrayForm({ value: ["item1", "item2"] });
// Should show array description
expect(screen.getByText("Test array field")).toBeDefined();
expect(screen.getByText("Items: Array item")).toBeDefined();
// Should show input fields for each item
const inputs = screen.getAllByRole("textbox");
expect(inputs).toHaveLength(2);
expect(inputs[0]).toHaveProperty("value", "item1");
expect(inputs[1]).toHaveProperty("value", "item2");
// Should show remove buttons
const removeButtons = screen.getAllByText("Remove");
expect(removeButtons).toHaveLength(2);
// Should show add button
expect(screen.getByText("Add Item")).toBeDefined();
});
it("should add new items when Add Item button is clicked", () => {
const onChange = jest.fn();
renderSimpleArrayForm({ value: ["item1"], onChange });
const addButton = screen.getByText("Add Item");
fireEvent.click(addButton);
expect(onChange).toHaveBeenCalledWith(["item1", ""]);
});
it("should remove items when Remove button is clicked", () => {
const onChange = jest.fn();
renderSimpleArrayForm({ value: ["item1", "item2"], onChange });
const removeButtons = screen.getAllByText("Remove");
fireEvent.click(removeButtons[0]);
expect(onChange).toHaveBeenCalledWith(["item2"]);
});
it("should update item values when input changes", () => {
const onChange = jest.fn();
renderSimpleArrayForm({ value: ["item1"], onChange });
const input = screen.getByRole("textbox");
fireEvent.change(input, { target: { value: "updated item" } });
expect(onChange).toHaveBeenCalledWith(["updated item"]);
});
it("should handle empty arrays", () => {
renderSimpleArrayForm({ value: [] });
// Should show description and add button but no items
expect(screen.getByText("Test array field")).toBeDefined();
expect(screen.getByText("Add Item")).toBeDefined();
expect(screen.queryByText("Remove")).toBeNull();
});
});
describe("Complex Array Fallback", () => {
it("should render JSON editor for complex arrays", () => {
renderComplexArrayForm();
// Should render as JSON editor (textarea)
const textarea = screen.getByRole("textbox");
expect(textarea).toHaveProperty("type", "textarea");
// Should not show form-specific array controls
expect(screen.queryByText("Add Item")).toBeNull();
expect(screen.queryByText("Remove")).toBeNull();
});
});
describe("Array Type Detection", () => {
it("should detect string arrays as simple", () => {
const schema = {
type: "array" as const,
items: { type: "string" as const },
};
renderSimpleArrayForm({ schema, value: ["test"] });
// Should render form fields, not JSON editor
expect(screen.getByRole("textbox")).not.toHaveProperty(
"type",
"textarea",
);
});
it("should detect number arrays as simple", () => {
const schema = {
type: "array" as const,
items: { type: "number" as const },
};
renderSimpleArrayForm({ schema, value: [1, 2] });
// Should render form fields (number inputs)
const inputs = screen.getAllByRole("spinbutton");
expect(inputs).toHaveLength(2);
});
it("should detect boolean arrays as simple", () => {
const schema = {
type: "array" as const,
items: { type: "boolean" as const },
};
renderSimpleArrayForm({ schema, value: [true, false] });
// Should render form fields (checkboxes)
const checkboxes = screen.getAllByRole("checkbox");
expect(checkboxes).toHaveLength(2);
});
it("should detect simple object arrays as simple", () => {
const schema = {
type: "array" as const,
items: {
type: "object" as const,
properties: {
name: { type: "string" as const },
age: { type: "number" as const },
},
},
};
renderSimpleArrayForm({ schema, value: [{ name: "John", age: 30 }] });
// Should render form fields for simple objects
expect(screen.getByText("Add Item")).toBeDefined();
expect(screen.getByText("Remove")).toBeDefined();
});
});
describe("Array with Different Item Types", () => {
it("should handle integer array items", () => {
const schema = {
type: "array" as const,
items: { type: "integer" as const },
};
const onChange = jest.fn();
renderSimpleArrayForm({ schema, value: [1, 2], onChange });
const inputs = screen.getAllByRole("spinbutton");
expect(inputs).toHaveLength(2);
expect(inputs[0]).toHaveProperty("value", "1");
expect(inputs[1]).toHaveProperty("value", "2");
// Test adding new integer item
const addButton = screen.getByText("Add Item");
fireEvent.click(addButton);
expect(onChange).toHaveBeenCalledWith([1, 2, 0]);
});
it("should handle boolean array items", () => {
const schema = {
type: "array" as const,
items: { type: "boolean" as const },
};
const onChange = jest.fn();
renderSimpleArrayForm({ schema, value: [true, false], onChange });
const checkboxes = screen.getAllByRole("checkbox");
expect(checkboxes).toHaveLength(2);
expect(checkboxes[0]).toHaveProperty("checked", true);
expect(checkboxes[1]).toHaveProperty("checked", false);
// Test adding new boolean item
const addButton = screen.getByText("Add Item");
fireEvent.click(addButton);
expect(onChange).toHaveBeenCalledWith([true, false, false]);
});
});
describe("Array Item Descriptions", () => {
it("should show item description when available", () => {
const schema = {
type: "array" as const,
description: "List of names",
items: {
type: "string" as const,
description: "Person name",
},
};
renderSimpleArrayForm({ schema });
expect(screen.getByText("List of names")).toBeDefined();
expect(screen.getByText("Items: Person name")).toBeDefined();
});
it("should use item description in add button title", () => {
const schema = {
type: "array" as const,
items: {
type: "string" as const,
description: "Email address",
},
};
renderSimpleArrayForm({ schema });
const addButton = screen.getByText("Add Item");
expect(addButton).toHaveProperty("title", "Add new Email address");
});
it("should use default title when no item description", () => {
const schema = {
type: "array" as const,
items: { type: "string" as const },
};
renderSimpleArrayForm({ schema });
const addButton = screen.getByText("Add Item");
expect(addButton).toHaveProperty("title", "Add new item");
});
});
});

View file

@ -111,6 +111,27 @@ describe("ToolsTab", () => {
});
});
it("should allow typing negative numbers", async () => {
renderToolsTab({
selectedTool: mockTools[0],
});
const input = screen.getByRole("spinbutton") as HTMLInputElement;
// Complete the negative number
fireEvent.change(input, { target: { value: "-42" } });
expect(input.value).toBe("-42");
const submitButton = screen.getByRole("button", { name: /run tool/i });
await act(async () => {
fireEvent.click(submitButton);
});
expect(defaultProps.callTool).toHaveBeenCalledWith(mockTools[0].name, {
num: -42,
});
});
it("should disable button and change text while tool is running", async () => {
// Create a promise that we can resolve later
let resolvePromise: ((value: unknown) => void) | undefined;

View file

@ -11,72 +11,68 @@ import type { Tool } from "@modelcontextprotocol/sdk/types.js";
describe("generateDefaultValue", () => {
test("generates default string", () => {
expect(generateDefaultValue({ type: "string", required: true })).toBe("");
const parentSchema = { type: "object" as const, required: ["testProp"] };
expect(
generateDefaultValue({ type: "string" }, "testProp", parentSchema),
).toBe("");
});
test("generates default number", () => {
expect(generateDefaultValue({ type: "number", required: true })).toBe(0);
const parentSchema = { type: "object" as const, required: ["testProp"] };
expect(
generateDefaultValue({ type: "number" }, "testProp", parentSchema),
).toBe(0);
});
test("generates default integer", () => {
expect(generateDefaultValue({ type: "integer", required: true })).toBe(0);
const parentSchema = { type: "object" as const, required: ["testProp"] };
expect(
generateDefaultValue({ type: "integer" }, "testProp", parentSchema),
).toBe(0);
});
test("generates default boolean", () => {
expect(generateDefaultValue({ type: "boolean", required: true })).toBe(
false,
);
const parentSchema = { type: "object" as const, required: ["testProp"] };
expect(
generateDefaultValue({ type: "boolean" }, "testProp", parentSchema),
).toBe(false);
});
test("generates default array", () => {
expect(generateDefaultValue({ type: "array", required: true })).toEqual([]);
expect(generateDefaultValue({ type: "array" })).toEqual([]);
});
test("generates default empty object", () => {
expect(generateDefaultValue({ type: "object", required: true })).toEqual(
{},
);
expect(generateDefaultValue({ type: "object" })).toEqual({});
});
test("generates default null for unknown types", () => {
// @ts-expect-error Testing with invalid type
expect(generateDefaultValue({ type: "unknown", required: true })).toBe(
null,
);
expect(generateDefaultValue({ type: "unknown" })).toBe(undefined);
});
test("generates empty array for non-required array", () => {
expect(generateDefaultValue({ type: "array", required: false })).toEqual(
[],
);
expect(generateDefaultValue({ type: "array" })).toEqual([]);
});
test("generates empty object for non-required object", () => {
expect(generateDefaultValue({ type: "object", required: false })).toEqual(
{},
);
expect(generateDefaultValue({ type: "object" })).toEqual({});
});
test("generates null for non-required primitive types", () => {
expect(generateDefaultValue({ type: "string", required: false })).toBe(
undefined,
);
expect(generateDefaultValue({ type: "number", required: false })).toBe(
undefined,
);
expect(generateDefaultValue({ type: "boolean", required: false })).toBe(
undefined,
);
test("generates undefined for non-required primitive types", () => {
expect(generateDefaultValue({ type: "string" })).toBe(undefined);
expect(generateDefaultValue({ type: "number" })).toBe(undefined);
expect(generateDefaultValue({ type: "boolean" })).toBe(undefined);
});
test("generates object with properties", () => {
const schema: JsonSchemaType = {
type: "object",
required: true,
required: ["name", "age", "isActive"],
properties: {
name: { type: "string", required: true },
age: { type: "number", required: true },
isActive: { type: "boolean", required: true },
name: { type: "string" },
age: { type: "number" },
isActive: { type: "boolean" },
},
};
expect(generateDefaultValue(schema)).toEqual({
@ -89,18 +85,18 @@ describe("generateDefaultValue", () => {
test("handles nested objects", () => {
const schema: JsonSchemaType = {
type: "object",
required: true,
required: ["user"],
properties: {
user: {
type: "object",
required: true,
required: ["name", "address"],
properties: {
name: { type: "string", required: true },
name: { type: "string" },
address: {
type: "object",
required: true,
required: ["city"],
properties: {
city: { type: "string", required: true },
city: { type: "string" },
},
},
},

View file

@ -4,12 +4,27 @@ import {
DEFAULT_INSPECTOR_CONFIG,
} from "@/lib/constants";
const getSearchParam = (key: string): string | null => {
try {
const url = new URL(window.location.href);
return url.searchParams.get(key);
} catch {
return null;
}
};
export const getMCPProxyAddress = (config: InspectorConfig): string => {
const proxyFullAddress = config.MCP_PROXY_FULL_ADDRESS.value as string;
let proxyFullAddress = config.MCP_PROXY_FULL_ADDRESS.value as string;
if (proxyFullAddress) {
proxyFullAddress = proxyFullAddress.replace(/\/+$/, "");
return proxyFullAddress;
}
return `${window.location.protocol}//${window.location.hostname}:${DEFAULT_MCP_PROXY_LISTEN_PORT}`;
// Check for proxy port from query params, fallback to default
const proxyPort =
getSearchParam("MCP_PROXY_PORT") || DEFAULT_MCP_PROXY_LISTEN_PORT;
return `${window.location.protocol}//${window.location.hostname}:${proxyPort}`;
};
export const getMCPServerRequestTimeout = (config: InspectorConfig): number => {
@ -40,15 +55,6 @@ export const getMCPProxyAuthToken = (
};
};
const getSearchParam = (key: string): string | null => {
try {
const url = new URL(window.location.href);
return url.searchParams.get(key);
} catch {
return null;
}
};
export const getInitialTransportType = ():
| "stdio"
| "sse"

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