Skip to content

feat(mcp)!: authenticate MCP clients with standard MCP OAuth - #167

Merged
jbouder merged 1 commit into
mainfrom
feat/mcp-oauth
Oct 21, 2026
Merged

jbouder merged 1 commit into
mainfrom
feat/mcp-oauth

Conversation

@jbouder

@jbouder jbouder commented Oct 21, 2026

Copy link
Copy Markdown
Contributor

Summary

Claude Code now speaks MCP protocol 2026-07-28. Those requests are sessionless: the SDK hands each one to handle_modern_request, and ctx.session_id is a fresh id every time. The authenticate tool kept the device flow and the token cache under that id, so every call started a new device code and never polled. This PR moves apps-mcp to standard MCP authorization, which doesn't depend on MCP sessions at all.

  • apps-mcp is an OAuth 2.0 protected resource, and the Keycloak realm is its authorization server. It uses FastMCP's RemoteAuthProvider:
    • It serves /.well-known/oauth-protected-resource/mcp (RFC 9728). The document has resource: <public url>/mcp, authorization_servers: [OIDC_ISSUER] and scopes_supported: ["openid"].
    • An unauthenticated /mcp request gets 401 with WWW-Authenticate: Bearer resource_metadata="…". A request whose token fails verification also gets error="invalid_token".
  • Tokens are checked by a KeycloakTokenVerifier that wraps the existing JWTValidator. That keeps the split-horizon JWKS (OIDC_ISSUER_INTERNAL/OIDC_JWKS_URL), OIDC_AUDIENCE and the OIDC_ALLOWED_CLIENTS azp/aud binding.
  • The in-process middleware still verifies the request's own bearer token before each tool call or resource read, so the check also holds on transports without HTTP. apps-api still verifies every call again with the user's token.
  • AUTH_ENABLED=false works as before. There's no provider, no metadata route, and the server is anonymous.
  • Removed: the authenticate tool, DeviceFlowAuth/TokenState/MissingSessionError, MCP_SESSION_TTL_SECONDS, and OIDC_DEVICE_CLIENT_ID in apps-mcp's settings.

How Claude Code gets a client id

I checked three options against the local cluster (Keycloak 26.5.0, realm nebari) and nebari-operator v0.1.0-alpha.20 and v0.2.0:

Option Result
Dynamic client registration The realm's anonymous "Trusted Hosts" policy has no trusted hosts, so Keycloak refuses every anonymous registration. FastMCP's own Keycloak provider says MCP-compatible DCR needs Keycloak 26.6 or later. Registered clients would also get random ids, which conflicts with the azp binding that apps-api and apps-mcp enforce.
Client ID metadata documents Keycloak's discovery document doesn't advertise them.
Pre-configured public client Chosen. Claude Code supports it with --client-id / --callback-port, or oauth.clientId / oauth.callbackPort in .mcp.json. It's a public client using PKCE with the redirect URI http://localhost:<port>/callback.

The nebari-operator can't provision that client. A NebariApp has a confidential client, an SPA client whose redirect URIs are https://<hostname>/*, and a device-flow client with the standard flow turned off. None of them accepts a loopback redirect, in the pinned version or in the latest (v0.2.0). I confirmed this live: the SPA client rejects redirect_uri=http://localhost:33418/callback with 400.

So this PR doesn't change the NebariApp. The admin creates one public client by hand (the docs include a kcadm.sh command), and the chart has two new values:

  • mcp.oauth.clientId (default ""). The chart appends it to OIDC_ALLOWED_CLIENTS for apps-api and apps-mcp. apps-api reports it through GET /api/v1/config as mcpOAuth, and the UI's Settings page then shows claude mcp add --transport http --client-id <id> --callback-port <port> nebari-apps <url>.
  • mcp.oauth.callbackPort (default 33418).

The chart also sets MCP_PUBLIC_URL (the UI host's origin) on apps-mcp. The UI's nginx now proxies /.well-known/oauth-protected-resource* to apps-mcp.

Follow-up needed outside this repo:

  • nebari-operator: it needs a way to provision a native/CLI public client with loopback redirect URIs, for example auth.nativeClient or redirect URIs on deviceFlowClient. Once that exists, the chart can provision the client and the manual step goes away.
  • nebari-local and any cluster with auth on: create the client and set mcp.oauth.clientId. Until then, Claude Code tries DCR and Keycloak refuses it.

The device-flow client is still provisioned and still accepted, for CLIs and scripts that send a bearer token themselves.

Breaking changes

  • The authenticate MCP tool is gone. Agents can no longer log in through a tool. The MCP client signs in itself (in Claude Code: /mcp → Authenticate, or claude mcp login nebari-apps).
  • MCP_SESSION_TTL_SECONDS is no longer read.
  • On clusters with auth on, /mcp now returns HTTP 401 with no token. Before, the request connected and each tool call failed. MCP clients need mcp.oauth.clientId configured to sign in on their own.

Docs

  • mcp.md has new Connecting and Authentication sections: the claude mcp add command, the .mcp.json shape, the sign-in client with its kcadm command, and enforcement.
  • Also updated: configuration.md (values, env, the NebariApp table), architecture.md, getting-started.md, troubleshooting.md, local-development.md, skill.md, index.md, api-reference.md, the skill's SKILL.md, AGENTS.md, and the chart README (regenerated with helm-docs).
  • mcp.replicaCount stays at 1 because handshake-era MCP sessions live in memory. Its docs now say that authentication no longer depends on those sessions.

Testing

Automated:

  • New mcp/tests/test_oauth.py runs the real Streamable HTTP app in process. It covers:

    • the metadata document (resource, authorization_servers, scopes_supported)
    • 401 with resource_metadata and no error for a missing token
    • 401 with error="invalid_token" for a malformed, expired, wrong-issuer or wrong-client token (azp binding)
    • a token bound by aud being accepted
    • two consecutive sessionless MCP-Protocol-Version: 2026-07-28 tools/call requests: both succeed and neither returns mcp-session-id
    • a handshake-era (2025-06-18) session with a bearer token being accepted, and initialize without a token getting 401
    • /healthz staying open
    • AUTH_ENABLED=false (no provider, no metadata, anonymous calls work)
    • an unconfigured JWKS rejecting every token
    • the mapping from claims to the access token

    The tokens are real RS256 tokens checked by the production JWTValidator.

  • I rewrote the existing token-verification, safety, resources and tools tests for the version without sessions.

  • mcp: pytest (71 passed, 90% coverage; I raised fail_under from 81 to 88), ruff, ruff format and mypy all pass. uv lock --check passes; anyio is now a direct dependency.

  • api: pytest, ruff and mypy pass. A new test covers mcpOAuth in /config.

  • ui: npm run check:ci, build, size, test:coverage and e2e/settings.spec.ts pass. A new unit test covers claudeCommand.

  • chart: helm lint, helm template with examples/nebari-values.yaml and mcp.oauth.clientId set (OIDC_ALLOWED_CLIENTS, MCP_PUBLIC_URL and MCP_OAUTH_* render as expected), the schema rejects a bad client id, helm-docs regenerated.

  • docs: bun test test and bun run build pass. check_doc_paths and check_versions pass.

  • nginx -t passes on the rendered ui/nginx.conf.

Live, against the local cluster (kind-nebari-local). Read-only; I changed no cluster or Keycloak config:

  • I ran this branch's apps-mcp locally with OIDC_ISSUER=https://keycloak.nebari.local/realms/nebari and MCP_PUBLIC_URL=https://apps.nebari.local:
    • The metadata document was served correctly.
    • A sessionless 2026-07-28 request with no token got 401 with resource_metadata.
    • A forged token got 401 invalid_token after a real JWKS fetch from Keycloak over TLS with the private root CA in SSL_CERT_FILE.
  • Keycloak's RFC 8414 path-insertion discovery URL (/.well-known/oauth-authorization-server/realms/nebari) returns 200.
  • Keycloak's authorize endpoint accepts scope=openid offline_access with PKCE and an RFC 8707 resource parameter. It rejects a loopback redirect on the SPA client.

Not verified live:

  • The full Claude Code browser login, or a call with a real user token. That needs the new Keycloak client and a deploy, and the cluster is Argo-managed from main.
  • The deployed chart and nginx route on the cluster.

🤖 Generated with Claude Code

apps-mcp is now an OAuth 2.0 protected resource with the Keycloak realm as
its authorization server. It serves protected resource metadata (RFC 9728),
answers unauthenticated /mcp requests with 401 and a resource_metadata
challenge, and verifies the bearer token on every request against the realm
JWKS with the existing azp/aud binding. Nothing is keyed on MCP sessions, so
sessionless (2026-07-28) clients such as Claude Code work.

BREAKING CHANGE: the authenticate tool and the per-session device-flow token
cache are removed, along with MCP_SESSION_TTL_SECONDS. MCP clients sign in
themselves; on clusters with auth on they need a public Keycloak client
(mcp.oauth.clientId) with a loopback redirect URI, created by an admin.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown

📄 Docs preview for feat/mcp-oauth:
https://feat-mcp-oauth.apps-pack.pages.dev

@jbouder
jbouder merged commit 848a5de into main Oct 21, 2026
15 checks passed
@jbouder
jbouder deleted the feat/mcp-oauth branch October 21, 2026 20:40
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