Files
sim/apps/sim/lib/mcp/middleware.ts
T
Waleed 46db40620f feat(mcp): OAuth 2.1 + PKCE for outbound MCP servers (#4441)
* feat(mcp): OAuth 2.1 support for outbound MCP servers

* fix(mcp): tighten OAuth refresh race and session-error detection

Re-load the OAuth row inside withMcpOauthRefreshLock so concurrent
callers observe predecessor-written tokens instead of a stale snapshot
loaded before lock acquisition. Without this, the second caller's
provider held a rotated-out refresh token and the SDK tripped
invalid_grant, forcing reauthorization.

Switch isSessionError to match the SDK's typed StreamableHTTPError
(code 404/400) instead of substring-checking arbitrary error messages,
removing false positives on URLs that happen to contain those digits.

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

* refactor(mcp): tighten OAuth callback contract and registration metadata

- Validate callback query params via mcpOauthCallbackContract instead of
  raw searchParams.get, matching the rest of the MCP route surface.
- Drop non-RFC-7591 application_type field from dynamic client registration
  to avoid rejection by strict authorization servers.
- Collapse the pre-lock OAuth row load in createClient — the row is now
  loaded exclusively inside withMcpOauthRefreshLock, removing a redundant
  query and a stale-snapshot path.

* fix(mcp): narrow workspaceId before async closure in OAuth createClient

* fix(mcp): return authType from create-server endpoint

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.

* fix(mcp): mirror server null normalization in optimistic oauthClientId update

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

* fix(mcp): revert optimistic oauthClientId to undefined to match McpServer 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>

* fix(mcp): tighten OAuth probe signal and clear stale popup interval

- 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>

* fix(mcp): normalize empty-string oauthClientId at route boundary

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>

* feat(canvas): expand MCP tool params into per-row labels on block tile

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>

* feat(logs): show MCP icon and strip prefix in trace tool spans

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>

* fix(logs): lift near-black trace icon backgrounds for dark-mode contrast

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>

* fix(logs): fall back to neutral gray for near-black trace icon bgs

#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>

* chore(db): drop 0209_mcp_oauth migration ahead of staging merge

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>

* chore(db): regenerate MCP OAuth migration as 0210

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>

* chore(audit): bump route baseline 748 → 749 after staging merge

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>

* chore: remove source-command skill files committed by accident

These were untracked-then-accidentally-staged in 05c4bc19e 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>

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 12:10:04 -07:00

209 lines
5.7 KiB
TypeScript

import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
import type { NextRequest, NextResponse } from 'next/server'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import { createMcpErrorResponse } from '@/lib/mcp/utils'
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
const logger = createLogger('McpAuthMiddleware')
export type McpPermissionLevel = 'read' | 'write' | 'admin'
export interface McpAuthContext {
userId: string
userName?: string | null
userEmail?: string | null
workspaceId: string
requestId: string
}
export type McpRouteHandler<TParams = Record<string, string>> = (
request: NextRequest,
context: McpAuthContext,
routeContext: { params: Promise<TParams> }
) => Promise<NextResponse>
interface AuthResult {
success: true
context: McpAuthContext
}
interface AuthFailure {
success: false
errorResponse: NextResponse
}
type AuthValidationResult = AuthResult | AuthFailure
/**
* Validates MCP authentication and authorization
*/
async function validateMcpAuth(
request: NextRequest,
permissionLevel: McpPermissionLevel
): Promise<AuthValidationResult> {
const requestId = generateRequestId()
try {
const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
if (!auth.success || !auth.userId) {
logger.warn(`[${requestId}] Authentication failed: ${auth.error}`)
return {
success: false,
errorResponse: createMcpErrorResponse(
new Error(auth.error || 'Authentication required'),
'Authentication failed',
401
),
}
}
let workspaceId: string | null = null
const { searchParams } = new URL(request.url)
workspaceId = searchParams.get('workspaceId')
if (!workspaceId) {
try {
const contentType = request.headers.get('content-type')
if (contentType?.includes('application/json')) {
const body = await request.json()
workspaceId = body.workspaceId
;(request as any)._parsedBody = body
}
} catch {}
}
if (!workspaceId) {
return {
success: false,
errorResponse: createMcpErrorResponse(
new Error('workspaceId is required'),
'Missing required parameter',
400
),
}
}
const userPermissions = await getUserEntityPermissions(auth.userId, 'workspace', workspaceId)
if (!userPermissions) {
return {
success: false,
errorResponse: createMcpErrorResponse(
new Error('Access denied to workspace'),
'Insufficient permissions',
403
),
}
}
const hasRequiredPermission = checkPermissionLevel(userPermissions, permissionLevel)
if (!hasRequiredPermission) {
const permissionError = getPermissionErrorMessage(permissionLevel)
return {
success: false,
errorResponse: createMcpErrorResponse(
new Error(permissionError),
'Insufficient permissions',
403
),
}
}
return {
success: true,
context: {
userId: auth.userId,
userName: auth.userName,
userEmail: auth.userEmail,
workspaceId,
requestId,
},
}
} catch (error) {
logger.error(`[${requestId}] Error during MCP auth validation:`, error)
return {
success: false,
errorResponse: createMcpErrorResponse(
toError(error),
'Authentication validation failed',
500
),
}
}
}
/**
* Check if user has required permission level
*/
function checkPermissionLevel(userPermission: string, requiredLevel: McpPermissionLevel): boolean {
switch (requiredLevel) {
case 'read':
return ['read', 'write', 'admin'].includes(userPermission)
case 'write':
return ['write', 'admin'].includes(userPermission)
case 'admin':
return userPermission === 'admin'
default:
return false
}
}
/**
* Get appropriate error message for permission level
*/
function getPermissionErrorMessage(permissionLevel: McpPermissionLevel): string {
switch (permissionLevel) {
case 'read':
return 'Workspace access required for MCP operations'
case 'write':
return 'Write or admin permission required for MCP server management'
case 'admin':
return 'Admin permission required for MCP server administration'
default:
return 'Insufficient permissions for MCP operation'
}
}
/**
* Higher-order function that wraps MCP route handlers with authentication middleware
*
* @param permissionLevel - Required permission level ('read', 'write', or 'admin')
* @returns Middleware wrapper function
*
*/
export function withMcpAuth<TParams = Record<string, string>>(
permissionLevel: McpPermissionLevel = 'read'
) {
return function middleware(handler: McpRouteHandler<TParams>) {
return async function wrappedHandler(
request: NextRequest,
routeContext: { params: Promise<TParams> }
): Promise<NextResponse> {
const authResult = await validateMcpAuth(request, permissionLevel)
if (!authResult.success) {
return (authResult as AuthFailure).errorResponse
}
try {
return await handler(request, (authResult as AuthResult).context, routeContext)
} catch (error) {
logger.error(
`[${(authResult as AuthResult).context.requestId}] Error in MCP route handler:`,
error
)
return createMcpErrorResponse(toError(error), 'Internal server error', 500)
}
}
}
}
/**
* Utility to get parsed request body
*/
export function getParsedBody(request: NextRequest): any {
return (request as any)._parsedBody
}