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:
143
src/mcplocal/tests/smoke/mcptoken.smoke.test.ts
Normal file
143
src/mcplocal/tests/smoke/mcptoken.smoke.test.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
/**
|
||||
* 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 <url> --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 <nonexistent> → 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';
|
||||
|
||||
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<boolean> {
|
||||
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;
|
||||
|
||||
beforeAll(async () => {
|
||||
gatewayUp = await healthz(MCPGW_URL);
|
||||
}, 20_000);
|
||||
|
||||
describe.skipIf(!gatewayUp)('mcptoken smoke (MCPGW_URL=' + MCPGW_URL + ')', () => {
|
||||
it('creates the project and a project-scoped mcptoken', () => {
|
||||
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', () => {
|
||||
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);
|
||||
// Remember a tool name for the next negative --expect-tools assertion
|
||||
knownToolName = report.tools?.[0];
|
||||
});
|
||||
|
||||
it('fails `mcpctl test mcp` against a different project with 403', () => {
|
||||
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/i);
|
||||
});
|
||||
|
||||
it('exits 2 (contract failure) when --expect-tools names a nonexistent tool', () => {
|
||||
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 () => {
|
||||
const del = run(`delete mcptoken ${TOKEN_NAME} --project ${PROJECT_NAME}`);
|
||||
expect(del.code).toBe(0);
|
||||
// Let the mcplocal negative-cache window expire. Introspection negative TTL
|
||||
// defaults to 5s; we 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', () => {
|
||||
run(`delete project ${PROJECT_NAME} --force`);
|
||||
run(`delete project ${OTHER_PROJECT} --force`);
|
||||
// Suppress the unused-var warning in strict setups
|
||||
expect(knownToolName === undefined || typeof knownToolName === 'string').toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe.skipIf(gatewayUp)('mcptoken smoke (SKIPPED)', () => {
|
||||
it('is skipped because MCPGW_URL is unreachable', () => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(`mcptoken smoke: skipped — ${MCPGW_URL}/healthz unreachable. Set MCPGW_URL to override.`);
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user