Merge branch 'main' into sampling-form
This commit is contained in:
2
.github/workflows/main.yml
vendored
2
.github/workflows/main.yml
vendored
@@ -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
4
.gitignore
vendored
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
217
bin/cli.js
217
bin/cli.js
@@ -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();
|
|
||||||
@@ -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": {}
|
|
||||||
}
|
|
||||||
@@ -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();
|
|
||||||
@@ -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"]
|
|
||||||
}
|
|
||||||
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"],
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -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 {
|
||||||
@@ -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
120
client/bin/start.js
Executable 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);
|
||||||
|
});
|
||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 ? (
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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({});
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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" }`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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", () => {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
78
client/src/utils/__tests__/oauthUtils.ts
Normal file
78
client/src/utils/__tests__/oauthUtils.ts
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import {
|
||||||
|
generateOAuthErrorDescription,
|
||||||
|
parseOAuthCallbackParams,
|
||||||
|
} from "@/utils/oauthUtils.ts";
|
||||||
|
|
||||||
|
describe("parseOAuthCallbackParams", () => {
|
||||||
|
it("Returns successful: true and code when present", () => {
|
||||||
|
expect(parseOAuthCallbackParams("?code=fake-code")).toEqual({
|
||||||
|
successful: true,
|
||||||
|
code: "fake-code",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
it("Returns successful: false and error when error is present", () => {
|
||||||
|
expect(parseOAuthCallbackParams("?error=access_denied")).toEqual({
|
||||||
|
successful: false,
|
||||||
|
error: "access_denied",
|
||||||
|
error_description: null,
|
||||||
|
error_uri: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
it("Returns optional error metadata fields when present", () => {
|
||||||
|
const search =
|
||||||
|
"?error=access_denied&" +
|
||||||
|
"error_description=User%20Denied%20Request&" +
|
||||||
|
"error_uri=https%3A%2F%2Fexample.com%2Ferror-docs";
|
||||||
|
expect(parseOAuthCallbackParams(search)).toEqual({
|
||||||
|
successful: false,
|
||||||
|
error: "access_denied",
|
||||||
|
error_description: "User Denied Request",
|
||||||
|
error_uri: "https://example.com/error-docs",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
it("Returns error when nothing present", () => {
|
||||||
|
expect(parseOAuthCallbackParams("?")).toEqual({
|
||||||
|
successful: false,
|
||||||
|
error: "invalid_request",
|
||||||
|
error_description: "Missing code or error in response",
|
||||||
|
error_uri: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("generateOAuthErrorDescription", () => {
|
||||||
|
it("When only error is present", () => {
|
||||||
|
expect(
|
||||||
|
generateOAuthErrorDescription({
|
||||||
|
successful: false,
|
||||||
|
error: "invalid_request",
|
||||||
|
error_description: null,
|
||||||
|
error_uri: null,
|
||||||
|
}),
|
||||||
|
).toBe("Error: invalid_request.");
|
||||||
|
});
|
||||||
|
it("When error description is present", () => {
|
||||||
|
expect(
|
||||||
|
generateOAuthErrorDescription({
|
||||||
|
successful: false,
|
||||||
|
error: "invalid_request",
|
||||||
|
error_description: "The request could not be completed as dialed",
|
||||||
|
error_uri: null,
|
||||||
|
}),
|
||||||
|
).toEqual(
|
||||||
|
"Error: invalid_request.\nDetails: The request could not be completed as dialed.",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
it("When all fields present", () => {
|
||||||
|
expect(
|
||||||
|
generateOAuthErrorDescription({
|
||||||
|
successful: false,
|
||||||
|
error: "invalid_request",
|
||||||
|
error_description: "The request could not be completed as dialed",
|
||||||
|
error_uri: "https://example.com/error-docs",
|
||||||
|
}),
|
||||||
|
).toEqual(
|
||||||
|
"Error: invalid_request.\nDetails: The request could not be completed as dialed.\nMore info: https://example.com/error-docs.",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
65
client/src/utils/oauthUtils.ts
Normal file
65
client/src/utils/oauthUtils.ts
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
// The parsed query parameters returned by the Authorization Server
|
||||||
|
// representing either a valid authorization_code or an error
|
||||||
|
// ref: https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1-12#section-4.1.2
|
||||||
|
type CallbackParams =
|
||||||
|
| {
|
||||||
|
successful: true;
|
||||||
|
// The authorization code is generated by the authorization server.
|
||||||
|
code: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
successful: false;
|
||||||
|
// The OAuth 2.1 Error Code.
|
||||||
|
// Usually one of:
|
||||||
|
// ```
|
||||||
|
// invalid_request, unauthorized_client, access_denied, unsupported_response_type,
|
||||||
|
// invalid_scope, server_error, temporarily_unavailable
|
||||||
|
// ```
|
||||||
|
error: string;
|
||||||
|
// Human-readable ASCII text providing additional information, used to assist the
|
||||||
|
// developer in understanding the error that occurred.
|
||||||
|
error_description: string | null;
|
||||||
|
// A URI identifying a human-readable web page with information about the error,
|
||||||
|
// used to provide the client developer with additional information about the error.
|
||||||
|
error_uri: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const parseOAuthCallbackParams = (location: string): CallbackParams => {
|
||||||
|
const params = new URLSearchParams(location);
|
||||||
|
|
||||||
|
const code = params.get("code");
|
||||||
|
if (code) {
|
||||||
|
return { successful: true, code };
|
||||||
|
}
|
||||||
|
|
||||||
|
const error = params.get("error");
|
||||||
|
const error_description = params.get("error_description");
|
||||||
|
const error_uri = params.get("error_uri");
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return { successful: false, error, error_description, error_uri };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
successful: false,
|
||||||
|
error: "invalid_request",
|
||||||
|
error_description: "Missing code or error in response",
|
||||||
|
error_uri: null,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const generateOAuthErrorDescription = (
|
||||||
|
params: Extract<CallbackParams, { successful: false }>,
|
||||||
|
): string => {
|
||||||
|
const error = params.error;
|
||||||
|
const errorDescription = params.error_description;
|
||||||
|
const errorUri = params.error_uri;
|
||||||
|
|
||||||
|
return [
|
||||||
|
`Error: ${error}.`,
|
||||||
|
errorDescription ? `Details: ${errorDescription}.` : "",
|
||||||
|
errorUri ? `More info: ${errorUri}.` : "",
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join("\n");
|
||||||
|
};
|
||||||
3063
package-lock.json
generated
3063
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
32
package.json
32
package.json
@@ -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",
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
Reference in New Issue
Block a user