Skip to content

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_token are 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.

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-resource
GET /v1/proxy/mcp/{server_id}/.well-known/openid-configuration
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 │
  1. Discovery. The client fetches the .well-known metadata 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"], PKCE S256).

  2. Consent. The user’s browser completes the normal authorization redirect at the IdP and comes back with an authorization code.

  3. Token exchange. The client posts the code (form-encoded, PKCE code_verifier supported) 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_token here is a gateway-minted JWT, not the IdP’s token. token_info.oauth_stored: true confirms the real credentials are vaulted server-side; auto_refresh: true means 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.)

  4. 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}}}'

List the caller’s stored token records — metadata only, secrets never leave the vault:

Terminal window
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:

Terminal window
curl -s -X POST "http://localhost:8100/v1/proxy/oauth/mcp-01KS.../idp-01HG.../revoke" \
-H "Authorization: Bearer eyJhbGciOiJIUzI1NiIs..."
{"revoked": true}

For IdPs that support RFC 7591, MCP clients can self-register through the proxy instead of an operator pre-creating an OAuth app:

Terminal window
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.

  • 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_IN seconds 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.