Merge branch 'main' into update-readme

This commit is contained in:
Hoa Lam
2025-04-17 08:36:43 +07:00
committed by GitHub
27 changed files with 423 additions and 3429 deletions

4
.gitignore vendored
View File

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

View File

@@ -22,7 +22,7 @@ You can pass both arguments and environment variables to your MCP server. Argume
```bash
# Pass arguments only
npx @modelcontextprotocol/inspector build/index.js arg1 arg2
npx @modelcontextprotocol/inspector node build/index.js arg1 arg2
# Pass environment variables only
npx @modelcontextprotocol/inspector -e key=value -e key2=$VALUE2 node build/index.js

View File

@@ -1,217 +0,0 @@
#!/usr/bin/env node
import { Command } from "commander";
import fs from "node:fs";
import path from "node:path";
import { dirname, resolve } from "path";
import { spawnPromise } from "spawn-rx";
import { fileURLToPath } from "url";
const __dirname = dirname(fileURLToPath(import.meta.url));
function handleError(error) {
let message;
if (error instanceof Error) {
message = error.message;
} else if (typeof error === "string") {
message = error;
} else {
message = "Unknown error";
}
console.error(message);
process.exit(1);
}
function delay(ms) {
return new Promise((resolve) => setTimeout(resolve, ms, true));
}
async function runWebClient(args) {
const inspectorServerPath = resolve(
__dirname,
"..",
"server",
"build",
"index.js",
);
// Path to the client entry point
const inspectorClientPath = resolve(
__dirname,
"..",
"client",
"bin",
"cli.js",
);
const CLIENT_PORT = process.env.CLIENT_PORT ?? "6274";
const SERVER_PORT = process.env.SERVER_PORT ?? "6277";
console.log("Starting MCP inspector...");
const abort = new AbortController();
let cancelled = false;
process.on("SIGINT", () => {
cancelled = true;
abort.abort();
});
let server;
let serverOk;
try {
server = spawnPromise(
"node",
[
inspectorServerPath,
...(args.command ? [`--env`, args.command] : []),
...(args.args ? [`--args=${args.args.join(" ")}`] : []),
],
{
env: {
...process.env,
PORT: SERVER_PORT,
MCP_ENV_VARS: JSON.stringify(args.envArgs),
},
signal: abort.signal,
echoOutput: true,
},
);
// Make sure server started before starting client
serverOk = await Promise.race([server, delay(2 * 1000)]);
} catch (error) {}
if (serverOk) {
try {
await spawnPromise("node", [inspectorClientPath], {
env: { ...process.env, PORT: CLIENT_PORT },
signal: abort.signal,
echoOutput: true,
});
} catch (e) {
if (!cancelled || process.env.DEBUG) throw e;
}
}
}
async function runCli(args) {
const projectRoot = resolve(__dirname, "..");
const cliPath = resolve(projectRoot, "cli", "build", "index.js");
const abort = new AbortController();
let cancelled = false;
process.on("SIGINT", () => {
cancelled = true;
abort.abort();
});
try {
await spawnPromise("node", [cliPath, args.command, ...args.args], {
env: { ...process.env, ...args.envArgs },
signal: abort.signal,
echoOutput: true,
});
} catch (e) {
if (!cancelled || process.env.DEBUG) {
throw e;
}
}
}
function loadConfigFile(configPath, serverName) {
try {
const resolvedConfigPath = path.isAbsolute(configPath)
? configPath
: path.resolve(process.cwd(), configPath);
if (!fs.existsSync(resolvedConfigPath)) {
throw new Error(`Config file not found: ${resolvedConfigPath}`);
}
const configContent = fs.readFileSync(resolvedConfigPath, "utf8");
const parsedConfig = JSON.parse(configContent);
if (!parsedConfig.mcpServers || !parsedConfig.mcpServers[serverName]) {
const availableServers = Object.keys(parsedConfig.mcpServers || {}).join(
", ",
);
throw new Error(
`Server '${serverName}' not found in config file. Available servers: ${availableServers}`,
);
}
const serverConfig = parsedConfig.mcpServers[serverName];
return serverConfig;
} catch (err) {
if (err instanceof SyntaxError) {
throw new Error(`Invalid JSON in config file: ${err.message}`);
}
throw err;
}
}
function parseKeyValuePair(value, previous = {}) {
const parts = value.split("=");
const key = parts[0];
const val = parts.slice(1).join("=");
if (val === undefined || val === "") {
throw new Error(
`Invalid parameter format: ${value}. Use key=value format.`,
);
}
return { ...previous, [key]: val };
}
function parseArgs() {
const program = new Command();
const argSeparatorIndex = process.argv.indexOf("--");
let preArgs = process.argv;
let postArgs = [];
if (argSeparatorIndex !== -1) {
preArgs = process.argv.slice(0, argSeparatorIndex);
postArgs = process.argv.slice(argSeparatorIndex + 1);
}
program
.name("inspector-bin")
.allowExcessArguments()
.allowUnknownOption()
.option(
"-e <env>",
"environment variables in KEY=VALUE format",
parseKeyValuePair,
{},
)
.option("--config <path>", "config file path")
.option("--server <n>", "server name from config file")
.option("--cli", "enable CLI mode");
// Parse only the arguments before --
program.parse(preArgs);
const options = program.opts();
const remainingArgs = program.args;
// Add back any arguments that came after --
const finalArgs = [...remainingArgs, ...postArgs];
// Validate that config and server are provided together
if (
(options.config && !options.server) ||
(!options.config && options.server)
) {
throw new Error(
"Both --config and --server must be provided together. If you specify one, you must specify the other.",
);
}
// If config file is specified, load and use the options from the file. We must merge the args
// from the command line and the file together, or we will miss the method options (--method,
// etc.)
if (options.config && options.server) {
const config = loadConfigFile(options.config, options.server);
return {
command: config.command,
args: [...(config.args || []), ...finalArgs],
envArgs: { ...(config.env || {}), ...(options.e || {}) },
cli: options.cli || false,
};
}
// Otherwise use command line arguments
const command = finalArgs[0] || "";
const args = finalArgs.slice(1);
return {
command,
args,
envArgs: options.e || {},
cli: options.cli || false,
};
}
async function main() {
process.on("uncaughtException", (error) => {
handleError(error);
});
try {
const args = parseArgs();
if (args.cli) {
runCli(args);
} else {
await runWebClient(args);
}
} catch (error) {
handleError(error);
}
}
main();

View File

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

View File

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

View File

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

View File

@@ -3,18 +3,21 @@
"version": "0.9.0",
"description": "CLI for the Model Context Protocol inspector",
"license": "MIT",
"author": "Nicolas Barraud",
"homepage": "https://github.com/nbarraud",
"main": "build/index.js",
"author": "Anthropic, PBC (https://anthropic.com)",
"homepage": "https://modelcontextprotocol.io",
"bugs": "https://github.com/modelcontextprotocol/inspector/issues",
"main": "build/cli.js",
"type": "module",
"bin": {
"mcp-inspector-cli": "build/index.js"
"mcp-inspector-cli": "build/cli.js"
},
"files": [
"build"
],
"scripts": {
"build": "tsc"
"build": "tsc",
"postbuild": "node scripts/make-executable.js",
"test": "node scripts/cli-tests.js"
},
"devDependencies": {},
"dependencies": {

View File

@@ -44,30 +44,12 @@ 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}`);
console.log("");
console.log(`${colors.BLUE}- Logging options (--log-level)${colors.NC}\n`);
// Get directory paths
const SCRIPTS_DIR = __dirname;
const BIN_DIR = path.resolve(SCRIPTS_DIR, "..");
const PROJECT_ROOT = path.resolve(BIN_DIR, "..");
// Compile bin and cli projects
console.log(
`${colors.YELLOW}Compiling MCP Inspector bin and cli...${colors.NC}`,
);
try {
process.chdir(BIN_DIR);
execSync("npm run build", { stdio: "inherit" });
process.chdir(path.join(PROJECT_ROOT, "cli"));
execSync("npm run build", { stdio: "inherit" });
process.chdir(BIN_DIR);
} catch (error) {
console.error(
`${colors.RED}Error during compilation: ${error.message}${colors.NC}`,
);
process.exit(1);
}
const PROJECT_ROOT = path.join(SCRIPTS_DIR, "../../");
const BUILD_DIR = path.resolve(SCRIPTS_DIR, "../build");
// Define the test server command using npx
const TEST_CMD = "npx";
@@ -80,9 +62,10 @@ if (!fs.existsSync(OUTPUT_DIR)) {
}
// Create a temporary directory for test files
const TEMP_DIR = fs.mkdirSync(path.join(os.tmpdir(), "mcp-inspector-tests-"), {
const TEMP_DIR = fs.mkdirSync(path.join(os.tmpdir(), "mcp-inspector-tests"), {
recursive: true,
});
process.on("exit", () => {
try {
fs.rmSync(TEMP_DIR, { recursive: true, force: true });
@@ -125,7 +108,7 @@ async function runBasicTest(testName, ...args) {
// Run the command and capture output
console.log(
`${colors.BLUE}Command: node ${BIN_DIR}/cli.js ${args.join(" ")}${colors.NC}`,
`${colors.BLUE}Command: node ${BUILD_DIR}/cli.js ${args.join(" ")}${colors.NC}`,
);
try {
@@ -134,7 +117,7 @@ async function runBasicTest(testName, ...args) {
// Spawn the process
return new Promise((resolve) => {
const child = spawn("node", [path.join(BIN_DIR, "cli.js"), ...args], {
const child = spawn("node", [path.join(BUILD_DIR, "cli.js"), ...args], {
stdio: ["ignore", "pipe", "pipe"],
});
@@ -205,7 +188,7 @@ async function runErrorTest(testName, ...args) {
// Run the command and capture output
console.log(
`${colors.BLUE}Command: node ${BIN_DIR}/cli.js ${args.join(" ")}${colors.NC}`,
`${colors.BLUE}Command: node ${BUILD_DIR}/cli.js ${args.join(" ")}${colors.NC}`,
);
try {
@@ -214,7 +197,7 @@ async function runErrorTest(testName, ...args) {
// Spawn the process
return new Promise((resolve) => {
const child = spawn("node", [path.join(BIN_DIR, "cli.js"), ...args], {
const child = spawn("node", [path.join(BUILD_DIR, "cli.js"), ...args], {
stdio: ["ignore", "pipe", "pipe"],
});

View File

@@ -6,7 +6,7 @@ import { platform } from "os";
import { execSync } from "child_process";
import path from "path";
const TARGET_FILE = path.resolve("build/index.js");
const TARGET_FILE = path.resolve("build/cli.js");
async function makeExecutable() {
try {

View File

@@ -46,13 +46,13 @@ function handleError(error: unknown): never {
}
function delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
return new Promise((resolve) => setTimeout(resolve, ms, true));
}
async function runWebClient(args: Args): Promise<void> {
const inspectorServerPath = resolve(
__dirname,
"..",
"../../",
"server",
"build",
"index.js",
@@ -61,10 +61,10 @@ async function runWebClient(args: Args): Promise<void> {
// Path to the client entry point
const inspectorClientPath = resolve(
__dirname,
"..",
"../../",
"client",
"bin",
"cli.js",
"client.js",
);
const CLIENT_PORT: string = process.env.CLIENT_PORT ?? "6274";
@@ -120,7 +120,7 @@ async function runWebClient(args: Args): Promise<void> {
async function runCli(args: Args): Promise<void> {
const projectRoot = resolve(__dirname, "..");
const cliPath = resolve(projectRoot, "cli", "build", "index.js");
const cliPath = resolve(projectRoot, "build", "index.js");
const abort = new AbortController();

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

@@ -0,0 +1,120 @@
#!/usr/bin/env node
import { resolve, dirname } from "path";
import { spawnPromise } from "spawn-rx";
import { fileURLToPath } from "url";
const __dirname = dirname(fileURLToPath(import.meta.url));
function delay(ms) {
return new Promise((resolve) => setTimeout(resolve, ms, true));
}
async function main() {
// Parse command line arguments
const args = process.argv.slice(2);
const envVars = {};
const mcpServerArgs = [];
let command = null;
let parsingFlags = true;
for (let i = 0; i < args.length; i++) {
const arg = args[i];
if (parsingFlags && arg === "--") {
parsingFlags = false;
continue;
}
if (parsingFlags && arg === "-e" && i + 1 < args.length) {
const envVar = args[++i];
const equalsIndex = envVar.indexOf("=");
if (equalsIndex !== -1) {
const key = envVar.substring(0, equalsIndex);
const value = envVar.substring(equalsIndex + 1);
envVars[key] = value;
} else {
envVars[envVar] = "";
}
} else if (!command) {
command = arg;
} else {
mcpServerArgs.push(arg);
}
}
const inspectorServerPath = resolve(
__dirname,
"../..",
"server",
"build",
"index.js",
);
// Path to the client entry point
const inspectorClientPath = resolve(
__dirname,
"../..",
"client",
"bin",
"client.js",
);
const CLIENT_PORT = process.env.CLIENT_PORT ?? "6274";
const SERVER_PORT = process.env.SERVER_PORT ?? "6277";
console.log("Starting MCP inspector...");
const abort = new AbortController();
let cancelled = false;
process.on("SIGINT", () => {
cancelled = true;
abort.abort();
});
let server, serverOk;
try {
server = spawnPromise(
"node",
[
inspectorServerPath,
...(command ? [`--env`, command] : []),
...(mcpServerArgs ? [`--args=${mcpServerArgs.join(" ")}`] : []),
],
{
env: {
...process.env,
PORT: SERVER_PORT,
MCP_ENV_VARS: JSON.stringify(envVars),
},
signal: abort.signal,
echoOutput: true,
},
);
// Make sure server started before starting client
serverOk = await Promise.race([server, delay(2 * 1000)]);
} catch (error) {}
if (serverOk) {
try {
await spawnPromise("node", [inspectorClientPath], {
env: { ...process.env, PORT: CLIENT_PORT },
signal: abort.signal,
echoOutput: true,
});
} catch (e) {
if (!cancelled || process.env.DEBUG) throw e;
}
}
return 0;
}
main()
.then((_) => process.exit(0))
.catch((e) => {
console.error(e);
process.exit(1);
});

View File

@@ -8,14 +8,14 @@
"bugs": "https://github.com/modelcontextprotocol/inspector/issues",
"type": "module",
"bin": {
"mcp-inspector-client": "./bin/cli.js"
"mcp-inspector-client": "./bin/start.js"
},
"files": [
"bin",
"dist"
],
"scripts": {
"dev": "vite",
"dev": "vite --port 6274",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview --port 6274",
@@ -72,6 +72,6 @@
"ts-jest": "^29.2.6",
"typescript": "^5.5.3",
"typescript-eslint": "^8.7.0",
"vite": "^5.4.8"
"vite": "^6.3.0"
}
}

View File

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

View File

@@ -1,5 +1,5 @@
import { useEffect, useRef } from "react";
import { authProvider } from "../lib/auth";
import { InspectorOAuthClientProvider } from "../lib/auth";
import { SESSION_KEYS } from "../lib/constants";
import { auth } from "@modelcontextprotocol/sdk/client/auth.js";
@@ -25,7 +25,10 @@ const OAuthCallback = () => {
}
try {
const result = await auth(authProvider, {
// Create an auth provider with the current server URL
const serverAuthProvider = new InspectorOAuthClientProvider(serverUrl);
const result = await auth(serverAuthProvider, {
serverUrl,
authorizationCode: code,
});

View File

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

View File

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

View File

@@ -522,7 +522,9 @@ const Sidebar = ({
</SelectTrigger>
<SelectContent>
{Object.values(LoggingLevelSchema.enum).map((level) => (
<SelectItem value={level}>{level}</SelectItem>
<SelectItem key={level} value={level}>
{level}
</SelectItem>
))}
</SelectContent>
</Select>

View File

@@ -43,7 +43,13 @@ const ToolsTab = ({
const [isToolRunning, setIsToolRunning] = useState(false);
useEffect(() => {
setParams({});
const params = Object.entries(
selectedTool?.inputSchema.properties ?? [],
).map(([key, value]) => [
key,
generateDefaultValue(value as JsonSchemaType),
]);
setParams(Object.fromEntries(params));
}, [selectedTool]);
const renderToolResult = () => {
@@ -217,13 +223,10 @@ const ToolsTab = ({
}}
/>
</div>
) : (
) : prop.type === "number" ||
prop.type === "integer" ? (
<Input
type={
prop.type === "number" || prop.type === "integer"
? "number"
: "text"
}
type="number"
id={key}
name={key}
placeholder={prop.description}
@@ -231,15 +234,29 @@ const ToolsTab = ({
onChange={(e) =>
setParams({
...params,
[key]:
prop.type === "number" ||
prop.type === "integer"
? Number(e.target.value)
: e.target.value,
[key]: Number(e.target.value),
})
}
className="mt-1"
/>
) : (
<div className="mt-1">
<DynamicJsonForm
schema={{
type: prop.type,
properties: prop.properties,
description: prop.description,
items: prop.items,
}}
value={params[key] as JsonValue}
onChange={(newValue: JsonValue) => {
setParams({
...params,
[key]: newValue,
});
}}
/>
</div>
)}
</div>
);

View File

@@ -1,4 +1,4 @@
import { render, screen, fireEvent } from "@testing-library/react";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { describe, it, expect, jest } from "@jest/globals";
import DynamicJsonForm from "../DynamicJsonForm";
import type { JsonSchemaType } from "@/utils/jsonUtils";
@@ -93,3 +93,47 @@ describe("DynamicJsonForm Integer Fields", () => {
});
});
});
describe("DynamicJsonForm Complex Fields", () => {
const renderForm = (props = {}) => {
const defaultProps = {
schema: {
type: "object",
properties: {
// The simplified JsonSchemaType does not accept oneOf fields
// But they exist in the more-complete JsonSchema7Type
nested: { oneOf: [{ type: "string" }, { type: "integer" }] },
},
} as unknown as JsonSchemaType,
value: undefined,
onChange: jest.fn(),
};
return render(<DynamicJsonForm {...defaultProps} {...props} />);
};
describe("Basic Operations", () => {
it("should render textbox and autoformat button, but no switch-to-form button", () => {
renderForm();
const input = screen.getByRole("textbox");
expect(input).toHaveProperty("type", "textarea");
const buttons = screen.getAllByRole("button");
expect(buttons).toHaveLength(1);
expect(buttons[0]).toHaveProperty("textContent", "Format JSON");
});
it("should pass changed values to onChange", () => {
const onChange = jest.fn();
renderForm({ onChange });
const input = screen.getByRole("textbox");
fireEvent.change(input, {
target: { value: `{ "nested": "i am string" }` },
});
// The onChange handler is debounced when using the JSON view, so we need to wait a little bit
waitFor(() => {
expect(onChange).toHaveBeenCalledWith(`{ "nested": "i am string" }`);
});
});
});
});

View File

@@ -5,9 +5,14 @@ import {
OAuthTokens,
OAuthTokensSchema,
} from "@modelcontextprotocol/sdk/shared/auth.js";
import { SESSION_KEYS } from "./constants";
import { SESSION_KEYS, getServerSpecificKey } from "./constants";
export class InspectorOAuthClientProvider implements OAuthClientProvider {
constructor(private serverUrl: string) {
// Save the server URL to session storage
sessionStorage.setItem(SESSION_KEYS.SERVER_URL, serverUrl);
}
class InspectorOAuthClientProvider implements OAuthClientProvider {
get redirectUrl() {
return window.location.origin + "/oauth/callback";
}
@@ -24,7 +29,11 @@ class InspectorOAuthClientProvider implements OAuthClientProvider {
}
async clientInformation() {
const value = sessionStorage.getItem(SESSION_KEYS.CLIENT_INFORMATION);
const key = getServerSpecificKey(
SESSION_KEYS.CLIENT_INFORMATION,
this.serverUrl,
);
const value = sessionStorage.getItem(key);
if (!value) {
return undefined;
}
@@ -33,14 +42,16 @@ class InspectorOAuthClientProvider implements OAuthClientProvider {
}
saveClientInformation(clientInformation: OAuthClientInformation) {
sessionStorage.setItem(
const key = getServerSpecificKey(
SESSION_KEYS.CLIENT_INFORMATION,
JSON.stringify(clientInformation),
this.serverUrl,
);
sessionStorage.setItem(key, JSON.stringify(clientInformation));
}
async tokens() {
const tokens = sessionStorage.getItem(SESSION_KEYS.TOKENS);
const key = getServerSpecificKey(SESSION_KEYS.TOKENS, this.serverUrl);
const tokens = sessionStorage.getItem(key);
if (!tokens) {
return undefined;
}
@@ -49,7 +60,8 @@ class InspectorOAuthClientProvider implements OAuthClientProvider {
}
saveTokens(tokens: OAuthTokens) {
sessionStorage.setItem(SESSION_KEYS.TOKENS, JSON.stringify(tokens));
const key = getServerSpecificKey(SESSION_KEYS.TOKENS, this.serverUrl);
sessionStorage.setItem(key, JSON.stringify(tokens));
}
redirectToAuthorization(authorizationUrl: URL) {
@@ -57,11 +69,19 @@ class InspectorOAuthClientProvider implements OAuthClientProvider {
}
saveCodeVerifier(codeVerifier: string) {
sessionStorage.setItem(SESSION_KEYS.CODE_VERIFIER, codeVerifier);
const key = getServerSpecificKey(
SESSION_KEYS.CODE_VERIFIER,
this.serverUrl,
);
sessionStorage.setItem(key, codeVerifier);
}
codeVerifier() {
const verifier = sessionStorage.getItem(SESSION_KEYS.CODE_VERIFIER);
const key = getServerSpecificKey(
SESSION_KEYS.CODE_VERIFIER,
this.serverUrl,
);
const verifier = sessionStorage.getItem(key);
if (!verifier) {
throw new Error("No code verifier saved for session");
}
@@ -69,5 +89,3 @@ class InspectorOAuthClientProvider implements OAuthClientProvider {
return verifier;
}
}
export const authProvider = new InspectorOAuthClientProvider();

View File

@@ -8,6 +8,15 @@ export const SESSION_KEYS = {
CLIENT_INFORMATION: "mcp_client_information",
} as const;
// Generate server-specific session storage keys
export const getServerSpecificKey = (
baseKey: string,
serverUrl?: string,
): string => {
if (!serverUrl) return baseKey;
return `[${serverUrl}] ${baseKey}`;
};
export type ConnectionStatus =
| "disconnected"
| "connected"

View File

@@ -45,9 +45,9 @@ jest.mock("@/hooks/use-toast", () => ({
// Mock the auth provider
jest.mock("../../auth", () => ({
authProvider: {
InspectorOAuthClientProvider: jest.fn().mockImplementation(() => ({
tokens: jest.fn().mockResolvedValue({ access_token: "mock-token" }),
},
})),
}));
describe("useConnection", () => {

View File

@@ -28,10 +28,10 @@ import { RequestOptions } from "@modelcontextprotocol/sdk/shared/protocol.js";
import { useState } from "react";
import { useToast } from "@/hooks/use-toast";
import { z } from "zod";
import { ConnectionStatus, SESSION_KEYS } from "../constants";
import { ConnectionStatus } from "../constants";
import { Notification, StdErrNotificationSchema } from "../notificationTypes";
import { auth } from "@modelcontextprotocol/sdk/client/auth.js";
import { authProvider } from "../auth";
import { InspectorOAuthClientProvider } from "../auth";
import packageJson from "../../../package.json";
import {
getMCPProxyAddress,
@@ -246,9 +246,10 @@ export function useConnection({
const handleAuthError = async (error: unknown) => {
if (error instanceof SseError && error.code === 401) {
sessionStorage.setItem(SESSION_KEYS.SERVER_URL, sseUrl);
// Create a new auth provider with the current server URL
const serverAuthProvider = new InspectorOAuthClientProvider(sseUrl);
const result = await auth(authProvider, { serverUrl: sseUrl });
const result = await auth(serverAuthProvider, { serverUrl: sseUrl });
return result === "AUTHORIZED";
}
@@ -292,8 +293,12 @@ export function useConnection({
// proxying through the inspector server first.
const headers: HeadersInit = {};
// Create an auth provider with the current server URL
const serverAuthProvider = new InspectorOAuthClientProvider(sseUrl);
// Use manually provided bearer token if available, otherwise use OAuth tokens
const token = bearerToken || (await authProvider.tokens())?.access_token;
const token =
bearerToken || (await serverAuthProvider.tokens())?.access_token;
if (token) {
const authHeaderName = headerName || "Authorization";
headers[authHeaderName] = `Bearer ${token}`;

3030
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -8,42 +8,36 @@
"bugs": "https://github.com/modelcontextprotocol/inspector/issues",
"type": "module",
"bin": {
"mcp-inspector": "./bin/cli.js"
"mcp-inspector": "cli/build/cli.js"
},
"files": [
"bin",
"client/bin",
"client/dist",
"server/build",
"cli/bin",
"cli/build"
],
"workspaces": [
"client",
"server",
"cli",
"bin"
"cli"
],
"scripts": {
"dev": "concurrently \"cd client && npm run dev\" \"cd server && npm run dev\"",
"dev:windows": "concurrently \"cd client && npm run dev\" \"cd server && npm run dev:windows\"",
"test": "npm run prettier-check && cd client && npm test",
"test-cli": "cd bin && npm run test",
"build-bin": "cd bin && npm run build",
"build": "npm run build-server && npm run build-client && npm run build-cli",
"build-server": "cd server && npm run build",
"build-client": "cd client && npm run build",
"build-cli": "cd cli && npm run build",
"build": "npm run build-bin && npm run build-server && npm run build-client && npm run build-cli",
"dev": "concurrently \"cd client && npm run dev\" \"cd server && npm run dev\"",
"dev:windows": "concurrently \"cd client && npm run dev\" \"cd server && npm run dev:windows\"",
"start": "node client/bin/start.js",
"start-server": "cd server && npm run start",
"start-client": "cd client && npm run preview",
"start": "node ./bin/cli.js",
"prepare": "npm run build",
"test": "npm run prettier-check && cd client && npm test",
"test-cli": "cd cli && npm run test",
"prettier-fix": "prettier --write .",
"prettier-check": "prettier --check .",
"publish-all": "npm publish --workspaces --access public && npm publish --access public"
},
"dependencies": {
"@modelcontextprotocol/inspector-bin": "^0.9.0",
"@modelcontextprotocol/inspector-cli": "^0.9.0",
"@modelcontextprotocol/inspector-client": "^0.9.0",
"@modelcontextprotocol/inspector-server": "^0.9.0",

View File

@@ -29,7 +29,7 @@
"dependencies": {
"@modelcontextprotocol/sdk": "^1.9.0",
"cors": "^2.8.5",
"express": "^4.21.0",
"express": "^5.1.0",
"ws": "^8.18.0",
"zod": "^3.23.8"
}