Merge branch 'main' into main

This commit is contained in:
Ola Hungerford 2025-09-01 06:52:44 -07:00 • committed by GitHub
commit 80ab4dcc28
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 1022 additions and 118 deletions

View file

@ -16,6 +16,7 @@ type Args = {
cli: boolean;
transport?: "stdio" | "sse" | "streamable-http";
serverUrl?: string;
headers?: Record<string, string>;
};
type CliOptions = {
@ -25,6 +26,7 @@ type CliOptions = {
cli?: boolean;
transport?: string;
serverUrl?: string;
header?: Record<string, string>;
};
type ServerConfig =
@ -131,6 +133,9 @@ async function runCli(args: Args): Promise<void> {
// Build CLI arguments
const cliArgs = [cliPath];
// Add target URL/command first
cliArgs.push(args.command, ...args.args);
// Add transport flag if specified
if (args.transport && args.transport !== "stdio") {
// Convert streamable-http back to http for CLI mode
@ -139,8 +144,12 @@ async function runCli(args: Args): Promise<void> {
cliArgs.push("--transport", cliTransport);
}
// Add command and remaining args
cliArgs.push(args.command, ...args.args);
// Add headers if specified
if (args.headers) {
for (const [key, value] of Object.entries(args.headers)) {
cliArgs.push("--header", `${key}: ${value}`);
}
}
await spawnPromise("node", cliArgs, {
env: { ...process.env, ...args.envArgs },
@ -209,6 +218,30 @@ function parseKeyValuePair(
return { ...previous, [key as string]: val };
}
function parseHeaderPair(
value: string,
previous: Record<string, string> = {},
): Record<string, string> {
const colonIndex = value.indexOf(":");
if (colonIndex === -1) {
throw new Error(
`Invalid header format: ${value}. Use "HeaderName: Value" format.`,
);
}
const key = value.slice(0, colonIndex).trim();
const val = value.slice(colonIndex + 1).trim();
if (key === "" || val === "") {
throw new Error(
`Invalid header format: ${value}. Use "HeaderName: Value" format.`,
);
}
return { ...previous, [key]: val };
}
function parseArgs(): Args {
const program = new Command();
@ -235,7 +268,13 @@ function parseArgs(): Args {
.option("--server <n>", "server name from config file")
.option("--cli", "enable CLI mode")
.option("--transport <type>", "transport type (stdio, sse, http)")
.option("--server-url <url>", "server URL for SSE/HTTP transport");
.option("--server-url <url>", "server URL for SSE/HTTP transport")
.option(
"--header <headers...>",
'HTTP headers as "HeaderName: Value" pairs (for HTTP/SSE transports)',
parseHeaderPair,
{},
);
// Parse only the arguments before --
program.parse(preArgs);
@ -288,6 +327,7 @@ function parseArgs(): Args {
envArgs: { ...(config.env || {}), ...(options.e || {}) },
cli: options.cli || false,
transport: "stdio",
headers: options.header,
};
} else if (config.type === "sse" || config.type === "streamable-http") {
return {
@ -297,6 +337,7 @@ function parseArgs(): Args {
cli: options.cli || false,
transport: config.type,
serverUrl: config.url,
headers: options.header,
};
} else {
// Backwards compatibility: if no type field, assume stdio
@ -306,6 +347,7 @@ function parseArgs(): Args {
envArgs: { ...((config as any).env || {}), ...(options.e || {}) },
cli: options.cli || false,
transport: "stdio",
headers: options.header,
};
}
}
@ -327,6 +369,7 @@ function parseArgs(): Args {
cli: options.cli || false,
transport: transport as "stdio" | "sse" | "streamable-http" | undefined,
serverUrl: options.serverUrl,
headers: options.header,
};
}

View file

@ -1,6 +1,16 @@
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { McpResponse } from "./types.js";
// JSON value type matching the client utils
type JsonValue =
| string
| number
| boolean
| null
| undefined
| JsonValue[]
| { [key: string]: JsonValue };
// List available prompts
export async function listPrompts(client: Client): Promise<McpResponse> {
try {
@ -17,12 +27,26 @@ export async function listPrompts(client: Client): Promise<McpResponse> {
export async function getPrompt(
client: Client,
name: string,
args?: Record<string, string>,
args?: Record<string, JsonValue>,
): Promise<McpResponse> {
try {
// Convert all arguments to strings for prompt arguments
const stringArgs: Record<string, string> = {};
if (args) {
for (const [key, value] of Object.entries(args)) {
if (typeof value === "string") {
stringArgs[key] = value;
} else if (value === null || value === undefined) {
stringArgs[key] = String(value);
} else {
stringArgs[key] = JSON.stringify(value);
}
}
}
const response = await client.getPrompt({
name,
arguments: args || {},
arguments: stringArgs,
});
return response;

View file

@ -2,6 +2,16 @@ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { Tool } from "@modelcontextprotocol/sdk/types.js";
import { McpResponse } from "./types.js";
// JSON value type matching the client utils
type JsonValue =
| string
| number
| boolean
| null
| undefined
| JsonValue[]
| { [key: string]: JsonValue };
type JsonSchemaType = {
type: "string" | "number" | "integer" | "boolean" | "array" | "object";
description?: string;
@ -20,7 +30,10 @@ export async function listTools(client: Client): Promise<McpResponse> {
}
}
function convertParameterValue(value: string, schema: JsonSchemaType): unknown {
function convertParameterValue(
value: string,
schema: JsonSchemaType,
): JsonValue {
if (!value) {
return value;
}
@ -35,7 +48,7 @@ function convertParameterValue(value: string, schema: JsonSchemaType): unknown {
if (schema.type === "object" || schema.type === "array") {
try {
return JSON.parse(value);
return JSON.parse(value) as JsonValue;
} catch (error) {
return value;
}
@ -47,8 +60,8 @@ function convertParameterValue(value: string, schema: JsonSchemaType): unknown {
function convertParameters(
tool: Tool,
params: Record<string, string>,
): Record<string, unknown> {
const result: Record<string, unknown> = {};
): Record<string, JsonValue> {
const result: Record<string, JsonValue> = {};
const properties = tool.inputSchema.properties || {};
for (const [key, value] of Object.entries(params)) {
@ -68,18 +81,29 @@ function convertParameters(
export async function callTool(
client: Client,
name: string,
args: Record<string, string>,
args: Record<string, JsonValue>,
): 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;
let convertedArgs: Record<string, JsonValue> = args;
if (tool) {
// Convert parameters based on the tool's schema
convertedArgs = convertParameters(tool, args);
// Convert parameters based on the tool's schema, but only for string values
// since we now accept pre-parsed values from the CLI
const stringArgs: Record<string, string> = {};
for (const [key, value] of Object.entries(args)) {
if (typeof value === "string") {
stringArgs[key] = value;
}
}
if (Object.keys(stringArgs).length > 0) {
const convertedStringArgs = convertParameters(tool, stringArgs);
convertedArgs = { ...args, ...convertedStringArgs };
}
}
const response = await client.callTool({

View file

@ -19,22 +19,35 @@ import {
} from "./client/index.js";
import { handleError } from "./error-handler.js";
import { createTransport, TransportOptions } from "./transport.js";
import { awaitableLog } from "./utils/awaitable-log.js";
// JSON value type for CLI arguments
type JsonValue =
| string
| number
| boolean
| null
| undefined
| JsonValue[]
| { [key: string]: JsonValue };
type Args = {
target: string[];
method?: string;
promptName?: string;
promptArgs?: Record<string, string>;
promptArgs?: Record<string, JsonValue>;
uri?: string;
logLevel?: LogLevel;
toolName?: string;
toolArg?: Record<string, string>;
toolArg?: Record<string, JsonValue>;
transport?: "sse" | "stdio" | "http";
headers?: Record<string, string>;
};
function createTransportOptions(
target: string[],
transport?: "sse" | "stdio" | "http",
headers?: Record<string, string>,
): TransportOptions {
if (target.length === 0) {
throw new Error(
@ -81,11 +94,16 @@ function createTransportOptions(
command: isUrl ? undefined : command,
args: isUrl ? undefined : commandArgs,
url: isUrl ? command : undefined,
headers,
};
}
async function callMethod(args: Args): Promise<void> {
const transportOptions = createTransportOptions(args.target, args.transport);
const transportOptions = createTransportOptions(
args.target,
args.transport,
args.headers,
);
const transport = createTransport(transportOptions);
const client = new Client({
name: "inspector-cli",
@ -150,7 +168,7 @@ async function callMethod(args: Args): Promise<void> {
);
}
console.log(JSON.stringify(result, null, 2));
await awaitableLog(JSON.stringify(result, null, 2));
} finally {
try {
await disconnect(transport);
@ -162,8 +180,8 @@ async function callMethod(args: Args): Promise<void> {
function parseKeyValuePair(
value: string,
previous: Record<string, string> = {},
): Record<string, string> {
previous: Record<string, JsonValue> = {},
): Record<string, JsonValue> {
const parts = value.split("=");
const key = parts[0];
const val = parts.slice(1).join("=");
@ -174,7 +192,40 @@ function parseKeyValuePair(
);
}
return { ...previous, [key as string]: val };
// Try to parse as JSON first
let parsedValue: JsonValue;
try {
parsedValue = JSON.parse(val) as JsonValue;
} catch {
// If JSON parsing fails, keep as string
parsedValue = val;
}
return { ...previous, [key as string]: parsedValue };
}
function parseHeaderPair(
value: string,
previous: Record<string, string> = {},
): Record<string, string> {
const colonIndex = value.indexOf(":");
if (colonIndex === -1) {
throw new Error(
`Invalid header format: ${value}. Use "HeaderName: Value" format.`,
);
}
const key = value.slice(0, colonIndex).trim();
const val = value.slice(colonIndex + 1).trim();
if (key === "" || val === "") {
throw new Error(
`Invalid header format: ${value}. Use "HeaderName: Value" format.`,
);
}
return { ...previous, [key]: val };
}
function parseArgs(): Args {
@ -256,12 +307,24 @@ function parseArgs(): Args {
}
return value as "sse" | "http" | "stdio";
},
)
//
// HTTP headers
//
.option(
"--header <headers...>",
'HTTP headers as "HeaderName: Value" pairs (for HTTP/SSE transports)',
parseHeaderPair,
{},
);
// Parse only the arguments before --
program.parse(preArgs);
const options = program.opts() as Omit<Args, "target">;
const options = program.opts() as Omit<Args, "target"> & {
header?: Record<string, string>;
};
let remainingArgs = program.args;
// Add back any arguments that came after --
@ -276,6 +339,7 @@ function parseArgs(): Args {
return {
target: finalArgs,
...options,
headers: options.header, // commander.js uses 'header' field, map to 'headers'
};
}
@ -287,8 +351,9 @@ async function main(): Promise<void> {
try {
const args = parseArgs();
await callMethod(args);
// Explicitly exit to ensure process terminates in CI
process.exit(0);
// Let Node.js naturally exit instead of force-exiting
// process.exit(0) was causing stdout truncation
} catch (error) {
handleError(error);
}

View file

@ -12,6 +12,7 @@ export type TransportOptions = {
command?: string;
args?: string[];
url?: string;
headers?: Record<string, string>;
};
function createStdioTransport(options: TransportOptions): Transport {
@ -64,11 +65,25 @@ export function createTransport(options: TransportOptions): Transport {
const url = new URL(options.url);
if (transportType === "sse") {
return new SSEClientTransport(url);
const transportOptions = options.headers
? {
requestInit: {
headers: options.headers,
},
}
: undefined;
return new SSEClientTransport(url, transportOptions);
}
if (transportType === "http") {
return new StreamableHTTPClientTransport(url);
const transportOptions = options.headers
? {
requestInit: {
headers: options.headers,
},
}
: undefined;
return new StreamableHTTPClientTransport(url, transportOptions);
}
throw new Error(`Unsupported transport type: ${transportType}`);

View file

@ -0,0 +1,7 @@
export function awaitableLog(logValue: string): Promise<void> {
return new Promise<void>((resolve) => {
process.stdout.write(logValue, () => {
resolve();
});
});
}