/** * Smoke tests: McpToken + HTTP-mode mcplocal end-to-end. * * Exercises the full public CLI contract: * 1. `mcpctl create project` + `mcpctl create mcptoken` * 2. `mcpctl test mcp --token $TOK --expect-tools …` → exit 0 * 3. Same token against a different project → exit 1 (403) * 4. Revoke the token, retry → exit 1 (401) within the negative-cache window * 5. --expect-tools → exit 2 (contract failure) * * Target endpoint: $MCPGW_URL (default https://mcp.ad.itaz.eu). The containerized * mcplocal must be deployed and reachable. If the /healthz preflight fails we * skip the whole suite with a clear message. * * Run with: pnpm test:smoke */ import { describe, it, expect, beforeAll } from 'vitest'; import http from 'node:http'; import https from 'node:https'; import { execSync } from 'node:child_process'; const MCPGW_URL = process.env.MCPGW_URL ?? 'https://mcp.ad.itaz.eu'; const PROJECT_NAME = `smoke-mcptoken-${Date.now().toString(36)}`; const TOKEN_NAME = 'smoketok'; const OTHER_PROJECT = 'smoke-mcptoken-other'; // The revocation assertion is only meaningful against the HTTP-mode `serve.ts` // entry, which has the token-introspection cache (5s negative TTL). The // systemd/STDIO entry caches the whole project router for minutes and is // deliberately agnostic to token state — so revocation propagation there is // mcpd's problem, not mcplocal's. We treat localhost as systemd-mode by // default; pass MCPGW_IS_HTTP_MODE=true to force the full assertion. const IS_HTTP_MODE = process.env.MCPGW_IS_HTTP_MODE === 'true' || (!/^(http|https):\/\/(localhost|127\.|0\.0\.0\.0)/i.test(MCPGW_URL)); interface CliResult { code: number; stdout: string; stderr: string } function run(args: string): CliResult { try { const stdout = execSync(`mcpctl ${args}`, { encoding: 'utf-8', timeout: 30_000, stdio: ['ignore', 'pipe', 'pipe'], }); return { code: 0, stdout: stdout.trim(), stderr: '' }; } catch (err) { const e = err as { status?: number; stdout?: Buffer | string; stderr?: Buffer | string }; return { code: e.status ?? 1, stdout: e.stdout ? (typeof e.stdout === 'string' ? e.stdout : e.stdout.toString('utf-8')) : '', stderr: e.stderr ? (typeof e.stderr === 'string' ? e.stderr : e.stderr.toString('utf-8')) : '', }; } } function healthz(url: string, timeoutMs = 5000): Promise { return new Promise((resolve) => { const parsed = new URL(`${url.replace(/\/$/, '')}/healthz`); const driver = parsed.protocol === 'https:' ? https : http; const req = driver.get( { hostname: parsed.hostname, port: parsed.port || (parsed.protocol === 'https:' ? 443 : 80), path: parsed.pathname, timeout: timeoutMs, }, (res) => { resolve((res.statusCode ?? 500) < 500); res.resume(); }, ); req.on('error', () => resolve(false)); req.on('timeout', () => { req.destroy(); resolve(false); }); }); } let gatewayUp = false; let rawToken = ''; let knownToolName: string | undefined; describe('mcptoken smoke', () => { beforeAll(async () => { gatewayUp = await healthz(MCPGW_URL); if (!gatewayUp) { // eslint-disable-next-line no-console console.warn(`\n ○ mcptoken smoke: skipped — ${MCPGW_URL}/healthz unreachable. Set MCPGW_URL to override.\n`); } }, 20_000); it('creates the project and a project-scoped mcptoken', () => { if (!gatewayUp) return; run(`delete project ${PROJECT_NAME} --force`); // cleanup leftovers — best-effort const createProj = run(`create project ${PROJECT_NAME} --force`); expect(createProj.code).toBe(0); const createTok = run(`create mcptoken ${TOKEN_NAME} --project ${PROJECT_NAME} --rbac clone`); expect(createTok.code).toBe(0); const match = createTok.stdout.match(/mcpctl_pat_[A-Za-z0-9]+/); expect(match, 'raw token was printed to stdout').not.toBeNull(); rawToken = match![0]; }); it('passes `mcpctl test mcp` against the token\'s project endpoint', () => { if (!gatewayUp) return; const result = run(`test mcp ${MCPGW_URL}/projects/${PROJECT_NAME}/mcp --token ${rawToken} -o json`); expect(result.code, result.stderr || result.stdout).toBe(0); const report = JSON.parse(result.stdout.slice(result.stdout.indexOf('{'))) as { exitCode: number; tools: string[] | null; initialize: string; }; expect(report.exitCode).toBe(0); expect(report.initialize).toBe('ok'); expect(Array.isArray(report.tools)).toBe(true); knownToolName = report.tools?.[0]; }); it('fails `mcpctl test mcp` against a different project with 403', () => { if (!gatewayUp) return; run(`create project ${OTHER_PROJECT} --force`); const result = run(`test mcp ${MCPGW_URL}/projects/${OTHER_PROJECT}/mcp --token ${rawToken} -o json`); expect(result.code).toBe(1); const report = JSON.parse(result.stdout.slice(result.stdout.indexOf('{'))) as { error?: string }; expect(report.error ?? '').toMatch(/403|not valid for|project|Invalid/i); }); it('exits 2 (contract failure) when --expect-tools names a nonexistent tool', () => { if (!gatewayUp) return; const result = run(`test mcp ${MCPGW_URL}/projects/${PROJECT_NAME}/mcp --token ${rawToken} --expect-tools __nonexistent_tool_xyz__`); expect(result.code).toBe(2); }); it('returns 401 after the token is revoked (within the negative-cache window)', async () => { if (!gatewayUp) return; if (!IS_HTTP_MODE) { // eslint-disable-next-line no-console console.warn(' ○ revocation assertion skipped — systemd mcplocal caches the project router, so this case is only meaningful against the HTTP-mode serve.ts entry. Set MCPGW_IS_HTTP_MODE=true to force it.'); // Still delete the token so cleanup runs the same way. run(`delete mcptoken ${TOKEN_NAME} --project ${PROJECT_NAME}`); return; } const del = run(`delete mcptoken ${TOKEN_NAME} --project ${PROJECT_NAME}`); expect(del.code).toBe(0); // Introspection negative TTL defaults to 5s — wait 7s to be safe. await new Promise((r) => setTimeout(r, 7_000)); const result = run(`test mcp ${MCPGW_URL}/projects/${PROJECT_NAME}/mcp --token ${rawToken} -o json`); expect(result.code).toBe(1); const report = JSON.parse(result.stdout.slice(result.stdout.indexOf('{'))) as { error?: string }; expect(report.error ?? '').toMatch(/401|revoked|Invalid token/i); }, 20_000); it('cleans up test fixtures', () => { if (!gatewayUp) return; run(`delete project ${PROJECT_NAME} --force`); run(`delete project ${OTHER_PROJECT} --force`); expect(knownToolName === undefined || typeof knownToolName === 'string').toBe(true); }); });