feat: McpToken — HTTP-mode mcplocal, CLI verbs, audit plumbing #50
Reference in New Issue
Block a user
Delete Branch "feat/mcptoken"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
Adds a new
McpTokenresource so non-Claude MCP clients (e.g. a vLLM-driven agent running off-host) can talk to mcpctl over the network with the same project-scoped UX Claude gets today.6 commits, 4 logical PRs + 2 follow-up fixes rolled into one branch:
2ddb493—McpTokenPrisma schema + mcpd CRUD routes +GET /introspect. NewMcpTokenRBAC subject kind. Creator-permission ceiling enforced server-side.efcfeea(breaking) —mcpctl create rbacflags migrated from--binding edit:servers/--operation logsto--roleBindings role:edit,resource:servers/--roleBindings action:logs. On-disk YAML shape unchanged.a151b2e—mcpctl create|get|describe|delete mcptoken; mcpd auth middleware dispatches on themcpctl_pat_prefix; audit collector tags events withtokenName/tokenSha.2127b41— HTTP-onlysrc/mcplocal/src/serve.tsentry, token-auth Fastify preHandler (introspect-cache 30s pos / 5s neg),deploy/Dockerfile.mcplocal,scripts/build-mcplocal.sh,fulldeploy.shextended to build + roll out both images. ReusableMcpHttpSessionin@mcpctl/shared. Newmcpctl test mcp <url>verb for any Streamable-HTTP MCP endpoint.f68e123— https support instatus.ts+api-client.ts(was silently broken against the https mcpd ingress);.dockerignorefix so mcplocal builds;scripts/demo-mcp-call.pystdlib-only demo script for non-Claude clients.913678e+3061a5f— smoke test fixes (runtimegatewayUpgate, HTTP-mode-only revocation assertion) and token-auth unit coverage + env-tunable introspection TTLs (MCPLOCAL_TOKEN_POSITIVE_TTL_MS/NEGATIVE_TTL_MS).Important design note for the Pulumi / k8s author
The pod has no persistent identity to mcpd — it needs no Kubernetes Secret. Every inbound request's
Authorization: Bearer mcpctl_pat_…is forwarded verbatim to mcpd for all downstream calls (introspect + project discovery). mcpd's auth middleware dispatches on themcpctl_pat_prefix, resolves theMcpTokenprincipal, and enforces RBAC as if the client were calling mcpd directly. This supersedes an earlier recommendation to mount anMCPLOCAL_MCPD_TOKENsession — that's no longer needed. Seesrc/mcplocal/src/serve.tsheader comment.Required pod env: just
MCPLOCAL_MCPD_URL. Optional:MCPLOCAL_HTTP_PORT(default 3200),MCPLOCAL_CACHE_DIR,MCPLOCAL_TOKEN_POSITIVE_TTL_MS(default 30_000),MCPLOCAL_TOKEN_NEGATIVE_TTL_MS(default 5_000).Image:
10.0.0.194:3012/michal/mcplocal:latest(internal registry — reuse whatever pullSecret the existingmcpddeployment uses).k8s shape: Deployment, Service
mcp(ClusterIP 3200→80), Ingressmcp.ad.itaz.eu, PVCmcplocal-cache(10Gi RWO mounted at/var/lib/mcplocal/cache), NetworkPolicy allowing ingress from the namespace + cluster ingress controller, egress to mcpd :3100 + any LLM providers used by gated projects.LLM config: only needed if any project served is
gated: true(uses LLM for gate decisions). Mount~/.mcpctl/config.jsonas a ConfigMap at/root/.mcpctl/config.json. For ungated projects (like thesreproject driving the LiteLLM experiment), nothing extra needed.Implementation log
docs/mcptoken-implementation.md— per-PR checklist with file:line citations and verification steps.What's deployed (verified live)
worker0-k8s0 / mcpctlhas the new routes. Confirmed:curl -H "Authorization: Bearer mcpctl_pat_…" https://mcpctl.ad.itaz.eu/api/v1/mcptokens/introspectreturns the token principal.mcpctl create mcptoken demo -p mcpctl-development --rbac clone→ token printed, appears inmcpctl get mcptokens -p mcpctl-development.mcpctl test mcp http://localhost:3200/projects/mcpctl-development/mcp --token mcpctl_pat_…→ PASS.node src/mcplocal/dist/serve.js(the container binary, run locally against prod mcpd): 6/6 smoke tests pass including revocation 401 within the 5s negative-cache window.mcpctl statusfixed to work against the https mcpd ingress.What's still owed (outside this repo)
../kubernetes-deploymentstackhomelab— see "Important design note" above for the exact shape.Test plan
mcpctl test mcp+scripts/demo-mcp-call.pyboth pass a token-authenticated MCP handshakemcptoken.smoke.test.ts— 6/6 against localserve.jspointed at prod mcpd (MCPGW_URL=http://127.0.0.1:3201 MCPGW_IS_HTTP_MODE=true pnpm --filter @mcpctl/mcplocal exec vitest run --config vitest.smoke.config.ts mcptoken)tests/http/token-auth.test.ts) and by the smoke suite abovecreate rbacbindings to --roleBindings kv syntax efcfeeab65BREAKING: `mcpctl create rbac` no longer accepts `--binding` or `--operation`. Use `--roleBindings` instead with key:value pairs: # resource binding --roleBindings role:view,resource:servers --roleBindings role:view,resource:servers,name:my-ha # operation binding (role:run is implied by action:) --roleBindings action:logs The on-disk YAML shape (`roleBindings: [{role, resource, name?}]` or `{role:'run', action}`) is unchanged, so Git backups and existing `apply -f` files continue to work. Only the command-line input format changes. The parser is extracted to src/cli/src/commands/rbac-bindings.ts so the upcoming `mcpctl create mcptoken --bind <kv>` verb can reuse it. Completions, tests, and the new parser unit test all pass (406/406). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>Adds the end-to-end CLI surface for McpTokens and the mcpd auth dispatch that recognizes them. mcpd auth middleware: - Dispatch on the `mcpctl_pat_` bearer prefix. McpToken bearers resolve through a new `findMcpToken(hash)` dep, populating `request.mcpToken` and `request.userId = ownerId`. Everything else follows the existing session path. - Returns 401 for revoked / expired / unknown tokens. - Global RBAC hook now threads `mcpTokenSha` into `canAccess` / `canRunOperation` / `getAllowedScope`, and enforces a hard project-scope check: a McpToken principal can only hit `/api/v1/projects/<its-project>/...`. CLI verbs: - `mcpctl create mcptoken <name> -p <proj> [--rbac empty|clone] [--bind role:view,resource:servers] [--ttl 30d|never|ISO] [--description ...] [--force]` — returns the raw token once. - `mcpctl get mcptokens [-p <proj>]` — table with NAME/PROJECT/PREFIX/CREATED/LAST USED/EXPIRES/STATUS. - `mcpctl get mcptoken <name> -p <proj>` and `mcpctl describe mcptoken <name> -p <proj>` — describe surfaces the auto-created RBAC bindings. - `mcpctl delete mcptoken <name> -p <proj>`. - `apply -f` support with `kind: mcptoken`. Tokens are immutable, so apply creates if missing and skips if the name is already active. Audit plumbing: - `AuditEvent` / collector now carry optional `tokenName` / `tokenSha`. `setSessionMcpToken` sits alongside `setSessionUserName`; both feed a per-session principal map used at emit time. - `AuditEventService` query accepts `tokenName` / `tokenSha` filters. - Console `AuditEvent` type carries the new fields so a follow-up can add a TOKEN column. Completions regenerated. 1764/1764 tests pass workspace-wide. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>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>Fixes the LiteLLM loop: LiteLLM's /mcp/ proxy doesn't propagate the mcp-session-id header, so every tool call from qwen3 landed on a fresh upstream session, which always started gated, so the only visible tool was begin_session — forever. The session-id gate works fine for Claude Code (stdio, long-lived), but breaks through session-stripping proxies. Identity that DOES survive: the McpToken (always in the Authorization header). So now the gate keys its ungate state on both: - sessionId → per-session (unchanged; Claude Code path) - tokenSha → per-token (NEW; service-token path) Flow for an McpToken caller: 1. first begin_session succeeds → session ungated + tokenSha cached 2. next request lands on a new mcp-session-id (proxy stripped it) 3. SessionGate.createSession sees tokenSha, finds active token entry, starts the new session ungated with the prior tags + retrievedPrompts 4. tools/list on the fresh session returns the full upstream set — no more begin_session loop Plumbing: - AuditCollector.getSessionMcpTokenSha(sessionId) exposes the already- tracked principal. - PluginSessionContext gets getMcpTokenSha() so plugins can read the token identity without knowing about the collector. - SessionGate gains (tokenSha?: string) on createSession/ungate, plus isTokenUngated and revokeToken. TTL defaults to 1hr; tunable via MCPLOCAL_TOKEN_UNGATE_TTL_MS env var. - Gate plugin passes ctx.getMcpTokenSha() at every ungate call site (begin_session, gated-intercept, intercept-fallback). Tests: 7 new cases in session-gate.test.ts covering cross-session persistence, token isolation, STDIO-path unchanged, TTL expiry, revokeToken, and the empty-string edge case. 21/21 pass; 690/690 in mcplocal overall. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>