Add CLI and config file support

This commit is contained in:
Nicolas Barraud
2025-03-10 20:19:23 -04:00
parent 0870a81990
commit 4c4c8a0884
20 changed files with 1456 additions and 57 deletions

24
cli/package.json Normal file
View File

@@ -0,0 +1,24 @@
{
"name": "@modelcontextprotocol/inspector-cli",
"version": "0.5.1",
"description": "CLI for the Model Context Protocol inspector",
"license": "MIT",
"author": "Nicolas Barraud",
"homepage": "https://github.com/nbarraud",
"main": "build/index.js",
"type": "module",
"bin": {
"mcp-inspector-cli": "build/index.js"
},
"files": [
"build"
],
"scripts": {
"build": "tsc"
},
"devDependencies": {},
"dependencies": {
"commander": "^13.1.0",
"spawn-rx": "^5.1.2"
}
}

View File

@@ -0,0 +1,51 @@
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
import { McpResponse } from "./types.js";
export const validLogLevels = [
"trace",
"debug",
"info",
"warn",
"error",
] as const;
export type LogLevel = (typeof validLogLevels)[number];
export async function connect(
client: Client,
transport: Transport,
): Promise<void> {
try {
await client.connect(transport);
} catch (error) {
throw new Error(
`Failed to connect to MCP server: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
export async function disconnect(transport: Transport): Promise<void> {
try {
await transport.close();
} catch (error) {
throw new Error(
`Failed to disconnect from MCP server: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
// Set logging level
export async function setLoggingLevel(
client: Client,
level: LogLevel,
): Promise<McpResponse> {
try {
const response = await client.setLoggingLevel(level as any);
return response;
} catch (error) {
throw new Error(
`Failed to set logging level: ${error instanceof Error ? error.message : String(error)}`,
);
}
}

6
cli/src/client/index.ts Normal file
View File

@@ -0,0 +1,6 @@
// Re-export everything from the client modules
export * from "./connection.js";
export * from "./prompts.js";
export * from "./resources.js";
export * from "./tools.js";
export * from "./types.js";

34
cli/src/client/prompts.ts Normal file
View File

@@ -0,0 +1,34 @@
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { McpResponse } from "./types.js";
// List available prompts
export async function listPrompts(client: Client): Promise<McpResponse> {
try {
const response = await client.listPrompts();
return response;
} catch (error) {
throw new Error(
`Failed to list prompts: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
// Get a prompt
export async function getPrompt(
client: Client,
name: string,
args?: Record<string, string>,
): Promise<McpResponse> {
try {
const response = await client.getPrompt({
name,
arguments: args || {},
});
return response;
} catch (error) {
throw new Error(
`Failed to get prompt: ${error instanceof Error ? error.message : String(error)}`,
);
}
}

View File

@@ -0,0 +1,43 @@
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { McpResponse } from "./types.js";
// List available resources
export async function listResources(client: Client): Promise<McpResponse> {
try {
const response = await client.listResources();
return response;
} catch (error) {
throw new Error(
`Failed to list resources: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
// Read a resource
export async function readResource(
client: Client,
uri: string,
): Promise<McpResponse> {
try {
const response = await client.readResource({ uri });
return response;
} catch (error) {
throw new Error(
`Failed to read resource ${uri}: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
// List resource templates
export async function listResourceTemplates(
client: Client,
): Promise<McpResponse> {
try {
const response = await client.listResourceTemplates();
return response;
} catch (error) {
throw new Error(
`Failed to list resource templates: ${error instanceof Error ? error.message : String(error)}`,
);
}
}

95
cli/src/client/tools.ts Normal file
View File

@@ -0,0 +1,95 @@
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { Tool } from "@modelcontextprotocol/sdk/types.js";
import { McpResponse } from "./types.js";
type JsonSchemaType = {
type: "string" | "number" | "integer" | "boolean" | "array" | "object";
description?: string;
properties?: Record<string, JsonSchemaType>;
items?: JsonSchemaType;
};
export async function listTools(client: Client): Promise<McpResponse> {
try {
const response = await client.listTools();
return response;
} catch (error) {
throw new Error(
`Failed to list tools: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
function convertParameterValue(value: string, schema: JsonSchemaType): unknown {
if (!value) {
return value;
}
if (schema.type === "number" || schema.type === "integer") {
return Number(value);
}
if (schema.type === "boolean") {
return value.toLowerCase() === "true";
}
if (schema.type === "object" || schema.type === "array") {
try {
return JSON.parse(value);
} catch (error) {
return value;
}
}
return value;
}
function convertParameters(
tool: Tool,
params: Record<string, string>,
): Record<string, unknown> {
const result: Record<string, unknown> = {};
const properties = tool.inputSchema.properties || {};
for (const [key, value] of Object.entries(params)) {
const paramSchema = properties[key] as JsonSchemaType | undefined;
if (paramSchema) {
result[key] = convertParameterValue(value, paramSchema);
} else {
// If no schema is found for this parameter, keep it as string
result[key] = value;
}
}
return result;
}
export async function callTool(
client: Client,
name: string,
args: Record<string, string>,
): Promise<McpResponse> {
try {
const toolsResponse = await listTools(client);
const tools = toolsResponse.tools as Tool[];
const tool = tools.find((t) => t.name === name);
let convertedArgs: Record<string, unknown> = args;
if (tool) {
// Convert parameters based on the tool's schema
convertedArgs = convertParameters(tool, args);
}
const response = await client.callTool({
name: name,
arguments: convertedArgs,
});
return response;
} catch (error) {
throw new Error(
`Failed to call tool ${name}: ${error instanceof Error ? error.message : String(error)}`,
);
}
}

1
cli/src/client/types.ts Normal file
View File

@@ -0,0 +1 @@
export type McpResponse = Record<string, unknown>;

20
cli/src/error-handler.ts Normal file
View File

@@ -0,0 +1,20 @@
function formatError(error: unknown): string {
let message: string;
if (error instanceof Error) {
message = error.message;
} else if (typeof error === "string") {
message = error;
} else {
message = "Unknown error";
}
return message;
}
export function handleError(error: unknown): never {
const errorMessage = formatError(error);
console.error(errorMessage);
process.exit(1);
}

254
cli/src/index.ts Normal file
View File

@@ -0,0 +1,254 @@
#!/usr/bin/env node
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { Command } from "commander";
import {
callTool,
connect,
disconnect,
getPrompt,
listPrompts,
listResources,
listResourceTemplates,
listTools,
LogLevel,
McpResponse,
readResource,
setLoggingLevel,
validLogLevels,
} from "./client/index.js";
import { handleError } from "./error-handler.js";
import { packageInfo } from "./package-info.js";
import { createTransport, TransportOptions } from "./transport.js";
type Args = {
target: string[];
method?: string;
promptName?: string;
promptArgs?: Record<string, string>;
uri?: string;
logLevel?: LogLevel;
toolName?: string;
toolArgs?: Record<string, string>;
};
function createTransportOptions(target: string[]): TransportOptions {
if (target.length === 0) {
throw new Error(
"Target is required. Specify a URL or a command to execute.",
);
}
const [command, ...commandArgs] = target;
if (!command) {
throw new Error("Command is required.");
}
const isUrl = command.startsWith("http://") || command.startsWith("https://");
if (isUrl && commandArgs.length > 0) {
throw new Error("Arguments cannot be passed to a URL-based MCP server.");
}
return {
transportType: isUrl ? "sse" : "stdio",
command: isUrl ? undefined : command,
args: isUrl ? undefined : commandArgs,
url: isUrl ? command : undefined,
};
}
async function callMethod(args: Args): Promise<void> {
const transportOptions = createTransportOptions(args.target);
const transport = createTransport(transportOptions);
const client = new Client({
name: packageInfo.name,
version: packageInfo.version,
});
try {
await connect(client, transport);
let result: McpResponse;
// Tools methods
if (args.method === "tools/list") {
result = await listTools(client);
} else if (args.method === "tools/call") {
if (!args.toolName) {
throw new Error(
"Tool name is required for tools/call method. Use --tool-name to specify the tool name.",
);
}
result = await callTool(client, args.toolName, args.toolArgs || {});
}
// Resources methods
else if (args.method === "resources/list") {
result = await listResources(client);
} else if (args.method === "resources/read") {
if (!args.uri) {
throw new Error(
"URI is required for resources/read method. Use --uri to specify the resource URI.",
);
}
result = await readResource(client, args.uri);
} else if (args.method === "resources/templates/list") {
result = await listResourceTemplates(client);
}
// Prompts methods
else if (args.method === "prompts/list") {
result = await listPrompts(client);
} else if (args.method === "prompts/get") {
if (!args.promptName) {
throw new Error(
"Prompt name is required for prompts/get method. Use --prompt-name to specify the prompt name.",
);
}
result = await getPrompt(client, args.promptName, args.promptArgs || {});
}
// Logging methods
else if (args.method === "logging/setLevel") {
if (!args.logLevel) {
throw new Error(
"Log level is required for logging/setLevel method. Use --log-level to specify the log level.",
);
}
result = await setLoggingLevel(client, args.logLevel);
} else {
throw new Error(
`Unsupported method: ${args.method}. Supported methods include: tools/list, tools/call, resources/list, resources/read, resources/templates/list, prompts/list, prompts/get, logging/setLevel`,
);
}
console.log(JSON.stringify(result, null, 2));
} finally {
try {
await disconnect(transport);
} catch (disconnectError) {
throw disconnectError;
}
}
}
function parseKeyValuePair(
value: string,
previous: Record<string, string> = {},
): Record<string, string> {
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 as string]: val };
}
function parseArgs(): Args {
const program = new Command();
// Find if there's a -- in the arguments and split them
const argSeparatorIndex = process.argv.indexOf("--");
let preArgs = process.argv;
let postArgs: string[] = [];
if (argSeparatorIndex !== -1) {
preArgs = process.argv.slice(0, argSeparatorIndex);
postArgs = process.argv.slice(argSeparatorIndex + 1);
}
program
.name("inspector-cli")
.allowUnknownOption()
.argument("<target...>", "Command and arguments or URL of the MCP server")
//
// Method selection
//
.option("--method <method>", "Method to invoke")
//
// Tool-related options
//
.option("--tool-name <toolName>", "Tool name (for tools/call method)")
.option(
"--tool-args <pairs...>",
"Tool arguments as key=value pairs",
parseKeyValuePair,
{},
)
//
// Resource-related options
//
.option("--uri <uri>", "URI of the resource (for resources/read method)")
//
// Prompt-related options
//
.option(
"--prompt-name <promptName>",
"Name of the prompt (for prompts/get method)",
)
.option(
"--prompt-args <pairs...>",
"Prompt arguments as key=value pairs",
parseKeyValuePair,
{},
)
//
// Logging options
//
.option(
"--log-level <level>",
"Logging level (for logging/setLevel method)",
(value: string) => {
if (!validLogLevels.includes(value as any)) {
throw new Error(
`Invalid log level: ${value}. Valid levels are: ${validLogLevels.join(", ")}`,
);
}
return value as LogLevel;
},
);
// Parse only the arguments before --
program.parse(preArgs);
const options = program.opts() as Omit<Args, "target">;
let remainingArgs = program.args;
// Add back any arguments that came after --
const finalArgs = [...remainingArgs, ...postArgs];
if (!options.method) {
throw new Error(
"Method is required. Use --method to specify the method to invoke.",
);
}
return {
target: finalArgs,
...options,
};
}
async function main(): Promise<void> {
process.on("uncaughtException", (error) => {
handleError(error);
});
try {
const args = parseArgs();
await callMethod(args);
} catch (error) {
handleError(error);
}
}
main();

24
cli/src/package-info.ts Normal file
View File

@@ -0,0 +1,24 @@
import { readFileSync } from "fs";
import { dirname, resolve } from "path";
import { fileURLToPath } from "url";
type PackageInfo = {
name: string;
version: string;
description: string;
};
function getPackageInfo(): PackageInfo {
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const packageJsonPath = resolve(__dirname, "..", "..", "package.json");
const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf-8"));
return {
name: packageJson.name,
version: packageJson.version,
description: packageJson.description,
};
}
export const packageInfo: PackageInfo = getPackageInfo();

76
cli/src/transport.ts Normal file
View File

@@ -0,0 +1,76 @@
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
import {
getDefaultEnvironment,
StdioClientTransport,
} from "@modelcontextprotocol/sdk/client/stdio.js";
import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
import { findActualExecutable } from "spawn-rx";
export type TransportOptions = {
transportType: "sse" | "stdio";
command?: string;
args?: string[];
url?: string;
};
function createSSETransport(options: TransportOptions): Transport {
const baseUrl = new URL(options.url ?? "");
const sseUrl = new URL("/sse", baseUrl);
return new SSEClientTransport(sseUrl);
}
function createStdioTransport(options: TransportOptions): Transport {
let args: string[] = [];
if (options.args !== undefined) {
args = options.args;
}
const processEnv: Record<string, string> = {};
for (const [key, value] of Object.entries(process.env)) {
if (value !== undefined) {
processEnv[key] = value;
}
}
const defaultEnv = getDefaultEnvironment();
const env: Record<string, string> = {
...processEnv,
...defaultEnv,
};
const { cmd: actualCommand, args: actualArgs } = findActualExecutable(
options.command ?? "",
args,
);
return new StdioClientTransport({
command: actualCommand,
args: actualArgs,
env,
stderr: "pipe",
});
}
export function createTransport(options: TransportOptions): Transport {
const { transportType } = options;
try {
if (transportType === "stdio") {
return createStdioTransport(options);
}
if (transportType === "sse") {
return createSSETransport(options);
}
throw new Error(`Unsupported transport type: ${transportType}`);
} catch (error) {
throw new Error(
`Failed to create transport: ${error instanceof Error ? error.message : String(error)}`,
);
}
}

17
cli/tsconfig.json Normal file
View File

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