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:
168
src/cli/tests/commands/test-mcp.test.ts
Normal file
168
src/cli/tests/commands/test-mcp.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user