Skip to content

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

Open
waleedlatif1 wants to merge 14 commits into
stagingfrom
waleedlatif1/mcp-oauth
Open

feat(mcp): OAuth 2.1 + PKCE for outbound MCP servers#4441
waleedlatif1 wants to merge 14 commits into
stagingfrom
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 12, 2026 3:29pm

Request Review

@cursor
Copy link
Copy Markdown

cursor Bot commented May 5, 2026

PR Summary

High Risk
Introduces OAuth token storage/refresh, popup-based authorization, and revocation across MCP server CRUD and tool execution paths, which is security- and reliability-sensitive. Mistakes could leak secrets, break connectivity, or incorrectly mark servers connected/disconnected across a workspace.

Overview
Adds first-class OAuth 2.1 + PKCE support for outbound MCP servers, including new /api/mcp/oauth/start and /api/mcp/oauth/callback routes that drive a popup consent flow with state/PKCE handling and post-auth tool refresh.

Introduces a new mcp_server_oauth table and authType/client-credential fields on mcp_servers, encrypting persisted tokens/client metadata/verifiers and adding best-effort token revocation + OAuth state clearing when server URL/credentials change or a server is deleted.

Updates the MCP client/service/connection manager to use an SDK authProvider for OAuth servers (with per-row refresh locking), propagate “reauth required” errors as 401 responses, and updates the settings/workflow UI to detect OAuth servers, collect optional preregistered client credentials, and offer a Connect with OAuth button plus query invalidation on successful authorization.

Reviewed by Cursor Bugbot for commit c292149. 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 spec-compliant OAuth 2.1 + PKCE support for outbound MCP servers, including auto-detection of OAuth requirements via an unauthenticated probe, encrypted per-server token storage, a popup-based consent flow with CSRF state protection, and optional pre-registered client credentials. A large number of previously-flagged issues (workspace-boundary revocation, stale token cleanup on credential change, UnauthorizedError handling, scoped per-server OAuth loading state) have been addressed in follow-up commits.

  • New mcp_server_oauth table stores encrypted tokens, client information, and the PKCE verifier; state is stored as a SHA-256 hash; withMcpOauthRefreshLock serialises concurrent SDK refreshes per process.
  • OAuth popup flow (/api/mcp/oauth/start → AS → /api/mcp/oauth/callback) burns state before token exchange, validates user identity and workspace ownership at every step, and surfaces reauth_required from tool execution so the UI can prompt reconnect.
  • detectMcpAuthType probe fires on new server creation (and URL changes) to auto-classify servers; existing servers preserve their authType on non-URL edits to avoid transient downgrade.

Confidence Score: 3/5

The core OAuth flow is sound, but a TTL design issue in the state-expiry logic can permanently block workspace members from starting new OAuth flows under realistic concurrent conditions.

Both the hasActiveFlow check in start/route.ts and the state lookup TTL in loadOauthRowByState are anchored to updatedAt. Because saveTokens also writes updatedAt: new Date() on every background token refresh, an abandoned OAuth flow (state column non-null) combined with any active tool usage against that server will keep resetting the 10-minute window indefinitely, leaving other workspace members permanently unable to start a new auth flow.

apps/sim/lib/mcp/oauth/storage.ts — the updatedAt column doubles as both a general row-modification timestamp and the anchor for state TTL checks; these concerns should be separated.

Important Files Changed

Filename Overview
apps/sim/lib/mcp/oauth/storage.ts New OAuth storage layer; encrypts tokens and client info correctly, but updatedAt is shared between state creation and token refresh, allowing token-refresh events to extend the state TTL and permanently block concurrent auth flows.
apps/sim/app/api/mcp/oauth/callback/route.ts New OAuth callback route; correctly burns the CSRF state before token exchange, validates user identity and workspace membership, and triggers a background tools refresh on success.
apps/sim/app/api/mcp/oauth/start/route.ts New OAuth start route; validates server URL, enforces workspace membership, serialises concurrent flows with a per-server 409, and delegates the redirect URL capture to the provider.
apps/sim/lib/mcp/oauth/provider.ts New SDK-compatible OAuthClientProvider; correctly handles pre-registered credentials and DCR, encrypts/decrypts secrets via storage layer, and throws McpOauthRedirectRequired for the popup flow.
apps/sim/app/api/mcp/servers/route.ts POST/GET/DELETE handlers updated with OAuth credential management, auth-type detection, ownership-gated token revocation, and proper credential encryption.
apps/sim/app/api/mcp/servers/[id]/route.ts PATCH handler extended with credential-change detection, OAuth token cleanup, and early-return 404 before any revocation logic runs.
apps/sim/lib/mcp/service.ts createClient now wires in the OAuth provider and refresh lock; discoverServerTools and getServerSummaries both handle UnauthorizedError by marking servers disconnected instead of errored.
apps/sim/hooks/mcp/use-mcp-oauth-popup.ts New popup-management hook; correctly scopes per-server loading state and cleans up intervals, but the already_authorized path doesn't invalidate server/tool queries leaving the UI potentially stale.
packages/db/schema.ts Adds mcp_server_oauth table with encrypted columns for tokens and client information; adds authType/oauthClientId/oauthClientSecret to mcpServers; schema comment for state column is slightly misleading (stores hash, not raw nonce).
apps/sim/app/api/mcp/tools/execute/route.ts Execute route now surfaces reauth_required (401) for McpOauthAuthorizationRequiredError/UnauthorizedError/McpOauthRedirectRequired; timeout handle is now properly cleared in a finally block.

Sequence Diagram

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

    UI->>Start: "GET ?serverId=&workspaceId="
    Start->>DB: getOrCreateOauthRow()
    Start->>SDK: "mcpAuth(provider, {serverUrl})"
    SDK->>DB: saveState(hash) + saveCodeVerifier(enc)
    SDK-->>Start: throws McpOauthRedirectRequired(url)
    Start-->>UI: "{status:redirect, authorizationUrl}"
    UI->>AS: window.open(authorizationUrl)
    AS-->>UI: "Redirect to /api/mcp/oauth/callback?code=&state="
    UI->>Callback: "GET ?code=&state="
    Callback->>DB: loadOauthRowByState(hash(state))
    Callback->>DB: clearState()
    Callback->>SDK: "mcpAuth(provider, {serverUrl, authorizationCode})"
    SDK->>AS: POST /token (code + PKCE verifier)
    AS-->>SDK: "{access_token, refresh_token}"
    SDK->>DB: saveTokens(encrypted)
    Callback->>DB: clearVerifier()
    Callback-->>UI: "postMessage({type:mcp-oauth, ok:true})"
    UI->>UI: Invalidate server/tool queries
Loading

Reviews (33): Last reviewed commit: "fix(mcp): release probe session, clean u..." | 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 Outdated
@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
Look up the OAuth row by state once at the top of the callback so the
serverId can be threaded into provider_error, missing_params, and
unauthenticated closes. Without it, the popup's postMessage omits
serverId and the parent UI can't clear the connecting state until the
500ms popup-closed poll fires.

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/revoke.ts
revokeMcpOauthTokens bailed when row.clientInformation was null, which is
always the case for pre-registered clients (SimMcpOauthProvider.saveClientInformation
is a no-op when preregistered is set). Fall back to mcpServers.oauthClientId so
revocation proceeds for the pre-registered path.

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/oauth/callback/route.ts
Comment thread apps/sim/app/api/mcp/oauth/callback/route.ts Outdated
- Burn the state row on the provider-error path so a replayed callback with the
  same state cannot complete a token exchange after the user saw the error.
- Read the OAuth state row once at the top of the callback and reuse it on the
  happy path, eliminating a redundant query and a TTL-boundary race where a
  legitimate flow could be marked invalid_state between two reads.

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

Comment thread apps/sim/hooks/queries/mcp.ts
The useUpdateMcpServer optimistic update was spreading the raw updates object
into the TanStack Query cache, which briefly placed the plaintext
oauthClientSecret in client-side cache. Strip it from the optimistic spread —
the cache holds the server-sanitized record after onSettled invalidation.

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/hooks/queries/mcp.ts
Comment thread apps/sim/lib/mcp/oauth/storage.ts
…mapping

- useCreateMcpServer was returning the raw serverData (including plaintext
  oauthClientSecret) as mutation.data; strip the secret to match the equivalent
  fix applied to useUpdateMcpServer.
- Extract the McpOauthRow mapping/decryption from loadOauthRow and
  loadOauthRowByState into a shared mapOauthRow helper.

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/probe.ts
Comment thread apps/sim/lib/mcp/oauth/storage.ts
Comment thread apps/sim/app/api/mcp/oauth/start/route.ts
- detectMcpAuthType now reads the Mcp-Session-Id response header and fires a
  best-effort DELETE to release the streamable-HTTP session the probe just
  allocated. Failures are ignored; servers will time the session out anyway.
- Rewrite the refresh-lock chain to `prev.catch(() => undefined).then(() => fn())`
  so the predecessor's rejection reason is explicitly discarded rather than
  threaded into fn as an ignored argument. Each caller's outcome stays its own.

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

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 1 potential issue.

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 c292149. Configure here.

}

await revokeMcpOauthTokens(serverId)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Delete handler doesn't clean up OAuth database rows

Medium Severity

The DELETE handler calls revokeMcpOauthTokens(serverId) before hard-deleting the mcpServers row but never deletes the corresponding mcpServerOauth row. Both the PATCH handler and the POST (update-existing) handler explicitly call tx.delete(mcpServerOauth).where(eq(mcpServerOauth.mcpServerId, serverId)) inside their transactions when OAuth state needs clearing. The DELETE handler is missing this step, leaving orphaned rows containing encrypted tokens and client credentials in the mcp_server_oauth table after a server is removed.

Additional Locations (2)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit c292149. Configure here.


export async function clearVerifier(rowId: string): Promise<void> {
await db
.update(mcpServerOauth)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1 updatedAt used for state TTL is refreshed by token saves, enabling permanent 409 loops

hasActiveFlow in start/route.ts checks !!row.state && row.updatedAt.getTime() > Date.now() - OAUTH_START_TTL_MS, and loadOauthRowByState checks gt(mcpServerOauth.updatedAt, …) with the same 10-minute window. Both TTLs are anchored to updatedAt, but saveTokens also sets updatedAt: new Date() on every token refresh.

Concretely: if user A starts an OAuth flow and abandons it (state column stays non-null), while user B is actively executing tools against the server (triggering background token refreshes via the SDK), updatedAt keeps getting pushed forward. User C who tries to start a new flow will see hasActiveFlow = true and get a 409, indefinitely — the 10-minute window resets on every tool execution.

The fix is a dedicated stateCreatedAt column (set only in saveState) so that token refreshes no longer interfere with state expiry, and so that loadOauthRowByState can expire on actual state age rather than row age.

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