Skip to content
Open
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
5 changes: 5 additions & 0 deletions cmd/explorer/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,7 @@ func main() {
noMCP := flag.Bool("no-mcp", !fileCfg.MCPEnabledOr(true), "Disable MCP (Model Context Protocol) server for AI tools")
mcpCatalogStdio := flag.Bool("mcp-catalog-stdio", false, "Start only the MCP catalog over stdio for registry/inspector introspection; skips Kubernetes initialization")
mcpCatalogOnly := flag.Bool("mcp-catalog-only", false, "Start only the MCP endpoint for registry/inspector catalog introspection; skips Kubernetes initialization")
mcpOAuth := flag.Bool("mcp-oauth", false, "Enable OAuth for remote MCP clients (OIDC mode, single replica only)")
// Auth flags
authMode := flag.String("auth-mode", "none", "Authentication mode: none, proxy, or oidc")
authSecret := flag.String("auth-secret", "", "HMAC secret key for session cookies (auto-generated if empty)")
Expand Down Expand Up @@ -349,6 +350,9 @@ func main() {
*kubeconfig, *kubeconfigDir, kubeconfigFlagSet, kubeconfigDirsFlagSet,
)
mcpEnabled := !*noMCP
if *mcpOAuth && (!mcpEnabled || *authMode != "oidc" || *cloudURL != "" || cloud.Mode() || *mcpCatalogOnly || *mcpCatalogStdio) {
log.Fatal("--mcp-oauth requires OIDC authentication and MCP enabled in a standalone deployment")
}
if *mcpCatalogOnly || *mcpCatalogStdio {
mcpEnabled = true
}
Expand Down Expand Up @@ -406,6 +410,7 @@ func main() {
BeylaJobSelector: *beylaJobSelector,
WorkloadMetricsScope: prom.WorkloadMetricsScope{SingleCluster: *workloadSingleCluster, ClusterLabels: workloadClusterLabels},
MCPEnabled: mcpEnabled,
MCPOAuthEnabled: *mcpOAuth,
AIHistory: *aiHistory,
AIHistoryDBPath: fileCfg.AIHistoryDBPath,
Version: version,
Expand Down
19 changes: 18 additions & 1 deletion deploy/helm/radar/templates/deployment.yaml
Original file line number Diff line number Diff line change
@@ -1,3 +1,17 @@
{{- if .Values.mcp.oauth.enabled }}
{{- if not .Values.mcp.enabled }}
{{- fail "mcp.oauth.enabled requires mcp.enabled=true" }}
{{- end }}
{{- if .Values.cloud.enabled }}
{{- fail "mcp.oauth.enabled is not supported with cloud.enabled=true" }}
{{- end }}
{{- if ne .Values.auth.mode "oidc" }}
{{- fail "mcp.oauth.enabled requires auth.mode=oidc" }}
{{- end }}
{{- if ne (int .Values.replicaCount) 1 }}
{{- fail "mcp.oauth.enabled requires replicaCount=1: OAuth clients and tokens are stored in memory and are not shared across pods" }}
{{- end }}
{{- end }}
{{- if and .Values.persistence.enabled (eq .Values.timeline.storage "sqlite") (gt (int .Values.replicaCount) 1) }}
{{- fail "replicaCount must be 1 when persistence.enabled=true and timeline.storage=sqlite: a single PVC cannot be safely shared across pods (RWO volumes won't attach to a second pod, RWX volumes risk SQLite corruption)" }}
{{- end }}
Expand Down Expand Up @@ -37,7 +51,7 @@ metadata:
{{- include "radar.labels" . | nindent 4 }}
spec:
replicas: {{ .Values.replicaCount }}
{{- if and .Values.persistence.enabled (eq .Values.timeline.storage "sqlite") }}
{{- if or .Values.mcp.oauth.enabled (and .Values.persistence.enabled (eq .Values.timeline.storage "sqlite")) }}
strategy:
type: Recreate
{{- end }}
Expand Down Expand Up @@ -125,6 +139,9 @@ spec:
{{- if not .Values.mcp.enabled }}
- --no-mcp
{{- end }}
{{- if .Values.mcp.oauth.enabled }}
- --mcp-oauth
{{- end }}
{{- if .Values.debug.image }}
- --debug-image={{ .Values.debug.image }}
{{- end }}
Expand Down
72 changes: 72 additions & 0 deletions deploy/helm/radar/tests/deployment_mcp_oauth_test.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
suite: MCP OAuth deployment
templates:
- deployment.yaml
tests:
- it: leaves MCP OAuth disabled by default
asserts:
- notContains:
path: spec.template.spec.containers[0].args
content: --mcp-oauth

- it: enables MCP OAuth with OIDC and prevents overlapping replicas during rollout
set:
mcp.oauth.enabled: true
auth.mode: oidc
auth.oidc.redirectURL: https://radar.example.com/auth/callback
persistence.enabled: false
asserts:
- contains:
path: spec.template.spec.containers[0].args
content: --mcp-oauth
- contains:
path: spec.template.spec.containers[0].args
content: --auth-mode=oidc
- equal:
path: spec.replicas
value: 1
- equal:
path: spec.strategy.type
value: Recreate

- it: rejects disabled MCP
set:
mcp.enabled: false
mcp.oauth.enabled: true
auth.mode: oidc
asserts:
- failedTemplate:
errorMessage: mcp.oauth.enabled requires mcp.enabled=true

- it: rejects unauthenticated deployments
set:
mcp.oauth.enabled: true
asserts:
- failedTemplate:
errorMessage: mcp.oauth.enabled requires auth.mode=oidc

- it: rejects proxy authentication
set:
mcp.oauth.enabled: true
auth.mode: proxy
asserts:
- failedTemplate:
errorMessage: mcp.oauth.enabled requires auth.mode=oidc

- it: rejects Cloud deployments
set:
mcp.oauth.enabled: true
auth.mode: oidc
cloud.enabled: true
asserts:
- failedTemplate:
errorMessage: mcp.oauth.enabled is not supported with cloud.enabled=true

- it: rejects multiple replicas without persistence
set:
mcp.oauth.enabled: true
auth.mode: oidc
replicaCount: 2
persistence.enabled: false
asserts:
- failedTemplate:
errorMessage: "mcp.oauth.enabled requires replicaCount=1: OAuth clients and tokens are stored in memory and are not shared across pods"
11 changes: 9 additions & 2 deletions deploy/helm/radar/values.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
"replicaCount": {
"type": "integer",
"minimum": 1,
"description": "Number of Radar pod replicas. Note: backchannel logout requires single replica."
"description": "Number of Radar pod replicas. Note: backchannel logout and MCP OAuth require a single replica."
},
"image": {
"type": "object",
Expand Down Expand Up @@ -338,7 +338,14 @@
"type": "object",
"additionalProperties": true,
"properties": {
"enabled": { "type": "boolean" }
"enabled": { "type": "boolean" },
"oauth": {
"type": "object",
"additionalProperties": false,
"properties": {
"enabled": { "type": "boolean", "description": "Enable MCP OAuth for a single-replica standalone OIDC deployment." }
}
}
}
},
"cost": {
Expand Down
8 changes: 7 additions & 1 deletion deploy/helm/radar/values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -528,8 +528,14 @@ usageReporting:

# MCP (Model Context Protocol) server for AI tools
mcp:
# Set to false to disable the MCP server (useful when deployed behind authentication)
# Set to false to disable the MCP server.
enabled: true
oauth:
# Browser-based MCP authorization for standalone OIDC deployments.
# Requires auth.mode=oidc, an HTTPS auth.oidc.redirectURL, and replicaCount=1.
# Uses Recreate rollouts: client registrations and tokens are held in memory,
# so clients must re-register and authorize again after a pod restart.
enabled: false

# OpenCost/Kubecost display configuration
cost:
Expand Down
8 changes: 8 additions & 0 deletions docs/authentication.md
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,14 @@ auth:

When using `caCert` in Kubernetes, mount the CA certificate into the pod via a ConfigMap or Secret volume.

### MCP clients with OIDC

Enable `--mcp-oauth` (Helm: `mcp.oauth.enabled: true`) to let compatible remote MCP clients authenticate through Radar's existing OIDC browser login. Radar acts as the MCP authorization server: it supports discovery, public client registration, authorization code flow with mandatory S256 PKCE, explicit browser consent, and access/refresh tokens bound to the requested MCP endpoint. The identity provider's tokens remain inside Radar; MCP clients receive separate Radar credentials and use the same per-user Kubernetes RBAC boundary.

This option requires standalone OIDC mode, MCP enabled, exactly one replica, and an HTTPS `auth.oidc.redirectURL` whose path is `{basePath}/auth/callback`. It is unavailable in proxy, unauthenticated or Radar Cloud modes. Existing browser-session authentication continues to work.

MCP access tokens last 10 minutes; refresh tokens rotate with an absolute 24-hour grant limit. OAuth state is held in memory, so pod restarts require client re-registration and authorization even when `auth.existingSecret` preserves browser sessions. The chart uses `Recreate` rollouts when this option is enabled. See [Remote MCP authentication](mcp.md#remote-authentication-with-oidc) for deployment, client setup, discovery checks and ingress requirements.

### Radar Cloud mode

If you see `RADAR_CLOUD_MODE` or `cloud.*` values in the chart, they control a specialized deployment mode used by [Radar Cloud](https://radarhq.io) — a hosted SaaS that lets a single Cloud frontend manage many in-cluster Radar instances over an outbound tunnel. You don't need to use it to run Radar standalone; leave `cloud.enabled: false` (the default).
Expand Down
5 changes: 4 additions & 1 deletion docs/in-cluster.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ scaling workloads still follow their existing permissions.
| Cost data | `cost.source`, `cost.kubecost.url`, `cost.kubecost.clusterId`, `cost.kubecost.existingSecret`, `cost.currency` |
| Audit policy | `audit.ignoredNamespaces`, `audit.disabledChecks` |
| Helm OCI chart sources | `helm.ociSources` |
| Timeline / MCP | `timeline`, `persistence`, `mcp.enabled` |
| Timeline / MCP | `timeline`, `persistence`, `mcp.enabled`, `mcp.oauth.enabled` |

For example, add this to your existing values file:

Expand Down Expand Up @@ -545,6 +545,8 @@ auth:
redirectURL: https://radar.example.com/auth/callback
```

To connect remote MCP clients through browser login, also set `mcp.oauth.enabled: true`. This requires standalone OIDC mode, an HTTPS callback URL, and `replicaCount: 1`. The chart uses `Recreate` rollouts because OAuth registrations and tokens are held in memory; clients must re-register and authorize after pod restarts. See [Remote MCP authentication](mcp.md#remote-authentication-with-oidc) for the full setup and discovery paths your ingress must expose.

## Security Considerations

When deploying Radar in-cluster:
Expand Down Expand Up @@ -598,6 +600,7 @@ See [Helm Chart README](../deploy/helm/radar/README.md) for all available values
| `service.port` | Service port | `9280` |
| `basePath` | URL prefix Radar serves under, e.g. `/radar` for no-strip-prefix subpath ingress | `""` |
| `mcp.enabled` | Enable MCP server for AI tools | `true` |
| `mcp.oauth.enabled` | Enable MCP OAuth for single-replica standalone OIDC deployments | `false` |
| `debug.image` | Image for ephemeral debug containers and node debug pods. In built-in restricted PodSecurity namespaces, pod debug containers may retry as the target/pod non-root UID, or UID `65532` by default; point at a compatible mirror for air-gapped / private-registry clusters. | `""` (busybox:latest) |
| `listPageSize` | Paginate the initial LIST of high-cardinality kinds (Pods, ReplicaSets) on very large clusters that fail to sync; `0` = off, try `2000`. Only used when the apiserver lacks WatchList streaming. | `0` |
| `timeline.storage` | Event storage (memory/sqlite/postgres) | `memory` |
Expand Down
46 changes: 46 additions & 0 deletions docs/mcp.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,52 @@ http://localhost:9280/mcp

The port matches your `--port` flag (default 9280). The MCP server uses HTTP transport with JSON-RPC.

## Remote authentication with OIDC

For a shared Radar deployment that uses built-in OIDC login, enable `--mcp-oauth` (Helm: `mcp.oauth.enabled: true`). A compatible remote MCP client can then discover authorization, open a browser for login and consent, and obtain its own access and refresh tokens. Copying the browser's session cookie into the client is unnecessary.

```yaml
replicaCount: 1
mcp:
enabled: true
oauth:
enabled: true
auth:
mode: oidc
oidc:
issuerURL: https://identity.example.com
scopes: [openid, profile, email]
clientID: your-client-id
existingSecret: radar-oidc-credentials
clientSecretKey: client-secret
redirectURL: https://radar.example.com/auth/callback
```

Configure the OIDC client and Kubernetes permissions as described in [Authentication & Authorization](authentication.md). The callback must be an HTTPS URL at exactly `{basePath}/auth/callback`; Radar uses this configured URL to derive its public origin rather than trusting request headers. With `basePath: /radar`, for example, use `https://example.com/radar/auth/callback` and connect the MCP client to `https://example.com/radar/mcp`.

Connect the client to `https://radar.example.com/mcp`, or `https://radar.example.com/mcp-readonly` for the read-only tool catalog. Use the client's native MCP sign-in flow. Radar shows a consent screen identifying the client and requested endpoint after browser login. Tokens are restricted to the exact endpoint authorized: a token for `/mcp-readonly` cannot access `/mcp`, and MCP tokens do not grant access to the web API. Both endpoints retain per-user Kubernetes RBAC enforcement.

The client must support MCP OAuth discovery, dynamic registration of public clients, authorization code flow with S256 PKCE, and resource indicators. Request the advertised `mcp` scope in the client’s scope settings. Client support varies by version; a client that only supports static headers cannot complete this browser flow. Browser-session authentication remains available for existing integrations.

### Discovery and ingress

Unauthenticated MCP requests return `401` with a `WWW-Authenticate` challenge pointing to protected resource metadata. The metadata identifies Radar's authorization server, whose discovery document advertises the registration, authorization, token and revocation endpoints. To inspect the challenge:

```bash
curl -i https://radar.example.com/mcp
curl -sS https://radar.example.com/.well-known/oauth-protected-resource/mcp
curl -sS https://radar.example.com/.well-known/oauth-authorization-server
```

Ensure your ingress routes the advertised `/.well-known/*` paths and `/auth/*` paths to Radar as well as the MCP endpoints. Keep the browser-facing HTTPS host and path consistent with `auth.oidc.redirectURL`. Use the URLs advertised in the challenge and metadata when deploying below a base path.

### Token lifecycle and deployment limits

- MCP OAuth is opt-in and requires standalone `auth.mode: oidc` with MCP enabled. Proxy auth, unauthenticated mode and Radar Cloud do not support this option.
- Access tokens expire after 10 minutes. Refreshing replaces both tokens and invalidates the previous access token. Refresh tokens expire at an absolute 24-hour session limit; reusing any old refresh token during that lifetime revokes its token family. Each grant occupies one access-token and one refresh-token slot, regardless of rotation count. The client must authorize again when its grant expires or is revoked.
- Dynamic registration accepts at most 20 valid registrations per minute per Radar process; malformed requests do not consume this shared capacity limit. Complete initial authorization within 10 minutes; approved client registrations last 30 days and are renewed on consent.
- Client registrations, authorization requests and tokens are bounded, in-memory state. Run exactly one replica. The chart uses `Recreate` rollouts to avoid routing requests across independent stores. Pod restarts invalidate this state even when browser session signing keys or timeline storage persist: reconnect the MCP client, re-register it and authorize again. If the client caches a stale client ID, remove and re-add its Radar server configuration.

## Catalog Introspection

MCP registries and inspectors can start Radar without a Kubernetes cluster when they only need the tool and resource catalog:
Expand Down
4 changes: 3 additions & 1 deletion internal/app/bootstrap.go
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ type AppConfig struct {
WorkloadMetricsScope prom.WorkloadMetricsScope
Version string
MCPEnabled bool
MCPOAuthEnabled bool
AIHistory bool // persist AI investigations across restarts
AIHistoryDBPath string // "" = ~/.radar/ai-runs.db
AuthConfig auth.Config
Expand Down Expand Up @@ -451,7 +452,8 @@ func CreateServer(cfg AppConfig) *server.Server {
HasPrometheusURL: cfg.PrometheusURL != "",
HasPrometheusHeaders: len(cfg.PrometheusHeaders) > 0,
},
AuthConfig: cfg.AuthConfig,
AuthConfig: cfg.AuthConfig,
MCPOAuthEnabled: cfg.MCPOAuthEnabled,
CloudConnect: server.CloudConnectConfig{
HubAPIURL: cfg.HubAPIURL,
HubAppURL: cfg.HubAppURL,
Expand Down
Loading