Appearance
MCP OAuth Authentication
MCP servers in DaraMex support four authentication types. The two OAuth types allow agents to connect to modern SaaS MCPs (Notion, Linear, Postman) that require browser-based authorization, and to service MCPs that use machine-to-machine credentials.
Auth Types
authType | Description | Use Case |
|---|---|---|
none | No authentication | Public MCPs |
static_headers | Fixed headers (API key) | Admin pastes a token manually |
oauth_auth_code | OAuth 2.0 Authorization Code + PKCE | Modern SaaS MCPs — user authorizes via browser popup |
client_credentials | OAuth Machine-to-Machine | Service MCPs — no human interaction |
Existing servers without authType are treated as static_headers if they have encrypted headers, otherwise none. No behavioral change for pre-existing servers.
Connection Status
Each MCP server tracks a connectionStatus field that drives frontend UX without exposing token details.
stateDiagram-v2
[*] --> disconnected: Server created (OAuth/CC)
disconnected --> auth_pending: POST .../oauth/initiate
auth_pending --> connected: Callback success
auth_pending --> disconnected: Callback failure
connected --> needs_reauth: Token refresh failed
connected --> disconnected: Auth config changed
needs_reauth --> auth_pending: Admin re-initiates OAuth
needs_reauth --> disconnected: Auth config changed
| Status | Meaning |
|---|---|
disconnected | Initial state for OAuth/CC servers, or after auth config change |
auth_pending | OAuth initiation done, waiting for provider callback |
connected | Tokens stored and valid |
needs_reauth | Token refresh failed — admin must re-authorize |
OAuth Authorization Code + PKCE Flow
This is the primary flow for connecting to SaaS MCPs. It happens in three steps.
Step 1 — Create the MCP Server
Admin creates a server with authType: 'oauth_auth_code' and an oauthConfig object containing endpoint URLs and optionally a clientId.
If no clientId is provided, the backend attempts Dynamic Client Registration (DCR) per RFC 7591: it fetches /.well-known/oauth-authorization-server from the MCP server's base URL, discovers endpoints, and registers a client automatically. If DCR fails, an error tells the admin to provide clientId manually.
The server starts in disconnected status — it has config but no tokens yet.
Step 2 — Initiate OAuth
sequenceDiagram
participant F as Frontend
participant B as Backend API
participant P as OAuth Provider
F->>B: POST /:serverId/oauth/initiate
B->>B: Generate code_verifier (32 random bytes, base64url)
B->>B: Generate code_challenge = SHA-256(code_verifier)
B->>B: Generate state (32 random bytes, hex)
B->>B: Encrypt code_verifier, store state + 15-min TTL
B->>B: Set connectionStatus = auth_pending
B-->>F: { authorizationUrl }
F->>P: window.open(authorizationUrl)
Note over F,P: User authorizes in popup
The backend generates a PKCE challenge pair and a random state token (CSRF protection). Both are stored on the server entity — the code_verifier encrypted, the state with a 15-minute TTL. The constructed authorization URL includes response_type=code, code_challenge (S256), state, client_id, redirect_uri, and scope.
The frontend opens this URL in a popup window.
Step 3 — OAuth Callback
sequenceDiagram
participant P as OAuth Provider
participant B as Backend API (public endpoint)
participant DB as Database
P->>B: GET /:serverId/oauth/callback?code=ABC&state=Y
B->>B: Load server by (serverId, agentId)
B->>B: Timing-safe state comparison (crypto.timingSafeEqual)
B->>B: Check state TTL (15 min)
B->>B: Decrypt PKCE code_verifier
B->>P: POST tokenEndpoint (authorization_code grant + code_verifier)
P-->>B: { access_token, refresh_token, expires_in }
B->>DB: BEGIN TRANSACTION
B->>DB: Encrypt + save tokens to agent_mcp_oauth_tokens
B->>DB: Set connectionStatus = connected, clear PKCE state
B->>DB: COMMIT
B-->>P: HTML: postMessage({ type: 'mcp-oauth-callback', status: 'success' }) + window.close()
The callback endpoint is public (@PublicRoute()) because the browser redirect from the OAuth provider cannot carry DaraMex session cookies. The state parameter serves as the CSRF protection mechanism (RFC 6749 section 10.12).
On success, token storage and status update are wrapped in a single DB transaction. The response is an HTML page that uses postMessage with a specific targetOrigin (from APP_FRONTEND_URL) to notify the opener window, then auto-closes the popup.
On failure (state mismatch, expired, token exchange error), PKCE state is cleared to prevent replay and connectionStatus resets to disconnected.
Client Credentials Flow
No popup or user interaction required. The admin provides clientId, clientSecret, and tokenEndpoint at server creation time.
Tokens are acquired on-demand during tool resolution: when McpAuthProvider.tokens() is called and no valid token exists, it POSTs grant_type=client_credentials to the token endpoint, encrypts and stores the result, and sets connectionStatus = connected.
Token Refresh
Token refresh is transparent and happens during tool resolution (McpToolProvider.build() calls McpAuthProvider.tokens()):
| Condition | Action |
|---|---|
| Token valid | Return it |
Token expired + has refresh_token | POST grant_type=refresh_token to token endpoint, save new tokens |
Token expired + no refresh_token | Set needs_reauth, skip server (graceful degradation) |
| Client credentials expired | Re-acquire with grant_type=client_credentials |
No background refresh jobs — the SDK calls tokens() before every MCP request, so idle servers don't waste refreshes.
Tool Resolution with Auth
McpToolProvider.build() determines how to create MCP clients per server:
none/static_headers:createMCPClient({ headers })— existing behavior, unchanged.oauth_auth_code/client_credentials:McpAuthProviderFactory.create(server)produces a per-serverMcpAuthProviderinstance →createMCPClient({ authProvider })— the SDK handles theAuthorizationheader automatically.connectionStatus === 'needs_reauth': Server is skipped with a warning log. Other servers continue normally.
Key Architecture Decisions
| Decision | Choice | Rationale |
|---|---|---|
| McpAuthProvider design | Per-server instance, NOT NestJS singleton | SDK expects one OAuthClientProvider per transport. Factory (McpAuthProviderFactory) is injectable. |
| Token storage | Separate agent_mcp_oauth_tokens table (1:1 with server) | Tokens change hourly on refresh. Separate table avoids rewriting server config row. |
| PKCE state storage | Columns on agent_mcp_servers entity | One active flow at a time per server. No cache infrastructure needed. |
| Callback auth | Public endpoint, state param as CSRF | Browser redirect from external OAuth provider cannot carry DaraMex auth headers. |
| DCR discovery | Standalone McpDcrService | Reusable across create/update. Fetches /.well-known/oauth-authorization-server, registers client. |
API Contract
New Endpoints
POST /agents/:agentId/mcp-servers/:serverId/oauth/initiate
- Auth:
@Auth()+@CheckPolicies(can('update', 'ai.mcp')) - Rate limit: 5 requests / 60 seconds per user
- Request: empty body
- Response 200:
{ authorizationUrl: string } - Errors: 404 server not found, 422
AI.MCP_AUTH_NOT_CONFIGURED(non-OAuth type)
GET /agents/:agentId/mcp-servers/:serverId/oauth/callback
- Auth: none (public — browser redirect from OAuth provider)
- Query params:
code: string,state: string - Response 200: HTML page with
postMessageto opener +window.close() - Errors: 422 state mismatch/expired, 502 token exchange failed
Modified Endpoints
| Endpoint | Change |
|---|---|
POST /agents/:agentId/mcp-servers | Accepts authType + oauthConfig. Triggers DCR if oauth_auth_code without clientId. |
PATCH /agents/:agentId/mcp-servers/:serverId | Accepts auth config changes. If oauthConfig changes: resets connectionStatus + deletes tokens. |
GET /agents/:agentId/mcp-servers (list) | Response now includes authType and connectionStatus. |
GET /agents/:agentId/mcp-servers/:serverId (detail) | Response includes oauthConfig with clientSecret redacted as ••••••••. |
Data Model Impact
Extended ai.agent_mcp_servers Columns
| Column | Type | Default | Notes |
|---|---|---|---|
auth_type | text NOT NULL | 'none' | Discriminator |
oauth_config_encrypted | text nullable | null | AES-256-GCM encrypted JSON |
connection_status | text NOT NULL | 'disconnected' | Frontend-visible state |
oauth_state | text nullable | null | Active PKCE state token |
oauth_state_expires_at | timestamptz nullable | null | 15-min TTL |
code_verifier_encrypted | text nullable | null | PKCE verifier, encrypted |
dcr_client_id | text nullable | null | From Dynamic Client Registration |
dcr_client_secret_encrypted | text nullable | null | DCR secret, encrypted |
New Table: ai.agent_mcp_oauth_tokens
| Column | Type | Constraints | Notes |
|---|---|---|---|
id | uuid PK | uuidv7 | Base entity |
server_id | uuid FK | NOT NULL, UNIQUE, CASCADE delete | 1:1 with server |
access_token_encrypted | text | NOT NULL | AES-256-GCM |
refresh_token_encrypted | text | nullable | Not all grants issue refresh tokens |
token_type | text | NOT NULL, default 'Bearer' | |
scope | text | nullable | Space-separated granted scopes |
expires_at | timestamptz | nullable | Unencrypted for query efficiency |
Failure Modes
| Code | HTTP | Trigger |
|---|---|---|
AI.MCP_AUTH_NOT_CONFIGURED | 422 | Initiate OAuth on none/static_headers server |
AI.MCP_OAUTH_STATE_MISMATCH | 422 | Callback state does not match stored value |
AI.MCP_OAUTH_STATE_EXPIRED | 422 | Callback arrives after 15-minute window |
AI.MCP_OAUTH_TOKEN_EXCHANGE_FAILED | 502 | Token endpoint returns error |
AI.MCP_DCR_FAILED | 502 | DCR discovery or registration fails |
AI.MCP_OAUTH_CALLBACK_FAILED | 400 | Generic callback failure |
| Token refresh failed | N/A (internal) | Logged as warning, sets needs_reauth, server skipped |
Graceful degradation: if one server fails during tool resolution, other servers continue normally. The chat does not crash.
Security
- Encryption at rest: All secrets (
oauthConfig.clientSecret,access_token,refresh_token,code_verifier,dcr_client_secret) encrypted with AES-256-GCM viaEncryptionService. - PKCE S256: Prevents authorization code interception.
- Timing-safe state comparison:
crypto.timingSafeEqual()prevents timing attacks on the state parameter. - State TTL: 15-minute expiry on PKCE state prevents stale authorization flows.
- SSRF guard: All URLs (server URL + OAuth endpoints) validated against private IPs, loopback, and link-local addresses.
- Tokens never in responses: Only
connectionStatusis visible.clientSecretredacted to••••••••in detail DTO. - Transactional token save: Token storage + status update wrapped in DB transaction.
- postMessage targetOrigin: Uses
APP_FRONTEND_URL, not'*'. - Rate limiting: OAuth initiate endpoint limited to 5 requests / 60 seconds per user.
- CASCADE delete: Deleting a server automatically deletes its tokens via FK constraint.
Frontend Status
The backend is fully implemented and tested. The frontend (agent-mcp-workspace.tsx) currently only supports basic CRUD (name, url, transport type, headers). The following frontend work is pending:
- Auth type selector in create/edit form
- Conditional OAuth config fields based on selected authType
initiateOAuth()service method + endpoint definition- Popup window +
postMessagelistener for callback connectionStatusdisplay (replacing the currentisEnabled + lastConnectionTestAtheuristic)needs_reauthindicator with re-authorization button
Source Paths
apps/api/src/modules/ai/domain/entities/agent-mcp-server.entity.tsapps/api/src/modules/ai/domain/entities/agent-mcp-oauth-token.entity.tsapps/api/src/modules/ai/domain/repositories/agent-mcp-oauth-token.repository.interface.tsapps/api/src/modules/ai/application/commands/mcp-server/initiate-mcp-oauth.command.tsapps/api/src/modules/ai/application/commands/mcp-server/handle-mcp-oauth-callback.command.tsapps/api/src/modules/ai/application/commands/mcp-server/create-mcp-server.command.tsapps/api/src/modules/ai/application/commands/mcp-server/update-mcp-server.command.tsapps/api/src/modules/ai/application/services/mcp-auth.provider.tsapps/api/src/modules/ai/application/services/mcp-auth-provider.factory.tsapps/api/src/modules/ai/application/services/mcp-dcr.service.tsapps/api/src/modules/ai/application/tools/mcp/mcp-tool.provider.tsapps/api/src/modules/ai/application/tools/mcp/mcp-ssrf-guard.tsapps/api/src/modules/ai/infrastructure/persistence/agent-mcp-oauth-token.persistence.tsapps/api/src/modules/ai/infrastructure/mappers/agent-mcp-oauth-token.mapper.tsapps/api/src/modules/ai/infrastructure/repositories/agent-mcp-oauth-token.repository.impl.tsapps/api/src/modules/ai/presentation/controllers/mcp-server.controller.tspackages/schemas/src/ai/agents/agent-mcp-oauth.schema.tspackages/schemas/src/ai/agents/agent-mcp-server.schema.tsapps/panel/src/features/dashboard/components/ai-agents/mcp/agent-mcp-workspace.tsx
Change Log
- 2026-04-08: Initial documentation. Backend fully implemented (SDD cycle complete). Frontend pending.