Skip to content

feat(mcp): remote MCP server with OAuth 2.1 authentication#224

Open
vigneshrajsb wants to merge 10 commits into
mainfrom
mcp-server-keycloak-auth
Open

feat(mcp): remote MCP server with OAuth 2.1 authentication#224
vigneshrajsb wants to merge 10 commits into
mainfrom
mcp-server-keycloak-auth

Conversation

@vigneshrajsb

Copy link
Copy Markdown
Contributor

Summary

Adds a remote MCP (Model Context Protocol) server to Lifecycle, so AI coding tools (Claude Code, Cursor, VS Code, Codex, and any other MCP client) can securely inspect preview environments.

  • Endpoint: Streamable HTTP at /mcp on the web service, feature-flagged via MCP_SERVER_ENABLED and off by default
  • Auth: OAuth 2.1 resource server backed by the deployment's Keycloak realm — RFC 9728 protected-resource metadata, bearer-token validation with a dedicated MCP audience, and paste-the-URL onboarding via anonymous dynamic client registration
  • Tools (v1, all read-only): list_builds, get_build, list_services, get_job_logs, list_sites, get_site, plus a lifecycle://builds/{uuid} resource
  • Tools run under the authenticated user's identity; deploy environment variables are never included in output
  • Stateless transport (no server-side sessions), so it works unchanged across multiple web replicas
  • scripts/mcp-keycloak-setup.py: idempotent admin-API script to configure an existing realm (client scope, audience/identity mappers, optional DCR policies)
  • Docs: docs/mcp-server.md

Validation

  • Unit tests for token verification (issuer/audience/expiry) and the HTTP handler (metadata, auth challenges, origin checks, tool calls)
  • End-to-end verified with MCP Inspector and Claude Code: discovery → dynamic client registration → browser SSO → tool calls

…ce-server auth

- Mount /mcp + RFC 9728 protected-resource metadata in ws-server HTTP chain,
  gated by MCP_SERVER_ENABLED and LIFECYCLE_MODE (web/all only)
- Bearer auth via jose/JWKS with dedicated MCP audience (MCP_RESOURCE_URL),
  isolated from the REST API's lifecycle-core audience
- Per-session McpServer bound to the authenticated user identity with
  read-only tools: list_builds, get_build, list_services, get_job_logs,
  list_sites, get_site + lifecycle://builds/{uuid} resource
- Session manager with idle eviction, per-user session binding, origin
  validation (DNS-rebinding protection), body size caps
- Bump @modelcontextprotocol/sdk to 1.29.0; allow jose ESM transform in jest
…ction, detailed job-log errors

- get_job_logs: use loadFromDefault (works on host and in-cluster) instead of
  loadFromCluster-first helper that defers ca.crt read to request time
- skip '[init] ' pseudo-container names when picking the log container
- surface kubernetes error status/message in tool errors
- add scripts/mcp-keycloak-setup.py: idempotent admin-API configurator for the
  mcp client scope + audience mapper and anonymous-DCR policies
…cy, correct scopes policy name

Keycloak rejects a Trusted Hosts policy with both host and client-URI checks
disabled; removing the anonymous policy component is the supported way to lift
the restriction for VPN-only DCR. The client-scopes policy providerId is the
legacy 'allowed-client-templates'; rely on allow-default-scopes + the realm
optional 'mcp' scope instead of an explicit allowlist.
- support repeatable --mcp-resource-url in the keycloak setup script (one
  audience mapper per URL) for realms serving several MCP deployments
- enable MCP on the Tilt in-cluster web (helm/environments/local) and add the
  mcp block to the local lifecycle-keycloak values (in-cluster :5001 primary,
  host dev :3000 extra audience)
Found in prod e2e with Claude Code: clients derive their DCR registration
scope from the 401 challenge and their authorization scope from PRM
scopes_supported. Keycloak's anonymous registration policy rejects unknown
scope names (including 'openid'), and authorization fails for scopes not
assigned to the client — so advertising openid/profile/email caused
invalid_scope. Advertise exactly ['mcp', 'offline_access'] in both places,
and move the identity claims (preferred_username, email, github_username)
onto the mcp client scope via protocol mappers in the setup script.
Found in prod e2e: lifecycle-web runs multiple replicas behind a load
balancer without session affinity, so per-pod in-memory sessions 404 when
consecutive requests land on different pods. Serve each POST with a fresh
stateless server/transport pair (sessionIdGenerator: undefined); GET/DELETE
return 405. This also pre-aligns with the 2026-07-28 MCP revision, which
drops sessions from the core protocol.
Prune stale positional audience mappers, fail loudly on missing scope during
optional-scope registration, guard non-numeric max-clients config, explicit
urllib.error import, document one-way Trusted Hosts removal.
- load the MCP handler only when MCP_SERVER_ENABLED (jose is ESM-only;
  require(esm) needs Node >= 20.19 — disabled deployments on older Node 20
  minors keep booting), and warn at startup when auth is on but
  MCP_RESOURCE_URL is unset
- list_builds myEnvironmentsOnly and list_sites mineOnly now error when the
  identity claim is missing instead of silently dropping the filter
- anonymous DCR in the keycloak setup script is now opt-in
  (--enable-anonymous-dcr) with a loud warning; urlopen timeouts added
- oversized bodies get a 413 that actually reaches the client;
  case-insensitive Bearer scheme; CORS headers reflect only allowlisted
  origins and are present on real responses, not just preflight
- tests: env hygiene, drop removed jose KeyLike type, add secret-redaction
  regression test and an auth-enabled 401-challenge HTTP test
- list_builds myEnvironmentsOnly filters on githubUsername only (a
  preferredUsername fallback could match a different user's GitHub login)
- pin jwtVerify algorithms to RS256 and allow 30s clock tolerance
- friendly error when the keycloak setup script's admin auth fails
- docs: document that all authenticated users can read all builds/logs/sites
  (filters are convenience, not access control), and that anonymous DCR is
  off by default and one-way to enable
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant