Skip to content

feat(mcp): OAuth 2.1 + PKCE for outbound MCP servers#4441

Open
waleedlatif1 wants to merge 34 commits intostagingfrom
waleedlatif1/mcp-oauth
Open

feat(mcp): OAuth 2.1 + PKCE for outbound MCP servers#4441
waleedlatif1 wants to merge 34 commits intostagingfrom
waleedlatif1/mcp-oauth

Conversation

@waleedlatif1
Copy link
Copy Markdown
Collaborator

Summary

  • Adds spec-compliant OAuth 2.1 + PKCE support for outbound MCP servers via the SDK's OAuthClientProvider
  • Auto-detects OAuth requirement on server create via unauthenticated probe (WWW-Authenticate / oauth-protected-resource)
  • Persists per-user-per-server tokens (encrypted) in new mcp_server_oauth table; SDK refreshes automatically before expiry
  • Popup-based consent flow (/api/mcp/oauth/start/api/mcp/oauth/callback) with state CSRF protection
  • Pre-registered OAuth client support (Client ID + Secret in Advanced settings) for servers without RFC 7591 DCR
  • Surfaces reauth_required from tool execution when refresh token is invalid so the UI can prompt to reconnect

Type of Change

  • New feature

Testing

Tested manually against OAuth-protected MCP servers (Linear). Existing header-auth servers regression-checked.

Checklist

  • Code follows project style guidelines
  • Self-reviewed my changes
  • Tests added/updated and passing
  • No new warnings introduced
  • I confirm that I have read and agree to the terms outlined in the Contributor License Agreement (CLA)

@vercel
Copy link
Copy Markdown

vercel Bot commented May 5, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
docs Skipped Skipped May 6, 2026 3:29am

Request Review

@cursor
Copy link
Copy Markdown

cursor Bot commented May 5, 2026

PR Summary

High Risk
Introduces new OAuth token storage and auth flows (including popup + callback) and changes MCP client connection logic; mistakes could impact authentication security, token isolation, or connectivity across users/servers.

Overview
Adds OAuth 2.1 + PKCE support for outbound MCP servers, including new /api/mcp/oauth/start and /api/mcp/oauth/callback endpoints that drive the SDK auth flow, persist state/verifiers, and refresh discovered tools after authorization.

Extends MCP server CRUD to support authType plus optional pre-registered OAuth client credentials (stored encrypted), auto-detects OAuth need via a probe on create, and clears per-user OAuth records when URL/credentials change; server responses now include hasOauthClientSecret instead of returning secrets.

Updates MCP execution and connectivity to treat OAuth reauth as a first-class state: McpClient/McpService attach an authProvider per user, the connection manager scopes persistent connections by serverId:userId, and tool execution returns a 401 reauth_required payload when OAuth authorization is needed. UI adds an advanced section for OAuth credentials and a Connect with OAuth flow using a new useMcpOauthPopup hook, plus query-key/mutation refactors to keep server/tool lists in sync.

Reviewed by Cursor Bugbot for commit ddae8eb. Configure here.

Comment thread apps/sim/lib/mcp/oauth/provider.ts
Comment thread apps/sim/hooks/queries/mcp.ts
@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented May 5, 2026

Greptile Summary

This PR adds OAuth 2.1 + PKCE support for outbound MCP servers, including a popup-based consent flow, per-user encrypted token storage in a new mcp_server_oauth table, automatic OAuth type detection via an unauthenticated probe, and pre-registered client credential support for servers without RFC 7591 DCR.

  • New mcp_server_oauth table holds per-(server, user) OAuth artifacts (client info, tokens, PKCE verifier, state hash); all sensitive fields are encrypted at rest using the existing encryptSecret/decryptSecret helpers.
  • Connection manager now keys OAuth connections per-user (preventing cross-user token leakage), while header/no-auth connections share a single keyed slot as before.
  • reauth_required response code is surfaced from tool execution when the refresh token is invalid, and UnauthorizedError is handled in both batch discovery and per-server summary paths so expired-token servers show disconnected (not error) in the settings UI.

Confidence Score: 5/5

Safe to merge; the OAuth flow, token storage, and credential-change cleanup are all correctly implemented and well-isolated from existing header-auth paths.

State is stored as a SHA-256 hash (never plaintext), tokens and the PKCE code verifier are encrypted at rest, state is burned before the token exchange to prevent replay, and the authorization URL is validated for https-only before window.open. The connection manager correctly scopes OAuth connections per-user to prevent cross-user token leakage. Both PATCH and POST upsert paths clear stale OAuth tokens when credentials or URLs change.

mcp-server-form-modal.tsx — the hasOauthClientSecret flag is tracked in state but not surfaced as a UI indicator on the password input, which could confuse users editing a server that already has a stored secret.

Important Files Changed

Filename Overview
apps/sim/lib/mcp/oauth/storage.ts New file: DB persistence for OAuth artifacts; tokens, client info, and code verifier are all encrypted; state stored as SHA-256 hash; race-safe upsert for row creation.
apps/sim/lib/mcp/oauth/provider.ts New file: implements OAuthClientProvider for the MCP SDK, routing state/verifier/token persistence to storage.ts; pre-registered client bypass correctly skips DCR save.
apps/sim/app/api/mcp/oauth/callback/route.ts New file: OAuth callback handler; state burned before token exchange; server ID sanitized with JSON.stringify + Unicode escaping in inline script; user identity verified against row.
apps/sim/app/api/mcp/oauth/start/route.ts New file: initiates OAuth flow via McpOauthRedirectRequired; authType guard prevents non-OAuth servers from being authorized; workspace membership enforced by withMcpAuth middleware.
apps/sim/lib/mcp/oauth/probe.ts New file: unauthenticated probe classifies server auth type; 401 without OAuth WWW-Authenticate params falls back to 'headers'; non-401/non-ok responses also default to 'headers'.
apps/sim/app/api/mcp/servers/route.ts POST upsert now preserves OAuth credentials when not re-supplied; token cleanup runs when URL or credentials change; authType preserved on URL-unchanged edits to prevent transient probe downgrade.
apps/sim/app/api/mcp/servers/[id]/route.ts PATCH wraps update + token cleanup in a transaction; secret only written when explicitly present; auto-promotes to OAuth when client ID is added to a non-OAuth server.
apps/sim/lib/mcp/service.ts createClient now builds per-user OAuthClientProvider for OAuth servers; McpOauthAuthorizationRequiredError and UnauthorizedError both set connectionStatus to 'disconnected' rather than incrementing failedCount.
apps/sim/lib/mcp/connection-manager.ts Connection key is now user-scoped for OAuth to prevent cross-user token leakage; disconnect refactored to disconnectByKey (private) + disconnectServer (public, all users for that server).
apps/sim/hooks/queries/mcp.ts useStartMcpOauth added; authorizationUrl validated for https-only (plus localhost http) before window.open; mcpKeys restructured with hierarchical factory functions for better invalidation granularity.
apps/sim/app/workspace/[workspaceId]/settings/components/mcp/components/mcp-server-form-modal/mcp-server-form-modal.tsx Advanced settings panel added for pre-registered credentials; secretTouched flag prevents unintentional secret wipes in edit mode; looksLikeAuthRequired heuristic bypasses test failure for OAuth servers.
packages/db/schema.ts New mcp_server_oauth table with ON DELETE CASCADE references; unique index on (mcpServerId, userId); state indexed for fast callback lookup. authType, oauthClientId, oauthClientSecret added to mcpServers.

Sequence Diagram

sequenceDiagram
    participant UI as Browser (MCP Settings)
    participant Start as /api/mcp/oauth/start
    participant CB as /api/mcp/oauth/callback
    participant AS as Authorization Server
    participant DB as mcp_server_oauth

    UI->>Start: GET ?serverId&workspaceId
    Start->>DB: getOrCreateOauthRow()
    Start->>Start: SimMcpOauthProvider.state() save hash(state) to DB
    Start->>AS: discover metadata (via SDK mcpAuth)
    AS-->>Start: McpOauthRedirectRequired(authorizationUrl)
    Start-->>UI: status redirect authorizationUrl

    UI->>UI: validate https scheme
    UI->>AS: window.open(authorizationUrl) user consent

    AS->>CB: GET /api/mcp/oauth/callback?code=&state=
    CB->>DB: loadOauthRowByState(hash(state))
    CB->>DB: clearState() burn before exchange
    CB->>AS: mcpAuth(provider, authorizationCode)
    Note over CB,AS: PKCE token exchange using codeVerifier()
    AS-->>CB: access_token + refresh_token
    CB->>DB: saveTokens(encrypted)
    CB->>DB: clearVerifier()
    CB-->>UI: postMessage type mcp-oauth ok true serverId

    UI->>UI: invalidate server/tool queries show Connected
Loading

Reviews (23): Last reviewed commit: "fix(mcp): use canonical serverId from co..." | Re-trigger Greptile

Comment thread apps/sim/app/workspace/[workspaceId]/settings/components/mcp/mcp.tsx Outdated
Comment thread apps/sim/lib/mcp/oauth/storage.ts
Comment thread apps/sim/lib/mcp/oauth/storage.ts
@waleedlatif1
Copy link
Copy Markdown
Collaborator Author

Greptile summary findings addressed in f587e82:

  • Edit modal drops existing OAuth Client ID: editInitialData now includes oauthClientId; the API already returns it (only the secret is masked) so the field populates and Advanced auto-expands.
  • Shared OAuth mutation disables all buttons: per-server pending tracked in a local Set<string>; the spinner is scoped to the card whose flow is in progress.
  • Plaintext PKCE codeVerifier: now encrypted at rest via encryptSecret to match tokens/clientInformation.

The point about clearing a pre-registered Client ID by emptying the field is a follow-up — oauthClientId || undefined collapses an intentional clear into a no-op. Will tackle when adding TTL cleanup for abandoned OAuth sessions.

@waleedlatif1
Copy link
Copy Markdown
Collaborator Author

@greptile

@waleedlatif1
Copy link
Copy Markdown
Collaborator Author

@cursor review

Comment thread apps/sim/app/api/mcp/servers/[id]/route.ts
Comment thread apps/sim/app/api/mcp/servers/[id]/route.ts
@waleedlatif1
Copy link
Copy Markdown
Collaborator Author

@greptile

@waleedlatif1
Copy link
Copy Markdown
Collaborator Author

@cursor review

Comment thread apps/sim/app/api/mcp/servers/[id]/route.ts Outdated
@waleedlatif1
Copy link
Copy Markdown
Collaborator Author

@greptile

@waleedlatif1
Copy link
Copy Markdown
Collaborator Author

@cursor review

Comment thread apps/sim/app/api/mcp/servers/route.ts Outdated
@waleedlatif1
Copy link
Copy Markdown
Collaborator Author

@greptile

@waleedlatif1
Copy link
Copy Markdown
Collaborator Author

@cursor review

Comment thread apps/sim/app/api/mcp/tools/execute/route.ts
Comment thread apps/sim/lib/mcp/service.ts Outdated
Comment thread apps/sim/lib/mcp/service.ts Outdated
@waleedlatif1
Copy link
Copy Markdown
Collaborator Author

@cursor review

@waleedlatif1
Copy link
Copy Markdown
Collaborator Author

@greptile

Comment thread apps/sim/app/api/mcp/oauth/callback/route.ts
waleedlatif1 and others added 11 commits May 5, 2026 18:09
- mcp.tsx: drop 8 useCallback wrappers (no React.memo'd children, no effect/memo deps observe them)
- mcp.tsx: drop filteredServers useMemo (cheap O(n) filter, no memoized consumers)
- mcp.tsx: serverToDelete {id, name} → serverToDeleteId; derive name from servers cache
- mcp-server-form-modal.tsx: drop 8 useCallback wrappers (same rationale)
- mcp-server-form-modal.tsx: drop hasChanges useMemo — deps change every keystroke so memo never caches
- mcp-server-form-modal.tsx: hover: → hover-hover: for codebase pointer:fine consistency

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…oken clear

- client.ts: pass requestInit.headers for OAuth servers too. Previously OAuth
  authType set requestInit to undefined, dropping all custom headers including
  SIM_VIA_HEADER for cross-call loop prevention. The SDK's authProvider adds
  Authorization on top, so user/system headers must still flow through.
- servers/[id]/route.ts: wrap server UPDATE and stale OAuth-token DELETE in a
  single transaction. Previously the update committed before the token clear,
  so a token-clear failure would leave new credentials with stale tokens.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…in edit modal

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…Id on tool reauth errors

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- use mcp-oauth-${serverId} window target so concurrent OAuth flows on
  different servers don't reuse and clobber the same popup
- drop redundant setQueryData before invalidate in useForceRefreshMcpTools
- replace hardcoded text-red-500 with text-[var(--text-error)] token
- normalize Plus icon to default h-[14px] w-[14px]
- drop useMemo on cheap toolsByServer/selectedServer derivations

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- hoist serverId from try-block const into outer scope so the catch's
  htmlClose carries it through to postMessage. Without it, parent's
  onMessage couldn't clear connectingOauthServers and the UI button
  stayed stuck on "Connecting…" until popup close.
- relax https-only authorization URL check to permit http://localhost,
  http://127.0.0.1, and http://[::1] per OAuth 2.1 loopback exemption,
  unblocking local OAuth-protected MCP server development.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Replaces the old 0202_unknown_newton_destine migration which collided
with staging's 0202/0203/0204. Bumps API validation route baseline to
735 to account for the two new MCP OAuth endpoints.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…er bug

- Prevent stored Authorization header from overwriting OAuth Bearer in McpClient
- Per-user connection cache keying in connection-manager (token-leak prevention)
- Tighten types in use-mcp-tools and tools/execute (drop `any`)
- Replace raw <button> with emcn Button in mcp settings + form modal
- Modal Cancel: variant='ghost' → 'default' to match design system
- Derive editInitialData and showDeleteDialog from existing state
- Replace refreshingServers Record + chained timer with mutation state
- Trigger MCP OAuth start on create when authType==='oauth' from tool-input
- Invalidate servers/storedTools alongside tools on force-refresh

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…ments

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@waleedlatif1
Copy link
Copy Markdown
Collaborator Author

@greptile

@waleedlatif1
Copy link
Copy Markdown
Collaborator Author

@cursor review

Comment thread apps/sim/app/api/mcp/servers/[id]/route.ts Outdated
Comment thread apps/sim/app/api/mcp/oauth/callback/route.ts
…, callback escape

- Extract useMcpOauthPopup hook so the tool-input "add server" flow gets the
  same postMessage/popup-poll lifecycle as the settings page.
- PATCH /mcp/servers/:id now returns hasOauthClientSecret to mirror GET.
- Escape '<' / '>' inside the JSON-stringified serverId emitted in the
  callback's <script> tag for defense-in-depth.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@waleedlatif1
Copy link
Copy Markdown
Collaborator Author

@greptile

@waleedlatif1
Copy link
Copy Markdown
Collaborator Author

@cursor review

Comment thread apps/sim/lib/mcp/oauth/storage.ts
…tate field

- tool-input now passes result.serverId (the contract-defined property) to
  startOauthForServer instead of the duplicated result.id alias.
- Drop the unused state field from McpOauthRow. The DB column stores a hash;
  the in-memory copy was only ever assigned (never read for logic), and
  provider.state() was setting it to plaintext, creating an inconsistent
  hash-vs-plaintext type. Removing it eliminates the foot-gun.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@waleedlatif1
Copy link
Copy Markdown
Collaborator Author

@cursor review

@waleedlatif1
Copy link
Copy Markdown
Collaborator Author

@greptile

Copy link
Copy Markdown

@cursor cursor Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 2 potential issues.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit ddae8eb. Configure here.

toast.error(toError(e).message || 'Failed to start authorization')
}
},
[startOauthMutation, workspaceId]
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unstable mutation object defeats useCallback memoization

Low Severity

The useCallback dependency array includes startOauthMutation (the entire mutation result object), which is NOT referentially stable in TanStack Query v5 — it gets a new reference on every render. Only .mutate and .mutateAsync are stable. This makes the useCallback wrapper completely ineffective, recreating startOauthForServer every render. The dependency should be startOauthMutation.mutateAsync instead. The PR author explicitly acknowledged .mutate stability in comments elsewhere, making this an inconsistency.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit ddae8eb. Configure here.

connectionStatus: 'connected',
lastConnected: new Date(),
connectionStatus: resolvedAuthType === 'oauth' ? 'disconnected' : 'connected',
lastConnected: resolvedAuthType === 'oauth' ? null : new Date(),
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

POST upsert forces OAuth servers to disconnected despite valid tokens

Low Severity

When an existing OAuth server is updated via the POST upsert path (same URL, matching generated ID), connectionStatus is unconditionally set to 'disconnected' and lastConnected to null if resolvedAuthType === 'oauth', regardless of whether the OAuth row (and its tokens) was actually cleared. When shouldClearOauth is false (no URL or credential change), the user's valid tokens are preserved but the server appears disconnected, prompting an unnecessary re-auth.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit ddae8eb. Configure here.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant