Merge branch 'main' into sampling-form

This commit is contained in:
Nathan Arseneau
2025-04-22 22:07:37 -04:00
33 changed files with 710 additions and 3516 deletions

View File

@@ -58,6 +58,8 @@ jobs:
# - run: npm ci # - run: npm ci
- run: npm install --no-package-lock - run: npm install --no-package-lock
- run: npm run build
# TODO: Add --provenance once the repo is public # TODO: Add --provenance once the repo is public
- run: npm run publish-all - run: npm run publish-all
env: env:

4
.gitignore vendored
View File

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

View File

@@ -6,6 +6,10 @@ The MCP inspector is a developer tool for testing and debugging MCP servers.
## Running the Inspector ## Running the Inspector
### Requirements
- Node.js: ^22.7.5
### From an MCP server repository ### From an MCP server repository
To inspect an MCP server implementation, there's no need to clone this repo. Instead, use `npx`. For example, if your server is built at `build/index.js`: To inspect an MCP server implementation, there's no need to clone this repo. Instead, use `npx`. For example, if your server is built at `build/index.js`:
@@ -99,7 +103,7 @@ Development mode:
npm run dev npm run dev
``` ```
> **Note for Windows users:** > **Note for Windows users:**
> On Windows, use the following command instead: > On Windows, use the following command instead:
> >
> ```bash > ```bash

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

@@ -1,23 +1,27 @@
{ {
"name": "@modelcontextprotocol/inspector-cli", "name": "@modelcontextprotocol/inspector-cli",
"version": "0.9.0", "version": "0.10.2",
"description": "CLI for the Model Context Protocol inspector", "description": "CLI for the Model Context Protocol inspector",
"license": "MIT", "license": "MIT",
"author": "Nicolas Barraud", "author": "Anthropic, PBC (https://anthropic.com)",
"homepage": "https://github.com/nbarraud", "homepage": "https://modelcontextprotocol.io",
"main": "build/index.js", "bugs": "https://github.com/modelcontextprotocol/inspector/issues",
"main": "build/cli.js",
"type": "module", "type": "module",
"bin": { "bin": {
"mcp-inspector-cli": "build/index.js" "mcp-inspector-cli": "build/cli.js"
}, },
"files": [ "files": [
"build" "build"
], ],
"scripts": { "scripts": {
"build": "tsc" "build": "tsc",
"postbuild": "node scripts/make-executable.js",
"test": "node scripts/cli-tests.js"
}, },
"devDependencies": {}, "devDependencies": {},
"dependencies": { "dependencies": {
"@modelcontextprotocol/sdk": "^1.10.0",
"commander": "^13.1.0", "commander": "^13.1.0",
"spawn-rx": "^5.1.2" "spawn-rx": "^5.1.2"
} }

View File

@@ -44,30 +44,12 @@ console.log(`${colors.BLUE}- Resource-related options (--uri)${colors.NC}`);
console.log( console.log(
`${colors.BLUE}- Prompt-related options (--prompt-name, --prompt-args)${colors.NC}`, `${colors.BLUE}- Prompt-related options (--prompt-name, --prompt-args)${colors.NC}`,
); );
console.log(`${colors.BLUE}- Logging options (--log-level)${colors.NC}`); console.log(`${colors.BLUE}- Logging options (--log-level)${colors.NC}\n`);
console.log("");
// Get directory paths // Get directory paths
const SCRIPTS_DIR = __dirname; const SCRIPTS_DIR = __dirname;
const BIN_DIR = path.resolve(SCRIPTS_DIR, ".."); const PROJECT_ROOT = path.join(SCRIPTS_DIR, "../../");
const PROJECT_ROOT = path.resolve(BIN_DIR, ".."); const BUILD_DIR = path.resolve(SCRIPTS_DIR, "../build");
// 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);
}
// Define the test server command using npx // Define the test server command using npx
const TEST_CMD = "npx"; const TEST_CMD = "npx";
@@ -80,9 +62,10 @@ if (!fs.existsSync(OUTPUT_DIR)) {
} }
// Create a temporary directory for test files // 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, recursive: true,
}); });
process.on("exit", () => { process.on("exit", () => {
try { try {
fs.rmSync(TEMP_DIR, { recursive: true, force: true }); fs.rmSync(TEMP_DIR, { recursive: true, force: true });
@@ -125,7 +108,7 @@ async function runBasicTest(testName, ...args) {
// Run the command and capture output // Run the command and capture output
console.log( 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 { try {
@@ -134,7 +117,7 @@ async function runBasicTest(testName, ...args) {
// Spawn the process // Spawn the process
return new Promise((resolve) => { 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"], stdio: ["ignore", "pipe", "pipe"],
}); });
@@ -205,7 +188,7 @@ async function runErrorTest(testName, ...args) {
// Run the command and capture output // Run the command and capture output
console.log( 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 { try {
@@ -214,7 +197,7 @@ async function runErrorTest(testName, ...args) {
// Spawn the process // Spawn the process
return new Promise((resolve) => { 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"], stdio: ["ignore", "pipe", "pipe"],
}); });

View File

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

View File

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

@@ -1,6 +1,6 @@
{ {
"name": "@modelcontextprotocol/inspector-client", "name": "@modelcontextprotocol/inspector-client",
"version": "0.9.0", "version": "0.10.2",
"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)",
@@ -8,14 +8,14 @@
"bugs": "https://github.com/modelcontextprotocol/inspector/issues", "bugs": "https://github.com/modelcontextprotocol/inspector/issues",
"type": "module", "type": "module",
"bin": { "bin": {
"mcp-inspector-client": "./bin/cli.js" "mcp-inspector-client": "./bin/start.js"
}, },
"files": [ "files": [
"bin", "bin",
"dist" "dist"
], ],
"scripts": { "scripts": {
"dev": "vite", "dev": "vite --port 6274",
"build": "tsc -b && vite build", "build": "tsc -b && vite build",
"lint": "eslint .", "lint": "eslint .",
"preview": "vite preview --port 6274", "preview": "vite preview --port 6274",
@@ -23,7 +23,7 @@
"test:watch": "jest --config jest.config.cjs --watch" "test:watch": "jest --config jest.config.cjs --watch"
}, },
"dependencies": { "dependencies": {
"@modelcontextprotocol/sdk": "^1.9.0", "@modelcontextprotocol/sdk": "^1.10.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",
@@ -72,6 +72,6 @@
"ts-jest": "^29.2.6", "ts-jest": "^29.2.6",
"typescript": "^5.5.3", "typescript": "^5.5.3",
"typescript-eslint": "^8.7.0", "typescript-eslint": "^8.7.0",
"vite": "^5.4.8" "vite": "^6.3.0"
} }
} }

View File

@@ -17,7 +17,13 @@ import {
Tool, Tool,
LoggingLevel, LoggingLevel,
} from "@modelcontextprotocol/sdk/types.js"; } from "@modelcontextprotocol/sdk/types.js";
import React, { Suspense, useEffect, useRef, useState } from "react"; import React, {
Suspense,
useCallback,
useEffect,
useRef,
useState,
} from "react";
import { useConnection } from "./lib/hooks/useConnection"; import { useConnection } from "./lib/hooks/useConnection";
import { useDraggablePane } from "./lib/hooks/useDraggablePane"; import { useDraggablePane } from "./lib/hooks/useDraggablePane";
import { StdErrNotification } from "./lib/notificationTypes"; import { StdErrNotification } from "./lib/notificationTypes";
@@ -46,14 +52,10 @@ import ToolsTab from "./components/ToolsTab";
import { DEFAULT_INSPECTOR_CONFIG } from "./lib/constants"; import { DEFAULT_INSPECTOR_CONFIG } from "./lib/constants";
import { InspectorConfig } from "./lib/configurationTypes"; import { InspectorConfig } from "./lib/configurationTypes";
import { getMCPProxyAddress } from "./utils/configUtils"; import { getMCPProxyAddress } from "./utils/configUtils";
import { useToast } from "@/hooks/use-toast";
const params = new URLSearchParams(window.location.search);
const CONFIG_LOCAL_STORAGE_KEY = "inspectorConfig_v1"; const CONFIG_LOCAL_STORAGE_KEY = "inspectorConfig_v1";
const App = () => { const App = () => {
const { toast } = useToast();
// Handle OAuth callback route
const [resources, setResources] = useState<Resource[]>([]); const [resources, setResources] = useState<Resource[]>([]);
const [resourceTemplates, setResourceTemplates] = useState< const [resourceTemplates, setResourceTemplates] = useState<
ResourceTemplate[] ResourceTemplate[]
@@ -79,9 +81,14 @@ const App = () => {
const [sseUrl, setSseUrl] = useState<string>(() => { const [sseUrl, setSseUrl] = useState<string>(() => {
return localStorage.getItem("lastSseUrl") || "http://localhost:3001/sse"; return localStorage.getItem("lastSseUrl") || "http://localhost:3001/sse";
}); });
const [transportType, setTransportType] = useState<"stdio" | "sse">(() => { const [transportType, setTransportType] = useState<
"stdio" | "sse" | "streamable-http"
>(() => {
return ( return (
(localStorage.getItem("lastTransportType") as "stdio" | "sse") || "stdio" (localStorage.getItem("lastTransportType") as
| "stdio"
| "sse"
| "streamable-http") || "stdio"
); );
}); });
const [logLevel, setLogLevel] = useState<LoggingLevel>("debug"); const [logLevel, setLogLevel] = useState<LoggingLevel>("debug");
@@ -221,31 +228,15 @@ const App = () => {
localStorage.setItem(CONFIG_LOCAL_STORAGE_KEY, JSON.stringify(config)); localStorage.setItem(CONFIG_LOCAL_STORAGE_KEY, JSON.stringify(config));
}, [config]); }, [config]);
const hasProcessedRef = useRef(false); // Auto-connect to previously saved serverURL after OAuth callback
// Auto-connect if serverUrl is provided in URL params (e.g. after OAuth callback) const onOAuthConnect = useCallback(
useEffect(() => { (serverUrl: string) => {
if (hasProcessedRef.current) {
// Only try to connect once
return;
}
const serverUrl = params.get("serverUrl");
if (serverUrl) {
setSseUrl(serverUrl); setSseUrl(serverUrl);
setTransportType("sse"); setTransportType("sse");
// Remove serverUrl from URL without reloading the page void connectMcpServer();
const newUrl = new URL(window.location.href); },
newUrl.searchParams.delete("serverUrl"); [connectMcpServer],
window.history.replaceState({}, "", newUrl.toString()); );
// Show success toast for OAuth
toast({
title: "Success",
description: "Successfully authenticated with OAuth",
});
hasProcessedRef.current = true;
// Connect to the server
connectMcpServer();
}
}, [connectMcpServer, toast]);
useEffect(() => { useEffect(() => {
fetch(`${getMCPProxyAddress(config)}/config`) fetch(`${getMCPProxyAddress(config)}/config`)
@@ -486,7 +477,7 @@ const App = () => {
); );
return ( return (
<Suspense fallback={<div>Loading...</div>}> <Suspense fallback={<div>Loading...</div>}>
<OAuthCallback /> <OAuthCallback onConnect={onOAuthConnect} />
</Suspense> </Suspense>
); );
} }

View File

@@ -1,28 +1,35 @@
import { useState, useEffect, useCallback, useRef } from "react"; import { useState, useEffect, useCallback, useRef } from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import JsonEditor from "./JsonEditor"; import JsonEditor from "./JsonEditor";
import { updateValueAtPath } from "@/utils/jsonUtils"; import { updateValueAtPath } from "@/utils/jsonUtils";
import { generateDefaultValue, formatFieldLabel } from "@/utils/schemaUtils"; import { generateDefaultValue } from "@/utils/schemaUtils";
import type { JsonValue, JsonSchemaType, JsonObject } from "@/utils/jsonUtils"; import type { JsonValue, JsonSchemaType } from "@/utils/jsonUtils";
interface DynamicJsonFormProps { interface DynamicJsonFormProps {
schema: JsonSchemaType; schema: JsonSchemaType;
value: JsonValue; value: JsonValue;
onChange: (value: JsonValue) => void; onChange: (value: JsonValue) => void;
maxDepth?: number; maxDepth?: number;
defaultIsJsonMode?: boolean;
} }
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 = ({ const DynamicJsonForm = ({
schema, schema,
value, value,
onChange, onChange,
maxDepth = 3, maxDepth = 3,
defaultIsJsonMode = false,
}: DynamicJsonFormProps) => { }: DynamicJsonFormProps) => {
const [isJsonMode, setIsJsonMode] = useState(defaultIsJsonMode); const isOnlyJSON = !isSimpleObject(schema);
const [isJsonMode, setIsJsonMode] = useState(isOnlyJSON);
const [jsonError, setJsonError] = useState<string>(); const [jsonError, setJsonError] = useState<string>();
// Store the raw JSON string to allow immediate feedback during typing // Store the raw JSON string to allow immediate feedback during typing
// while deferring parsing until the user stops typing // while deferring parsing until the user stops typing
@@ -209,111 +216,6 @@ const DynamicJsonForm = ({
required={propSchema.required} 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: default:
return null; return null;
} }
@@ -357,14 +259,11 @@ const DynamicJsonForm = ({
Format JSON Format JSON
</Button> </Button>
)} )}
<Button {!isOnlyJSON && (
type="button" <Button variant="outline" size="sm" onClick={handleSwitchToFormMode}>
variant="outline" {isJsonMode ? "Switch to Form" : "Switch to JSON"}
size="sm" </Button>
onClick={handleSwitchToFormMode} )}
>
{isJsonMode ? "Switch to Form" : "Switch to JSON"}
</Button>
</div> </div>
{isJsonMode ? ( {isJsonMode ? (

View File

@@ -1,9 +1,19 @@
import { useEffect, useRef } from "react"; import { useEffect, useRef } from "react";
import { authProvider } from "../lib/auth"; import { InspectorOAuthClientProvider } from "../lib/auth";
import { SESSION_KEYS } from "../lib/constants"; import { SESSION_KEYS } from "../lib/constants";
import { auth } from "@modelcontextprotocol/sdk/client/auth.js"; import { auth } from "@modelcontextprotocol/sdk/client/auth.js";
import { useToast } from "@/hooks/use-toast.ts";
import {
generateOAuthErrorDescription,
parseOAuthCallbackParams,
} from "@/utils/oauthUtils.ts";
const OAuthCallback = () => { interface OAuthCallbackProps {
onConnect: (serverUrl: string) => void;
}
const OAuthCallback = ({ onConnect }: OAuthCallbackProps) => {
const { toast } = useToast();
const hasProcessedRef = useRef(false); const hasProcessedRef = useRef(false);
useEffect(() => { useEffect(() => {
@@ -14,37 +24,56 @@ const OAuthCallback = () => {
} }
hasProcessedRef.current = true; hasProcessedRef.current = true;
const params = new URLSearchParams(window.location.search); const notifyError = (description: string) =>
const code = params.get("code"); void toast({
const serverUrl = sessionStorage.getItem(SESSION_KEYS.SERVER_URL); title: "OAuth Authorization Error",
description,
variant: "destructive",
});
if (!code || !serverUrl) { const params = parseOAuthCallbackParams(window.location.search);
console.error("Missing code or server URL"); if (!params.successful) {
window.location.href = "/"; return notifyError(generateOAuthErrorDescription(params));
return;
} }
try { const serverUrl = sessionStorage.getItem(SESSION_KEYS.SERVER_URL);
const result = await auth(authProvider, { if (!serverUrl) {
serverUrl, return notifyError("Missing Server URL");
authorizationCode: code, }
});
if (result !== "AUTHORIZED") {
throw new Error(
`Expected to be authorized after providing auth code, got: ${result}`,
);
}
// Redirect back to the main app with server URL to trigger auto-connect let result;
window.location.href = `/?serverUrl=${encodeURIComponent(serverUrl)}`; try {
// Create an auth provider with the current server URL
const serverAuthProvider = new InspectorOAuthClientProvider(serverUrl);
result = await auth(serverAuthProvider, {
serverUrl,
authorizationCode: params.code,
});
} catch (error) { } catch (error) {
console.error("OAuth callback error:", error); console.error("OAuth callback error:", error);
window.location.href = "/"; return notifyError(`Unexpected error occurred: ${error}`);
} }
if (result !== "AUTHORIZED") {
return notifyError(
`Expected to be authorized after providing auth code, got: ${result}`,
);
}
// Finally, trigger auto-connect
toast({
title: "Success",
description: "Successfully authenticated with OAuth",
variant: "default",
});
onConnect(serverUrl);
}; };
void handleCallback(); handleCallback().finally(() => {
}, []); window.history.replaceState({}, document.title, "/");
});
}, [toast, onConnect]);
return ( return (
<div className="flex items-center justify-center h-screen"> <div className="flex items-center justify-center h-screen">

View File

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

View File

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

View File

@@ -46,7 +46,7 @@ const SamplingRequest = ({
properties: { properties: {
model: { model: {
type: "string", type: "string",
default: "GPT-4o", default: "stub-model",
description: "model name", description: "model name",
}, },
stopReason: { stopReason: {
@@ -140,7 +140,6 @@ const SamplingRequest = ({
<form className="flex-1 space-y-4"> <form className="flex-1 space-y-4">
<div className="space-y-2"> <div className="space-y-2">
<DynamicJsonForm <DynamicJsonForm
defaultIsJsonMode={true}
schema={schema} schema={schema}
value={messageResult} value={messageResult}
onChange={(newValue: JsonValue) => { onChange={(newValue: JsonValue) => {

View File

@@ -39,8 +39,8 @@ import {
interface SidebarProps { interface SidebarProps {
connectionStatus: ConnectionStatus; connectionStatus: ConnectionStatus;
transportType: "stdio" | "sse"; transportType: "stdio" | "sse" | "streamable-http";
setTransportType: (type: "stdio" | "sse") => void; setTransportType: (type: "stdio" | "sse" | "streamable-http") => void;
command: string; command: string;
setCommand: (command: string) => void; setCommand: (command: string) => void;
args: string; args: string;
@@ -117,7 +117,7 @@ const Sidebar = ({
</label> </label>
<Select <Select
value={transportType} value={transportType}
onValueChange={(value: "stdio" | "sse") => onValueChange={(value: "stdio" | "sse" | "streamable-http") =>
setTransportType(value) setTransportType(value)
} }
> >
@@ -127,6 +127,7 @@ const Sidebar = ({
<SelectContent> <SelectContent>
<SelectItem value="stdio">STDIO</SelectItem> <SelectItem value="stdio">STDIO</SelectItem>
<SelectItem value="sse">SSE</SelectItem> <SelectItem value="sse">SSE</SelectItem>
<SelectItem value="streamable-http">Streamable HTTP</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>

View File

@@ -43,7 +43,13 @@ const ToolsTab = ({
const [isToolRunning, setIsToolRunning] = useState(false); const [isToolRunning, setIsToolRunning] = useState(false);
useEffect(() => { useEffect(() => {
setParams({}); const params = Object.entries(
selectedTool?.inputSchema.properties ?? [],
).map(([key, value]) => [
key,
generateDefaultValue(value as JsonSchemaType),
]);
setParams(Object.fromEntries(params));
}, [selectedTool]); }, [selectedTool]);
const renderToolResult = () => { const renderToolResult = () => {
@@ -217,13 +223,10 @@ const ToolsTab = ({
}} }}
/> />
</div> </div>
) : ( ) : prop.type === "number" ||
prop.type === "integer" ? (
<Input <Input
type={ type="number"
prop.type === "number" || prop.type === "integer"
? "number"
: "text"
}
id={key} id={key}
name={key} name={key}
placeholder={prop.description} placeholder={prop.description}
@@ -231,15 +234,29 @@ const ToolsTab = ({
onChange={(e) => onChange={(e) =>
setParams({ setParams({
...params, ...params,
[key]: [key]: Number(e.target.value),
prop.type === "number" ||
prop.type === "integer"
? Number(e.target.value)
: e.target.value,
}) })
} }
className="mt-1" 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> </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 { describe, it, expect, jest } from "@jest/globals";
import DynamicJsonForm from "../DynamicJsonForm"; import DynamicJsonForm from "../DynamicJsonForm";
import type { JsonSchemaType } from "@/utils/jsonUtils"; 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, OAuthTokens,
OAuthTokensSchema, OAuthTokensSchema,
} from "@modelcontextprotocol/sdk/shared/auth.js"; } 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() { get redirectUrl() {
return window.location.origin + "/oauth/callback"; return window.location.origin + "/oauth/callback";
} }
@@ -24,7 +29,11 @@ class InspectorOAuthClientProvider implements OAuthClientProvider {
} }
async clientInformation() { 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) { if (!value) {
return undefined; return undefined;
} }
@@ -33,14 +42,16 @@ class InspectorOAuthClientProvider implements OAuthClientProvider {
} }
saveClientInformation(clientInformation: OAuthClientInformation) { saveClientInformation(clientInformation: OAuthClientInformation) {
sessionStorage.setItem( const key = getServerSpecificKey(
SESSION_KEYS.CLIENT_INFORMATION, SESSION_KEYS.CLIENT_INFORMATION,
JSON.stringify(clientInformation), this.serverUrl,
); );
sessionStorage.setItem(key, JSON.stringify(clientInformation));
} }
async tokens() { async tokens() {
const tokens = sessionStorage.getItem(SESSION_KEYS.TOKENS); const key = getServerSpecificKey(SESSION_KEYS.TOKENS, this.serverUrl);
const tokens = sessionStorage.getItem(key);
if (!tokens) { if (!tokens) {
return undefined; return undefined;
} }
@@ -49,7 +60,8 @@ class InspectorOAuthClientProvider implements OAuthClientProvider {
} }
saveTokens(tokens: OAuthTokens) { 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) { redirectToAuthorization(authorizationUrl: URL) {
@@ -57,17 +69,35 @@ class InspectorOAuthClientProvider implements OAuthClientProvider {
} }
saveCodeVerifier(codeVerifier: string) { saveCodeVerifier(codeVerifier: string) {
sessionStorage.setItem(SESSION_KEYS.CODE_VERIFIER, codeVerifier); const key = getServerSpecificKey(
SESSION_KEYS.CODE_VERIFIER,
this.serverUrl,
);
sessionStorage.setItem(key, codeVerifier);
} }
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) { if (!verifier) {
throw new Error("No code verifier saved for session"); throw new Error("No code verifier saved for session");
} }
return verifier; return verifier;
} }
}
export const authProvider = new InspectorOAuthClientProvider(); clear() {
sessionStorage.removeItem(
getServerSpecificKey(SESSION_KEYS.CLIENT_INFORMATION, this.serverUrl),
);
sessionStorage.removeItem(
getServerSpecificKey(SESSION_KEYS.TOKENS, this.serverUrl),
);
sessionStorage.removeItem(
getServerSpecificKey(SESSION_KEYS.CODE_VERIFIER, this.serverUrl),
);
}
}

View File

@@ -8,6 +8,15 @@ export const SESSION_KEYS = {
CLIENT_INFORMATION: "mcp_client_information", CLIENT_INFORMATION: "mcp_client_information",
} as const; } 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 = export type ConnectionStatus =
| "disconnected" | "disconnected"
| "connected" | "connected"

View File

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

View File

@@ -28,10 +28,10 @@ import { RequestOptions } from "@modelcontextprotocol/sdk/shared/protocol.js";
import { useState } from "react"; import { useState } from "react";
import { useToast } from "@/hooks/use-toast"; import { useToast } from "@/hooks/use-toast";
import { z } from "zod"; import { z } from "zod";
import { ConnectionStatus, SESSION_KEYS } from "../constants"; import { ConnectionStatus } from "../constants";
import { Notification, StdErrNotificationSchema } from "../notificationTypes"; import { Notification, StdErrNotificationSchema } from "../notificationTypes";
import { auth } from "@modelcontextprotocol/sdk/client/auth.js"; import { auth } from "@modelcontextprotocol/sdk/client/auth.js";
import { authProvider } from "../auth"; import { InspectorOAuthClientProvider } from "../auth";
import packageJson from "../../../package.json"; import packageJson from "../../../package.json";
import { import {
getMCPProxyAddress, getMCPProxyAddress,
@@ -42,7 +42,7 @@ import { getMCPServerRequestTimeout } from "@/utils/configUtils";
import { InspectorConfig } from "../configurationTypes"; import { InspectorConfig } from "../configurationTypes";
interface UseConnectionOptions { interface UseConnectionOptions {
transportType: "stdio" | "sse"; transportType: "stdio" | "sse" | "streamable-http";
command: string; command: string;
args: string; args: string;
sseUrl: string; sseUrl: string;
@@ -246,9 +246,10 @@ export function useConnection({
const handleAuthError = async (error: unknown) => { const handleAuthError = async (error: unknown) => {
if (error instanceof SseError && error.code === 401) { 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"; return result === "AUTHORIZED";
} }
@@ -292,8 +293,12 @@ export function useConnection({
// proxying through the inspector server first. // proxying through the inspector server first.
const headers: HeadersInit = {}; 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 // 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) { if (token) {
const authHeaderName = headerName || "Authorization"; const authHeaderName = headerName || "Authorization";
headers[authHeaderName] = `Bearer ${token}`; headers[authHeaderName] = `Bearer ${token}`;
@@ -391,6 +396,8 @@ export function useConnection({
const disconnect = async () => { const disconnect = async () => {
await mcpClient?.close(); await mcpClient?.close();
const authProvider = new InspectorOAuthClientProvider(sseUrl);
authProvider.clear();
setMcpClient(null); setMcpClient(null);
setConnectionStatus("disconnected"); setConnectionStatus("disconnected");
setCompletionsSupported(false); setCompletionsSupported(false);

View File

@@ -0,0 +1,78 @@
import {
generateOAuthErrorDescription,
parseOAuthCallbackParams,
} from "@/utils/oauthUtils.ts";
describe("parseOAuthCallbackParams", () => {
it("Returns successful: true and code when present", () => {
expect(parseOAuthCallbackParams("?code=fake-code")).toEqual({
successful: true,
code: "fake-code",
});
});
it("Returns successful: false and error when error is present", () => {
expect(parseOAuthCallbackParams("?error=access_denied")).toEqual({
successful: false,
error: "access_denied",
error_description: null,
error_uri: null,
});
});
it("Returns optional error metadata fields when present", () => {
const search =
"?error=access_denied&" +
"error_description=User%20Denied%20Request&" +
"error_uri=https%3A%2F%2Fexample.com%2Ferror-docs";
expect(parseOAuthCallbackParams(search)).toEqual({
successful: false,
error: "access_denied",
error_description: "User Denied Request",
error_uri: "https://example.com/error-docs",
});
});
it("Returns error when nothing present", () => {
expect(parseOAuthCallbackParams("?")).toEqual({
successful: false,
error: "invalid_request",
error_description: "Missing code or error in response",
error_uri: null,
});
});
});
describe("generateOAuthErrorDescription", () => {
it("When only error is present", () => {
expect(
generateOAuthErrorDescription({
successful: false,
error: "invalid_request",
error_description: null,
error_uri: null,
}),
).toBe("Error: invalid_request.");
});
it("When error description is present", () => {
expect(
generateOAuthErrorDescription({
successful: false,
error: "invalid_request",
error_description: "The request could not be completed as dialed",
error_uri: null,
}),
).toEqual(
"Error: invalid_request.\nDetails: The request could not be completed as dialed.",
);
});
it("When all fields present", () => {
expect(
generateOAuthErrorDescription({
successful: false,
error: "invalid_request",
error_description: "The request could not be completed as dialed",
error_uri: "https://example.com/error-docs",
}),
).toEqual(
"Error: invalid_request.\nDetails: The request could not be completed as dialed.\nMore info: https://example.com/error-docs.",
);
});
});

View File

@@ -0,0 +1,65 @@
// The parsed query parameters returned by the Authorization Server
// representing either a valid authorization_code or an error
// ref: https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1-12#section-4.1.2
type CallbackParams =
| {
successful: true;
// The authorization code is generated by the authorization server.
code: string;
}
| {
successful: false;
// The OAuth 2.1 Error Code.
// Usually one of:
// ```
// invalid_request, unauthorized_client, access_denied, unsupported_response_type,
// invalid_scope, server_error, temporarily_unavailable
// ```
error: string;
// Human-readable ASCII text providing additional information, used to assist the
// developer in understanding the error that occurred.
error_description: string | null;
// A URI identifying a human-readable web page with information about the error,
// used to provide the client developer with additional information about the error.
error_uri: string | null;
};
export const parseOAuthCallbackParams = (location: string): CallbackParams => {
const params = new URLSearchParams(location);
const code = params.get("code");
if (code) {
return { successful: true, code };
}
const error = params.get("error");
const error_description = params.get("error_description");
const error_uri = params.get("error_uri");
if (error) {
return { successful: false, error, error_description, error_uri };
}
return {
successful: false,
error: "invalid_request",
error_description: "Missing code or error in response",
error_uri: null,
};
};
export const generateOAuthErrorDescription = (
params: Extract<CallbackParams, { successful: false }>,
): string => {
const error = params.error;
const errorDescription = params.error_description;
const errorUri = params.error_uri;
return [
`Error: ${error}.`,
errorDescription ? `Details: ${errorDescription}.` : "",
errorUri ? `More info: ${errorUri}.` : "",
]
.filter(Boolean)
.join("\n");
};

3063
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{ {
"name": "@modelcontextprotocol/inspector", "name": "@modelcontextprotocol/inspector",
"version": "0.9.0", "version": "0.10.2",
"description": "Model Context Protocol inspector", "description": "Model Context Protocol inspector",
"license": "MIT", "license": "MIT",
"author": "Anthropic, PBC (https://anthropic.com)", "author": "Anthropic, PBC (https://anthropic.com)",
@@ -8,46 +8,40 @@
"bugs": "https://github.com/modelcontextprotocol/inspector/issues", "bugs": "https://github.com/modelcontextprotocol/inspector/issues",
"type": "module", "type": "module",
"bin": { "bin": {
"mcp-inspector": "./bin/cli.js" "mcp-inspector": "cli/build/cli.js"
}, },
"files": [ "files": [
"bin",
"client/bin", "client/bin",
"client/dist", "client/dist",
"server/build", "server/build",
"cli/bin",
"cli/build" "cli/build"
], ],
"workspaces": [ "workspaces": [
"client", "client",
"server", "server",
"cli", "cli"
"bin"
], ],
"scripts": { "scripts": {
"dev": "concurrently \"cd client && npm run dev\" \"cd server && npm run dev\"", "build": "npm run build-server && npm run build-client && npm run build-cli",
"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-server": "cd server && npm run build", "build-server": "cd server && npm run build",
"build-client": "cd client && npm run build", "build-client": "cd client && npm run build",
"build-cli": "cd cli && 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-server": "cd server && npm run start",
"start-client": "cd client && npm run preview", "start-client": "cd client && npm run preview",
"start": "node ./bin/cli.js", "test": "npm run prettier-check && cd client && npm test",
"prepare": "npm run build", "test-cli": "cd cli && npm run test",
"prettier-fix": "prettier --write .", "prettier-fix": "prettier --write .",
"prettier-check": "prettier --check .", "prettier-check": "prettier --check .",
"publish-all": "npm publish --workspaces --access public && npm publish --access public" "publish-all": "npm publish --workspaces --access public && npm publish --access public"
}, },
"dependencies": { "dependencies": {
"@modelcontextprotocol/inspector-bin": "^0.9.0", "@modelcontextprotocol/inspector-cli": "^0.10.2",
"@modelcontextprotocol/inspector-cli": "^0.9.0", "@modelcontextprotocol/inspector-client": "^0.10.2",
"@modelcontextprotocol/inspector-client": "^0.9.0", "@modelcontextprotocol/inspector-server": "^0.10.2",
"@modelcontextprotocol/inspector-server": "^0.9.0", "@modelcontextprotocol/sdk": "^1.10.0",
"@modelcontextprotocol/sdk": "^1.9.0",
"concurrently": "^9.0.1", "concurrently": "^9.0.1",
"shell-quote": "^1.8.2", "shell-quote": "^1.8.2",
"spawn-rx": "^5.1.2", "spawn-rx": "^5.1.2",

View File

@@ -1,6 +1,6 @@
{ {
"name": "@modelcontextprotocol/inspector-server", "name": "@modelcontextprotocol/inspector-server",
"version": "0.9.0", "version": "0.10.2",
"description": "Server-side application for the Model Context Protocol inspector", "description": "Server-side application for the Model Context Protocol inspector",
"license": "MIT", "license": "MIT",
"author": "Anthropic, PBC (https://anthropic.com)", "author": "Anthropic, PBC (https://anthropic.com)",
@@ -27,9 +27,9 @@
"typescript": "^5.6.2" "typescript": "^5.6.2"
}, },
"dependencies": { "dependencies": {
"@modelcontextprotocol/sdk": "^1.9.0", "@modelcontextprotocol/sdk": "^1.10.0",
"cors": "^2.8.5", "cors": "^2.8.5",
"express": "^4.21.0", "express": "^5.1.0",
"ws": "^8.18.0", "ws": "^8.18.0",
"zod": "^3.23.8" "zod": "^3.23.8"
} }

View File

@@ -14,11 +14,13 @@ import {
} from "@modelcontextprotocol/sdk/client/stdio.js"; } from "@modelcontextprotocol/sdk/client/stdio.js";
import { Transport } from "@modelcontextprotocol/sdk/shared/transport.js"; import { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js"; import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
import express from "express"; import express from "express";
import { findActualExecutable } from "spawn-rx"; import { findActualExecutable } from "spawn-rx";
import mcpProxy from "./mcpProxy.js"; import mcpProxy from "./mcpProxy.js";
const SSE_HEADERS_PASSTHROUGH = ["authorization"]; const SSE_HEADERS_PASSTHROUGH = ["authorization"];
const STREAMABLE_HTTP_HEADERS_PASSTHROUGH = ["authorization"];
const defaultEnvironment = { const defaultEnvironment = {
...getDefaultEnvironment(), ...getDefaultEnvironment(),
@@ -94,6 +96,29 @@ const createTransport = async (req: express.Request): Promise<Transport> => {
console.log("Connected to SSE transport"); console.log("Connected to SSE transport");
return transport; return transport;
} else if (transportType === "streamable-http") {
const headers: HeadersInit = {};
for (const key of STREAMABLE_HTTP_HEADERS_PASSTHROUGH) {
if (req.headers[key] === undefined) {
continue;
}
const value = req.headers[key];
headers[key] = Array.isArray(value) ? value[value.length - 1] : value;
}
const transport = new StreamableHTTPClientTransport(
new URL(query.url as string),
{
requestInit: {
headers,
},
},
);
await transport.start();
console.log("Connected to Streamable HTTP transport");
return transport;
} else { } else {
console.error(`Invalid transport type: ${transportType}`); console.error(`Invalid transport type: ${transportType}`);
throw new Error("Invalid transport type specified"); throw new Error("Invalid transport type specified");