Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 48 additions & 1 deletion apps/docs/openapi-v2-workflows.json
Original file line number Diff line number Diff line change
Expand Up @@ -6215,6 +6215,9 @@
},
{
"$ref": "#/components/schemas/AgentMcpTool"
},
{
"$ref": "#/components/schemas/AgentMcpServerAdvanced"
}
],
"title": "Agent tool",
Expand All @@ -6227,7 +6230,7 @@
"type": "string",
"minLength": 1,
"maxLength": 255,
"pattern": "^(?!(?:custom-tool|mcp)$).+$",
"pattern": "^(?!(?:custom-tool|mcp|mcp-server-advanced)$).+$",
"description": "Catalog block id, such as `cloudwatch` or `slack`. Use the block id, never an underlying tool id."
},
"operation": {
Expand Down Expand Up @@ -6439,6 +6442,50 @@
}
]
},
"AgentMcpServerAdvanced": {
"type": "object",
"properties": {
"type": {
"type": "string",
"const": "mcp-server-advanced",
"description": "Server-wide MCP binding discriminator."
},
"params": {
"type": "object",
"properties": {
"serverId": {
"type": "string",
"minLength": 1,
"maxLength": 128,
"description": "Workspace MCP server ID or explicit credential-group managed MCP connection ID."
}
},
"required": ["serverId"],
"additionalProperties": false,
"description": "Server identity for discovering and invoking every available MCP tool."
},
"usageControl": {
"type": "string",
"enum": ["auto", "force", "none"],
"description": "When the Agent may call the tool: `auto` lets the model decide, `force` requires a call, and `none` disables it. Omitted means `auto`."
}
},
"required": ["type", "params"],
"additionalProperties": {
"description": "Forward-compatible MCP server metadata preserved by the workflow editor."
},
"title": "Agent MCP server (advanced)",
"description": "All tools available to the executing subject from one MCP server.",
"examples": [
{
"type": "mcp-server-advanced",
"params": {
"serverId": "mcp_01J9X2ABCDEF"
},
"usageControl": "auto"
}
]
},
"ApplyWorkflowOperationsRequest": {
"type": "object",
"properties": {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import type { NextRequest } from 'next/server'
import { NextResponse } from 'next/server'
import { startCredentialGroupMcpOAuthContract } from '@/lib/api/contracts/credential-groups'
import { parseRequest } from '@/lib/api/server'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { authenticateCredentialGroupEnrollment } from '@/lib/credential-groups/application/enrollment-auth'
import { startPublicCredentialGroupMcpOAuth } from '@/lib/credential-groups/application/public-enrollment'
import {
enforceCredentialGroupEnrollmentOAuthRateLimit,
enforcePublicCredentialGroupOAuthStartIpRateLimit,
} from '@/lib/credential-groups/rate-limit'
import { makeTimedStep } from '@/lib/mcp/oauth'
import { createCredentialGroupEnrollmentRedirect } from '@/app/api/credential-groups/enrollment-redirect'

export const dynamic = 'force-dynamic'
export const runtime = 'nodejs'

const logger = createLogger('CredentialGroupMcpOAuthStartAPI')
const timedStep = makeTimedStep(logger)
const MANAGED_MCP_OAUTH_START_TIMEOUT_MS = 26_000

export const GET = withRouteHandler(
async (
request: NextRequest,
context: { params: Promise<{ token: string; mcpServerId: string }> }
) => {
const limited = await enforcePublicCredentialGroupOAuthStartIpRateLimit(request)
const parsed = await parseRequest(startCredentialGroupMcpOAuthContract, request, context)
if (!parsed.success) return limited ?? parsed.response
const { token, mcpServerId } = parsed.data.params
if (limited) return createCredentialGroupEnrollmentRedirect(token, { oauth: 'rate_limited' })

const principal = await authenticateCredentialGroupEnrollment(token)
if (!principal) {
return createCredentialGroupEnrollmentRedirect(token, { oauth: 'unavailable' })
}
const enrollmentLimited = await enforceCredentialGroupEnrollmentOAuthRateLimit(
principal.enrollmentId
)
if (enrollmentLimited) {
return createCredentialGroupEnrollmentRedirect(token, { oauth: 'rate_limited' })
}

try {
const { authorizationUrl } = await timedStep(
'startPublicCredentialGroupMcpOAuth',
MANAGED_MCP_OAUTH_START_TIMEOUT_MS,
() =>
startPublicCredentialGroupMcpOAuth.execute({
principal,
input: { invitationToken: token, mcpServerId },
request,
})
)
const response = NextResponse.redirect(authorizationUrl)
response.headers.set('Cache-Control', 'no-store')
response.headers.set('Referrer-Policy', 'no-referrer')
return response
} catch (error) {
logger.error('Failed to start managed MCP OAuth authorization', {
error: getErrorMessage(error),
})
return createCredentialGroupEnrollmentRedirect(token, { oauth: 'unavailable' })
}
}
)
20 changes: 20 additions & 0 deletions apps/sim/app/api/mcp/managed-connections/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { listManagedMcpCatalogContract } from '@/lib/api/contracts/mcp'
import {
defineInternalJsonRoute,
internalOrchestrationErrorPolicy,
internalRateLimits,
internalSessionAuth,
} from '@/lib/api/server/routes'
import { listManagedMcpConnectionsUseCase } from '@/lib/mcp/application/managed-connections'
import { mcpServerOperations } from '@/lib/mcp/application/operations'

export const GET = defineInternalJsonRoute({
contract: listManagedMcpCatalogContract,
auth: internalSessionAuth,
operation: mcpServerOperations.listManagedConnections,
rateLimit: internalRateLimits.none({ reason: 'Managed MCP metadata is workspace-scoped' }),
errorPolicy: internalOrchestrationErrorPolicy,
mapInput: ({ query }) => ({ workspaceId: query.workspaceId }),
useCase: listManagedMcpConnectionsUseCase,
present: (result) => result,
})
85 changes: 84 additions & 1 deletion apps/sim/app/api/mcp/oauth/callback/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,37 @@ import {
import { NextRequest } from 'next/server'
import { beforeEach, describe, expect, it, vi } from 'vitest'

const { mockDiscoverServerTools } = vi.hoisted(() => ({
const {
mockAuthenticateEnrollment,
mockCompleteManagedMcpOAuth,
mockConsumeManagedAttempt,
mockDiscoverServerTools,
mockEnforceCallbackRateLimit,
} = vi.hoisted(() => ({
mockAuthenticateEnrollment: vi.fn(),
mockCompleteManagedMcpOAuth: vi.fn(),
mockConsumeManagedAttempt: vi.fn(),
mockDiscoverServerTools: vi.fn(),
mockEnforceCallbackRateLimit: vi.fn(),
}))

vi.mock('@/lib/mcp/oauth', () => mcpOauthMock)
vi.mock('@/lib/mcp/service', () => ({
mcpService: { discoverServerTools: mockDiscoverServerTools },
}))
vi.mock('@/lib/credential-groups/application/enrollment-auth', () => ({
authenticateCredentialGroupEnrollment: mockAuthenticateEnrollment,
}))
vi.mock('@/lib/credential-groups/application/public-enrollment', () => ({
completePublicCredentialGroupMcpOAuth: { execute: mockCompleteManagedMcpOAuth },
}))
vi.mock('@/lib/credential-groups/mcp-oauth-state', () => ({
consumeCredentialGroupMcpOAuthAttempt: mockConsumeManagedAttempt,
isCredentialGroupMcpOAuthState: (state: string) => state.startsWith('mcp_cg_'),
}))
vi.mock('@/lib/credential-groups/rate-limit', () => ({
enforcePublicCredentialGroupIpRateLimit: mockEnforceCallbackRateLimit,
}))

import { GET } from './route'

Expand All @@ -43,6 +66,28 @@ describe('MCP OAuth callback route', () => {
mcpOauthMockFns.mockLoadPreregisteredClient.mockResolvedValue(undefined)
mcpOauthMockFns.mockMcpAuthGuarded.mockResolvedValue('AUTHORIZED')
mockDiscoverServerTools.mockResolvedValue(undefined)
mockConsumeManagedAttempt.mockResolvedValue({
state: 'mcp_cg_state-1',
enrollmentId: 'enrollment-1',
credentialGroupId: 'group-1',
mcpServerId: 'server-1',
codeVerifier: 'code-verifier',
invitationToken: 'invitation-token',
createdAt: Date.now(),
})
mockAuthenticateEnrollment.mockResolvedValue({
kind: 'credential_group_enrollment',
workspaceId: 'workspace-1',
credentialGroupId: 'group-1',
enrollmentId: 'enrollment-1',
email: 'invitee@example.com',
invitationTokenHash: 'token-hash',
})
mockCompleteManagedMcpOAuth.mockResolvedValue({
connectionId: 'mcp-cg-connection-1',
mcpServerId: 'server-1',
})
mockEnforceCallbackRateLimit.mockResolvedValue(null)
})

it('performs the token exchange through the SSRF-guarded mcpAuthGuarded wrapper', async () => {
Expand Down Expand Up @@ -104,4 +149,42 @@ describe('MCP OAuth callback route', () => {
expect(body).toContain('"state-1"')
expect(body).toContain('serverId: undefined')
})

it('completes a managed grant from one-time invitation state without a Sim session', async () => {
const request = new NextRequest(
'http://localhost:3000/api/mcp/oauth/callback?state=mcp_cg_state-1&code=auth-code-1'
)

const response = await GET(request)

expect(mockEnforceCallbackRateLimit).toHaveBeenCalledWith(request, 'oauth-callback')
expect(mockConsumeManagedAttempt).toHaveBeenCalledWith('mcp_cg_state-1')
expect(mockAuthenticateEnrollment).toHaveBeenCalledWith('invitation-token')
expect(mockCompleteManagedMcpOAuth).toHaveBeenCalledWith(
expect.objectContaining({
input: expect.objectContaining({
code: 'auth-code-1',
attempt: expect.objectContaining({ mcpServerId: 'server-1' }),
}),
})
)
expect(authMockFns.mockGetSession).not.toHaveBeenCalled()
expect(response.headers.get('location')).toContain(
'/credential-groups/enroll/invitation-token?mcp=connected&mcpServerId=server-1'
)
})

it('rate limits a managed callback before consuming its one-time state', async () => {
const limitedResponse = new Response('rate limited', { status: 429 })
mockEnforceCallbackRateLimit.mockResolvedValueOnce(limitedResponse)
const request = new NextRequest(
'http://localhost:3000/api/mcp/oauth/callback?state=mcp_cg_state-1&code=auth-code-1'
)

const response = await GET(request)

expect(response.status).toBe(429)
expect(mockConsumeManagedAttempt).not.toHaveBeenCalled()
expect(mockCompleteManagedMcpOAuth).not.toHaveBeenCalled()
})
})
54 changes: 54 additions & 0 deletions apps/sim/app/api/mcp/oauth/callback/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,13 @@ import { mcpOauthCallbackContract } from '@/lib/api/contracts/mcp'
import { parseRequest } from '@/lib/api/server'
import { getSession } from '@/lib/auth'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { authenticateCredentialGroupEnrollment } from '@/lib/credential-groups/application/enrollment-auth'
import { completePublicCredentialGroupMcpOAuth } from '@/lib/credential-groups/application/public-enrollment'
import {
consumeCredentialGroupMcpOAuthAttempt,
isCredentialGroupMcpOAuthState,
} from '@/lib/credential-groups/mcp-oauth-state'
import { enforcePublicCredentialGroupIpRateLimit } from '@/lib/credential-groups/rate-limit'
import {
assertSafeOauthServerUrl,
clearState,
Expand All @@ -21,6 +28,7 @@ import {
SimMcpOauthProvider,
} from '@/lib/mcp/oauth'
import { mcpService } from '@/lib/mcp/service'
import { createCredentialGroupEnrollmentRedirect } from '@/app/api/credential-groups/enrollment-redirect'

const logger = createLogger('McpOauthCallbackAPI')
const timedStep = makeTimedStep(logger)
Expand Down Expand Up @@ -70,13 +78,59 @@ function htmlClose(
})
}

async function completeManagedMcpCallback(params: {
request: NextRequest
state: string
code?: string
error?: string
}): Promise<NextResponse> {
const attempt = await consumeCredentialGroupMcpOAuthAttempt(params.state)
Comment thread
TheodoreSpeaks marked this conversation as resolved.
if (!attempt) {
return htmlClose('Invalid or expired authorization state.', false, 'invalid_state')
}
if (params.error) {
return createCredentialGroupEnrollmentRedirect(attempt.invitationToken, { oauth: 'denied' })
}
if (!params.code) {
return createCredentialGroupEnrollmentRedirect(attempt.invitationToken, {
oauth: 'failed',
})
}
try {
const principal = await authenticateCredentialGroupEnrollment(attempt.invitationToken)
if (!principal) {
return createCredentialGroupEnrollmentRedirect(attempt.invitationToken, {
oauth: 'unavailable',
})
}
const result = await completePublicCredentialGroupMcpOAuth.execute({
principal,
input: { attempt, code: params.code },
request: params.request,
})
return createCredentialGroupEnrollmentRedirect(attempt.invitationToken, {
mcp: 'connected',
mcpServerId: result.mcpServerId,
})
} catch (error) {
logger.error('Managed MCP OAuth callback failed', error)
return createCredentialGroupEnrollmentRedirect(attempt.invitationToken, { oauth: 'failed' })
}
}

export const GET = withRouteHandler(async (request: NextRequest) => {
const parsed = await parseRequest(mcpOauthCallbackContract, request, {})
if (!parsed.success) {
return htmlClose('Malformed authorization callback.', false, 'missing_params')
}
const { state, code, error: errorParam } = parsed.data.query

if (state && isCredentialGroupMcpOAuthState(state)) {
Comment thread
TheodoreSpeaks marked this conversation as resolved.
const limited = await enforcePublicCredentialGroupIpRateLimit(request, 'oauth-callback')
if (limited) return limited
return completeManagedMcpCallback({ request, state, code, error: errorParam })
}

// Echo the flow's `state` on every result so the opener can correlate a broadcast back to
// the exact flow it started — including failures (e.g. `invalid_state`) that never resolve
// a serverId. Without it those results would strand the initiating tab on "Connecting…".
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,4 +24,5 @@ export const DELETE = defineInternalJsonRoute({
enrollmentId: params.enrollmentId,
}),
useCase: deleteCredentialGroupEnrollmentSettings,
present: ({ credentialGroupEnrollment }) => ({ credentialGroupEnrollment }),
})
Loading
Loading