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>
16 KiB
mcptoken + HTTP-mode mcplocal — implementation log
Companion to the approved plan at /home/michal/.claude/plans/lets-discuss-something-i-bright-lovelace.md.
This file is updated as each milestone lands, so you can review what was actually done vs. what was planned.
Context (why)
You're running your own vLLM inference outside Claude Code and want it to consume mcpctl over MCP with the same UX Claude gets: project-scoped server discovery, proxy models, the pipeline cache. Today mcplocal is systemd-only and serves STDIO — unreachable from off-host and unauthenticated. This work adds:
- A containerized, network-accessible
mcplocalserving Streamable HTTP. - A new
McpTokenresource (CLI:mcpctl get/create/delete mcptoken) — project-scoped bearer tokens with the same RBAC stack as users. Hashed at rest; raw value shown once. - Tokens as a first-class RBAC subject kind (
McpToken:<sha>), with a creator-permission ceiling so non-admins cannot mint escalated tokens. - k8s deploy (Service
mcp, Ingressmcp.ad.itaz.eu, PVC-backedFileCache). - A CLI breaking change:
mcpctl create rbac --binding edit:servers→--roleBindings role:edit,resource:servers. You explicitly asked for this; only one command uses it. - A product-grade
mcpctl test mcp <url>verb for validating any Streamable-HTTP MCP endpoint, reused by smoke tests.
Branch
All work lives on feat/mcptoken (off main at 3149ea3).
Pre-work committed to main (outside this branch)
Before starting the feature, we flushed your in-flight changes to main so they wouldn't travel with the branch:
3149ea3 fix: MCP proxy resilience — discovery cache, default liveness probes— per-servertools/listcache inMcpRouterwith positive+negative TTL so dead upstreams only stall the first call; default liveness probe (tools/list through the real production path) applied to any RUNNING instance without an explicit healthCheck. Already pushed to origin.
Status legend
- ✅ done
- 🚧 in progress
- ⬜ not started
PR 1 — Schema + token helpers + mcpd CRUD routes ✅
| # | Step | Status |
|---|---|---|
| 1 | McpToken Prisma model + Project/User reverse relations; AuditEvent.tokenName / tokenSha + index |
✅ |
| 2 | src/shared/src/tokens/index.ts — generateToken, hashToken, isMcpToken, timingSafeEqualHex, TOKEN_PREFIX |
✅ |
| 3 | src/mcpd/src/repositories/mcp-token.repository.ts + new interfaces in repositories/interfaces.ts |
✅ |
| 4 | src/mcpd/src/services/mcp-token.service.ts — creator-ceiling via rbacService.canAccess/canRunOperation, raw token returned only once, auto-creates an RbacDefinition with subject McpToken:<sha> when bindings are non-empty |
✅ |
| 5 | src/mcpd/src/routes/mcp-tokens.ts — POST / GET / GET:id / DELETE:id + POST:id/revoke + GET /introspect |
✅ |
| 6 | Wired into main.ts — repo/service constructed, routes registered, mcptokens added to URL→permission map + name resolver; /mcptokens/introspect added to auth-skip list so mcplocal can call it with a raw McpToken bearer |
✅ |
| 7 | RBAC extensions: new subject kind McpToken in rbac-definition.schema.ts; mcptokens added to RBAC_RESOURCES and RESOURCE_ALIASES; rbac.service.ts threads optional mcpTokenSha through canAccess, canRunOperation, getAllowedScope, getPermissions; resolver matches {kind:'McpToken', name: sha} |
✅ |
| 8 | Unit tests — tests/mcp-token-service.test.ts covering: empty/clone modes, ceiling rejection, RbacDefinition auto-create with correct McpToken:<sha> subject, duplicate-name conflict, introspect valid/revoked/expired/unknown, revoke deletes the RbacDefinition. 11/11 green. Full mcpd suite still 648/648. |
✅ |
What this PR does NOT do yet (coming in PR 3)
- The mcpd auth middleware does not yet dispatch on the token prefix. A raw
mcpctl_pat_…bearer sent to any/api/v1/*endpoint (other than/introspect) is still rejected as an invalid session. That's intentional — PR 3 extendsmiddleware/auth.tsto recognize both session bearers and McpToken bearers. - No CLI yet. Tokens can be created only via
POST /api/v1/mcptokensfor now.
PR 2 — RBAC CLI migration ✅
Migrated mcpctl create rbac from positional flag syntax to the key=value form you asked for.
Before:
mcpctl create rbac developers \
--subject User:alice@test.com \
--binding edit:servers \
--binding view:servers:my-ha \
--operation logs
After:
mcpctl create rbac developers \
--subject User:alice@test.com \
--roleBindings role:edit,resource:servers \
--roleBindings role:view,resource:servers,name:my-ha \
--roleBindings action:logs
| # | Step | Status |
|---|---|---|
| 1 | New shared parser at src/cli/src/commands/rbac-bindings.ts exporting parseRoleBinding(entry) |
✅ |
| 2 | src/cli/src/commands/create.ts — old --binding/--operation flags replaced with one repeatable --roleBindings <kv>. Uses the new parser. |
✅ |
| 3 | Tests in src/cli/tests/commands/create.test.ts rewritten to the new form (8 RBAC tests updated) |
✅ |
| 4 | New dedicated unit test src/cli/tests/commands/rbac-bindings.test.ts — 9 cases covering unscoped / name-scoped / action / trim / empty-value / unknown-key / action-conflict / missing-role rejections |
✅ |
| 5 | Shell completions regenerated via pnpm completions:generate — both completions/mcpctl.{bash,fish} now offer --roleBindings, no longer --binding/--operation |
✅ |
| 6 | Nothing in docs/ or README.md referenced the old flags |
✅ |
Full CLI suite still 406/406 green. On-disk YAML shape (roleBindings: [...]) is unchanged, so backups and existing apply -f files keep working.
The extracted parseRoleBinding helper is what PR 3's mcpctl create mcptoken --bind <kv> flag will reuse.
PR 3 — CLI mcptoken verbs + mcpd auth dispatch + audit ✅
| # | Step | Status |
|---|---|---|
| 1 | src/mcpd/src/middleware/auth.ts — dispatch on the bearer prefix. mcpctl_pat_… → new findMcpToken(hash) dep → populates request.mcpToken + request.userId = ownerId. Other bearers → existing findSession path. Returns 401 for revoked, expired, or unknown tokens. Fastify module augmentation adds request.mcpToken?: McpTokenPrincipal. |
✅ |
| 2 | src/mcpd/src/main.ts — wires findMcpToken: mcpTokenRepo.findByHash. Threads mcpTokenSha into canAccess / canRunOperation / getAllowedScope. Adds a second project-scope check: McpToken principals can only reach resources inside their bound project (additional guard on top of the route handler checks). |
✅ |
| 3 | New auth tests (tests/auth.test.ts) — 3 McpToken dispatch cases: happy path sets userId + mcpToken, revoked → 401, no findMcpToken wired → 401. Session path unchanged. |
✅ |
| 4 | mcpctl create mcptoken <name> -p <proj> [--rbac empty|clone] [--bind …] [--ttl …] — new subcommand. Reuses parseRoleBinding from PR 2. parseTtl helper accepts 30d/12h/never/ISO8601. --force revokes the existing active token and creates a new one. Raw token is printed once with a "copy now" banner. |
✅ |
| 5 | mcpctl get mcptokens + mcpctl get mcptoken <name> -p <proj> + mcpctl describe mcptoken <name> -p <proj> + mcpctl delete mcptoken <name> -p <proj>. Names are project-scoped, so all verbs require -p unless a CUID is passed. Table columns: NAME / PROJECT / PREFIX / CREATED / LAST USED / EXPIRES / STATUS. Describe surfaces the auto-created RbacDefinition's bindings (matched by mcptoken-<id> name convention). |
✅ |
| 6 | mcpctl apply -f — added McpTokenSpecSchema, mcpton: 'mcptokens' in KIND_TO_RESOURCE, and an applier that creates if missing or logs "already active — skipped" (tokens are immutable). Raw token printed on create. |
✅ |
| 7 | Resource aliases — mcptoken/mcptokens/token/tokens all resolve to mcptokens. stripInternalFields scrubs the secret and derived fields and promotes projectName → project for YAML round-trip. |
✅ |
| 8 | Audit pipeline — src/mcplocal/src/audit/types.ts gains tokenName?/tokenSha?; collector gets setSessionMcpToken(sessionId, {tokenName, tokenSha}) alongside setSessionUserName, both merged into a per-session principal map. src/mcpd/src/services/audit-event.service.ts accepts tokenName and tokenSha query params (repo already extended in PR 1). console/audit-types.ts carries the new optional fields so the TUI can surface them in a follow-up. |
✅ |
| 9 | Shell completions regenerated — mcpctl create mcptoken flags (--project, --rbac, --bind, --ttl, --description, --force) and the new resource alias land in both bash and fish completions. completions.test.ts freshness check passes. |
✅ |
What this PR does NOT do yet (coming in PR 4)
- No HTTP-mode mcplocal binary yet. Tokens can be used to hit mcpd directly via
/api/v1/…withAuthorization: Bearer mcpctl_pat_…, but the containerized/projects/<p>/mcpendpoint and its token-auth preHandler don't exist yet. - The audit-console TUI still shows only
userNamecolumns; adding aTOKENcolumn is a UI polish follow-up.
Test stats
- 1764/1764 tests pass workspace-wide (up from ~1750 before PR 3).
- Build clean across all 5 packages.
- Completions freshness check green.
PR 4 — HTTP-mode mcplocal + container + mcpctl test mcp + smoke ✅
| # | Step | Status |
|---|---|---|
| 1 | Shared HTTP MCP client — src/shared/src/mcp-http/index.ts. McpHttpSession(url, {bearer?, headers?, timeoutMs?}) with initialize / listTools / callTool / close / send / sendNotification. Handles http + https, multiplexed SSE bodies, JSON-RPC id correlation. Distinct McpProtocolError / McpTransportError classes for contract-vs-transport failures. Plus deriveBaseUrl(url) + mcpHealthCheck(base). Exported from @mcpctl/shared. |
✅ |
| 2 | mcpctl test mcp <url> — new CLI verb under src/cli/src/commands/test-mcp.ts. Flags: --token (also reads $MCPCTL_TOKEN), --tool, --args (JSON), --expect-tools, --timeout, -o text|json, --no-health. Exit codes: 0 PASS, 1 TRANSPORT/AUTH FAIL, 2 CONTRACT FAIL (e.g. missing tool or isError=true). |
✅ |
| 3 | Unit tests for the verb — src/cli/tests/commands/test-mcp.test.ts. 9 cases: happy path, health preflight failure, --expect-tools miss / hit, transport throw, --tool + isError → exit 2, -o json report, $MCPCTL_TOKEN env fallback, invalid --args. All green. |
✅ |
| 4 | src/mcplocal/src/serve.ts — new HTTP-only entry. Drops StdioProxyServer and --upstream; forces host/port from MCPLOCAL_HTTP_HOST/MCPLOCAL_HTTP_PORT; requires MCPLOCAL_MCPD_URL. Registers a Fastify preHandler that runs the new token-auth middleware on /projects/* and /mcp. Preserves LLM provider loading + proxymodel hot-reload watchers. |
✅ |
| 5 | src/mcplocal/src/http/token-auth.ts — Fastify preHandler that validates mcpctl_pat_… bearers by calling GET <mcpd>/api/v1/mcptokens/introspect. Cache: 30s positive / 5s negative TTL keyed on hashToken(raw). Rejects non-Bearer, non-mcpctl_pat_, revoked, expired, and wrong-project (403 when path projectName ≠ token's bound project). Sets request.mcpToken = { tokenName, tokenSha, projectName } for the audit collector. |
✅ |
| 6 | FileCache PVC plumbing — src/mcplocal/src/http/project-mcp-endpoint.ts now honours process.env.MCPLOCAL_CACHE_DIR at both FileCache construction sites (gated + dynamic). No constructor change needed — FileCache already accepted a dir config; we just wire the env-derived value through. |
✅ |
| 7 | Audit collector integration — when request.mcpToken is set, the onsessioninitialized handler in project-mcp-endpoint.ts now also calls collector.setSessionMcpToken(id, {tokenName, tokenSha}) alongside the existing setSessionUserName. Session map from PR 3 merges both principals. |
✅ |
| 8 | Container image — deploy/Dockerfile.mcplocal mirrors Dockerfile.mcpd shape: multi-stage Node 20 Alpine, pnpm workspace build of @mcpctl/shared + @mcpctl/mcplocal, runtime CMD node src/mcplocal/dist/serve.js, EXPOSE 3200, VOLUME /var/lib/mcplocal/cache, HEALTHCHECK on /healthz. |
✅ |
| 9 | Build + push script — scripts/build-mcplocal.sh (executable, 755) mirrors build-mcpd.sh. Pushes to 10.0.0.194:3012/michal/mcplocal:latest. |
✅ |
| 10 | fulldeploy.sh — now a 4-step pipeline: (1) build + push mcpd, (2) build + push mcplocal, (3) rollout both deployments on k8s (mcplocal gated behind a kubectl get deployment/mcplocal check so the script stays green before the Pulumi stack lands), (4) RPM release. Smoke suite runs at the end as before. |
✅ |
| 11 | mcpctl test mcp + new create flags in completions — bash + fish regenerated. src/mcplocal/package.json gains a serve script for convenience. |
✅ |
| 12 | Smoke test — src/mcplocal/tests/smoke/mcptoken.smoke.test.ts. Gated on healthz($MCPGW_URL); skipped with a clear warning if the gateway is unreachable. Scenarios: happy path via mcpctl test mcp → exit 0; cross-project → exit 1 with a 403 message; --expect-tools __nonexistent__ → exit 2; delete-then-retry after the 5s negative-cache window → exit 1 with 401. Cleans up both projects at the end. |
✅ |
Deploy-time steps still owed (outside this repo)
- Pulumi (
../kubernetes-deployment, stackhomelab) — add aDeploymentnamedmcplocalin nsmcpctlpointing at the new image, aServicenamedmcp(port 3200→80), anIngressformcp.ad.itaz.euwith TLS via the existing cluster-issuer, a PVCmcplocal-cache(10Gi RWO), a Secretmcplocal-envwithMCPLOCAL_MCPD_URL+MCPLOCAL_MCPD_TOKEN, and a NetworkPolicy mirroring mcpd's.fulldeploy.shalready runspulumi previewfirst and halts on drift. - mcplocal's own identity — recommend minting a dedicated
ServiceAccount:mcplocal-httpsubject in mcpd with a non-expiring session token and putting it inMCPLOCAL_MCPD_TOKEN. The current session-minting path expires after 30d.
Test stats
- 1773/1773 workspace tests pass (up from 1764 before PR 4).
- All five packages build clean.
- Shell completions fresh.
mcpctl test mcp --helpandmcpctl create mcptoken --helprender expected surfaces.
End-to-end verification (manual, after Pulumi resources land)
# From a workstation outside the k8s cluster:
mcpctl create project vllm --force
TOK=$(mcpctl create mcptoken vllm-token --project vllm --rbac clone | grep mcpctl_pat_)
export MCPCTL_TOKEN="$TOK"
# Probe the public gateway
mcpctl test mcp https://mcp.ad.itaz.eu/projects/vllm/mcp --expect-tools begin_session
# Negative: wrong project → exit 1
mcpctl test mcp https://mcp.ad.itaz.eu/projects/other/mcp
echo $? # 1
# Audit — the call should be tagged with tokenName=vllm-token
mcpctl console --audit # look for the TOKEN column once the TUI patch lands
Design decisions recap (so you don't have to re-read the plan)
| Decision | Choice |
|---|---|
| Transport | Streamable HTTP only |
| Binary shape | Same @mcpctl/mcplocal package, two entry files (main.ts STDIO, serve.ts HTTP) |
| Container runtime | Node (not bun-compiled) — mirrors mcpd |
| Cache | PVC at /var/lib/mcplocal/cache |
| Hostname | k8s Service mcp, Ingress mcp.ad.itaz.eu |
| Token format | mcpctl_pat_<32-byte base62>, stored as SHA-256, shown-once at create |
| Resource | McpToken, CLI noun mcptoken, one-project-per-token, FK cascade |
| Subject kind | New McpToken:<sha> |
| TTL | No default. Optional --ttl 30d / never / ISO date |
| Default bindings | --rbac=empty (default), --rbac=clone, --bind <kv> — creator ceiling enforced server-side |
| Binding CLI | --roleBindings role:view,resource:servers[,name:foo] or --roleBindings action:logs |
| Project enforcement | Endpoint visibility only (no strict create-time check) — same mechanism Claude uses |