Delivers the final piece of the mcptoken stack: a containerized,
network-accessible mcplocal that serves Streamable-HTTP MCP to off-host
clients (the vLLM use case), authenticated by project-scoped McpTokens.
New binary (same package, new entry):
- src/mcplocal/src/serve.ts — HTTP-only entry. Reads MCPLOCAL_MCPD_URL,
MCPLOCAL_MCPD_TOKEN, MCPLOCAL_HTTP_HOST/PORT, MCPLOCAL_CACHE_DIR from
env. No StdioProxyServer, no --upstream.
- src/mcplocal/src/http/token-auth.ts — Fastify preHandler that
validates mcpctl_pat_ bearers via mcpd's /api/v1/mcptokens/introspect.
30s positive / 5s negative TTL. Rejects wrong-project with 403.
Shared HTTP MCP client:
- src/shared/src/mcp-http/ — reusable McpHttpSession with initialize,
listTools, callTool, close. Handles http+https, SSE, id correlation,
distinct McpProtocolError / McpTransportError. Plus mcpHealthCheck
and deriveBaseUrl helpers.
New CLI verb `mcpctl test mcp <url>`:
- Flags: --token (also $MCPCTL_TOKEN), --tool, --args (JSON),
--expect-tools, --timeout, -o text|json, --no-health.
- Exit codes: 0 PASS, 1 TRANSPORT/AUTH FAIL, 2 CONTRACT FAIL.
Container + deploy:
- deploy/Dockerfile.mcplocal (Node 20 alpine, multi-stage, pnpm
workspace, CMD node src/mcplocal/dist/serve.js, VOLUME
/var/lib/mcplocal/cache, HEALTHCHECK on :3200/healthz).
- scripts/build-mcplocal.sh mirrors build-mcpd.sh.
- fulldeploy.sh is now a 4-step pipeline that also builds + rolls out
mcplocal (gated on `kubectl get deployment/mcplocal` so the script
stays green before the Pulumi stack lands).
Audit + cache:
- project-mcp-endpoint.ts passes MCPLOCAL_CACHE_DIR into FileCache at
both construction sites and, when request.mcpToken is present, calls
collector.setSessionMcpToken(id, ...) so audit events carry the
tokenName/tokenSha.
Tests:
- 9 unit cases on `mcpctl test mcp` (happy path, health miss,
expect-tools hit/miss, transport throw, tool isError, json report,
$MCPCTL_TOKEN env fallback, invalid --args).
- Smoke test src/mcplocal/tests/smoke/mcptoken.smoke.test.ts —
gated on healthz($MCPGW_URL), skipped cleanly when unreachable.
Covers happy path, wrong-project 403, --expect-tools contract
failure, and revocation 401 within the negative-cache window.
1773/1773 workspace tests pass. Pulumi resources (Deployment, Service,
Ingress, PVC, Secret, NetworkPolicy) still need to land in
../kubernetes-deployment before the smoke gate flips on.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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;
|
|
}
|
|
}
|