Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand Down
6 changes: 6 additions & 0 deletions api/src/nebari_apps_api/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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-<commit>
# for released charts), reported next to the version by /api/v1/version.
Expand Down
5 changes: 5 additions & 0 deletions api/src/nebari_apps_api/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
10 changes: 10 additions & 0 deletions api/tests/test_apps.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
2 changes: 2 additions & 0 deletions charts/nebari-apps/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:<callbackPort>/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. |
Expand Down
11 changes: 6 additions & 5 deletions charts/nebari-apps/templates/_env.tpl
Original file line number Diff line number Diff line change
@@ -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" "<env var the service reads the SPA id from>").
*/}}
Expand Down Expand Up @@ -31,7 +32,7 @@ Takes (dict "root" $ "spaVar" "<env var the service reads the SPA id from>").
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 }}

7 changes: 7 additions & 0 deletions charts/nebari-apps/templates/_helpers.tpl
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,13 @@ Domain apps are exposed under (https://<subdomain>.<appsDomain>).
{{- .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
*/}}
Expand Down
7 changes: 7 additions & 0 deletions charts/nebari-apps/templates/api.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand Down
8 changes: 7 additions & 1 deletion charts/nebari-apps/templates/mcp.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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_PUBLIC_URL>/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 }}
Expand Down
18 changes: 18 additions & 0 deletions charts/nebari-apps/values.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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:<port>/callback)."
}
},
"additionalProperties": false,
"description": "MCP OAuth client settings."
},
"orgCABundle": {
"type": "object",
"properties": {
Expand Down
20 changes: 18 additions & 2 deletions charts/nebari-apps/values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:<callbackPort>/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 }
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion docs/src/content/docs/api-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -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). |

Expand Down
18 changes: 11 additions & 7 deletions docs/src/content/docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand Down
Loading
Loading