177 lines
6.6 KiB
TypeScript
177 lines
6.6 KiB
TypeScript
|
|
import { Command } from 'commander';
|
||
|
|
import { McpHttpSession, McpProtocolError, McpTransportError, deriveBaseUrl, mcpHealthCheck } from '@mcpctl/shared';
|
||
|
|
|
||
|
|
export interface TestMcpCommandDeps {
|
||
|
|
log: (...args: unknown[]) => void;
|
||
|
|
/**
|
||
|
|
* Inject a session factory for testing. The default creates a real `McpHttpSession`.
|
||
|
|
*/
|
||
|
|
createSession?: (url: string, opts: { bearer?: string; timeoutMs?: number }) => {
|
||
|
|
initialize(): Promise<unknown>;
|
||
|
|
listTools(): Promise<Array<{ name: string }>>;
|
||
|
|
callTool(name: string, args: Record<string, unknown>): Promise<unknown>;
|
||
|
|
close(): Promise<void>;
|
||
|
|
};
|
||
|
|
healthCheck?: (baseUrl: string) => Promise<boolean>;
|
||
|
|
}
|
||
|
|
|
||
|
|
export type TestMcpExitCode = 0 | 1 | 2;
|
||
|
|
|
||
|
|
export interface TestMcpReport {
|
||
|
|
url: string;
|
||
|
|
health: 'ok' | 'fail' | 'skipped';
|
||
|
|
initialize: 'ok' | 'fail';
|
||
|
|
tools: string[] | null;
|
||
|
|
toolCall?: { name: string; result: unknown; isError?: boolean };
|
||
|
|
missingTools?: string[];
|
||
|
|
exitCode: TestMcpExitCode;
|
||
|
|
error?: string;
|
||
|
|
}
|
||
|
|
|
||
|
|
export function createTestCommand(deps: TestMcpCommandDeps): Command {
|
||
|
|
const { log } = deps;
|
||
|
|
const createSession = deps.createSession ?? ((url, opts) => new McpHttpSession(url, opts));
|
||
|
|
const healthCheck = deps.healthCheck ?? mcpHealthCheck;
|
||
|
|
|
||
|
|
const test = new Command('test').description('Utilities for testing MCP endpoints and config');
|
||
|
|
|
||
|
|
test
|
||
|
|
.command('mcp')
|
||
|
|
.description('Verify a Streamable-HTTP MCP endpoint: health, initialize, tools/list, optionally call a tool.')
|
||
|
|
.argument('<url>', 'Full URL of the MCP endpoint (e.g. https://mcp.example.com/projects/foo/mcp)')
|
||
|
|
.option('--token <bearer>', 'Bearer token (also reads $MCPCTL_TOKEN)')
|
||
|
|
.option('--tool <name>', 'Invoke a specific tool after listing')
|
||
|
|
.option('--args <json>', 'JSON-encoded arguments for --tool', '{}')
|
||
|
|
.option('--expect-tools <list>', 'Comma-separated tool names that MUST appear; fails otherwise')
|
||
|
|
.option('--timeout <seconds>', 'Per-request timeout in seconds', '10')
|
||
|
|
.option('-o, --output <format>', 'Output format: text or json', 'text')
|
||
|
|
.option('--no-health', 'Skip the /healthz preflight check')
|
||
|
|
.action(async (url: string, opts: {
|
||
|
|
token?: string;
|
||
|
|
tool?: string;
|
||
|
|
args: string;
|
||
|
|
expectTools?: string;
|
||
|
|
timeout: string;
|
||
|
|
output: string;
|
||
|
|
health: boolean;
|
||
|
|
}) => {
|
||
|
|
const bearer = opts.token ?? process.env.MCPCTL_TOKEN;
|
||
|
|
const timeoutMs = Number(opts.timeout) * 1000;
|
||
|
|
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
|
||
|
|
throw new Error(`--timeout must be a positive number of seconds (got '${opts.timeout}')`);
|
||
|
|
}
|
||
|
|
|
||
|
|
const report: TestMcpReport = {
|
||
|
|
url,
|
||
|
|
health: 'skipped',
|
||
|
|
initialize: 'fail',
|
||
|
|
tools: null,
|
||
|
|
exitCode: 1,
|
||
|
|
};
|
||
|
|
|
||
|
|
// 1. Health preflight
|
||
|
|
if (opts.health !== false) {
|
||
|
|
const baseUrl = deriveBaseUrl(url);
|
||
|
|
const ok = await healthCheck(baseUrl);
|
||
|
|
report.health = ok ? 'ok' : 'fail';
|
||
|
|
if (!ok) {
|
||
|
|
report.error = `healthz preflight failed at ${baseUrl}/healthz`;
|
||
|
|
return emit(report, opts.output, log);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
const sessionOpts: { bearer?: string; timeoutMs: number } = { timeoutMs };
|
||
|
|
if (bearer !== undefined) sessionOpts.bearer = bearer;
|
||
|
|
const session = createSession(url, sessionOpts);
|
||
|
|
|
||
|
|
try {
|
||
|
|
// 2. Initialize
|
||
|
|
await session.initialize();
|
||
|
|
report.initialize = 'ok';
|
||
|
|
|
||
|
|
// 3. tools/list
|
||
|
|
const tools = await session.listTools();
|
||
|
|
report.tools = tools.map((t) => t.name);
|
||
|
|
|
||
|
|
// 4. --expect-tools check
|
||
|
|
if (opts.expectTools !== undefined && opts.expectTools.trim() !== '') {
|
||
|
|
const expected = opts.expectTools.split(',').map((s) => s.trim()).filter(Boolean);
|
||
|
|
const missing = expected.filter((name) => !report.tools!.includes(name));
|
||
|
|
if (missing.length > 0) {
|
||
|
|
report.missingTools = missing;
|
||
|
|
report.exitCode = 2;
|
||
|
|
report.error = `Missing tools: ${missing.join(', ')}`;
|
||
|
|
return emit(report, opts.output, log);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// 5. Optional --tool call
|
||
|
|
if (opts.tool !== undefined) {
|
||
|
|
let parsedArgs: Record<string, unknown> = {};
|
||
|
|
try {
|
||
|
|
parsedArgs = JSON.parse(opts.args) as Record<string, unknown>;
|
||
|
|
} catch {
|
||
|
|
throw new Error(`--args must be valid JSON (got '${opts.args}')`);
|
||
|
|
}
|
||
|
|
const result = await session.callTool(opts.tool, parsedArgs);
|
||
|
|
const toolCall: TestMcpReport['toolCall'] = { name: opts.tool, result };
|
||
|
|
if (typeof result === 'object' && result !== null && 'isError' in result) {
|
||
|
|
toolCall.isError = Boolean((result as { isError?: boolean }).isError);
|
||
|
|
}
|
||
|
|
report.toolCall = toolCall;
|
||
|
|
if (toolCall.isError) {
|
||
|
|
report.exitCode = 2;
|
||
|
|
report.error = `Tool '${opts.tool}' returned isError=true`;
|
||
|
|
return emit(report, opts.output, log);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
report.exitCode = 0;
|
||
|
|
} catch (err) {
|
||
|
|
if (err instanceof McpProtocolError) {
|
||
|
|
report.exitCode = 1;
|
||
|
|
report.error = `protocol error ${err.code}: ${err.message}`;
|
||
|
|
} else if (err instanceof McpTransportError) {
|
||
|
|
report.exitCode = 1;
|
||
|
|
report.error = `transport error (HTTP ${err.status}): ${err.message}`;
|
||
|
|
} else {
|
||
|
|
report.exitCode = 1;
|
||
|
|
report.error = err instanceof Error ? err.message : String(err);
|
||
|
|
}
|
||
|
|
} finally {
|
||
|
|
await session.close().catch(() => { /* best-effort */ });
|
||
|
|
}
|
||
|
|
|
||
|
|
return emit(report, opts.output, log);
|
||
|
|
});
|
||
|
|
|
||
|
|
return test;
|
||
|
|
}
|
||
|
|
|
||
|
|
function emit(report: TestMcpReport, output: string, log: (...args: unknown[]) => void): void {
|
||
|
|
if (output === 'json') {
|
||
|
|
log(JSON.stringify(report, null, 2));
|
||
|
|
} else {
|
||
|
|
log(`URL: ${report.url}`);
|
||
|
|
log(`Health: ${report.health}`);
|
||
|
|
log(`Initialize: ${report.initialize}`);
|
||
|
|
if (report.tools !== null) {
|
||
|
|
log(`Tools (${report.tools.length}): ${report.tools.slice(0, 10).join(', ')}${report.tools.length > 10 ? `, …(+${report.tools.length - 10})` : ''}`);
|
||
|
|
}
|
||
|
|
if (report.missingTools !== undefined) {
|
||
|
|
log(`Missing: ${report.missingTools.join(', ')}`);
|
||
|
|
}
|
||
|
|
if (report.toolCall !== undefined) {
|
||
|
|
log(`Tool call: ${report.toolCall.name} → ${report.toolCall.isError ? 'ERROR' : 'ok'}`);
|
||
|
|
}
|
||
|
|
if (report.error !== undefined) {
|
||
|
|
log(`Error: ${report.error}`);
|
||
|
|
}
|
||
|
|
log(`Result: ${report.exitCode === 0 ? 'PASS' : report.exitCode === 2 ? 'CONTRACT FAIL' : 'TRANSPORT/AUTH FAIL'}`);
|
||
|
|
}
|
||
|
|
|
||
|
|
if (report.exitCode !== 0) {
|
||
|
|
process.exitCode = report.exitCode;
|
||
|
|
}
|
||
|
|
}
|