Files
mcpctl/src/cli/tests/commands/test-mcp.test.ts

169 lines
5.8 KiB
TypeScript
Raw Normal View History

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>
2026-04-17 01:21:42 +01:00
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');
});
});