Skip to content

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

Merged
vigneshrajsb merged 15 commits into
mainfrom
mcp-server-keycloak-auth
Jul 10, 2026
Merged

feat(mcp): remote MCP server with OAuth 2.1 authentication#224
vigneshrajsb merged 15 commits into
mainfrom
mcp-server-keycloak-auth

Conversation

@vigneshrajsb

@vigneshrajsb vigneshrajsb commented Jul 5, 2026

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 when anonymous dynamic client registration is enabled (off by default; a pre-registered client path is documented)
  • 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
  • Log responses are capped at 200 KB for both live and archived logs
  • Stateless transport (no server-side sessions), so it works unchanged across multiple web replicas
  • Keycloak realm configuration (client scope, audience/identity mappers, optional DCR policies) is handled by the lifecycle-keycloak chart's idempotent setup Job — see feat(lifecycle-keycloak): MCP server realm setup helm-charts#61
  • Docs: docs/mcp-server.md

Validation

  • Unit tests for token verification (issuer/audience/expiry), the HTTP handler (metadata, auth challenges, origin checks, tool calls), and log truncation
  • 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
The chart's post-install/post-upgrade Job (helm-charts#61) is idempotent
and already covers both fresh installs and existing realms, making the
one-off admin-API script redundant now that prod has been bootstrapped.
Archived logs were only limited by line count, so a single large line could
return an arbitrarily large MCP response. Apply the same 200 KB cap as live
pod logs, UTF-8-safely, and flag truncation in the result.
…e trust boundary

Review follow-ups: paste-the-URL onboarding is now described as conditional on
anonymous DCR (which defaults off), a pre-registered-client procedure is
documented for the secure default, extraAudiences is documented as a single
token trust boundary, and the admin role is called admin instead of operator.
@vigneshrajsb vigneshrajsb merged commit b92eeb1 into main Jul 10, 2026
1 check passed
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.

2 participants