MCP OAuth Integration#
MCP providers in Dify support OAuth 2.0 for authenticating against protected MCP servers. The flow covers two provider types with separate pipelines:
- MCP providers β flow managed by
core/mcp/auth/auth_flow.pywith state stored in Redis and tokens persisted per-provider viaMCPToolManageService - Builtin (plugin) tool providers β flow managed by
OAuthProxyService+OAuthHandler, with credentials stored inBuiltinToolProviderviaBuiltinToolManageService
Both flows share the same frontend callback page and window-messaging mechanism.
End-to-End OAuth Flow#
1. Initiate (Backend)#
MCP provider: The frontend calls POST /workspaces/current/tool-provider/mcp/auth with a provider_id. The backend calls auth() from core/mcp/auth/auth_flow.py, which discovers OAuth metadata (RFC 8414 / RFC 9470), determines grant type (Authorization Code or Client Credentials), handles dynamic client registration if needed, and returns an AuthResult containing an authorization_url and a list of AuthAction objects .
Builtin/plugin tool provider: The frontend calls GET /oauth/plugin/<provider>/tool/authorization-url with an optional visibility query parameter (only_me or all_team_members). The backend validates the visibility, defaulting to only_me if absent or invalid, then creates a one-time CSRF context via OAuthProxyService.create_proxy_context(), storing the chosen visibility in the context's extra_data. The context includes a context_id cookie (HTTPOnly, SameSite=Lax, 5-minute TTL) and returns the authorization URL .
2. User Redirect β External Provider#
The frontend opens the authorization URL in a popup via openOAuthPopup(). The popup is centered at 600Γ600 px. A message event listener is registered to receive the callback result .
3. Callback (Backend)#
MCP provider: The provider redirects to /mcp/oauth/callback?code=...&state=.... ToolMCPCallbackApi calls handle_callback(), which validates the Redis state, exchanges the code for tokens, then calls mcp_service.save_oauth_data() to persist the tokens. It then redirects the popup to {CONSOLE_WEB_URL}/oauth-callback .
Builtin/plugin provider: The provider redirects to /oauth/plugin/<provider>/tool/callback. ToolOAuthCallback reads the context_id cookie and calls OAuthProxyService.use_proxy_context() (which deletes the Redis key, preventing replay), exchanges the code for credentials, retrieves the visibility value from the context's extra_data (falling back to only_me for older cookies or unexpected values), then persists the credentials via BuiltinToolManageService.add_builtin_tool_provider() with the chosen visibility. The handler then redirects the popup to {CONSOLE_WEB_URL}/oauth-callback .
4. Frontend Callback Page β window.postMessage#
/oauth-callback renders only useOAuthCallback(). On mount, the hook reads subscription_id, error, and error_description from the URL params. If window.opener exists, it posts a { type: 'oauth_callback', ... } message to the opener using window.opener.origin as the target origin (not '*'), then closes itself .
Three message shapes:
| Condition | Payload |
|---|---|
subscription_id present | { type, success: true, subscriptionId } |
error present | { type, success: false, error, errorDescription } |
| Neither | { type } (bare β popup closed manually) |
5. Parent Window Handles Result#
The handleMessage listener registered in step 2 fires, removes itself, and calls the user-supplied callback(event.data) . A polling fallback via setInterval (1 s) detects if the popup was closed without posting a message (e.g., user dismissed it) and calls callback() with no argument .
Token Persistence#
MCP provider tokens are saved into the encrypted MCPProvider record by MCPToolManageService.save_oauth_data(). The function maps OAuthDataType values (TOKENS, CLIENT_INFO, CODE_VERIFIER) to the right credential fields before encrypting . execute_auth_actions() iterates over all AuthAction objects in the AuthResult and calls save_oauth_data() for each one β decoupling network-level auth from database writes .
Builtin/plugin provider tokens are written as a new BuiltinToolProvider row with credential_type=oauth2, the user-chosen visibility (only_me or all_team_members), and expires_at from the token response. The visibility is passed as a query parameter when initiating the OAuth flow, stored in the OAuth proxy context, and retrieved during the callback. Encryption follows the same AES path used for API keys .
Error Handling#
| Scenario | Behavior |
|---|---|
window.opener is null (direct navigation to /oauth-callback) | Hook does nothing β page renders an empty <div />. No message is sent. |
MCPAuthError on connect | auth() is called with the error's resource_metadata_url and scope_hint; result redirects user for authorization |
MCPRefreshTokenError | Provider credentials are cleared and a ValueError is raised, prompting re-authorization |
Generic MCPError / ValueError | Credentials are cleared, URL is sanitized before logging |
| OAuth authorize page renders blank (issue #38543) | Fixed in PR #38546: layout now always renders children inside the standard container; removed login-state probing logic that silently failed before the fix |
Key Files#
| File | Role |
|---|---|
web/hooks/use-oauth.ts | useOAuthCallback (callback page hook) and openOAuthPopup (popup launcher + message listener) |
web/app/oauth-callback/page.tsx | Thin callback page β mounts useOAuthCallback, renders <div /> |
api/controllers/console/workspace/tool_providers.py | ToolMCPAuthApi (MCP auth), ToolMCPCallbackApi (MCP callback), ToolPluginOAuthApi (plugin auth), ToolOAuthCallback (plugin callback) |
api/core/mcp/auth/auth_flow.py | auth() β OAuth metadata discovery, grant type selection, token exchange; handle_callback() β state validation + code exchange |
api/services/tools/mcp_tools_manage_service.py | save_oauth_data(), execute_auth_actions() β token persistence for MCP providers |
api/services/plugin/oauth_service.py | OAuthProxyService β Redis-backed CSRF context for builtin/plugin providers |