feat(mcp): OAuth 2.1 + PKCE for outbound MCP servers#4441
feat(mcp): OAuth 2.1 + PKCE for outbound MCP servers#4441waleedlatif1 wants to merge 34 commits intostagingfrom
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub. |
PR SummaryHigh Risk Overview Extends MCP server CRUD to support Updates MCP execution and connectivity to treat OAuth reauth as a first-class state: Reviewed by Cursor Bugbot for commit ddae8eb. Configure here. |
Greptile SummaryThis 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
Confidence Score: 5/5Safe 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
Sequence DiagramsequenceDiagram
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
Reviews (23): Last reviewed commit: "fix(mcp): use canonical serverId from co..." | Re-trigger Greptile |
|
Greptile summary findings addressed in f587e82:
The point about clearing a pre-registered Client ID by emptying the field is a follow-up — |
|
@greptile |
|
@cursor review |
|
@greptile |
|
@cursor review |
|
@greptile |
|
@cursor review |
|
@greptile |
|
@cursor review |
|
@cursor review |
|
@greptile |
- 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>
3f840d6 to
0de4158
Compare
…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>
|
@greptile |
|
@cursor review |
…, 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>
|
@greptile |
|
@cursor review |
…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>
|
@cursor review |
|
@greptile |
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 2 potential issues.
❌ 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] |
There was a problem hiding this comment.
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.
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(), |
There was a problem hiding this comment.
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.
Reviewed by Cursor Bugbot for commit ddae8eb. Configure here.


Summary
OAuthClientProviderWWW-Authenticate/oauth-protected-resource)mcp_server_oauthtable; SDK refreshes automatically before expiry/api/mcp/oauth/start→/api/mcp/oauth/callback) withstateCSRF protectionreauth_requiredfrom tool execution when refresh token is invalid so the UI can prompt to reconnectType of Change
Testing
Tested manually against OAuth-protected MCP servers (Linear). Existing header-auth servers regression-checked.
Checklist