From 1e8e043ced06f612d318e1fc8c8c7ca87db09cf1 Mon Sep 17 00:00:00 2001 From: Johnny Bouder Date: Wed, 21 Oct 2026 16:23:40 -0400 Subject: [PATCH] feat(mcp)!: authenticate MCP clients with standard MCP OAuth 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 --- AGENTS.md | 2 +- api/src/nebari_apps_api/config.py | 6 + api/src/nebari_apps_api/main.py | 5 + api/tests/test_apps.py | 10 + charts/nebari-apps/README.md | 2 + charts/nebari-apps/templates/_env.tpl | 11 +- charts/nebari-apps/templates/_helpers.tpl | 7 + charts/nebari-apps/templates/api.yaml | 7 + charts/nebari-apps/templates/mcp.yaml | 8 +- charts/nebari-apps/values.schema.json | 18 ++ charts/nebari-apps/values.yaml | 20 +- docs/src/content/docs/api-reference.md | 2 +- docs/src/content/docs/architecture.md | 18 +- docs/src/content/docs/configuration.md | 25 +- docs/src/content/docs/getting-started.md | 15 +- docs/src/content/docs/index.md | 2 +- docs/src/content/docs/local-development.md | 4 +- docs/src/content/docs/mcp.md | 110 ++++++--- docs/src/content/docs/skill.md | 2 +- docs/src/content/docs/troubleshooting.md | 18 +- examples/static/index.html | 2 +- mcp/pyproject.toml | 5 +- mcp/src/nebari_apps_mcp/auth.py | 236 ++++++------------- mcp/src/nebari_apps_mcp/config.py | 31 +-- mcp/src/nebari_apps_mcp/server.py | 94 +++----- mcp/tests/test_oauth.py | 261 +++++++++++++++++++++ mcp/tests/test_resources.py | 5 +- mcp/tests/test_safety.py | 37 +-- mcp/tests/test_token_verification.py | 63 ++--- mcp/tests/test_tools.py | 6 - mcp/uv.lock | 2 + skill/new-nebari-app/SKILL.md | 5 +- ui/nginx.conf | 10 + ui/src/lib/types.ts | 5 + ui/src/pages/settings.tsx | 17 +- ui/tests/pages/settings.test.tsx | 33 ++- 36 files changed, 689 insertions(+), 415 deletions(-) create mode 100644 mcp/tests/test_oauth.py diff --git a/AGENTS.md b/AGENTS.md index fb3e423..b399cad 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -20,7 +20,7 @@ Keycloak auth. See [`docs/src/content/docs/architecture.md`](docs/src/content/do | `operator/` | Go operator (controller-runtime) | `api/v1alpha1/app_types.go` (the CRD), `internal/controller/app_controller.go` (reconcile pipeline), `internal/controller/workload.go` (pod spec), `internal/controller/authz.go` (the authz sidecar for Apps with groups, that can idle, or that run an image), `internal/controller/idle.go` (scale to zero: activity polling, the Idle phase, the activator ReferenceGrant), `internal/controller/activator.go` (the page idle Apps serve while they wake), `internal/controller/sources.go` (source volumes and init containers), `internal/controller/imageref.go` (image references and the registry allowlist for `source.type: image`), `internal/controller/routing.go` (NebariApp) | | `authz/` | Go sidecar: verifies the forwarded access token and enforces `access.groups` (403 otherwise; Apps restrict access by group only); in passthrough mode (unrestricted Apps that can idle) it only proxies. Either way it reports activity for the idle check | `main.go` (env config), `internal/gate/verify.go` (token checks), `internal/gate/proxy.go` (proxy + probes), `internal/gate/activity.go` (`/activity`) | | `api/` | FastAPI REST API over App CRs | `src/nebari_apps_api/main.py` (routes), `auth.py` (JWT + owner/admin checks), `models.py` (request/response models), `cr.py` (model ↔ CR), `images.py` (image allowlist, mirrors `imageref.go`), `k8s.py` (store) | -| `mcp/` | FastMCP server, agent tools, resources and prompts at `/mcp` | `src/nebari_apps_mcp/server.py` (tools, `nebari-apps://` resources, prompts), `auth.py` (device flow, sessions), `untrusted.py` (fencing app output) | +| `mcp/` | FastMCP server, agent tools, resources and prompts at `/mcp` | `src/nebari_apps_mcp/server.py` (tools, `nebari-apps://` resources, prompts), `auth.py` (MCP OAuth: protected resource metadata, JWT verification), `untrusted.py` (fencing app output) | | `ui/` | React + Vite UI on the Nebari design registry | `src/pages/`, `src/components/` (`app-form/`: state, sections), `src/hooks/use-app-actions.ts`, `src/lib/api.ts`, `src/lib/types.ts` (over the generated `crd.gen.ts`) | | `charts/nebari-apps/` | Helm chart: CRD, operator, api, ui, mcp | `values.yaml`, `templates/`, `crds/apps.nebari.dev_apps.yaml` (generated) | | `skill/new-nebari-app/` | Claude Code skill that scaffolds an app + launch manifest | `SKILL.md`, `references/manifest.md` | diff --git a/api/src/nebari_apps_api/config.py b/api/src/nebari_apps_api/config.py index 3d94b70..80791eb 100644 --- a/api/src/nebari_apps_api/config.py +++ b/api/src/nebari_apps_api/config.py @@ -67,6 +67,12 @@ class Settings: ui_keycloak_url: str = field(default_factory=lambda: os.environ.get("UI_KEYCLOAK_URL", "")) ui_keycloak_realm: str = field(default_factory=lambda: os.environ.get("UI_KEYCLOAK_REALM", "nebari")) ui_keycloak_client_id: str = field(default_factory=lambda: os.environ.get("UI_KEYCLOAK_CLIENT_ID", "")) + # The public Keycloak client MCP clients sign in to apps-mcp with + # (mcp.oauth), so the UI can show a complete `claude mcp add` command. + mcp_oauth_client_id: str = field(default_factory=lambda: os.environ.get("MCP_OAUTH_CLIENT_ID", "")) + mcp_oauth_callback_port: int = field( + default_factory=lambda: int(os.environ.get("MCP_OAUTH_CALLBACK_PORT", "0") or 0) + ) # The image this API runs from (the chart passes its image tag, sha- # for released charts), reported next to the version by /api/v1/version. diff --git a/api/src/nebari_apps_api/main.py b/api/src/nebari_apps_api/main.py index 9533130..9472f2d 100644 --- a/api/src/nebari_apps_api/main.py +++ b/api/src/nebari_apps_api/main.py @@ -260,6 +260,11 @@ def ui_config() -> dict[str, Any]: }, "appsDomain": settings.apps_domain, "appsScheme": "http" if settings.tls_disabled else "https", + # Empty clientId: no MCP OAuth client is configured. + "mcpOAuth": { + "clientId": settings.mcp_oauth_client_id, + "callbackPort": settings.mcp_oauth_callback_port, + }, } @app.get(PREFIX + "/capabilities", response_model=Capabilities) diff --git a/api/tests/test_apps.py b/api/tests/test_apps.py index 49ef89b..f04aff7 100644 --- a/api/tests/test_apps.py +++ b/api/tests/test_apps.py @@ -260,6 +260,16 @@ def test_catalogs(client): assert caps["idleTimeoutMinutes"] == 0 config = client.get("/api/v1/config").json() assert config["authEnabled"] is False + assert config["mcpOAuth"] == {"clientId": "", "callbackPort": 0} + + +def test_config_mcp_oauth_client(client, monkeypatch): + from nebari_apps_api.config import settings + + monkeypatch.setattr(settings, "mcp_oauth_client_id", "nebari-apps-mcp") + monkeypatch.setattr(settings, "mcp_oauth_callback_port", 33418) + config = client.get("/api/v1/config").json() + assert config["mcpOAuth"] == {"clientId": "nebari-apps-mcp", "callbackPort": 33418} def test_version(client, monkeypatch): diff --git a/charts/nebari-apps/README.md b/charts/nebari-apps/README.md index 45719b7..147a6e9 100644 --- a/charts/nebari-apps/README.md +++ b/charts/nebari-apps/README.md @@ -62,6 +62,8 @@ wrong type fails the install. | mcp.image.pullPolicy | string | `"IfNotPresent"` | Image pull policy. | | mcp.image.repository | string | `"ghcr.io/nebari-dev/apps-pack/apps-mcp"` | apps-mcp image repository. | | mcp.image.tag | string | `""` | Image tag. Empty defaults to the chart's appVersion. | +| mcp.oauth.callbackPort | int | `33418` | Port of the MCP client's OAuth callback (`--callback-port` in Claude Code); must match the client's redirect URI. | +| mcp.oauth.clientId | string | `""` | Client id of a public Keycloak client (standard flow, PKCE S256) with the redirect URI `http://localhost:/callback`. Its tokens are accepted by apps-api and apps-mcp, and the UI's Settings page puts it in the `claude mcp add` command. Empty: MCP clients must bring their own way to a token. | | mcp.orgCABundle.configMapName | string | `""` | ConfigMap in the release namespace holding the bundle. Empty disables it and the image's default roots apply. | | mcp.orgCABundle.key | string | `"ca-bundle.crt"` | Key in the ConfigMap that holds the bundle. | | mcp.replicaCount | int | `1` | apps-mcp replicas. | diff --git a/charts/nebari-apps/templates/_env.tpl b/charts/nebari-apps/templates/_env.tpl index 021f068..82ab830 100644 --- a/charts/nebari-apps/templates/_env.tpl +++ b/charts/nebari-apps/templates/_env.tpl @@ -1,9 +1,10 @@ {{/* OIDC settings shared by apps-api and apps-mcp: both verify Keycloak tokens -against the same issuer and accept only this pack's two clients (the UI's -SPA client and the device-flow client), whose ids the UI's NebariApp writes -into its OIDC secret. If the secret is missing the $(...) references stay -unexpanded and every token is rejected (fail closed). +against the same issuer and accept only this pack's clients: the UI's SPA +client and the device-flow client, whose ids the UI's NebariApp writes into +its OIDC secret, plus the MCP OAuth client (mcp.oauth.clientId) when set. If +the secret is missing the $(...) references stay unexpanded and their tokens +are rejected (fail closed). Takes (dict "root" $ "spaVar" ""). */}} @@ -31,7 +32,7 @@ Takes (dict "root" $ "spaVar" ""). key: device-client-id optional: true - name: OIDC_ALLOWED_CLIENTS - value: "$({{ .spaVar }}),$(OIDC_DEVICE_CLIENT_ID)" + value: "$({{ .spaVar }}),$(OIDC_DEVICE_CLIENT_ID){{ with $root.Values.mcp.oauth.clientId }},{{ . }}{{ end }}" {{- end }} {{- end }} diff --git a/charts/nebari-apps/templates/_helpers.tpl b/charts/nebari-apps/templates/_helpers.tpl index 14a61ab..5dee417 100644 --- a/charts/nebari-apps/templates/_helpers.tpl +++ b/charts/nebari-apps/templates/_helpers.tpl @@ -57,6 +57,13 @@ Domain apps are exposed under (https://.). {{- .Values.appsDomain | default (printf "apps.%s" .Values.clusterDomain) }} {{- end }} +{{/* +Public origin of the UI host, which also serves /mcp (the UI's nginx proxies it). +*/}} +{{- define "nebari-apps.publicUrl" -}} +{{- printf "%s://%s" (ternary "https" "http" .Values.tls.enabled) (.Values.ui.hostname | default (include "nebari-apps.appsDomain" .)) -}} +{{- end }} + {{/* Operator image reference */}} diff --git a/charts/nebari-apps/templates/api.yaml b/charts/nebari-apps/templates/api.yaml index 1a771a5..88aafe6 100644 --- a/charts/nebari-apps/templates/api.yaml +++ b/charts/nebari-apps/templates/api.yaml @@ -161,6 +161,13 @@ spec: value: {{ .Values.keycloak.url }} - name: UI_KEYCLOAK_REALM value: {{ .Values.keycloak.realm }} + {{- if and .Values.mcp.enabled .Values.mcp.oauth.clientId }} + # Served to the UI for its `claude mcp add` command. + - name: MCP_OAUTH_CLIENT_ID + value: {{ .Values.mcp.oauth.clientId | quote }} + - name: MCP_OAUTH_CALLBACK_PORT + value: {{ .Values.mcp.oauth.callbackPort | quote }} + {{- end }} {{- end }} {{- /* Last, so an extraEnv entry overrides a chart variable of the same name. */}} {{- with .Values.api.extraEnv }} diff --git a/charts/nebari-apps/templates/mcp.yaml b/charts/nebari-apps/templates/mcp.yaml index 0b9f4b7..d7ebbd5 100644 --- a/charts/nebari-apps/templates/mcp.yaml +++ b/charts/nebari-apps/templates/mcp.yaml @@ -31,8 +31,14 @@ spec: value: http://{{ include "nebari-apps.fullname" . }}-api.{{ .Release.Namespace }}.svc.cluster.local:8080 # The SPA id: bearer passthrough may carry a UI token. {{- include "nebari-apps.oidcEnv" (dict "root" . "spaVar" "OIDC_SPA_CLIENT_ID") | nindent 12 }} + {{- if .Values.api.auth.enabled }} + # The OAuth protected resource is /mcp; its metadata + # names OIDC_ISSUER as the authorization server. + - name: MCP_PUBLIC_URL + value: {{ include "nebari-apps.publicUrl" . | quote }} + {{- end }} {{- if .Values.mcp.orgCABundle.configMapName }} - # httpx (the device flow's Keycloak calls) reads SSL_CERT_FILE. + # The JWKS fetch (from keycloak.url unless internalUrl is set) reads SSL_CERT_FILE. - name: SSL_CERT_FILE value: /etc/ssl/nebari/ca-bundle.crt {{- end }} diff --git a/charts/nebari-apps/values.schema.json b/charts/nebari-apps/values.schema.json index ab2c5de..a7d0c17 100644 --- a/charts/nebari-apps/values.schema.json +++ b/charts/nebari-apps/values.schema.json @@ -958,6 +958,24 @@ }, "description": "Extra container env vars (Kubernetes EnvVar)." }, + "oauth": { + "type": "object", + "properties": { + "clientId": { + "type": "string", + "pattern": "^[A-Za-z0-9._:-]*$", + "description": "Public Keycloak client MCP clients sign in with; empty disables." + }, + "callbackPort": { + "type": "integer", + "minimum": 1024, + "maximum": 65535, + "description": "MCP client OAuth callback port (redirect URI http://localhost:/callback)." + } + }, + "additionalProperties": false, + "description": "MCP OAuth client settings." + }, "orgCABundle": { "type": "object", "properties": { diff --git a/charts/nebari-apps/values.yaml b/charts/nebari-apps/values.yaml index 73bba13..2d84404 100644 --- a/charts/nebari-apps/values.yaml +++ b/charts/nebari-apps/values.yaml @@ -288,6 +288,22 @@ mcp: pullPolicy: IfNotPresent # -- apps-mcp replicas. replicaCount: 1 + # MCP OAuth (when `api.auth.enabled`): apps-mcp is an OAuth protected + # resource and the Keycloak realm its authorization server, so MCP clients + # such as Claude Code sign the user in through the browser themselves. They + # need a public Keycloak client to do it with. The nebari-operator cannot + # provision one yet (its NebariApp clients have no loopback redirect URI), + # so create it in Keycloak (see the MCP server docs) and name it here. + oauth: + # -- Client id of a public Keycloak client (standard flow, PKCE S256) + # with the redirect URI `http://localhost:/callback`. Its + # tokens are accepted by apps-api and apps-mcp, and the UI's Settings page + # puts it in the `claude mcp add` command. Empty: MCP clients must bring + # their own way to a token. + clientId: "" + # -- Port of the MCP client's OAuth callback (`--callback-port` in Claude + # Code); must match the client's redirect URI. + callbackPort: 33418 # -- Container resources. Requests equal limits, so the pod is Guaranteed. resources: requests: { cpu: 250m, memory: 256Mi } @@ -296,8 +312,8 @@ mcp: # entry with the same name wins). extraEnv: [] # A PEM CA bundle apps-mcp trusts for outbound HTTPS, for clusters whose - # Keycloak certificate (`keycloak.url`, which the login device flow calls) - # is signed by a private CA. The bundle replaces the image's trust store, + # Keycloak certificate (`keycloak.url`, whose JWKS apps-mcp fetches unless + # `keycloak.internalUrl` is set) is signed by a private CA. The bundle replaces the image's trust store, # so include the public roots too if anything else needs them. orgCABundle: # -- ConfigMap in the release namespace holding the bundle. Empty diff --git a/docs/src/content/docs/api-reference.md b/docs/src/content/docs/api-reference.md index 9770c6f..aaa9007 100644 --- a/docs/src/content/docs/api-reference.md +++ b/docs/src/content/docs/api-reference.md @@ -53,7 +53,7 @@ create. | `GET /healthz` | Liveness (public). Always `200` while the process serves. | | `GET /readyz` | Readiness (public): `200 {"status": "ok", "checks": {...}}` when the Kubernetes API answers a namespace list and, with auth on, the realm JWKS loads (each within 3s; a good JWKS fetch is reused for 60s). Otherwise `503`, with the failing check marked `"failed"` and the reason in the API's log. | | `GET /version` | Release and image build (public): `{"version": "0.2.0", "build": "sha-abc1234"}`. `build` is the chart's `api.image.tag` (`unknown` outside the chart). | -| `GET /config` | UI bootstrap config (public): `authEnabled`, Keycloak url/realm/SPA client id, `appsDomain`, `appsScheme`, and the `version`/`build` above. | +| `GET /config` | UI bootstrap config (public): `authEnabled`, Keycloak url/realm/SPA client id, `appsDomain`, `appsScheme`, `mcpOAuth` (`clientId` and `callbackPort` of the MCP sign-in client; empty `clientId` when none is configured), and the `version`/`build` above. | | `GET /capabilities` | `{appsDomain, sourceTypes, namespaces, idleTimeoutMinutes, imageRegistries}` — what this cluster supports and where the caller may launch. `sourceTypes` is `["git", "inline", "pvc"]`, plus `"image"` when `imageRegistries` (the normalized `apps.image.allowedRegistries`) is not empty; `idleTimeoutMinutes` is the cluster default for [scale to zero](/app-crd-reference/#scale-to-zero) (`0`: apps stay up unless they set one). | | `GET /auth/me` | The caller's username, email, groups, and `admin` (member of an admin group). | diff --git a/docs/src/content/docs/architecture.md b/docs/src/content/docs/architecture.md index 4eda151..61b6bc4 100644 --- a/docs/src/content/docs/architecture.md +++ b/docs/src/content/docs/architecture.md @@ -81,14 +81,15 @@ Two deliberately different models: The UI's `NebariApp` sets `enforceAtGateway: false` and provisions the pack's Keycloak clients: a confidential one, a **public SPA client** (`auth.spaClient`), and — when the MCP -server is enabled — the **device-flow client** (`auth.deviceFlowClient`) used by agent -logins. The UI boots +server is enabled — a **device-flow client** (`auth.deviceFlowClient`) for CLI tokens. The +UI boots [keycloak-js](https://www.keycloak.org/securing-apps/javascript-adapter) with runtime config served by the API (`GET /api/v1/config`), runs the PKCE login flow, and attaches the access token to every request. The API validates tokens against the realm **JWKS** (with an optional in-cluster JWKS URL for split-horizon clusters) and derives the caller's identity and groups from the claims. Tokens must have been issued to one of this pack's own clients -(the SPA or the device-flow client, checked against `azp`/`aud`), so a token another realm +(the SPA client, the device-flow client, or the MCP sign-in client `mcp.oauth.clientId`, +checked against `azp`/`aud`), so a token another realm client obtained is refused. It then authorizes every request: the caller must own the app (`spec.owner`, stamped from `preferred_username` on create), be one of its managers (`spec.access.managers`: a listed username, or a group in the token's `groups` claim), or @@ -97,10 +98,13 @@ Lists and analytics are filtered the same way. ### The MCP endpoint — verified twice -The MCP endpoint at `/mcp` is connectable without a token (the device-flow login has to -start somewhere), but middleware verifies every tool call's JWT against the realm JWKS -before it runs — anonymous or forged callers get told to `authenticate`. apps-api then -validates the same token again, so the MCP proxies only as the authenticated user. +The MCP endpoint at `/mcp` is an OAuth 2.0 protected resource with the Keycloak realm as +its authorization server ([MCP authorization](/mcp/#authentication)): a request without a +valid token gets `401` pointing at the protected resource metadata, from which MCP clients +such as Claude Code run the browser login themselves. Every request carries its own token, +verified against the realm JWKS before any tool or resource logic runs; nothing is kept per +MCP session. apps-api then validates the same token again, so the MCP proxies only as the +authenticated user. ### Launched apps — gateway-enforced only diff --git a/docs/src/content/docs/configuration.md b/docs/src/content/docs/configuration.md index 17ab1c9..51cdb35 100644 --- a/docs/src/content/docs/configuration.md +++ b/docs/src/content/docs/configuration.md @@ -123,9 +123,11 @@ namespace. Environment the API container receives: `APPS_DOMAIN`, `APPS_NAMESPACES`, `APPS_ADMIN_GROUPS`, `AUTH_ENABLED`, `TLS_DISABLED`, `IMAGE_REGISTRIES` (from `apps.image.allowedRegistries`), `OIDC_ISSUER`, `OIDC_ISSUER_INTERNAL`, `UI_KEYCLOAK_URL`, `UI_KEYCLOAK_REALM`, `UI_KEYCLOAK_CLIENT_ID` and `OIDC_DEVICE_CLIENT_ID` (from the Secret the UI's `NebariApp` -provisions), and `OIDC_ALLOWED_CLIENTS` (built from those two: a token is accepted only if its -`azp` or `aud` names one of them; if the Secret is missing every token is rejected). The MCP -server receives the same `OIDC_ALLOWED_CLIENTS`. Not rendered by the chart but honoured when set on the container: `OIDC_JWKS_URL`, `OIDC_AUDIENCE`, +provisions), and `OIDC_ALLOWED_CLIENTS` (built from those two plus `mcp.oauth.clientId` when +set: a token is accepted only if its `azp` or `aud` names one of them; if the Secret is missing +only the `mcp.oauth` client's tokens are accepted). The MCP server receives the same +`OIDC_ALLOWED_CLIENTS`. With `mcp.oauth.clientId` set, the API also gets `MCP_OAUTH_CLIENT_ID` +and `MCP_OAUTH_CALLBACK_PORT`, which `GET /api/v1/config` passes to the UI. Not rendered by the chart but honoured when set on the container: `OIDC_JWKS_URL`, `OIDC_AUDIENCE`, `LOG_LEVEL` (default `INFO`), `MAX_UPLOAD_BYTES` (default 921600, the cap on an upload and on its extracted contents) and `MAX_REQUEST_BYTES` (default 2097152, the cap on any request body, enforced while it streams in). @@ -153,14 +155,17 @@ in). |---|---|---| | `mcp.enabled` | `true` | Deploy the MCP server at `https:///mcp` (proxied by the UI's nginx; no separate hostname). When off, `/mcp` returns 404. | | `mcp.image.repository` / `tag` / `pullPolicy` | `ghcr.io/nebari-dev/apps-pack/apps-mcp` / `""` / `IfNotPresent` | MCP image. | -| `mcp.replicaCount` | `1` | **Keep at 1**: device-flow sessions are held in process memory. | +| `mcp.replicaCount` | `1` | **Keep at 1**: handshake-era MCP sessions are held in process memory (authentication is per request and does not depend on them). | +| `mcp.oauth.clientId` | `""` | Public Keycloak client MCP clients sign in with ([MCP authentication](/mcp/#the-sign-in-client)); create it in Keycloak first. The chart adds it to the allowed clients of apps-api and apps-mcp, and the UI's `claude mcp add` command passes it as `--client-id`. Empty: MCP clients cannot sign in on their own and need a token from elsewhere. | +| `mcp.oauth.callbackPort` | `33418` | The MCP client's OAuth callback port (`--callback-port`); the client's redirect URI must be `http://localhost:/callback`. | | `mcp.resources` | `250m/256Mi` (requests = limits) | Pod resources. | -| `mcp.orgCABundle.configMapName` / `key` | `""` / `ca-bundle.crt` | ConfigMap (release namespace) with a PEM CA bundle the MCP server trusts for outbound HTTPS. Set it when `keycloak.url` has a certificate from a private CA, or `authenticate` fails with `CERTIFICATE_VERIFY_FAILED`. The bundle replaces the image's trust store, so include the public roots too. | +| `mcp.orgCABundle.configMapName` / `key` | `""` / `ca-bundle.crt` | ConfigMap (release namespace) with a PEM CA bundle the MCP server trusts for outbound HTTPS. Set it when `keycloak.url` has a certificate from a private CA (and `keycloak.internalUrl` is not set), or every token is rejected because the JWKS fetch fails with `CERTIFICATE_VERIFY_FAILED`. The bundle replaces the image's trust store, so include the public roots too. | -Environment: `API_URL`, `AUTH_ENABLED`, `OIDC_ISSUER`, `OIDC_ISSUER_INTERNAL`, -`OIDC_DEVICE_CLIENT_ID` (from the UI's OIDC Secret); optional `OIDC_JWKS_URL`, -`OIDC_AUDIENCE`, `HOST`, `PORT`, `LOG_LEVEL`, `MCP_SESSION_TTL_SECONDS` (default 28800: -how long an idle device-flow login is kept), `SSL_CERT_FILE` (set by `mcp.orgCABundle`). +Environment: `API_URL`, `AUTH_ENABLED`, `OIDC_ISSUER` (the authorization server the +protected resource metadata names), `OIDC_ISSUER_INTERNAL`, `OIDC_ALLOWED_CLIENTS`, +`MCP_PUBLIC_URL` (the UI host's origin; the protected resource is `/mcp`); +optional `OIDC_JWKS_URL`, `OIDC_AUDIENCE`, `HOST`, `PORT`, `LOG_LEVEL`, `SSL_CERT_FILE` (set +by `mcp.orgCABundle`). ## Logs and probes @@ -251,7 +256,7 @@ The pack integrates with the platform through two kinds of `NebariApp` | `auth.redirectURI` | `/*` | fixed | | `auth.scopes` | `openid profile email groups` | fixed | | `auth.spaClient.enabled` | `true` | fixed — provisions the public PKCE client the UI uses; its id lands in the `-ui-oidc-client` Secret (`spa-client-id`) | -| `auth.deviceFlowClient.enabled` | `true` when `mcp.enabled` | provisions the RFC 8628 client the MCP server uses (`device-client-id` in the same Secret) | +| `auth.deviceFlowClient.enabled` | `true` when `mcp.enabled` | provisions an RFC 8628 client (`device-client-id` in the same Secret) whose tokens apps-api and apps-mcp accept, for CLIs and scripts that send a bearer token themselves | | `landingPage.enabled` | `true` | fixed | | `landingPage.displayName` / `description` / `category` / `icon` | `ui.landingPage.*` | chart (`icon` defaults to the bundled SVG as a data URI) | | `landingPage.healthCheck.*` | `ui.landingPage.healthCheck.*` | chart | diff --git a/docs/src/content/docs/getting-started.md b/docs/src/content/docs/getting-started.md index 9ca573f..28c3f8f 100644 --- a/docs/src/content/docs/getting-started.md +++ b/docs/src/content/docs/getting-started.md @@ -136,11 +136,14 @@ When `PHASE` reaches `Running`, open the URL. Private apps redirect to Keycloak; Point Claude Code (or any MCP client) at the cluster and launch with natural language: ```bash -claude mcp add --transport http nebari-apps https://apps.example.ai/mcp +claude mcp add --transport http --client-id nebari-apps-mcp --callback-port 33418 \ + nebari-apps https://apps.example.ai/mcp ``` -The first tool call will ask you to log in via the Keycloak device flow (the agent shows a -verification URL and code). See the [MCP server guide](/mcp/) for the tool list and the +The client id is the Keycloak client your admin created for MCP sign-in (`mcp.oauth.clientId`; +the UI's Settings page shows the exact command). Run `/mcp` in Claude Code, select +`nebari-apps` and choose **Authenticate** to sign in through the browser. See the +[MCP server guide](/mcp/) for the tool list and the [scaffolding skill](/skill/) for generating apps the agent can launch. ## Verify a deployment @@ -209,8 +212,10 @@ or pilot. See [Container images](/launching-apps/#container-images). - **One API version (`v1alpha1`) and no conversion webhook yet.** `helm upgrade` does not update CRDs; re-apply `charts/nebari-apps/crds/` on upgrades (see [Upgrading](/upgrading/)). -- **The MCP server must run as a single replica**: device-flow sessions are held in process - memory. +- **The MCP server must run as a single replica**: handshake-era MCP sessions are held in + process memory. +- **The MCP sign-in client is created by hand.** The nebari-operator cannot provision a + Keycloak client with a loopback redirect URI yet; see [The sign-in client](/mcp/#the-sign-in-client). - **Local dev disables authentication** (`api.auth.enabled=false` with `tls.enabled=false`; the chart refuses that with TLS on unless `api.auth.insecureAllowAnonymous=true`). Never carry that flag to a real cluster. diff --git a/docs/src/content/docs/index.md b/docs/src/content/docs/index.md index 5347138..3bca8bd 100644 --- a/docs/src/content/docs/index.md +++ b/docs/src/content/docs/index.md @@ -33,7 +33,7 @@ https://.apps. restarting apps; per-app detail pages with live logs, events, usage, restart, and a copyable manifest; and launch / edit forms (drag-and-drop upload, git, or PVC). - **apps-mcp** — an MCP server at `/mcp` so coding agents launch and manage apps with - natural language (Keycloak device-flow auth). + natural language (standard MCP OAuth against the cluster's Keycloak). - **`new-nebari-app` skill** — teaches Claude Code to scaffold apps in the expected layout with a `nebari-app.yaml` manifest, so "generate, then launch it" is one flow. diff --git a/docs/src/content/docs/local-development.md b/docs/src/content/docs/local-development.md index a8a1b87..f4f7ba1 100644 --- a/docs/src/content/docs/local-development.md +++ b/docs/src/content/docs/local-development.md @@ -101,8 +101,8 @@ whole stack: claude mcp add --transport http nebari-apps http://apps.nebari.test/mcp ``` -Because local dev runs with auth disabled, the `authenticate` tool reports `not_required` -and every tool works anonymously — ask the agent to "launch the site in ./my-site as a +Because local dev runs with auth disabled, `/mcp` needs no sign-in and every tool works +anonymously — ask the agent to "launch the site in ./my-site as a public app called my-site" and it will call `launch_app` directly. See the [MCP server guide](/mcp/) for the full tool list. diff --git a/docs/src/content/docs/mcp.md b/docs/src/content/docs/mcp.md index ed32d5d..9936c60 100644 --- a/docs/src/content/docs/mcp.md +++ b/docs/src/content/docs/mcp.md @@ -13,61 +13,109 @@ business logic of its own, so agent launches behave exactly like UI launches. ## Connecting The UI's **Settings** page (account menu → **Settings**, under **Connect an agent**) shows -your cluster's MCP URL and this command with copy buttons. +your cluster's MCP URL and the Claude Code command with copy buttons, including the sign-in +client below when one is configured. ```bash -# Claude Code -claude mcp add --transport http nebari-apps https://apps.example.ai/mcp +# Claude Code, on a cluster with auth on (the client id and port come from mcp.oauth) +claude mcp add --transport http --client-id nebari-apps-mcp --callback-port 33418 \ + nebari-apps https://apps.example.ai/mcp ``` -Or in any MCP client configuration: +Then run `/mcp` in Claude Code, select `nebari-apps` and choose **Authenticate** (or run +`claude mcp login nebari-apps` from a shell). A browser opens on the cluster's Keycloak; +after you sign in, Claude Code keeps the token, refreshes it, and sends it with every +request. On a cluster with auth off, leave out `--client-id` and `--callback-port`. + +The same in a project's `.mcp.json`: ```json { "mcpServers": { - "nebari-apps": { "type": "http", "url": "https://apps.example.ai/mcp" } + "nebari-apps": { + "type": "http", + "url": "https://apps.example.ai/mcp", + "oauth": { "clientId": "nebari-apps-mcp", "callbackPort": 33418 } + } } } ``` ## Authentication -Two ways to present an identity: +The MCP server uses standard [MCP authorization](https://modelcontextprotocol.io/specification/latest/basic/authorization): +it is an OAuth 2.0 protected resource, and the cluster's Keycloak realm is its +authorization server. + +1. A request to `/mcp` without a token gets `401` with + `WWW-Authenticate: Bearer resource_metadata="https://apps.example.ai/.well-known/oauth-protected-resource/mcp"`. +2. That document ([RFC 9728](https://www.rfc-editor.org/rfc/rfc9728)) names the realm + (`keycloak.url` + `/realms/`) as the authorization server. +3. The client signs the user in to Keycloak in the browser (authorization code with PKCE) + and sends `Authorization: Bearer ` on every request. + +Nothing is stored per MCP session: each request carries its own token. That is what lets +sessionless clients (MCP protocol `2026-07-28` and later, which Claude Code speaks) work +exactly like clients that keep a session. Clients that already hold a token from this realm, +such as scripts, can send the same header directly; the token must still belong to one of +the pack's clients (below). + +### The sign-in client + +The MCP client needs a Keycloak client to sign in with. The pack cannot provision it: the +nebari-operator's `NebariApp` creates a confidential client, an SPA client whose redirect +URIs are the UI's own host, and a device-flow client without the authorization code flow, +and none of them accepts the loopback redirect (`http://localhost:/callback`) a CLI +listens on. Keycloak's default policy also refuses anonymous dynamic client registration, +and it does not support client ID metadata documents yet, so a cluster admin creates one +public client once: + +```bash +# Inside the Keycloak pod (or anywhere kcadm.sh is logged in to the realm) +kcadm.sh create clients -r nebari \ + -s clientId=nebari-apps-mcp -s name="Nebari Apps MCP" \ + -s publicClient=true -s standardFlowEnabled=true \ + -s directAccessGrantsEnabled=false -s implicitFlowEnabled=false \ + -s 'redirectUris=["http://localhost:33418/callback"]' \ + -s 'attributes."pkce.code.challenge.method"=S256' +``` -1. **Device flow (interactive)** — call the `authenticate` tool. It returns a Keycloak - verification URL and user code; the user approves in a browser, the agent calls - `authenticate` again, and the token is cached (and refreshed) **per MCP session**. The - public device-flow client is provisioned automatically by the pack's `NebariApp` - (`auth.deviceFlowClient`). -2. **Bearer passthrough** — clients that already hold a token send a standard - `Authorization: Bearer ` header. +It needs the realm's default client scopes, which include `groups` on a Nebari realm +(group-based manager and admin checks read that claim). Then set +`mcp.oauth.clientId=nebari-apps-mcp` (and `mcp.oauth.callbackPort` if you chose another +port). The chart adds the client to the allowed clients of apps-api and apps-mcp, and the +UI puts it in the `claude mcp add` command. Every user connects with the same client id and +port; the port is only a local listener on the user's machine. + +### Enforcement Enforcement is layered: -- **At the MCP layer** — middleware verifies the token's signature, issuer, and expiry - against the realm JWKS before *any* tool or resource logic runs. Every tool except - `authenticate` (the login bootstrap), and every resource read, is rejected for anonymous or - forged callers with an error telling the agent to call `authenticate`. Listing resources - still answers, without any Apps. Prompts carry no cluster data and need no login. +- **At the HTTP layer** — every request to `/mcp` must carry a bearer token whose + signature, issuer and expiry verify against the realm JWKS, and that was issued to (`azp`) + or for (`aud`) one of the pack's clients: the UI's SPA client, the device-flow client, or + `mcp.oauth.clientId`. A token another realm client obtained is refused with `401` + (`error="invalid_token"`). `/healthz` and the metadata document need no token. +- **At the MCP layer** — the same check runs again before any tool call or resource read, + so it also holds on transports without HTTP. Listing resources still answers, without any + Apps, and prompts carry no cluster data. - **At the API layer** — apps-api independently validates the JWT on every request, so the MCP can never act with more privilege than the calling user: an agent sees and manages only the apps its user owns or [manages](/launching-apps/#managers) (or every app, for members of an admin group). Resources make the same API calls as the matching tools, so they are authorized the same way. -On clusters with auth disabled, `authenticate` reports `not_required` and every tool works -anonymously. +On clusters with auth disabled, `/mcp` needs no token, serves no metadata, and every tool +works anonymously. -The device flow calls Keycloak at its browser-facing URL (`keycloak.url`) from inside the -cluster. If that certificate comes from a private CA, `authenticate` fails with -`CERTIFICATE_VERIFY_FAILED` until the CA is trusted through -[`mcp.orgCABundle`](/configuration/#apps-mcp). +apps-mcp fetches the realm JWKS from `keycloak.internalUrl` when it is set, otherwise from +`keycloak.url`. If that certificate comes from a private CA, every token is rejected until +the CA is trusted through [`mcp.orgCABundle`](/configuration/#apps-mcp). ## Tools | Tool | Purpose | |---|---| -| `authenticate` | Start/complete the device-flow login, or confirm the cached token. | | `describe_cluster` | Source types, launchable namespaces, apps domain, the cluster's default idle timeout and, when `image` is a source type, the `imageRegistries` images may come from — call first to pick valid options. | | `launch_app` | Create + launch (static, Python/pixi via `pixi_task`, or a prebuilt container image with `source_type: image`, `image_reference` (tag or digest, from an allowed registry) and `image_port`, plus optional `image_command`, `image_args` and `image_run_as_user`; see [Container images](/launching-apps/#container-images)); optional `resources` (requests/limits, cluster defaults otherwise) and `groups`/`users` to restrict a private app (a member of any group or a listed user gets in; everyone else gets `403`); `idle_timeout_minutes` and `keep_alive` control [scale to zero](/app-crd-reference/#scale-to-zero); `manager_users`/`manager_groups` set who else may [manage](/launching-apps/#managers) it (omit to keep the current list); `secrets` (`{NAME: value}`) stores credentials in the app's own Secret instead of `env` (see [Secrets](#secrets)). **Idempotent on (namespace, name)** — re-launching an app you own or manage updates it instead of failing, so retries are safe; any other app is refused, never overwritten. Only the owner or an admin may change the managers. | | `list_apps` | Apps with phase, URL, owner; optional namespace filter. Inline sources list file `paths` only. | @@ -143,14 +191,14 @@ value. Values an agent passes still appear in its own transcript, which coding a log: the tool descriptions steer agents to ask the user to type credentials into the UI's **Secrets** section instead, and never to put them in `env`. -### Sessions - -Device-flow logins are kept in the MCP server's memory per MCP session, and dropped after -`MCP_SESSION_TTL_SECONDS` (default 8 hours) without use. A connection without a session -id cannot log in. Because sessions are in memory, `mcp.replicaCount` must stay 1. +### Errors Every tool returns structured JSON. Errors carry the API's human-readable `detail`, and a -`401` includes `nextAction: call the authenticate tool`, so agents can self-recover. +`401` includes a `nextAction` telling the agent to have the user sign in to the server again +from their MCP client (in Claude Code: `/mcp`, then **Authenticate**). + +Clients that use the handshake-era protocol (before `2026-07-28`) keep an MCP session in the +server's memory, so `mcp.replicaCount` stays 1. Authentication does not depend on it. ## Example agent flow diff --git a/docs/src/content/docs/skill.md b/docs/src/content/docs/skill.md index e9b46f2..1742a57 100644 --- a/docs/src/content/docs/skill.md +++ b/docs/src/content/docs/skill.md @@ -75,7 +75,7 @@ UI's **Secrets** section (preferred) or handed to the agent for `launch_app`'s ` When the user asks, the agent: 1. reads `nebari-app.yaml`, -2. calls `describe_cluster` (and `authenticate` if prompted), +2. calls `describe_cluster` (if it reports an authentication problem, the user signs in to the server from `/mcp`), 3. maps the manifest onto `launch_app` — idempotent, so iterating is safe, 4. polls `get_app_status` until `Running` (checking `get_app_logs` on failure), 5. reports the app URL. diff --git a/docs/src/content/docs/troubleshooting.md b/docs/src/content/docs/troubleshooting.md index 84bef26..dea7c89 100644 --- a/docs/src/content/docs/troubleshooting.md +++ b/docs/src/content/docs/troubleshooting.md @@ -149,8 +149,18 @@ kubectl -n nebari-operator-system logs deploy/nebari-operator-controller-manager ## The MCP server -- **Every tool says to call `authenticate`.** Call it and finish the device login in the - browser; the session expires after `MCP_SESSION_TTL_SECONDS` (8 hours by default). -- **`authenticate` fails with a TLS error.** The MCP pod could not verify Keycloak's - certificate (common with self-signed certificates on local clusters). +- **Claude Code shows the server as needing authentication.** Run `/mcp`, select it and + choose **Authenticate**, or `claude mcp login nebari-apps`. +- **The browser login fails with "Invalid parameter: redirect_uri".** The `--callback-port` + (or `oauth.callbackPort`) does not match the client's redirect URI in Keycloak, or the + server was added without `--client-id`. Re-add it with the command from the UI's Settings + page. +- **Claude Code says dynamic client registration failed.** No client id was configured, so + it tried to register one and Keycloak refused. Add the server with `--client-id`; see + [The sign-in client](/mcp/#the-sign-in-client). +- **Every request gets `401` with `error="invalid_token"` after signing in.** The token is + from a client the pack does not accept (set `mcp.oauth.clientId` to the client you sign in + with), or apps-mcp cannot fetch the realm JWKS: its log says `token rejected` with the + reason. A TLS error there means Keycloak's certificate comes from a private CA; trust it + with `mcp.orgCABundle` or set `keycloak.internalUrl`. - **`remove_app` does nothing.** It requires `confirm=true`. diff --git a/examples/static/index.html b/examples/static/index.html index 7f04b69..7dbdae7 100644 --- a/examples/static/index.html +++ b/examples/static/index.html @@ -366,7 +366,7 @@

The pieces

apps-mcp

Python · FastMCP

-

Agent tools at /mcp; Keycloak device-flow auth.

+

Agent tools at /mcp; MCP OAuth against Keycloak.

new-nebari-app
diff --git a/mcp/pyproject.toml b/mcp/pyproject.toml index 1623100..6f4cc2f 100644 --- a/mcp/pyproject.toml +++ b/mcp/pyproject.toml @@ -4,6 +4,7 @@ dynamic = ["version"] description = "MCP server for the Nebari Apps Pack - launch and manage web apps from coding agents" requires-python = ">=3.12" dependencies = [ + "anyio>=4.5", "fastmcp>=2.10", "httpx>=0.27", "PyJWT[crypto]>=2.15", @@ -43,12 +44,12 @@ testpaths = ["tests"] asyncio_mode = "auto" # CI runs pytest with --cov (measuring the source below); the floor sits a little under today's -# 78% so a change that drops coverage noticeably fails. Raise it as tests land. +# 90% so a change that drops coverage noticeably fails. Raise it as tests land. [tool.coverage.run] source = ["nebari_apps_mcp"] [tool.coverage.report] -fail_under = 81 +fail_under = 88 show_missing = true skip_covered = true diff --git a/mcp/src/nebari_apps_mcp/auth.py b/mcp/src/nebari_apps_mcp/auth.py index f72fb5b..d4833e8 100644 --- a/mcp/src/nebari_apps_mcp/auth.py +++ b/mcp/src/nebari_apps_mcp/auth.py @@ -1,183 +1,33 @@ -"""Keycloak device-flow authentication (RFC 8628) with per-session tokens. +"""MCP OAuth: apps-mcp is an OAuth 2.0 protected resource, Keycloak the authorization server. -The MCP server is shared, so tokens are cached per MCP session id - one -agent's login never leaks into another session. Clients that already have a -token can skip the device flow entirely by sending an Authorization header; -it is passed through to apps-api unchanged. +The MCP client (Claude Code, for one) discovers Keycloak from this server's +Protected Resource Metadata (RFC 9728), signs the user in through the browser, +and sends `Authorization: Bearer ` on every request. Nothing is +kept per MCP session: each request carries its own token, which is what makes +sessionless (2026-07-28 and later) requests work like handshake-era ones. + +The token is verified here against the realm JWKS (and bound to this pack's +clients) and then passed through to apps-api, which verifies it again. """ from __future__ import annotations -import time -from dataclasses import dataclass, field from typing import Any -import httpx +import anyio +import structlog +from fastmcp.server.auth import AccessToken, AuthProvider, RemoteAuthProvider, TokenVerifier +from pydantic import AnyHttpUrl from .config import settings +log = structlog.get_logger("nebari_apps_mcp.auth") -@dataclass -class TokenState: - access_token: str = "" - refresh_token: str = "" - expires_at: float = 0.0 - # An in-flight device authorization, if any. - device_code: str = "" - verification_uri: str = "" - user_code: str = "" - interval: float = 5.0 - device_expires_at: float = 0.0 - last_used: float = field(default_factory=time.time) - - @property - def valid(self) -> bool: - return bool(self.access_token) and time.time() < self.expires_at - 15 - - -@dataclass -class DeviceFlowAuth: - _sessions: dict[str, TokenState] = field(default_factory=dict) - - def state(self, session_id: str) -> TokenState: - """This session's login. Sessions idle longer than the TTL are dropped first. - - Without a session id there is nothing to key a login on, and a shared - fallback would hand one agent's token to another, so it is refused. - """ - if not session_id: - raise MissingSessionError("this MCP connection has no session id; reconnect with a stateful client") - now = time.time() - for stale in [sid for sid, s in self._sessions.items() if now - s.last_used > settings.session_ttl]: - del self._sessions[stale] - state = self._sessions.setdefault(session_id, TokenState()) - state.last_used = now - return state - - async def authenticate(self, session_id: str) -> dict[str, Any]: - """Start, poll, or confirm a device-flow login for this session.""" - if not settings.auth_enabled: - return { - "status": "not_required", - "message": "authentication is disabled on this cluster; tools work without logging in", - } - if not settings.oidc_issuer or not settings.oidc_device_client_id: - return { - "status": "unavailable", - "message": "the device-flow client is not configured; ask the operator to set " - "keycloak.url (the OIDC secret provides the client id once the NebariApp reconciles)", - } - - try: - state = self.state(session_id) - except MissingSessionError as exc: - return {"status": "unavailable", "message": str(exc)} - - if state.valid: - return {"status": "authenticated", "message": "already logged in; token is valid"} - - if state.refresh_token and await self._refresh(state): - return {"status": "authenticated", "message": "session refreshed; token is valid"} - - async with httpx.AsyncClient(timeout=15) as client: - # Poll an in-flight device authorization first. - if state.device_code and time.time() < state.device_expires_at: - result = await self._poll(client, state) - if result is not None: - return result - - # Start a new device authorization. - resp = await client.post( - settings.device_endpoint, - data={"client_id": settings.oidc_device_client_id, "scope": "openid profile email groups"}, - ) - resp.raise_for_status() - data = resp.json() - state.device_code = data["device_code"] - state.user_code = data["user_code"] - state.verification_uri = data.get("verification_uri_complete") or data["verification_uri"] - state.interval = float(data.get("interval", 5)) - state.device_expires_at = time.time() + float(data.get("expires_in", 600)) - - return { - "status": "action_required", - "verificationUrl": state.verification_uri, - "userCode": state.user_code, - "message": ( - f"Ask the user to open {state.verification_uri} and approve the login " - f"(code: {state.user_code}). Then call the authenticate tool again to complete." - ), - } - - async def _poll(self, client: httpx.AsyncClient, state: TokenState) -> dict[str, Any] | None: - """One token-endpoint poll. None means the flow is dead - restart it.""" - resp = await client.post( - settings.token_endpoint, - data={ - "grant_type": "urn:ietf:params:oauth:grant-type:device_code", - "client_id": settings.oidc_device_client_id, - "device_code": state.device_code, - }, - ) - if resp.status_code == 200: - self._store(state, resp.json()) - return {"status": "authenticated", "message": "login approved; token cached for this session"} - error = resp.json().get("error", "") - if error == "authorization_pending": - return { - "status": "pending", - "verificationUrl": state.verification_uri, - "userCode": state.user_code, - "message": "the user has not approved the login yet; ask them to finish, then retry", - } - if error == "slow_down": - state.interval += 5 - return {"status": "pending", "message": "polling too fast; wait a few seconds and retry"} - # expired_token / access_denied / anything else: restart the flow. - state.device_code = "" - return None - - async def _refresh(self, state: TokenState) -> bool: - try: - async with httpx.AsyncClient(timeout=15) as client: - resp = await client.post( - settings.token_endpoint, - data={ - "grant_type": "refresh_token", - "client_id": settings.oidc_device_client_id, - "refresh_token": state.refresh_token, - }, - ) - if resp.status_code != 200: - return False - self._store(state, resp.json()) - return True - except httpx.HTTPError: - return False - - def _store(self, state: TokenState, data: dict[str, Any]) -> None: - state.access_token = data["access_token"] - state.refresh_token = data.get("refresh_token", state.refresh_token) - state.expires_at = time.time() + float(data.get("expires_in", 300)) - state.device_code = "" - - async def bearer(self, session_id: str, passthrough: str = "") -> str: - """The Authorization value for apps-api calls, or '' when anonymous.""" - if passthrough: - return passthrough - if not settings.auth_enabled: - return "" - state = self.state(session_id) - if not state.valid and state.refresh_token: - await self._refresh(state) - return f"Bearer {state.access_token}" if state.valid else "" - - -auth = DeviceFlowAuth() - - -class MissingSessionError(Exception): - pass +# Scopes advertised in the protected resource metadata. Only `openid`: every +# Keycloak client has it, and a client's default scopes (profile, email, +# groups) are added to its tokens without being asked for. Advertising a scope +# the client lacks would make Keycloak refuse the login with invalid_scope. +SCOPES_SUPPORTED = ["openid"] class TokenVerificationError(Exception): @@ -249,3 +99,51 @@ def get_validator() -> JWTValidator: settings.oidc_allowed_clients, ) return _validator + + +class KeycloakTokenVerifier(TokenVerifier): + """FastMCP token verifier over JWTValidator (split-horizon JWKS, azp/aud binding).""" + + async def verify_token(self, token: str) -> AccessToken | None: + try: + # PyJWKClient fetches the JWKS with blocking I/O on a cache miss. + claims = await anyio.to_thread.run_sync(lambda: get_validator().validate(token)) + except TokenVerificationError as exc: + log.info("token rejected", error=str(exc)) + return None + exp = claims.get("exp") + return AccessToken( + token=token, + client_id=str(claims.get("azp") or ""), + scopes=str(claims.get("scope") or "").split(), + expires_at=int(exp) if isinstance(exp, int | float) else None, + subject=str(claims["sub"]) if claims.get("sub") else None, + claims=claims, + ) + + +def build_auth_provider() -> AuthProvider | None: + """The HTTP-layer auth for /mcp, or None when auth is disabled. + + With the issuer and the public URL known, this serves Protected Resource + Metadata naming the Keycloak realm, and unauthenticated requests get a 401 + whose WWW-Authenticate points at it, so MCP clients can start the OAuth + login on their own. Without them it still requires a verified bearer token, + but cannot tell clients where to get one. + """ + if not settings.auth_enabled: + return None + verifier = KeycloakTokenVerifier() + if not (settings.oidc_issuer and settings.public_url): + log.warning( + "OIDC_ISSUER or MCP_PUBLIC_URL is not set: /mcp requires a bearer token but serves no " + "OAuth protected resource metadata, so MCP clients cannot sign in on their own" + ) + return verifier + return RemoteAuthProvider( + token_verifier=verifier, + authorization_servers=[AnyHttpUrl(settings.oidc_issuer)], + base_url=settings.public_url, + scopes_supported=SCOPES_SUPPORTED, + resource_name="Nebari Apps", + ) diff --git a/mcp/src/nebari_apps_mcp/config.py b/mcp/src/nebari_apps_mcp/config.py index c7d032f..f3e997a 100644 --- a/mcp/src/nebari_apps_mcp/config.py +++ b/mcp/src/nebari_apps_mcp/config.py @@ -31,18 +31,20 @@ class Settings: # http://nebari-apps-api.nebari-apps.svc.cluster.local:8080 api_url: str = field(default_factory=lambda: os.environ.get("API_URL", "http://localhost:8000")) - # When false (local dev), tools call apps-api anonymously and the - # authenticate tool reports that auth is disabled. + # When false (local dev), /mcp needs no token and tools call apps-api + # anonymously. auth_enabled: bool = field(default_factory=lambda: _env_bool("AUTH_ENABLED", True)) - # Browser-facing Keycloak realm issuer for the device flow, e.g. - # https://keycloak.example.ai/auth/realms/nebari. The verification URL - # returned to users comes from here, so it must be browser-reachable. + # Browser-facing Keycloak realm issuer, e.g. + # https://keycloak.example.ai/auth/realms/nebari. Tokens must carry it as + # `iss`, and the protected resource metadata names it as the authorization + # server MCP clients sign in with, so it must be browser-reachable. oidc_issuer: str = field(default_factory=lambda: os.environ.get("OIDC_ISSUER", "")) - # Public device-flow client id, provisioned by this pack's NebariApp - # (auth.deviceFlowClient) and read from its OIDC secret. - oidc_device_client_id: str = field(default_factory=lambda: os.environ.get("OIDC_DEVICE_CLIENT_ID", "")) + # Public origin agents reach this server at (no path), e.g. + # https://apps.example.ai: the MCP endpoint is /mcp, which is + # the `resource` the protected resource metadata advertises. + public_url: str = field(default_factory=lambda: os.environ.get("MCP_PUBLIC_URL", "").rstrip("/")) # In-cluster issuer URL (split horizon): used only to reach JWKS when the # browser-facing issuer is not resolvable from inside the cluster. @@ -57,22 +59,9 @@ class Settings: # so a plain audience check cannot do this. Unset disables the check. oidc_allowed_clients: list[str] | None = field(default_factory=lambda: _client_list("OIDC_ALLOWED_CLIENTS")) - # Seconds a device-flow login (and its refresh token) is kept after the - # session last used it. Sessions live in this process's memory, which is - # why mcp.replicaCount must stay 1. - session_ttl: float = field(default_factory=lambda: float(os.environ.get("MCP_SESSION_TTL_SECONDS", "28800"))) - host: str = field(default_factory=lambda: os.environ.get("HOST", "0.0.0.0")) port: int = field(default_factory=lambda: int(os.environ.get("PORT", "8080"))) - @property - def device_endpoint(self) -> str: - return f"{self.oidc_issuer.rstrip('/')}/protocol/openid-connect/auth/device" - - @property - def token_endpoint(self) -> str: - return f"{self.oidc_issuer.rstrip('/')}/protocol/openid-connect/token" - @property def jwks_url(self) -> str: if self.oidc_jwks_url: diff --git a/mcp/src/nebari_apps_mcp/server.py b/mcp/src/nebari_apps_mcp/server.py index f6e686d..45454e3 100644 --- a/mcp/src/nebari_apps_mcp/server.py +++ b/mcp/src/nebari_apps_mcp/server.py @@ -13,6 +13,7 @@ import time from typing import TYPE_CHECKING, Annotated, Any, Literal, cast +import anyio import httpx import structlog from fastmcp import Context, FastMCP @@ -26,7 +27,7 @@ from starlette.responses import JSONResponse, Response from . import __version__ -from .auth import MissingSessionError, TokenVerificationError, auth, get_validator +from .auth import TokenVerificationError, build_auth_provider, get_validator from .client import ApiClient, ApiError from .config import settings from .logs import REQUEST_ID_HEADER, RequestIdMiddleware, configure_logging @@ -50,8 +51,10 @@ "(source_type=image, from the registries describe_cluster lists). " "Typical flow: describe_cluster to see what is available, then launch_app; " "poll get_app_status until phase is Running and share the app URL with the user. " - "If a tool reports an authentication problem, call authenticate and follow its " - "instructions. " + "Sign-in is standard MCP OAuth: the client logs the user in to the cluster's Keycloak " + "in a browser and sends the token with every request. If a tool reports an " + "authentication problem, ask the user to sign in to this server again from their " + "MCP client (in Claude Code: /mcp, select nebari-apps, Authenticate). " "Read-only views of each app are also resources (nebari-apps://apps/{namespace}/{name}, " "plus /status, /logs and /events), and the triage_app, launch_from_manifest and " "review_access prompts walk through the common workflows." @@ -71,7 +74,7 @@ def _request_id() -> str: def _passthrough() -> str: - """The caller's Authorization header, for bearer passthrough. + """The caller's Authorization header, passed through to apps-api. get_http_headers() drops credential headers unless they are asked for by name, so a plain .get("authorization") is always empty. @@ -133,37 +136,34 @@ class NotAuthenticatedError(Exception): """The caller has no verified token; the message tells the agent what to do.""" -async def _verify_caller(session_id: str) -> None: - """Raise NotAuthenticatedError unless the caller presents a verified JWT. +# What an agent should tell its user when the token is missing or rejected. +SIGN_IN_AGAIN = "sign in to this MCP server again from your MCP client (in Claude Code: /mcp, select it, Authenticate)" - The token comes from the Authorization header (bearer passthrough) or from - this session's device-flow login; its signature, issuer and expiry must - verify against the realm JWKS. + +async def _verify_caller() -> None: + """Raise NotAuthenticatedError unless the request carries a verified JWT. + + The token is the request's own Authorization header; nothing is cached per + MCP session. Over HTTP the OAuth layer (auth.build_auth_provider) has + already verified it; this check covers every other transport too. The + signature, issuer and expiry must verify against the realm JWKS, and the + token must belong to one of this pack's clients. """ + scheme, _, token = _passthrough().partition(" ") + if scheme.lower() != "bearer" or not token.strip(): + raise NotAuthenticatedError(f"not authenticated: {SIGN_IN_AGAIN}") try: - bearer = await auth.bearer(session_id, _passthrough()) - except MissingSessionError as exc: - raise NotAuthenticatedError(str(exc)) from exc - if not bearer: - raise NotAuthenticatedError("not authenticated: call the authenticate tool and follow its instructions") - try: - get_validator().validate(bearer.removeprefix("Bearer ").strip()) + await anyio.to_thread.run_sync(lambda: get_validator().validate(token.strip())) except TokenVerificationError as exc: - raise NotAuthenticatedError(f"token rejected ({exc}): call the authenticate tool to log in again") from exc - - -def _middleware_session(context: MiddlewareContext[Any]) -> str: - if context.fastmcp_context is None: - return "" - return context.fastmcp_context.session_id or "" + raise NotAuthenticatedError(f"token rejected ({exc}): {SIGN_IN_AGAIN}") from exc class TokenVerificationMiddleware(Middleware): """Reject unverified callers before any tool or resource logic runs. - Every tool except `authenticate` (the device-flow bootstrap) and every - resource read requires a verified JWT. Listing resources never fails: an - unverified caller just sees no Apps (see AppResourceListMiddleware). + Every tool call and every resource read requires a verified JWT. Listing + resources never fails: an unverified caller just sees no Apps (see + AppResourceListMiddleware). apps-api verifies again downstream; this layer just refuses to proxy for anonymous or forged callers at all. Prompts only return text that names resources and tools, so they need no identity. @@ -174,9 +174,9 @@ async def on_call_tool( context: MiddlewareContext[mt.CallToolRequestParams], call_next: CallNext[mt.CallToolRequestParams, ToolResult], ) -> ToolResult: - if settings.auth_enabled and context.message.name != "authenticate": + if settings.auth_enabled: try: - await _verify_caller(_middleware_session(context)) + await _verify_caller() except NotAuthenticatedError as exc: raise ToolError(str(exc)) from exc return await call_next(context) @@ -188,7 +188,7 @@ async def on_read_resource( ) -> ResourceResult: if settings.auth_enabled: try: - await _verify_caller(_middleware_session(context)) + await _verify_caller() except NotAuthenticatedError as exc: raise ResourceError(str(exc)) from exc return await call_next(context) @@ -212,14 +212,12 @@ async def on_list_resources( call_next: CallNext[mt.ListResourcesRequest, Sequence[Resource]], ) -> Sequence[Resource]: resources = list(await call_next(context)) - session_id = _middleware_session(context) try: if settings.auth_enabled: - await _verify_caller(session_id) - bearer = await auth.bearer(session_id, _passthrough()) - async with ApiClient(bearer, request_id=_request_id()) as api: + await _verify_caller() + async with ApiClient(_passthrough(), request_id=_request_id()) as api: apps = await api.get("/apps") - except (NotAuthenticatedError, MissingSessionError, ApiError, httpx.HTTPError) as exc: + except (NotAuthenticatedError, ApiError, httpx.HTTPError) as exc: log.info("resource list", outcome="apps omitted", error=str(exc)) return resources for app in apps if isinstance(apps, list) else []: @@ -246,14 +244,9 @@ async def on_list_resources( mcp.add_middleware(AppResourceListMiddleware()) -def _session_id(ctx: Context) -> str: - return ctx.session_id or "" - - async def _api(ctx: Context) -> ApiClient: - passthrough = _passthrough() - bearer = await auth.bearer(_session_id(ctx), passthrough) - return ApiClient(bearer, request_id=_request_id()) + # The caller's own token, verified above; apps-api verifies it again. + return ApiClient(_passthrough(), request_id=_request_id()) # Hints for clients that auto-approve tools: these only read. @@ -263,21 +256,10 @@ async def _api(ctx: Context) -> ApiClient: def _error(exc: ApiError) -> dict[str, Any]: out: dict[str, Any] = {"error": exc.detail, "status": exc.status} if exc.status == 401: - out["nextAction"] = "call the authenticate tool and follow its instructions" + out["nextAction"] = SIGN_IN_AGAIN return out -@mcp.tool(annotations={"readOnlyHint": False, "destructiveHint": False, "idempotentHint": True, "openWorldHint": False}) -async def authenticate(ctx: Context) -> dict[str, Any]: - """Log in to the cluster via the Keycloak device flow, or confirm the cached login. - - Call this when another tool returns a 401. If the result is - 'action_required', show the user the verificationUrl and userCode, wait for - them to approve in a browser, then call authenticate again to complete. - """ - return await auth.authenticate(_session_id(ctx)) - - @mcp.tool(annotations=READ_ONLY) async def describe_cluster(ctx: Context) -> dict[str, Any]: """Describe what this cluster supports: available source types, namespaces apps may be @@ -727,7 +709,7 @@ def _check_names(**names: str) -> None: def _resource_error(exc: ApiError) -> ResourceError: message = f"apps-api returned {exc.status}: {exc.detail}" if exc.status == 401: - message += "; call the authenticate tool and follow its instructions" + message += f"; {SIGN_IN_AGAIN}" return ResourceError(message) @@ -898,7 +880,7 @@ def launch_from_manifest( landingPage.enabled -> landing_page (omit when the manifest omits it). 2. Read {SCHEME}://cluster (tool: describe_cluster). Check that the namespace is listed and the source type is in \ sourceTypes; for an image source, that the image comes from one of imageRegistries. If a call reports an \ -authentication problem, call authenticate and follow its instructions. +authentication problem, ask the user to sign in to this MCP server again from their MCP client. 3. runtime.secretKeys lists names only. Never put credential values in env or the manifest. After launching, ask \ the user to enter each value in the Apps UI (Edit, Secrets); pass secrets to launch_app only if the user hands \ you a value and asks you to. @@ -951,6 +933,8 @@ def main() -> None: "AUTHENTICATION IS DISABLED (AUTH_ENABLED=false): every tool call is anonymous. " "Use this for local development only." ) + # OAuth protected resource (RFC 9728) with Keycloak as the authorization server. + mcp.auth = build_auth_provider() mcp.run( transport="http", host=settings.host, diff --git a/mcp/tests/test_oauth.py b/mcp/tests/test_oauth.py new file mode 100644 index 0000000..f02d852 --- /dev/null +++ b/mcp/tests/test_oauth.py @@ -0,0 +1,261 @@ +"""MCP OAuth at the HTTP layer: protected resource metadata, 401 challenges, bearer tokens. + +These run the real Streamable HTTP app (auth middleware included) in process. +Nothing is keyed on MCP sessions, so a sessionless 2026-07-28 request and a +handshake-era session authenticate the same way: by the token on each request. +""" + +from __future__ import annotations + +import json +import time +from collections.abc import AsyncIterator, Iterator +from typing import Any + +import jwt +import pytest +from cryptography.hazmat.primitives.asymmetric import rsa +from fastmcp.server.auth import RemoteAuthProvider +from fastmcp.utilities.tests import ASGIServer, asgi_server + +from nebari_apps_mcp import auth as auth_mod +from nebari_apps_mcp.auth import JWTValidator, TokenVerificationError, build_auth_provider +from nebari_apps_mcp.config import settings as mcp_settings +from nebari_apps_mcp.server import mcp + +ISSUER = "https://keycloak.example.test/realms/nebari" +PUBLIC_URL = "https://apps.example.test" +METADATA_PATH = "/.well-known/oauth-protected-resource/mcp" +MODERN = "2026-07-28" +KEY = rsa.generate_private_key(public_exponent=65537, key_size=2048) +ALLOWED = ["apps-ui-spa", "apps-ui-device", "nebari-apps-mcp"] + + +class _Key: + key = KEY.public_key() + + +def real_validator(allowed: list[str] | None = ALLOWED) -> JWTValidator: + """The production validator, with the JWKS fetch replaced by a fixed key.""" + v = JWTValidator("https://unused.example.test/certs", ISSUER, "", allowed) + v._jwks.get_signing_key_from_jwt = lambda _t: _Key() # type: ignore[method-assign] + return v + + +def token(azp: str = "nebari-apps-mcp", **claims: Any) -> str: + body = {"iss": ISSUER, "azp": azp, "aud": "account", "sub": "u-1", "exp": int(time.time()) + 300, **claims} + return jwt.encode(body, KEY, algorithm="RS256") + + +@pytest.fixture +def oauth(monkeypatch) -> Iterator[None]: + monkeypatch.setattr(mcp_settings, "auth_enabled", True) + monkeypatch.setattr(mcp_settings, "oidc_issuer", ISSUER) + monkeypatch.setattr(mcp_settings, "public_url", PUBLIC_URL) + monkeypatch.setattr(auth_mod, "_validator", real_validator()) + mcp.auth = build_auth_provider() + yield + mcp.auth = None + + +@pytest.fixture +async def server(oauth) -> AsyncIterator[ASGIServer]: + async with asgi_server(mcp) as running: + yield running + + +def modern_call(tool: str, arguments: dict[str, Any] | None = None, request_id: int = 1) -> dict[str, Any]: + """A 2026-07-28 tools/call: no initialize, no session, the envelope in _meta.""" + return { + "jsonrpc": "2.0", + "id": request_id, + "method": "tools/call", + "params": { + "name": tool, + "arguments": arguments or {}, + "_meta": { + "io.modelcontextprotocol/protocolVersion": MODERN, + "io.modelcontextprotocol/clientCapabilities": {}, + "io.modelcontextprotocol/clientInfo": {"name": "test", "version": "0"}, + }, + }, + } + + +def modern_headers(tool: str, bearer: str | None) -> dict[str, str]: + headers = { + "Accept": "application/json, text/event-stream", + "Content-Type": "application/json", + "MCP-Protocol-Version": MODERN, + "Mcp-Method": "tools/call", + "Mcp-Name": tool, + } + if bearer is not None: + headers["Authorization"] = f"Bearer {bearer}" + return headers + + +def jsonrpc_result(text: str) -> dict[str, Any]: + """The JSON-RPC message from a JSON or SSE response body.""" + if text.lstrip().startswith("{"): + return dict(json.loads(text)) + data = [line.removeprefix("data:").strip() for line in text.splitlines() if line.startswith("data:")] + return dict(json.loads(data[-1])) + + +async def test_protected_resource_metadata(server): + async with server.http_client() as http: + resp = await http.get(METADATA_PATH) + assert resp.status_code == 200 + meta = resp.json() + assert meta["resource"] == f"{PUBLIC_URL}/mcp" + assert meta["authorization_servers"] == [ISSUER] + assert meta["scopes_supported"] == ["openid"] + assert meta["bearer_methods_supported"] == ["header"] + + +async def test_missing_token_gets_401_challenge(server): + async with server.http_client() as http: + resp = await http.post( + "/mcp", json=modern_call("describe_cluster"), headers=modern_headers("describe_cluster", None) + ) + assert resp.status_code == 401 + challenge = resp.headers["www-authenticate"] + assert challenge.startswith("Bearer") + assert f'resource_metadata="{PUBLIC_URL}{METADATA_PATH}"' in challenge + # RFC 6750 §3.1: no error attribute when no credentials were sent. + assert "error=" not in challenge + + +@pytest.mark.parametrize( + "bad", + [ + "not-a-jwt", + token(exp=int(time.time()) - 10), # expired + token(iss="https://evil.example.test/realms/nebari"), # wrong issuer + token(azp="grafana"), # another realm client: azp/aud binding + ], +) +async def test_invalid_token_gets_401_invalid_token(server, bad): + async with server.http_client() as http: + resp = await http.post( + "/mcp", json=modern_call("describe_cluster"), headers=modern_headers("describe_cluster", bad) + ) + assert resp.status_code == 401 + challenge = resp.headers["www-authenticate"] + assert 'error="invalid_token"' in challenge + assert f'resource_metadata="{PUBLIC_URL}{METADATA_PATH}"' in challenge + + +async def test_modern_sessionless_requests_authenticate_per_request(server): + """Each 2026-07-28 request stands alone; the token on it is all the auth there is.""" + good = token() + async with server.http_client() as http: + for request_id in (1, 2): + resp = await http.post( + "/mcp", + json=modern_call("describe_cluster", request_id=request_id), + headers=modern_headers("describe_cluster", good), + ) + assert resp.status_code == 200, resp.text + assert "mcp-session-id" not in resp.headers + result = jsonrpc_result(resp.text)["result"] + assert not result.get("isError"), result + assert "inline" in result["structuredContent"]["sourceTypes"] + + +async def test_aud_binding_accepts_pack_client_in_aud(server): + # azp is some other client, but the token names an allowed client as audience. + bound = token(azp="cli-helper", aud=["nebari-apps-mcp", "account"]) + async with server.http_client() as http: + resp = await http.post( + "/mcp", json=modern_call("describe_cluster"), headers=modern_headers("describe_cluster", bound) + ) + assert resp.status_code == 200, resp.text + + +async def test_handshake_session_with_bearer(server): + async with server.client(auth=token()) as client: + caps = (await client.call_tool("describe_cluster", {})).data + assert "inline" in caps["sourceTypes"] + + +async def test_handshake_session_without_token_is_refused(server): + async with server.http_client() as http: + resp = await http.post( + "/mcp", + json={ + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": { + "protocolVersion": "2025-06-18", + "capabilities": {}, + "clientInfo": {"name": "test", "version": "0"}, + }, + }, + headers={"Accept": "application/json, text/event-stream", "Content-Type": "application/json"}, + ) + assert resp.status_code == 401 + assert "resource_metadata=" in resp.headers["www-authenticate"] + + +async def test_healthz_needs_no_token(server): + async with server.http_client() as http: + assert (await http.get("/healthz")).status_code == 200 + + +def test_provider_shape(oauth): + provider = mcp.auth + assert isinstance(provider, RemoteAuthProvider) + assert [str(s).rstrip("/") for s in provider.authorization_servers] == [ISSUER] + + +def test_no_provider_when_auth_disabled(monkeypatch): + monkeypatch.setattr(mcp_settings, "auth_enabled", False) + assert build_auth_provider() is None + + +def test_bare_verifier_without_public_url(monkeypatch): + """Without a public URL there is no metadata to point at, but tokens are still required.""" + monkeypatch.setattr(mcp_settings, "auth_enabled", True) + monkeypatch.setattr(mcp_settings, "oidc_issuer", ISSUER) + monkeypatch.setattr(mcp_settings, "public_url", "") + provider = build_auth_provider() + assert provider is not None + assert not isinstance(provider, RemoteAuthProvider) + + +async def test_verifier_maps_claims(oauth): + provider = build_auth_provider() + assert provider is not None + access = await provider.verify_token(token(scope="openid profile email")) + assert access is not None + assert access.client_id == "nebari-apps-mcp" + assert access.subject == "u-1" + assert access.scopes == ["openid", "profile", "email"] + assert await provider.verify_token(token(azp="grafana")) is None + + +async def test_unconfigured_jwks_fails_closed(monkeypatch): + monkeypatch.setattr(mcp_settings, "auth_enabled", True) + monkeypatch.setattr(mcp_settings, "oidc_issuer", "") + monkeypatch.setattr(mcp_settings, "oidc_jwks_url", "") + monkeypatch.setattr(mcp_settings, "oidc_issuer_internal", "") + monkeypatch.setattr(auth_mod, "_validator", None) + with pytest.raises(TokenVerificationError): + auth_mod.get_validator() + provider = build_auth_provider() + assert provider is not None + assert await provider.verify_token(token()) is None + + +async def test_auth_disabled_http_is_anonymous(): + # The default fixtures run with auth disabled and mcp.auth unset. + assert mcp.auth is None + async with asgi_server(mcp) as running, running.http_client() as http: + assert (await http.get(METADATA_PATH)).status_code == 404 + resp = await http.post( + "/mcp", json=modern_call("describe_cluster"), headers=modern_headers("describe_cluster", None) + ) + assert resp.status_code == 200, resp.text diff --git a/mcp/tests/test_resources.py b/mcp/tests/test_resources.py index 763a673..ed55489 100644 --- a/mcp/tests/test_resources.py +++ b/mcp/tests/test_resources.py @@ -178,9 +178,6 @@ async def test_foreign_owner_and_non_manager_cannot_read(store, api_users): def auth_on(monkeypatch): monkeypatch.setattr(mcp_settings, "auth_enabled", True) monkeypatch.setattr(auth_mod, "_validator", FakeValidator()) - auth_mod.auth._sessions.clear() - yield - auth_mod.auth._sessions.clear() async def test_unauthenticated_reads_refused(store, auth_on): @@ -191,7 +188,7 @@ async def test_unauthenticated_reads_refused(store, auth_on): # Listing still answers, with only the static entries and no Apps. assert await listed() == {"nebari-apps://cluster", "nebari-apps://apps"} for uri in ["nebari-apps://cluster", "nebari-apps://apps", *APP_URIS]: - with pytest.raises(MCPError, match="authenticate"): + with pytest.raises(MCPError, match="not authenticated: sign in"): await read(uri) # Prompts carry no cluster data, so they need no login. assert "nebari-apps://cluster" in await prompt("launch_from_manifest", {}) diff --git a/mcp/tests/test_safety.py b/mcp/tests/test_safety.py index 49f6ad3..c57e63a 100644 --- a/mcp/tests/test_safety.py +++ b/mcp/tests/test_safety.py @@ -1,18 +1,14 @@ -"""Tool annotations, confirmation on delete, fenced app output, session eviction.""" +"""Tool annotations, confirmation on delete, fenced app output.""" from __future__ import annotations import re -import time import pytest from fastmcp import Client from fastmcp.exceptions import ToolError from test_tools import LAUNCH_ARGS, call -from nebari_apps_mcp import auth as auth_mod -from nebari_apps_mcp.auth import MissingSessionError, TokenState -from nebari_apps_mcp.config import settings as mcp_settings from nebari_apps_mcp.server import mcp from nebari_apps_mcp.untrusted import FILE_CAP, LOG_CAP @@ -24,7 +20,7 @@ async def test_annotations(): async with Client(mcp) as client: tools = {t.name: t.annotations for t in await client.list_tools()} - assert len(tools) == 13 + assert len(tools) == 12 for name, ann in tools.items(): assert ann is not None, name assert ann.open_world_hint is False, name @@ -94,32 +90,3 @@ async def test_stop_returns_fenced_app(store): await call("launch_app", {**LAUNCH_ARGS, "inline_files": injected_files()}) app = await call("stop_app", {"namespace": "apps", "name": "docs-site"}) assert INJECTION in fenced(app["source"]["inline"]["files"]["index.html"]) - - -@pytest.fixture -def sessions(): - auth_mod.auth._sessions.clear() - yield auth_mod.auth - auth_mod.auth._sessions.clear() - - -def test_idle_sessions_are_evicted(sessions, monkeypatch): - monkeypatch.setattr(mcp_settings, "session_ttl", 60) - sessions._sessions["old"] = TokenState(access_token="a", refresh_token="r", last_used=time.time() - 61) - sessions._sessions["recent"] = TokenState(access_token="b", last_used=time.time() - 10) - sessions.state("new") - assert set(sessions._sessions) == {"recent", "new"} - - -def test_empty_session_id_refused(sessions): - with pytest.raises(MissingSessionError): - sessions.state("") - assert sessions._sessions == {} - - -async def test_authenticate_without_session_is_unavailable(sessions, monkeypatch): - monkeypatch.setattr(mcp_settings, "auth_enabled", True) - monkeypatch.setattr(mcp_settings, "oidc_issuer", "https://kc.test/realms/nebari") - monkeypatch.setattr(mcp_settings, "oidc_device_client_id", "device") - result = await sessions.authenticate("") - assert result["status"] == "unavailable" diff --git a/mcp/tests/test_token_verification.py b/mcp/tests/test_token_verification.py index 6d7a33c..38f1fc4 100644 --- a/mcp/tests/test_token_verification.py +++ b/mcp/tests/test_token_verification.py @@ -1,4 +1,10 @@ -"""The middleware must reject unverified callers on every tool but authenticate.""" +"""The middleware must reject unverified callers on every tool call and resource read. + +There is no login tool and nothing is cached per MCP session: the token on the +request is the only identity. test_oauth.py covers the HTTP-layer OAuth (401 +challenges, protected resource metadata); these cover the in-process check +that runs behind it on every transport. +""" from __future__ import annotations @@ -9,7 +15,7 @@ from fastmcp.exceptions import ToolError from nebari_apps_mcp import auth as auth_mod -from nebari_apps_mcp.auth import TokenState, TokenVerificationError +from nebari_apps_mcp.auth import TokenVerificationError from nebari_apps_mcp.config import settings as mcp_settings from nebari_apps_mcp.server import mcp @@ -30,57 +36,24 @@ def validate(self, token: str) -> dict: def auth_on(monkeypatch): monkeypatch.setattr(mcp_settings, "auth_enabled", True) monkeypatch.setattr(auth_mod, "_validator", FakeValidator()) - auth_mod.auth._sessions.clear() - yield - auth_mod.auth._sessions.clear() - - -def seed_session_token(token: str) -> None: - """Simulate a completed device-flow login for the in-memory client session.""" - state = TokenState(access_token=token, expires_at=time.time() + 300) - # The in-memory transport has its own session id; seed every session the - # test client might use by patching state lookup to a shared entry. - auth_mod.auth._sessions.clear() - auth_mod.auth._sessions["shared"] = state - original = auth_mod.auth.state - auth_mod.auth.state = lambda session_id: auth_mod.auth._sessions["shared"] # type: ignore[method-assign] - seed_session_token.restore = lambda: setattr(auth_mod.auth, "state", original) # type: ignore[attr-defined] async def test_tools_rejected_without_token(auth_on): async with Client(mcp) as client: - with pytest.raises(ToolError, match="authenticate"): + tools = {t.name for t in await client.list_tools()} + assert "authenticate" not in tools + with pytest.raises(ToolError, match="not authenticated: sign in"): await client.call_tool("describe_cluster", {}) - with pytest.raises(ToolError, match="authenticate"): + with pytest.raises(ToolError, match="not authenticated: sign in"): await client.call_tool("list_apps", {}) -async def test_authenticate_still_callable_without_token(auth_on, monkeypatch): - # No issuer configured -> authenticate reports unavailable rather than 401. - monkeypatch.setattr(mcp_settings, "oidc_issuer", "") - async with Client(mcp) as client: - result = (await client.call_tool("authenticate", {})).data - assert result["status"] == "unavailable" - - -async def test_valid_session_token_passes(auth_on): - seed_session_token("good-token") - try: - async with Client(mcp) as client: - caps = (await client.call_tool("describe_cluster", {})).data - assert "inline" in caps["sourceTypes"] - finally: - seed_session_token.restore() # type: ignore[attr-defined] - +async def test_non_bearer_authorization_rejected(auth_on): + from fastmcp.utilities.tests import asgi_client -async def test_forged_session_token_rejected(auth_on): - seed_session_token("forged-token") - try: - async with Client(mcp) as client: - with pytest.raises(ToolError, match="token rejected"): - await client.call_tool("describe_cluster", {}) - finally: - seed_session_token.restore() # type: ignore[attr-defined] + async with asgi_client(mcp, headers={"Authorization": "Basic Z29vZC10b2tlbg=="}) as client: + with pytest.raises(ToolError, match="not authenticated"): + await client.call_tool("describe_cluster", {}) async def test_auth_disabled_skips_verification(): @@ -149,5 +122,5 @@ async def handle_async_request(self, request: httpx.Request) -> httpx.Response: assert seen and all(h == "Bearer good-token" for h in seen) async with asgi_client(mcp, auth="forged-token") as client: - with pytest.raises(ToolError, match="token rejected"): + with pytest.raises(ToolError, match=r"token rejected .*sign in"): await client.call_tool("describe_cluster", {}) diff --git a/mcp/tests/test_tools.py b/mcp/tests/test_tools.py index 79853c0..462dd14 100644 --- a/mcp/tests/test_tools.py +++ b/mcp/tests/test_tools.py @@ -33,7 +33,6 @@ async def test_tool_catalog(): async with Client(mcp) as client: tools = {t.name for t in await client.list_tools()} assert tools == { - "authenticate", "describe_cluster", "list_apps", "launch_app", @@ -228,11 +227,6 @@ async def test_logs_and_missing_app(): assert missing["status"] == 404 -async def test_authenticate_reports_disabled(): - result = await call("authenticate") - assert result["status"] == "not_required" - - async def test_launch_landing_page_opt_in(store): app = await call("launch_app", LAUNCH_ARGS) assert app["landingPage"] is None # cluster default diff --git a/mcp/uv.lock b/mcp/uv.lock index b02f051..3c62ef4 100644 --- a/mcp/uv.lock +++ b/mcp/uv.lock @@ -1636,6 +1636,7 @@ wheels = [ name = "nebari-apps-mcp" source = { editable = "." } dependencies = [ + { name = "anyio" }, { name = "fastmcp" }, { name = "httpx" }, { name = "pyjwt", extra = ["crypto"] }, @@ -1659,6 +1660,7 @@ dev = [ [package.metadata] requires-dist = [ + { name = "anyio", specifier = ">=4.5" }, { name = "fastapi", marker = "extra == 'dev'", specifier = ">=0.115" }, { name = "fastmcp", specifier = ">=2.10" }, { name = "httpx", specifier = ">=0.27" }, diff --git a/skill/new-nebari-app/SKILL.md b/skill/new-nebari-app/SKILL.md index 71d4801..96bcdec 100644 --- a/skill/new-nebari-app/SKILL.md +++ b/skill/new-nebari-app/SKILL.md @@ -64,8 +64,9 @@ manifest or its directory) returns these same steps; get it when your client sup prompts, and use the `triage_app` prompt (`namespace`, `name`) if the app lands in `Failed`: 1. `describe_cluster` — confirm the target `namespace` is available and get `appsDomain`. - If a tool returns an auth error, call `authenticate` and follow its instructions - (show the user the verification URL + code, then retry). + If a tool returns an auth error, ask the user to sign in to the `nebari-apps` server + (in Claude Code: `/mcp`, select it, **Authenticate**; the browser login is the + client's, not a tool), then retry. 2. Map the manifest to a `launch_app` call — the full mapping table is in [references/manifest.md](references/manifest.md). The common case: - **`source.type: files`**: read every text file under `files.path` diff --git a/ui/nginx.conf b/ui/nginx.conf index 55a1daa..3b33177 100644 --- a/ui/nginx.conf +++ b/ui/nginx.conf @@ -93,6 +93,16 @@ http { add_header X-Accel-Buffering no; } + # MCP OAuth protected resource metadata (RFC 9728) for /mcp, served by + # apps-mcp: MCP clients read it to find the Keycloak realm to sign in with. + location /.well-known/oauth-protected-resource { + proxy_pass ${MCP_URL}; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Request-ID $req_id; + proxy_set_header Connection ""; + } + # Serve pre-built assets directly; fall back to index.html for SPA routes. location / { try_files $uri $uri/ /index.html; diff --git a/ui/src/lib/types.ts b/ui/src/lib/types.ts index 4057b7f..d33a14c 100644 --- a/ui/src/lib/types.ts +++ b/ui/src/lib/types.ts @@ -156,6 +156,11 @@ export interface UiConfig { keycloak: { url: string; realm: string; clientId: string }; appsDomain: string; appsScheme?: string; + /** + * The public Keycloak client MCP clients sign in to the MCP server with + * (chart value mcp.oauth); an empty clientId means none is configured. + */ + mcpOAuth?: { clientId: string; callbackPort: number }; /** Release (e.g. 0.2.0) and image build (e.g. sha-abc1234) of the API. */ version?: string; build?: string; diff --git a/ui/src/pages/settings.tsx b/ui/src/pages/settings.tsx index 6d5adc8..af96328 100644 --- a/ui/src/pages/settings.tsx +++ b/ui/src/pages/settings.tsx @@ -16,7 +16,7 @@ import { getConfig } from "@/lib/auth"; import { getBranding } from "@/lib/branding"; import { copyText } from "@/lib/clipboard"; import { FADE_IN } from "@/lib/motion"; -import type { ApiVersion, Capabilities, Me } from "@/lib/types"; +import type { ApiVersion, Capabilities, Me, UiConfig } from "@/lib/types"; import { cn } from "@/lib/utils"; const REPO = "https://github.com/nebari-dev/apps-pack"; @@ -415,10 +415,23 @@ function CopyField({ ); } +/** + * The `claude mcp add` command. With auth on and an MCP OAuth client + * configured, Claude Code signs in with that client: Keycloak only accepts + * its registered redirect URI, hence the fixed callback port. + */ +export function claudeCommand(mcpUrl: string, config: UiConfig): string { + const oauth = config.authEnabled ? config.mcpOAuth : undefined; + const flags = oauth?.clientId + ? ` --client-id ${oauth.clientId}${oauth.callbackPort ? ` --callback-port ${oauth.callbackPort}` : ""}` + : ""; + return `claude mcp add --transport http${flags} nebari-apps ${mcpUrl}`; +} + function ConnectAgent() { // The UI's nginx proxies /mcp to the MCP server, so it shares the UI's host. const mcpUrl = `${window.location.origin}/mcp`; - const command = `claude mcp add --transport http nebari-apps ${mcpUrl}`; + const command = claudeCommand(mcpUrl, getConfig()); return (
{ }); describe("settings helpers", () => { + it("adds the MCP OAuth client to the claude mcp add command when auth is on", () => { + const url = "https://apps.example.ai/mcp"; + const base = { keycloak: { url: "", realm: "", clientId: "" }, appsDomain: "" }; + const mcpOAuth = { clientId: "nebari-apps-mcp", callbackPort: 33418 }; + expect(claudeCommand(url, { ...base, authEnabled: true, mcpOAuth })).toBe( + `claude mcp add --transport http --client-id nebari-apps-mcp --callback-port 33418 nebari-apps ${url}`, + ); + expect( + claudeCommand(url, { + ...base, + authEnabled: true, + mcpOAuth: { clientId: "c", callbackPort: 0 }, + }), + ).toBe(`claude mcp add --transport http --client-id c nebari-apps ${url}`); + expect(claudeCommand(url, { ...base, authEnabled: false, mcpOAuth })).toBe( + `claude mcp add --transport http nebari-apps ${url}`, + ); + expect(claudeCommand(url, { ...base, authEnabled: true })).toBe( + `claude mcp add --transport http nebari-apps ${url}`, + ); + }); + it("formats a version with its build", () => { expect(versionLine("0.4.0", "sha-abc")).toBe("0.4.0 (sha-abc)"); expect(versionLine("0.4.0", "0.4.0")).toBe("0.4.0");