OAuth for MCP Tools
MCP servers that front SaaS products — HubSpot, Slack, GitHub, Jira, Google — need per-user OAuth: the tool must act as this user, with their consented scopes, not with one shared service credential. Handing every agent and chat client a raw OAuth access token is how tokens end up in logs, repos, and prompt history.
Brutor’s OAuth proxy solves this with one architectural rule:
Real OAuth tokens never leave the gateway. The client completes the OAuth dance through the proxy; the upstream
access_token/refresh_tokenare encrypted (ENCRYPTION_KEY) and stored server-side; the client receives only a Gateway JWT. On every MCP call, the gateway injects the real token upstream.
If a client is compromised, you revoke a JWT — the SaaS token was never in the client’s hands. Refresh happens server-side too, invisibly.
Endpoints
Section titled “Endpoints”Everything lives under one prefix that binds an MCP server to an identity provider:
/v1/proxy/oauth/{mcp_server_id}/{idp_server_id}| Method + path | Purpose |
|---|---|
GET .../.well-known/oauth-protected-resource |
Protected-resource metadata, URLs rewritten to the proxy |
GET .../.well-known/openid-configuration |
OIDC discovery, URLs rewritten to the proxy |
POST .../token |
Token exchange (authorization_code, refresh_token) — returns a Gateway JWT |
POST .../revoke |
Revoke the stored upstream token + gateway session |
GET .../tokens |
List the caller’s stored token records (metadata, never secrets) |
POST .../register |
RFC 7591 Dynamic Client Registration, proxied to the IdP |
MCP clients usually never construct these URLs by hand — the governed MCP endpoint advertises them:
GET /v1/proxy/mcp/{server_id}/.well-known/oauth-protected-resourceGET /v1/proxy/mcp/{server_id}/.well-known/openid-configurationThe flow
Section titled “The flow”MCP Client Brutor Proxy IdP (GitHub, HubSpot, ...) │ │ │ │ GET .well-known/* │ │ │─────────────────────────────>│ GET .well-known/* │ │ │──────────────────────────────>│ │ │ (original discovery doc) │ │ (rewritten URLs) │<──────────────────────────────│ │<─────────────────────────────│ │ │ │ │ │ (user completes OAuth │ │ │ consent in the browser) │ │ │ │ │ │ POST /token (auth_code) │ │ │─────────────────────────────>│ POST /token (auth_code) │ │ │──────────────────────────────>│ │ │ (access_token, refresh_token)│ │ │<──────────────────────────────│ │ │ encrypt + store tokens │ │ (Gateway JWT only) │ │ │<─────────────────────────────│ │ │ │ │ │ MCP request + Gateway JWT │ │ │─────────────────────────────>│ inject stored access_token │ │ │ ──> upstream MCP server │-
Discovery. The client fetches the
.well-knownmetadata via the proxy. The gateway forwards to the IdP and rewrites the authorization/token/registration URLs so every subsequent OAuth hop also transits the proxy. Defaults are filled in for sparse IdPs (response_types_supported: ["code"],grant_types_supported: ["authorization_code", "refresh_token"], PKCES256). -
Consent. The user’s browser completes the normal authorization redirect at the IdP and comes back with an authorization
code. -
Token exchange. The client posts the code (form-encoded, PKCE
code_verifiersupported) to the proxy’s/token:Terminal window curl -s -X POST \"http://localhost:8100/v1/proxy/oauth/mcp-01KS.../idp-01HG.../token" \-H "Content-Type: application/x-www-form-urlencoded" \-d "grant_type=authorization_code" \-d "code=4/0AY0e-..." \-d "redirect_uri=http://localhost:8085/callback" \-d "client_id=hs-client-..." \-d "code_verifier=dBjftJeZ4CVP..."The gateway exchanges the code with the IdP, encrypts and stores the real tokens, normalizes the user profile, and returns a Gateway JWT:
{"access_token": "eyJhbGciOiJIUzI1NiIs...","token_type": "Bearer","expires_in": 3600,"scope": "crm.objects.contacts.read crm.objects.deals.read","refresh_token": "def502...","user": {"user_id": "idp|1234567","username": "martin","email": "martin@example.com","name": "Martin B"},"token_info": {"type": "mcp_auth","description": "Use this token for all requests to MCP server via the gateway","oauth_stored": true,"auto_refresh": true}}The
access_tokenhere is a gateway-minted JWT, not the IdP’s token.token_info.oauth_stored: trueconfirms the real credentials are vaulted server-side;auto_refresh: truemeans the gateway will renew them with the stored refresh token without client involvement. (Token exchange is idempotency-locked, so retry-happy clients can’t double-spend an auth code.) -
Use it. The client sends the Gateway JWT on normal MCP calls; the gateway looks up the caller’s stored upstream token, decrypts it, and injects it toward the MCP server:
Terminal window curl -s -X POST http://localhost:8100/v1/proxy/mcp/mcp-01KS... \-H "Authorization: Bearer eyJhbGciOiJIUzI1NiIs..." \-H "Content-Type: application/json" \-H "Accept: application/json, text/event-stream" \-d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"get_contacts","arguments":{"limit":10}}}'
Token management
Section titled “Token management”List the caller’s stored token records — metadata only, secrets never leave the vault:
curl -s "http://localhost:8100/v1/proxy/oauth/mcp-01KS.../idp-01HG.../tokens" \ -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIs..."{ "user_id": "idp|1234567", "tenant_id": "default", "tokens": [ {"mcp_server_id": "mcp-01KS...", "idp_server_id": "idp-01HG...", "scope": "crm.objects.contacts.read", "expires_at": "2026-07-02T12:00:00Z"} ]}Revoke when a user disconnects a tool:
curl -s -X POST "http://localhost:8100/v1/proxy/oauth/mcp-01KS.../idp-01HG.../revoke" \ -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIs..."{"revoked": true}Dynamic Client Registration (DCR)
Section titled “Dynamic Client Registration (DCR)”For IdPs that support RFC 7591, MCP clients can self-register through the proxy instead of an operator pre-creating an OAuth app:
curl -s -X POST "http://localhost:8100/v1/proxy/oauth/mcp-01KS.../idp-01HG.../register" \ -H "Content-Type: application/json" \ -d '{ "client_name": "My MCP Client", "redirect_uris": ["http://localhost:8085/callback"], "grant_types": ["authorization_code", "refresh_token"], "token_endpoint_auth_method": "none" }'The proxy forwards the registration to the IdP’s registration endpoint and relays the issued client_id (and secret, if any). IdPs without DCR support return an error explaining that the operator must configure the OAuth app manually.
Why this design holds up
Section titled “Why this design holds up”- No token sprawl — one encrypted store, per user × server × IdP, instead of N copies in N clients.
- Blast-radius control — a leaked Gateway JWT expires in
JWT_EXPIRES_INseconds and can’t be replayed against the SaaS API directly; the upstream token it referenced stays vaulted. - Multi-server, multi-identity — a Virtual MCP aggregating five OAuth-backed servers works with zero extra client logic: each delegated upstream call gets its own stored token injected.
- Auditable — every token exchange, refresh, and revocation is logged like all gateway traffic.
