feat: HTTP-mode mcplocal container + mcpctl test mcp + token-auth preHandler

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>
This commit is contained in:
Michal
2026-04-17 01:21:42 +01:00
parent a151b2e756
commit 2127b41d9f
16 changed files with 1202 additions and 9 deletions

View File

@@ -0,0 +1,176 @@
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;
}
}

View File

@@ -8,6 +8,7 @@ import { createDescribeCommand } from './commands/describe.js';
import { createDeleteCommand } from './commands/delete.js';
import { createLogsCommand } from './commands/logs.js';
import { createApplyCommand } from './commands/apply.js';
import { createTestCommand } from './commands/test-mcp.js';
import { createCreateCommand } from './commands/create.js';
import { createEditCommand } from './commands/edit.js';
import { createBackupCommand } from './commands/backup.js';
@@ -244,6 +245,10 @@ export function createProgram(): Command {
mcplocalUrl: config.mcplocalUrl,
}));
program.addCommand(createTestCommand({
log: (...args) => console.log(...args),
}));
return program;
}

View File

@@ -0,0 +1,168 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { createTestCommand } from '../../src/commands/test-mcp.js';
function makeSession(overrides: Partial<{
initialize: () => Promise<unknown>;
listTools: () => Promise<Array<{ name: string }>>;
callTool: (name: string, args: Record<string, unknown>) => Promise<unknown>;
close: () => Promise<void>;
}> = {}) {
return {
initialize: overrides.initialize ?? vi.fn(async () => ({ protocolVersion: '2024-11-05' })),
listTools: overrides.listTools ?? vi.fn(async () => [{ name: 'echo' }, { name: 'search' }]),
callTool: overrides.callTool ?? vi.fn(async () => ({ content: [{ type: 'text', text: 'hi' }] })),
close: overrides.close ?? vi.fn(async () => { /* no-op */ }),
};
}
describe('mcpctl test mcp', () => {
const output: string[] = [];
const log = (...args: unknown[]) => {
output.push(args.map(String).join(' '));
};
beforeEach(() => {
output.length = 0;
process.exitCode = 0;
});
afterEach(() => {
process.exitCode = 0;
});
it('exits 0 on happy path (health + initialize + tools/list)', async () => {
const session = makeSession();
const cmd = createTestCommand({
log,
createSession: () => session,
healthCheck: async () => true,
});
await cmd.parseAsync(['mcp', 'https://mcp.example.com/projects/foo/mcp'], { from: 'user' });
expect(process.exitCode).toBe(0);
expect(session.initialize).toHaveBeenCalled();
expect(session.listTools).toHaveBeenCalled();
expect(output.join('\n')).toContain('Result: PASS');
});
it('exits 1 when the /healthz preflight fails', async () => {
const cmd = createTestCommand({
log,
createSession: () => makeSession(),
healthCheck: async () => false,
});
await cmd.parseAsync(['mcp', 'https://mcp.example.com/projects/foo/mcp'], { from: 'user' });
expect(process.exitCode).toBe(1);
expect(output.join('\n')).toContain('healthz preflight failed');
});
it('exits 2 (contract fail) when --expect-tools are missing', async () => {
const cmd = createTestCommand({
log,
createSession: () => makeSession({
listTools: async () => [{ name: 'echo' }],
}),
healthCheck: async () => true,
});
await cmd.parseAsync(
['mcp', 'https://mcp.example.com/projects/foo/mcp', '--expect-tools', 'echo,search'],
{ from: 'user' },
);
expect(process.exitCode).toBe(2);
expect(output.join('\n')).toContain('Missing: search');
expect(output.join('\n')).toContain('CONTRACT FAIL');
});
it('exits 0 when --expect-tools all match', async () => {
const cmd = createTestCommand({
log,
createSession: () => makeSession({
listTools: async () => [{ name: 'echo' }, { name: 'search' }, { name: 'x' }],
}),
healthCheck: async () => true,
});
await cmd.parseAsync(
['mcp', 'https://mcp.example.com/projects/foo/mcp', '--expect-tools', 'echo,search'],
{ from: 'user' },
);
expect(process.exitCode).toBe(0);
});
it('exits 1 on transport/auth failure (initialize throws)', async () => {
const cmd = createTestCommand({
log,
createSession: () => makeSession({
initialize: async () => { throw new Error('HTTP 401: unauthorized'); },
}),
healthCheck: async () => true,
});
await cmd.parseAsync(['mcp', 'https://mcp.example.com/projects/foo/mcp'], { from: 'user' });
expect(process.exitCode).toBe(1);
expect(output.join('\n')).toContain('Error:');
expect(output.join('\n')).toContain('TRANSPORT/AUTH FAIL');
});
it('invokes --tool with --args and reports isError', async () => {
const callTool = vi.fn(async () => ({ content: [{ type: 'text', text: 'oops' }], isError: true }));
const cmd = createTestCommand({
log,
createSession: () => makeSession({ callTool }),
healthCheck: async () => true,
});
await cmd.parseAsync(
['mcp', 'https://mcp.example.com/projects/foo/mcp', '--tool', 'echo', '--args', '{"msg":"hi"}'],
{ from: 'user' },
);
expect(callTool).toHaveBeenCalledWith('echo', { msg: 'hi' });
expect(process.exitCode).toBe(2);
});
it('outputs a JSON report with -o json', async () => {
const cmd = createTestCommand({
log,
createSession: () => makeSession(),
healthCheck: async () => true,
});
await cmd.parseAsync(
['mcp', 'https://mcp.example.com/projects/foo/mcp', '-o', 'json'],
{ from: 'user' },
);
const parsed = JSON.parse(output.join('\n')) as { exitCode: number; tools: string[] };
expect(parsed.exitCode).toBe(0);
expect(parsed.tools).toEqual(['echo', 'search']);
});
it('reads $MCPCTL_TOKEN when --token is not given', async () => {
let observedBearer: string | undefined;
const cmd = createTestCommand({
log,
createSession: (_url, opts) => {
observedBearer = opts.bearer;
return makeSession();
},
healthCheck: async () => true,
});
const prev = process.env.MCPCTL_TOKEN;
process.env.MCPCTL_TOKEN = 'mcpctl_pat_fromenv';
try {
await cmd.parseAsync(['mcp', 'https://mcp.example.com/projects/foo/mcp'], { from: 'user' });
} finally {
if (prev === undefined) delete process.env.MCPCTL_TOKEN;
else process.env.MCPCTL_TOKEN = prev;
}
expect(observedBearer).toBe('mcpctl_pat_fromenv');
});
it('rejects invalid --args as JSON', async () => {
const cmd = createTestCommand({
log,
createSession: () => makeSession(),
healthCheck: async () => true,
});
await cmd.parseAsync(
['mcp', 'https://mcp.example.com/projects/foo/mcp', '--tool', 'echo', '--args', 'not-json'],
{ from: 'user' },
);
expect(process.exitCode).toBe(1);
expect(output.join('\n')).toContain('must be valid JSON');
});
});