Repository navigation
feat(mcp)!: authenticate MCP clients with standard MCP OAuth - #167
Merged
Merged
Conversation
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>
|
📄 Docs preview for |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Claude Code now speaks MCP protocol
2026-07-28. Those requests are sessionless: the SDK hands each one tohandle_modern_request, andctx.session_idis a fresh id every time. Theauthenticatetool 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.RemoteAuthProvider:/.well-known/oauth-protected-resource/mcp(RFC 9728). The document hasresource: <public url>/mcp,authorization_servers: [OIDC_ISSUER]andscopes_supported: ["openid"]./mcprequest gets401withWWW-Authenticate: Bearer resource_metadata="…". A request whose token fails verification also getserror="invalid_token".KeycloakTokenVerifierthat wraps the existingJWTValidator. That keeps the split-horizon JWKS (OIDC_ISSUER_INTERNAL/OIDC_JWKS_URL),OIDC_AUDIENCEand theOIDC_ALLOWED_CLIENTSazp/aud binding.AUTH_ENABLED=falseworks as before. There's no provider, no metadata route, and the server is anonymous.authenticatetool,DeviceFlowAuth/TokenState/MissingSessionError,MCP_SESSION_TTL_SECONDS, andOIDC_DEVICE_CLIENT_IDin 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-operatorv0.1.0-alpha.20andv0.2.0:--client-id/--callback-port, oroauth.clientId/oauth.callbackPortin.mcp.json. It's a public client using PKCE with the redirect URIhttp://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 rejectsredirect_uri=http://localhost:33418/callbackwith400.So this PR doesn't change the NebariApp. The admin creates one public client by hand (the docs include a
kcadm.shcommand), and the chart has two new values:mcp.oauth.clientId(default""). The chart appends it toOIDC_ALLOWED_CLIENTSfor apps-api and apps-mcp. apps-api reports it throughGET /api/v1/configasmcpOAuth, and the UI's Settings page then showsclaude mcp add --transport http --client-id <id> --callback-port <port> nebari-apps <url>.mcp.oauth.callbackPort(default33418).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:
auth.nativeClientor redirect URIs ondeviceFlowClient. Once that exists, the chart can provision the client and the manual step goes away.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
authenticateMCP tool is gone. Agents can no longer log in through a tool. The MCP client signs in itself (in Claude Code:/mcp→ Authenticate, orclaude mcp login nebari-apps).MCP_SESSION_TTL_SECONDSis no longer read./mcpnow returns HTTP401with no token. Before, the request connected and each tool call failed. MCP clients needmcp.oauth.clientIdconfigured to sign in on their own.Docs
mcp.mdhas new Connecting and Authentication sections: theclaude mcp addcommand, the.mcp.jsonshape, the sign-in client with itskcadmcommand, and enforcement.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'sSKILL.md,AGENTS.md, and the chart README (regenerated with helm-docs).mcp.replicaCountstays 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.pyruns the real Streamable HTTP app in process. It covers:resource,authorization_servers,scopes_supported)401withresource_metadataand noerrorfor a missing token401witherror="invalid_token"for a malformed, expired, wrong-issuer or wrong-client token (azp binding)audbeing acceptedMCP-Protocol-Version: 2026-07-28tools/callrequests: both succeed and neither returnsmcp-session-id2025-06-18) session with a bearer token being accepted, andinitializewithout a token getting401/healthzstaying openAUTH_ENABLED=false(no provider, no metadata, anonymous calls work)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_underfrom 81 to 88), ruff, ruff format and mypy all pass.uv lock --checkpasses;anyiois now a direct dependency.api: pytest, ruff and mypy pass. A new test covers
mcpOAuthin/config.ui:
npm run check:ci, build, size,test:coverageande2e/settings.spec.tspass. A new unit test coversclaudeCommand.chart:
helm lint,helm templatewithexamples/nebari-values.yamlandmcp.oauth.clientIdset (OIDC_ALLOWED_CLIENTS,MCP_PUBLIC_URLandMCP_OAUTH_*render as expected), the schema rejects a bad client id, helm-docs regenerated.docs:
bun test testandbun run buildpass.check_doc_pathsandcheck_versionspass.nginx -tpasses on the renderedui/nginx.conf.Live, against the local cluster (
kind-nebari-local). Read-only; I changed no cluster or Keycloak config:OIDC_ISSUER=https://keycloak.nebari.local/realms/nebariandMCP_PUBLIC_URL=https://apps.nebari.local:2026-07-28request with no token got401withresource_metadata.401 invalid_tokenafter a real JWKS fetch from Keycloak over TLS with the private root CA inSSL_CERT_FILE./.well-known/oauth-authorization-server/realms/nebari) returns 200.scope=openid offline_accesswith PKCE and an RFC 8707resourceparameter. It rejects a loopback redirect on the SPA client.Not verified live:
main.🤖 Generated with Claude Code