Skip to content

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

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

feat(mcp): OAuth 2.1 + PKCE for outbound MCP servers#4441
waleedlatif1 wants to merge 13 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 6:39am

Request Review

@cursor
Copy link
Copy Markdown

cursor Bot commented May 5, 2026

PR Summary

High Risk
High risk because it introduces new OAuth token storage/refresh, revocation, and popup-based callback flows plus schema changes to mcp_servers, affecting authentication and connection behavior across tool discovery/execution.

Overview
Adds OAuth 2.1 + PKCE support for outbound MCP servers via new /api/mcp/oauth/start and /api/mcp/oauth/callback routes, including popup messaging back to the UI and post-auth tool refresh.

Introduces encrypted, workspace-scoped OAuth persistence in a new mcp_server_oauth table plus new mcp_servers fields (authType, oauthClientId, encrypted oauthClientSecret), with server create/update/delete flows now revoking/clearing OAuth state when URL or client credentials change and returning hasOauthClientSecret instead of the secret.

Updates MCP runtime paths (mcpService, McpClient, McpConnectionManager, tool discover/execute APIs) to use an SDK authProvider, serialize refreshes, surface reauth required as 401 (and a structured reauth_required response on tool execution), and auto-detect OAuth requirement on server creation via a WWW-Authenticate probe; UI settings gains advanced OAuth credential inputs and a "Connect with OAuth" button with query invalidation.

Reviewed by Cursor Bugbot for commit 08fe845. 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 using the SDK's OAuthClientProvider. It introduces a popup-based consent flow (/api/mcp/oauth/start/api/mcp/oauth/callback), per-server token persistence in a new mcp_server_oauth table (encrypted at rest), automatic auth-type detection via an unauthenticated probe, and surfacing of reauth_required from tool execution when tokens expire.

  • Adds mcp_server_oauth table to store encrypted OAuth tokens/client-info per server, with withMcpOauthRefreshLock for in-process refresh serialization and revokeMcpOauthTokens for RFC 7009 best-effort revocation on server delete/credential change.
  • Extends McpClient and McpConnectionManager to accept an OAuthClientProvider (SDK-delegated token refresh/retry), and updates service.ts to inject SimMcpOauthProvider for authType === 'oauth' servers.
  • Adds "Advanced settings" to the server form modal for pre-registered client_id/client_secret, implements the useMcpOauthPopup hook for the popup-based consent flow, and surfaces a "Connect with OAuth" button in the server detail view.

Confidence Score: 5/5

Safe to merge. The OAuth flow is well-structured: state is burned before token exchange, PKCE verifier is cleared in a finally block, cross-workspace revocation is guarded, and the execute path propagates reauth_required correctly. All previously identified issues have been addressed.

The security-sensitive paths (callback state validation, workspace isolation on DELETE/PATCH, secret encryption, URL scheme checks before window.open) are all handled correctly. The two remaining observations are a documentation mismatch (workspace-shared vs. per-user credentials) and a UX edge case where tool discovery failure after OAuth doesn't immediately flip connectionStatus to connected — neither affects correctness or security of the authorization flow itself.

packages/db/schema.ts and apps/sim/app/api/mcp/oauth/callback/route.ts warrant a second look for the workspace-shared credential design and the post-auth discovery error path.

Important Files Changed

Filename Overview
apps/sim/lib/mcp/oauth/storage.ts New file: implements all DB I/O for mcp_server_oauth rows; tokens/clientInfo/codeVerifier are AES-encrypted, state is SHA-256 hashed, in-process refresh lock via chained Promise. Unique index is on mcpServerId only (workspace-shared row).
apps/sim/app/api/mcp/oauth/callback/route.ts New file: OAuth callback burns state before token exchange, verifier cleared in finally block, workspace/user cross-checks enforced, HTML close with postMessage to opener.
apps/sim/app/api/mcp/oauth/start/route.ts New file: initiates OAuth flow; 409 guards against concurrent flows from different users; raises McpOauthRedirectRequired with the authorization URL.
apps/sim/app/api/mcp/servers/route.ts Updated POST to auto-probe authType, encrypt oauthClientSecret, skip probe when URL unchanged, clear OAuth rows on credential change, and guard DELETE with workspace ownership check before revocation.
apps/sim/app/api/mcp/servers/[id]/route.ts PATCH now early-returns 404 when currentServer is null (preventing cross-workspace revocation), encrypts client secret, promotes authType to oauth when clientId added, clears OAuth rows in transaction on URL/credential change.
apps/sim/lib/mcp/service.ts createClient injects SimMcpOauthProvider for oauth servers, throws McpOauthAuthorizationRequiredError when no tokens; discoverAllTools and getServerSummaries now treat UnauthorizedError identically to the no-tokens case.
packages/db/schema.ts Adds mcp_server_oauth table and authType/oauthClientId/oauthClientSecret columns to mcp_servers. Unique index on mcpServerId only (one row per server, workspace-shared credential model).
apps/sim/hooks/mcp/use-mcp-oauth-popup.ts New file: manages popup lifecycle with per-server interval polling; validates authorizationUrl scheme (https or loopback http) before window.open; clears connecting state on postMessage or popup close.
apps/sim/app/workspace/[workspaceId]/settings/components/mcp/components/mcp-server-form-modal/mcp-server-form-modal.tsx Adds Advanced settings section for oauthClientId/oauthClientSecret; secretTouched flag prevents wiping stored secret on unrelated edits; oauthClientIdChanged tracks explicit changes only.
apps/sim/app/workspace/[workspaceId]/settings/components/mcp/mcp.tsx Adds Connect with OAuth button (per-server connecting state), buildEditInitialData now includes oauthClientId/hasOauthClientSecret, auto-starts OAuth popup after probe-detected server creation.

Sequence Diagram

sequenceDiagram
    participant UI as Browser (mcp.tsx)
    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 (DB)
    participant Svc as McpService

    UI->>Start: "GET ?serverId=&workspaceId="
    Start->>DB: getOrCreateOauthRow (mcpServerId unique)
    Start->>AS: mcpAuth(provider, serverUrl) → redirectToAuthorization
    Start-->>UI: "{status:redirect, authorizationUrl}"
    UI->>UI: window.open(authorizationUrl, popup)
    UI->>AS: user consents in popup
    AS->>CB: "GET /callback?code=&state="
    CB->>DB: loadOauthRowByState(hashState)
    CB->>DB: clearState (burn before exchange)
    CB->>AS: mcpAuth(provider, authorizationCode) → token exchange
    AS-->>CB: access_token + refresh_token
    CB->>DB: saveTokens (encrypted)
    CB->>DB: clearVerifier
    CB->>Svc: discoverServerTools (post-auth refresh)
    CB-->>UI: "postMessage {type:mcp-oauth, ok:true, serverId}"
    UI->>UI: invalidate queries, toast Authorized
Loading

Reviews (32): Last reviewed commit: "fix(mcp): strip secret from create mutat..." | 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
@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
Comment thread apps/sim/app/api/mcp/oauth/callback/route.ts
Comment thread apps/sim/app/api/mcp/servers/[id]/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

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 3 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 08fe845. Configure here.

},
}),
signal: controller.signal,
})
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Probe leaks MCP session by sending initialize without cleanup

Low Severity

detectMcpAuthType sends a real JSON-RPC initialize request to the target MCP server to probe for OAuth. When the server responds with 200 (no auth required), it has allocated a server-side session, but the probe never sends a corresponding close/disconnect. This leaks a session on the remote server for every probe invocation, which happens on every new MCP server creation where authType auto-detection runs.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 08fe845. Configure here.

}
next.then(cleanup, cleanup)
return next
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Refresh lock silently swallows predecessor errors in chain

Medium Severity

withMcpOauthRefreshLock chains via prev.then(fn, fn), meaning fn is invoked as the rejection handler for the predecessor. If the previous operation failed, fn receives the rejection reason as its first argument (silently ignored since fn takes no args). More critically, if two callers chain and the first rejects, the second caller's fn runs but its completion/failure doesn't propagate back to the first caller — the first caller only gets the original rejection from its own fn. This makes debugging silent failures harder and could mask the root cause of token refresh failures.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 08fe845. Configure here.

userId,
workspaceId,
})
const hasActiveFlow = !!row.state && row.updatedAt.getTime() > Date.now() - OAUTH_START_TTL_MS
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Active flow check uses mutable updatedAt instead of state creation time

Low Severity

The hasActiveFlow TTL check compares row.updatedAt against OAUTH_START_TTL_MS, but updatedAt is modified by any write to the OAuth row (e.g., saveCodeVerifier, saveTokens, clearVerifier). This means the effective TTL window can be silently extended beyond 10 minutes. The same issue affects loadOauthRowByState, which also gates on updatedAt > now - STATE_TTL_MS. A dedicated stateCreatedAt or stable timestamp from when the state was saved would make the TTL accurate.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 08fe845. 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