feat(mcp): OAuth 2.1 + PKCE for outbound MCP servers#4441
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
PR SummaryHigh Risk Overview Expands MCP server create/update/list APIs and UI. Server CRUD now accepts Improves MCP tool discovery/execution failure handling. Discovery and execution routes treat OAuth-required/unauthorized errors as Docs-only additions. Adds several Reviewed by Cursor Bugbot for commit b7c937d. Configure here. |
Greptile SummaryThis PR adds spec-compliant OAuth 2.1 + PKCE support for outbound MCP servers, including auto-detection via an unauthenticated probe, a popup-based consent flow, encrypted per-server token storage, and pre-registered client credential support.
Confidence Score: 3/5The delete path can revoke another workspace's OAuth tokens before confirming ownership; safe to merge only after moving revocation to after the delete confirms the row belongs to the caller. The apps/sim/lib/mcp/orchestration/server-lifecycle.ts — the
|
| Filename | Overview |
|---|---|
| apps/sim/lib/mcp/orchestration/server-lifecycle.ts | Core lifecycle orchestration for MCP server CRUD; performDeleteMcpServer still calls token revocation before workspace-scoped ownership is confirmed, enabling cross-workspace revocation. |
| apps/sim/app/api/mcp/oauth/callback/route.ts | New OAuth callback handler; properly burns state before token exchange, validates user session, checks workspace ownership, and escapes HTML in popup messages. |
| apps/sim/app/api/mcp/oauth/start/route.ts | New OAuth start handler; validates server ownership, URL safety, and active-flow TTL before initiating the PKCE flow via the SDK provider. |
| apps/sim/lib/mcp/oauth/storage.ts | OAuth persistence layer; encrypts tokens, client information, and code verifier; anchors state TTL on dedicated stateCreatedAt column so token refreshes don't extend the active-flow window. |
| apps/sim/lib/mcp/oauth/provider.ts | SDK-compatible OAuthClientProvider implementation; delegates storage to the encrypted DB layer and supports pre-registered client credentials to bypass DCR. |
| apps/sim/lib/mcp/service.ts | Adds OAuth-aware createClient path with per-row refresh lock; handles UnauthorizedError and McpOauthAuthorizationRequiredError by marking servers disconnected rather than error. |
| packages/db/schema.ts | Adds authType, oauthClientId, oauthClientSecret to mcpServers and introduces the new mcp_server_oauth table with proper cascade deletes and a dedicated stateCreatedAt column. |
| apps/sim/hooks/queries/mcp.ts | Refactors query keys to a two-level hierarchy, converts useForceRefreshMcpTools to a proper useMutation, and adds useStartMcpOauth with HTTPS scheme validation before opening the popup. |
| apps/sim/app/api/mcp/tools/execute/route.ts | Adds OAuth re-auth error handling returning a typed reauth_required 401; fixes timeout handle leak with .finally(clearTimeout) and removes the any cast on input schema properties. |
Comments Outside Diff (1)
-
apps/sim/lib/mcp/orchestration/server-lifecycle.ts, line 416-424 (link)Cross-workspace revocation before ownership check
revokeMcpOauthTokens(params.serverId)fires beforedb.deleteproves the server belongs to the caller's workspace. An authenticated user in workspace A can sendDELETE /api/mcp/servers/<id-from-workspace-B>; revocation executes against the external authorization server and invalidates workspace B's tokens, while the subsequentdb.deletesilently returns zero rows (wrongworkspaceId) and returnsnot_found. The previous fix was applied to the old route handler, but was not carried forward when this logic was extracted intoperformDeleteMcpServer.Move
revokeMcpOauthTokensto afterserveris confirmed non-null (i.e., afterif (!server) return ...), mirroring howperformUpdateMcpServerfirst fetchescurrentServerwith a workspace filter before calling revocation.
Reviews (42): Last reviewed commit: "fix(mcp): normalize empty-string oauthCl..." | 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 |
The POST /api/mcp/servers handler omitted authType from the success response, so useCreateMcpServer always saw data.data.authType as undefined and never triggered the OAuth popup after creating an OAuth-protected server. Thread authType through performCreateMcpServer into the response so the client can decide whether to auto-start OAuth.
|
@cursor review |
…d update Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
|
@greptile |
|
@cursor review |
…rver type The response contract preprocesses null → undefined, so McpServer.oauthClientId is string | undefined. Using null broke type checking. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
|
@greptile |
|
@cursor review |
- probe: only classify as OAuth on resource_metadata or scope params. Bare `Bearer error="invalid_token"` is generic and used by API-key servers, so it must not auto-flip the auth type to OAuth. - popup hook: clear any existing close-watcher interval before overwriting when startOauthForServer is invoked twice for the same serverId. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
|
@greptile |
|
@cursor review |
Orchestration already converts falsy → null via `|| null` (server-lifecycle.ts), so the DB was never receiving an empty string. Tightening the route layer to match the same convention keeps the boundary contract consistent and avoids relying on downstream normalization. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
|
@greptile |
|
@cursor review |
There was a problem hiding this comment.
✅ Bugbot reviewed your changes and found no new issues!
Comment @cursor review or bugbot run to trigger another review on this PR
Reviewed by Cursor Bugbot for commit b7c937d. Configure here.
The MCP Tool block on the workflow canvas previously crammed every selected- tool parameter into a stringified blob under the `Tool` row. Now, when a tool is selected, the tile reads the cached `_toolSchema` and emits one labeled SubBlockRow per parameter (matching the Exa block's per-param layout). Labels reuse `formatParameterLabel` for parity with the editor panel; values pass through the existing `getDisplayValue` so booleans/numbers/arrays render identically to other blocks. Deterministic tile height counts expanded rows so the tile sizes correctly. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Tool spans for MCP calls were rendering the raw id (e.g. `mcp-f908f259-planetscale_list_organizations`) with the default blank- square icon. Now they read just the tool name and render the MCP block's icon and bgColor, matching how workflow-execute tools render. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Block bgColors below a small luminance threshold (e.g. the MCP block's #181C1E) rendered nearly invisible against the dark-mode surface (--bg: #1b1b1b). Adds a tiny adjustBgForContrast helper that floors each RGB channel at 0x33 only when luminance is below 30,000, leaving every branded color above that band untouched. Applied to both the trace tree row and the detail pane. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
#333333 was still too close to the dark-mode surface to read. For bgs below the luminance threshold (e.g. the MCP block's #181C1E) we now fall back to DEFAULT_BLOCK_COLOR (#6b7280) — the same neutral the renderer uses for blocks with no distinct identity. Clearly visible in both themes; brighter brand colors still pass through. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Staging shipped 0209_smiling_fixer; the MCP OAuth migration will be regenerated on top of staging as 0210. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…auth # Conflicts: # scripts/check-api-validation-contracts.ts
Re-runs drizzle-kit generate on top of staging's 0209_smiling_fixer. Same schema (mcp_server_oauth table + mcp_servers.auth_type / oauth_* columns) as the dropped 0209_mcp_oauth. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The post-merge route count is 749 (this branch's OAuth start/callback plus staging's new route). I had set the baseline to 748 in the merge conflict resolution — bumping to match reality so the strict audit passes. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
These were untracked-then-accidentally-staged in 05c4bc1 via a wide `git add -A`. They aren't part of this PR's scope. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
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