feat(ingestion): add OAuth 2.0 client credentials flow to brain MCP server #5

Closed
opened 2026-05-10 20:57:28 +00:00 by mathias · 2 comments
Owner

Problem

claude.ai only supports OAuth 2.0 for remote MCP servers — no static Bearer token option in the UI (only client ID + client secret fields). The brain MCP endpoint at https://brain-mcp.d-ma.be/mcp currently uses a static Bearer token (BRAIN_MCP_TOKEN), which claude.ai cannot use.

Result: "Couldn't reach the MCP server" in claude.ai — the server returns 401 with no WWW-Authenticate header, so claude.ai has no way to authenticate.

Solution

Implement a minimal OAuth 2.0 client credentials flow on the ingestion server. claude.ai exchanges a client ID + secret for an access token, then uses that token as the Bearer on /mcp. The existing BearerAuth middleware stays unchanged — the issued token is just BRAIN_MCP_TOKEN.

What to build

New package: ingestion/internal/oauth/

metadata.go — serves GET /.well-known/oauth-authorization-server:

{
  "issuer": "https://brain-mcp.d-ma.be",
  "token_endpoint": "https://brain-mcp.d-ma.be/oauth/token",
  "grant_types_supported": ["client_credentials"],
  "token_endpoint_auth_methods_supported": ["client_secret_post", "client_secret_basic"]
}

token.go — serves POST /oauth/token:

  • Accepts grant_type=client_credentials + client_id + client_secret (form-encoded or Basic auth)
  • Validates against OAUTH_CLIENT_ID + OAUTH_CLIENT_SECRET env vars
  • Returns {"access_token": "<BRAIN_MCP_TOKEN>", "token_type": "bearer"}
  • Returns 400/401 on bad grant type or wrong credentials

Routes added to ingestion/cmd/server/main.go

GET  /.well-known/oauth-authorization-server  (no auth)
POST /oauth/token                              (no auth — credentials in body)

Existing /mcp route with BearerAuth unchanged.

New env vars

Var Purpose
OAUTH_CLIENT_ID Client ID to register for claude.ai
OAUTH_CLIENT_SECRET Client secret to register for claude.ai

Both required at startup (exit 1 if missing, same pattern as BRAIN_MCP_TOKEN).

Setup after shipping

  1. Set OAUTH_CLIENT_ID + OAUTH_CLIENT_SECRET in the ingestion pod Secret (SOPS-encrypted in infra repo)
  2. In claude.ai Settings → Integrations: URL = https://brain-mcp.d-ma.be/mcp, client ID + secret matching above
  3. claude.ai discovers token endpoint → exchanges credentials → Bearer works

Out of scope

  • Authorization code flow (not needed for single-user personal server)
  • JWT tokens (static BRAIN_MCP_TOKEN is sufficient)
  • Token expiry / refresh (long-lived token acceptable here)
## Problem claude.ai only supports OAuth 2.0 for remote MCP servers — no static Bearer token option in the UI (only client ID + client secret fields). The brain MCP endpoint at `https://brain-mcp.d-ma.be/mcp` currently uses a static Bearer token (`BRAIN_MCP_TOKEN`), which claude.ai cannot use. Result: "Couldn't reach the MCP server" in claude.ai — the server returns `401` with no `WWW-Authenticate` header, so claude.ai has no way to authenticate. ## Solution Implement a minimal OAuth 2.0 client credentials flow on the ingestion server. claude.ai exchanges a client ID + secret for an access token, then uses that token as the Bearer on `/mcp`. The existing `BearerAuth` middleware stays unchanged — the issued token is just `BRAIN_MCP_TOKEN`. ## What to build ### New package: `ingestion/internal/oauth/` **`metadata.go`** — serves `GET /.well-known/oauth-authorization-server`: ```json { "issuer": "https://brain-mcp.d-ma.be", "token_endpoint": "https://brain-mcp.d-ma.be/oauth/token", "grant_types_supported": ["client_credentials"], "token_endpoint_auth_methods_supported": ["client_secret_post", "client_secret_basic"] } ``` **`token.go`** — serves `POST /oauth/token`: - Accepts `grant_type=client_credentials` + `client_id` + `client_secret` (form-encoded or Basic auth) - Validates against `OAUTH_CLIENT_ID` + `OAUTH_CLIENT_SECRET` env vars - Returns `{"access_token": "<BRAIN_MCP_TOKEN>", "token_type": "bearer"}` - Returns `400`/`401` on bad grant type or wrong credentials ### Routes added to `ingestion/cmd/server/main.go` ``` GET /.well-known/oauth-authorization-server (no auth) POST /oauth/token (no auth — credentials in body) ``` Existing `/mcp` route with `BearerAuth` unchanged. ### New env vars | Var | Purpose | |-----|---------| | `OAUTH_CLIENT_ID` | Client ID to register for claude.ai | | `OAUTH_CLIENT_SECRET` | Client secret to register for claude.ai | Both required at startup (exit 1 if missing, same pattern as `BRAIN_MCP_TOKEN`). ## Setup after shipping 1. Set `OAUTH_CLIENT_ID` + `OAUTH_CLIENT_SECRET` in the ingestion pod Secret (SOPS-encrypted in infra repo) 2. In claude.ai Settings → Integrations: URL = `https://brain-mcp.d-ma.be/mcp`, client ID + secret matching above 3. claude.ai discovers token endpoint → exchanges credentials → Bearer works ## Out of scope - Authorization code flow (not needed for single-user personal server) - JWT tokens (static `BRAIN_MCP_TOKEN` is sufficient) - Token expiry / refresh (long-lived token acceptable here)
Author
Owner

Superseded by #6. Dex is already deployed at auth.d-ma.be — the correct approach is JWT validation middleware with static token fallback, not a bespoke client credentials server in the ingestion pod. Closing in favour of #6.

Superseded by #6. Dex is already deployed at `auth.d-ma.be` — the correct approach is JWT validation middleware with static token fallback, not a bespoke client credentials server in the ingestion pod. Closing in favour of #6.
Author
Owner

Shipped in 58c5741. CI/CD auto-rebuilds the ingestion image and updates infra.

What landed

New package ingestion/internal/oauth:

  • MetadataHandler(issuer)GET /.well-known/oauth-authorization-server
  • TokenHandler(cfg)POST /oauth/token with client_credentials grant only

Wiring in cmd/server/main.go: routes mount only when both OAUTH_CLIENT_ID and OAUTH_CLIENT_SECRET are set. Setting one alone → exit 1 (deliberate fail-fast on misconfiguration). MCP_RESOURCE_URL doubles as the issuer.

The access token returned is BRAIN_MCP_TOKEN — no JWT machinery downstream, so the dual-auth middleware shipped in #9 already accepts it.

Acceptance criteria

Tests (ingestion/internal/oauth/{metadata,token}_test.go):

  • client_secret_post: form-encoded client_id/client_secret → 200 + {access_token, token_type: "bearer"}
  • client_secret_basic: HTTP Basic header → 200
  • Wrong client_secret → 401 + {"error":"invalid_client"}
  • Bad grant_type (e.g. password) → 400 + {"error":"unsupported_grant_type"}
  • GET → 405
  • Malformed Basic header → 401 (no crash)
  • Basic without : → 401
  • Metadata: correct issuer, token endpoint, grant types, auth methods
  • Metadata: trailing slash on issuer URL stripped

Setup before claude.ai can use it

  1. Generate creds: OAUTH_CLIENT_ID=brain-mcp-claudeai, OAUTH_CLIENT_SECRET=$(openssl rand -hex 32)
  2. Add both to infra/k3s/apps/supervisor/secrets.enc.yaml via SOPS
  3. Wait for rollout (secrets-revision annotation bump or pod restart)
  4. claude.ai → Settings → Integrations: URL = https://brain-mcp.d-ma.be/mcp, client ID + secret matching above
  5. claude.ai fetches /.well-known/oauth-authorization-server → exchanges creds → uses Bearer on /mcp

Closing.

Shipped in `58c5741`. CI/CD auto-rebuilds the ingestion image and updates infra. ### What landed New package `ingestion/internal/oauth`: - `MetadataHandler(issuer)` — `GET /.well-known/oauth-authorization-server` - `TokenHandler(cfg)` — `POST /oauth/token` with `client_credentials` grant only Wiring in `cmd/server/main.go`: routes mount only when **both** `OAUTH_CLIENT_ID` and `OAUTH_CLIENT_SECRET` are set. Setting one alone → exit 1 (deliberate fail-fast on misconfiguration). `MCP_RESOURCE_URL` doubles as the issuer. The access token returned is `BRAIN_MCP_TOKEN` — no JWT machinery downstream, so the dual-auth middleware shipped in #9 already accepts it. ### Acceptance criteria Tests (`ingestion/internal/oauth/{metadata,token}_test.go`): - [x] `client_secret_post`: form-encoded `client_id`/`client_secret` → 200 + `{access_token, token_type: "bearer"}` - [x] `client_secret_basic`: HTTP Basic header → 200 - [x] Wrong `client_secret` → 401 + `{"error":"invalid_client"}` - [x] Bad `grant_type` (e.g. `password`) → 400 + `{"error":"unsupported_grant_type"}` - [x] GET → 405 - [x] Malformed Basic header → 401 (no crash) - [x] Basic without `:` → 401 - [x] Metadata: correct issuer, token endpoint, grant types, auth methods - [x] Metadata: trailing slash on issuer URL stripped ### Setup before claude.ai can use it 1. Generate creds: `OAUTH_CLIENT_ID=brain-mcp-claudeai`, `OAUTH_CLIENT_SECRET=$(openssl rand -hex 32)` 2. Add both to `infra/k3s/apps/supervisor/secrets.enc.yaml` via SOPS 3. Wait for rollout (`secrets-revision` annotation bump or pod restart) 4. claude.ai → Settings → Integrations: URL = `https://brain-mcp.d-ma.be/mcp`, client ID + secret matching above 5. claude.ai fetches `/.well-known/oauth-authorization-server` → exchanges creds → uses Bearer on `/mcp` Closing.
Sign in to join this conversation.
No Label
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: mathias/hyperguild#5