Skip to content

Commit 2fe9da0

Browse files
feat(credential-groups): add personal MCP OAuth connections
1 parent 358af42 commit 2fe9da0

75 files changed

Lines changed: 65596 additions & 189 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import { createLogger } from '@sim/logger'
2+
import { getErrorMessage } from '@sim/utils/errors'
3+
import type { NextRequest } from 'next/server'
4+
import { NextResponse } from 'next/server'
5+
import { startCredentialGroupMcpOAuthContract } from '@/lib/api/contracts/credential-groups'
6+
import { parseRequest } from '@/lib/api/server'
7+
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
8+
import { authenticateCredentialGroupEnrollment } from '@/lib/credential-groups/application/enrollment-auth'
9+
import { startPublicCredentialGroupMcpOAuth } from '@/lib/credential-groups/application/public-enrollment'
10+
import {
11+
enforceCredentialGroupEnrollmentOAuthRateLimit,
12+
enforcePublicCredentialGroupOAuthStartIpRateLimit,
13+
} from '@/lib/credential-groups/rate-limit'
14+
import { createCredentialGroupEnrollmentRedirect } from '@/app/api/credential-groups/enrollment-redirect'
15+
16+
export const dynamic = 'force-dynamic'
17+
export const runtime = 'nodejs'
18+
19+
const logger = createLogger('CredentialGroupMcpOAuthStartAPI')
20+
21+
export const GET = withRouteHandler(
22+
async (
23+
request: NextRequest,
24+
context: { params: Promise<{ token: string; mcpServerId: string }> }
25+
) => {
26+
const limited = await enforcePublicCredentialGroupOAuthStartIpRateLimit(request)
27+
const parsed = await parseRequest(startCredentialGroupMcpOAuthContract, request, context)
28+
if (!parsed.success) return limited ?? parsed.response
29+
const { token, mcpServerId } = parsed.data.params
30+
if (limited) return createCredentialGroupEnrollmentRedirect(token, { oauth: 'rate_limited' })
31+
32+
const principal = await authenticateCredentialGroupEnrollment(token)
33+
if (!principal) {
34+
return createCredentialGroupEnrollmentRedirect(token, { oauth: 'unavailable' })
35+
}
36+
const enrollmentLimited = await enforceCredentialGroupEnrollmentOAuthRateLimit(
37+
principal.enrollmentId
38+
)
39+
if (enrollmentLimited) {
40+
return createCredentialGroupEnrollmentRedirect(token, { oauth: 'rate_limited' })
41+
}
42+
43+
try {
44+
const { authorizationUrl } = await startPublicCredentialGroupMcpOAuth.execute({
45+
principal,
46+
input: { invitationToken: token, mcpServerId },
47+
request,
48+
})
49+
const response = NextResponse.redirect(authorizationUrl)
50+
response.headers.set('Cache-Control', 'no-store')
51+
response.headers.set('Referrer-Policy', 'no-referrer')
52+
return response
53+
} catch (error) {
54+
logger.error('Failed to start managed MCP OAuth authorization', {
55+
error: getErrorMessage(error),
56+
})
57+
return createCredentialGroupEnrollmentRedirect(token, { oauth: 'unavailable' })
58+
}
59+
}
60+
)
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { listManagedMcpCatalogContract } from '@/lib/api/contracts/mcp'
2+
import {
3+
defineInternalJsonRoute,
4+
internalOrchestrationErrorPolicy,
5+
internalRateLimits,
6+
internalSessionAuth,
7+
} from '@/lib/api/server/routes'
8+
import { listManagedMcpConnectionsUseCase } from '@/lib/mcp/application/managed-connections'
9+
import { mcpServerOperations } from '@/lib/mcp/application/operations'
10+
11+
export const GET = defineInternalJsonRoute({
12+
contract: listManagedMcpCatalogContract,
13+
auth: internalSessionAuth,
14+
operation: mcpServerOperations.listManagedConnections,
15+
rateLimit: internalRateLimits.none({ reason: 'Managed MCP metadata is workspace-scoped' }),
16+
errorPolicy: internalOrchestrationErrorPolicy,
17+
mapInput: ({ query }) => ({ workspaceId: query.workspaceId }),
18+
useCase: listManagedMcpConnectionsUseCase,
19+
present: (result) => result,
20+
})

apps/sim/app/api/mcp/oauth/callback/route.test.ts

Lines changed: 63 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,14 +11,32 @@ import {
1111
import { NextRequest } from 'next/server'
1212
import { beforeEach, describe, expect, it, vi } from 'vitest'
1313

14-
const { mockDiscoverServerTools } = vi.hoisted(() => ({
14+
const {
15+
mockAuthenticateEnrollment,
16+
mockCompleteManagedMcpOAuth,
17+
mockConsumeManagedAttempt,
18+
mockDiscoverServerTools,
19+
} = vi.hoisted(() => ({
20+
mockAuthenticateEnrollment: vi.fn(),
21+
mockCompleteManagedMcpOAuth: vi.fn(),
22+
mockConsumeManagedAttempt: vi.fn(),
1523
mockDiscoverServerTools: vi.fn(),
1624
}))
1725

1826
vi.mock('@/lib/mcp/oauth', () => mcpOauthMock)
1927
vi.mock('@/lib/mcp/service', () => ({
2028
mcpService: { discoverServerTools: mockDiscoverServerTools },
2129
}))
30+
vi.mock('@/lib/credential-groups/application/enrollment-auth', () => ({
31+
authenticateCredentialGroupEnrollment: mockAuthenticateEnrollment,
32+
}))
33+
vi.mock('@/lib/credential-groups/application/public-enrollment', () => ({
34+
completePublicCredentialGroupMcpOAuth: { execute: mockCompleteManagedMcpOAuth },
35+
}))
36+
vi.mock('@/lib/credential-groups/mcp-oauth-state', () => ({
37+
consumeCredentialGroupMcpOAuthAttempt: mockConsumeManagedAttempt,
38+
isCredentialGroupMcpOAuthState: (state: string) => state.startsWith('mcp_cg_'),
39+
}))
2240

2341
import { GET } from './route'
2442

@@ -43,6 +61,27 @@ describe('MCP OAuth callback route', () => {
4361
mcpOauthMockFns.mockLoadPreregisteredClient.mockResolvedValue(undefined)
4462
mcpOauthMockFns.mockMcpAuthGuarded.mockResolvedValue('AUTHORIZED')
4563
mockDiscoverServerTools.mockResolvedValue(undefined)
64+
mockConsumeManagedAttempt.mockResolvedValue({
65+
state: 'mcp_cg_state-1',
66+
enrollmentId: 'enrollment-1',
67+
credentialGroupId: 'group-1',
68+
mcpServerId: 'server-1',
69+
codeVerifier: 'code-verifier',
70+
invitationToken: 'invitation-token',
71+
createdAt: Date.now(),
72+
})
73+
mockAuthenticateEnrollment.mockResolvedValue({
74+
kind: 'credential_group_enrollment',
75+
workspaceId: 'workspace-1',
76+
credentialGroupId: 'group-1',
77+
enrollmentId: 'enrollment-1',
78+
email: 'invitee@example.com',
79+
invitationTokenHash: 'token-hash',
80+
})
81+
mockCompleteManagedMcpOAuth.mockResolvedValue({
82+
connectionId: 'mcp-cg-connection-1',
83+
mcpServerId: 'server-1',
84+
})
4685
})
4786

4887
it('performs the token exchange through the SSRF-guarded mcpAuthGuarded wrapper', async () => {
@@ -104,4 +143,27 @@ describe('MCP OAuth callback route', () => {
104143
expect(body).toContain('"state-1"')
105144
expect(body).toContain('serverId: undefined')
106145
})
146+
147+
it('completes a managed grant from one-time invitation state without a Sim session', async () => {
148+
const request = new NextRequest(
149+
'http://localhost:3000/api/mcp/oauth/callback?state=mcp_cg_state-1&code=auth-code-1'
150+
)
151+
152+
const response = await GET(request)
153+
154+
expect(mockConsumeManagedAttempt).toHaveBeenCalledWith('mcp_cg_state-1')
155+
expect(mockAuthenticateEnrollment).toHaveBeenCalledWith('invitation-token')
156+
expect(mockCompleteManagedMcpOAuth).toHaveBeenCalledWith(
157+
expect.objectContaining({
158+
input: expect.objectContaining({
159+
code: 'auth-code-1',
160+
attempt: expect.objectContaining({ mcpServerId: 'server-1' }),
161+
}),
162+
})
163+
)
164+
expect(authMockFns.mockGetSession).not.toHaveBeenCalled()
165+
expect(response.headers.get('location')).toContain(
166+
'/credential-groups/enroll/invitation-token?mcp=connected&mcpServerId=server-1'
167+
)
168+
})
107169
})

apps/sim/app/api/mcp/oauth/callback/route.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,12 @@ import { mcpOauthCallbackContract } from '@/lib/api/contracts/mcp'
99
import { parseRequest } from '@/lib/api/server'
1010
import { getSession } from '@/lib/auth'
1111
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
12+
import { authenticateCredentialGroupEnrollment } from '@/lib/credential-groups/application/enrollment-auth'
13+
import { completePublicCredentialGroupMcpOAuth } from '@/lib/credential-groups/application/public-enrollment'
14+
import {
15+
consumeCredentialGroupMcpOAuthAttempt,
16+
isCredentialGroupMcpOAuthState,
17+
} from '@/lib/credential-groups/mcp-oauth-state'
1218
import {
1319
assertSafeOauthServerUrl,
1420
clearState,
@@ -21,6 +27,7 @@ import {
2127
SimMcpOauthProvider,
2228
} from '@/lib/mcp/oauth'
2329
import { mcpService } from '@/lib/mcp/service'
30+
import { createCredentialGroupEnrollmentRedirect } from '@/app/api/credential-groups/enrollment-redirect'
2431

2532
const logger = createLogger('McpOauthCallbackAPI')
2633
const timedStep = makeTimedStep(logger)
@@ -70,13 +77,57 @@ function htmlClose(
7077
})
7178
}
7279

80+
async function completeManagedMcpCallback(params: {
81+
request: NextRequest
82+
state: string
83+
code?: string
84+
error?: string
85+
}): Promise<NextResponse> {
86+
const attempt = await consumeCredentialGroupMcpOAuthAttempt(params.state)
87+
if (!attempt) {
88+
return htmlClose('Invalid or expired authorization state.', false, 'invalid_state')
89+
}
90+
if (params.error) {
91+
return createCredentialGroupEnrollmentRedirect(attempt.invitationToken, { oauth: 'denied' })
92+
}
93+
if (!params.code) {
94+
return createCredentialGroupEnrollmentRedirect(attempt.invitationToken, {
95+
oauth: 'failed',
96+
})
97+
}
98+
try {
99+
const principal = await authenticateCredentialGroupEnrollment(attempt.invitationToken)
100+
if (!principal) {
101+
return createCredentialGroupEnrollmentRedirect(attempt.invitationToken, {
102+
oauth: 'unavailable',
103+
})
104+
}
105+
const result = await completePublicCredentialGroupMcpOAuth.execute({
106+
principal,
107+
input: { attempt, code: params.code },
108+
request: params.request,
109+
})
110+
return createCredentialGroupEnrollmentRedirect(attempt.invitationToken, {
111+
mcp: 'connected',
112+
mcpServerId: result.mcpServerId,
113+
})
114+
} catch (error) {
115+
logger.error('Managed MCP OAuth callback failed', error)
116+
return createCredentialGroupEnrollmentRedirect(attempt.invitationToken, { oauth: 'failed' })
117+
}
118+
}
119+
73120
export const GET = withRouteHandler(async (request: NextRequest) => {
74121
const parsed = await parseRequest(mcpOauthCallbackContract, request, {})
75122
if (!parsed.success) {
76123
return htmlClose('Malformed authorization callback.', false, 'missing_params')
77124
}
78125
const { state, code, error: errorParam } = parsed.data.query
79126

127+
if (state && isCredentialGroupMcpOAuthState(state)) {
128+
return completeManagedMcpCallback({ request, state, code, error: errorParam })
129+
}
130+
80131
// Echo the flow's `state` on every result so the opener can correlate a broadcast back to
81132
// the exact flow it started — including failures (e.g. `invalid_state`) that never resolve
82133
// a serverId. Without it those results would strand the initiating tab on "Connecting…".

apps/sim/app/api/workspaces/[id]/credential-groups/[groupId]/enrollments/[enrollmentId]/route.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,4 +24,5 @@ export const DELETE = defineInternalJsonRoute({
2424
enrollmentId: params.enrollmentId,
2525
}),
2626
useCase: deleteCredentialGroupEnrollmentSettings,
27+
present: ({ credentialGroupEnrollment }) => ({ credentialGroupEnrollment }),
2728
})

apps/sim/app/api/workspaces/[id]/credential-groups/[groupId]/route.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ export const PATCH = defineInternalJsonRoute({
4747
update: body,
4848
}),
4949
useCase: updateCredentialGroupSettings,
50+
present: ({ credentialGroup }) => ({ credentialGroup }),
5051
})
5152

5253
export const DELETE = defineInternalJsonRoute({
@@ -60,4 +61,5 @@ export const DELETE = defineInternalJsonRoute({
6061
credentialGroupId: params.groupId,
6162
}),
6263
useCase: deleteCredentialGroupSettings,
64+
present: () => ({ success: true as const }),
6365
})

apps/sim/app/credential-groups/enroll/[token]/page.tsx

Lines changed: 38 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { type ReactNode, Suspense } from 'react'
22
import { Chip } from '@sim/emcn'
33
import type { Metadata } from 'next'
44
import { headers } from 'next/headers'
5+
import { McpIcon } from '@/components/icons'
56
import { asOrchestrationError } from '@/lib/core/orchestration/types'
67
import { authenticateCredentialGroupEnrollment } from '@/lib/credential-groups/application/enrollment-auth'
78
import { readPublicCredentialGroupEnrollment } from '@/lib/credential-groups/application/public-enrollment'
@@ -106,6 +107,10 @@ export default async function CredentialGroupEnrollmentPage({
106107
const resolvedSearchParams = await searchParams
107108
const oauthStatus = getSearchParam(resolvedSearchParams, 'oauth')
108109
const connectedOptionId = getSearchParam(resolvedSearchParams, 'connected')
110+
const connectedMcpServerId =
111+
getSearchParam(resolvedSearchParams, 'mcp') === 'connected'
112+
? getSearchParam(resolvedSearchParams, 'mcpServerId')
113+
: undefined
109114
const oauthMessage =
110115
oauthStatus && oauthStatus in OAUTH_MESSAGES
111116
? OAUTH_MESSAGES[oauthStatus as keyof typeof OAUTH_MESSAGES]
@@ -114,14 +119,22 @@ export default async function CredentialGroupEnrollmentPage({
114119
const connectedOption = connectedOptionId
115120
? activeOptions.find((option) => option.id === connectedOptionId)
116121
: undefined
117-
const notification = connectedOptionId
122+
const connectedMcpServer = connectedMcpServerId
123+
? enrollment.mcpServers.find((server) => server.id === connectedMcpServerId)
124+
: undefined
125+
const notification = connectedMcpServerId
118126
? {
119-
message: `${connectedOption ? getCredentialGroupProviderService(connectedOption.provider).name : 'Account'} connected successfully.`,
127+
message: `${connectedMcpServer?.name ?? 'MCP server'} connected successfully.`,
120128
variant: 'success' as const,
121129
}
122-
: oauthMessage
123-
? { message: oauthMessage, variant: 'error' as const }
124-
: null
130+
: connectedOptionId
131+
? {
132+
message: `${connectedOption ? getCredentialGroupProviderService(connectedOption.provider).name : 'Account'} connected successfully.`,
133+
variant: 'success' as const,
134+
}
135+
: oauthMessage
136+
? { message: oauthMessage, variant: 'error' as const }
137+
: null
125138
return (
126139
<PageShell>
127140
{notification && (
@@ -168,6 +181,26 @@ export default async function CredentialGroupEnrollmentPage({
168181
/>
169182
)
170183
})}
184+
{enrollment.mcpServers.map((server) => (
185+
<SettingsResourceRow
186+
key={server.id}
187+
icon={<McpIcon />}
188+
title={server.name}
189+
description={
190+
server.connection?.status === 'connected'
191+
? 'Connected'
192+
: server.connection
193+
? 'Reconnect required'
194+
: server.description || 'Not connected'
195+
}
196+
trailing={
197+
<OAuthConnectLink
198+
href={`/api/credential-groups/enroll/${token}/mcp/${server.id}`}
199+
reconnect={Boolean(server.connection)}
200+
/>
201+
}
202+
/>
203+
))}
171204
</div>
172205
</SettingsSection>
173206
<form

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/mcp-server-modal/mcp-server-selector.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import { getWorkflowSearchLabelHighlight } from '@/app/workspace/[workspaceId]/w
88
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value'
99
import { useActiveSearchTarget } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/providers/active-search-target-provider'
1010
import type { SubBlockConfig } from '@/blocks/types'
11-
import { useMcpServers } from '@/hooks/queries/mcp'
11+
import { useMcpToolServers } from '@/hooks/queries/mcp'
1212

1313
interface McpServerSelectorProps {
1414
blockId: string
@@ -30,7 +30,7 @@ export function McpServerSelector({
3030
const workspaceId = params.workspaceId as string
3131
const [inputValue, setInputValue] = useState('')
3232

33-
const { data: servers = [], isLoading, error } = useMcpServers(workspaceId)
33+
const { data: servers = [], isLoading, error } = useMcpToolServers(workspaceId)
3434
const enabledServers = servers.filter((s) => s.enabled && !s.deletedAt)
3535

3636
const [storeValue, setStoreValue] = useSubBlockValue(blockId, subBlock.id)

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/tool-input.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,7 @@ import {
8383
useAllowedMcpDomains,
8484
useCreateMcpServer,
8585
useForceRefreshMcpTools,
86-
useMcpServers,
86+
useMcpToolServers,
8787
useStoredMcpTools,
8888
} from '@/hooks/queries/mcp'
8989
import { useWorkflowState, useWorkflows } from '@/hooks/queries/workflows'
@@ -574,7 +574,7 @@ export const ToolInput = memo(function ToolInput({
574574
return names
575575
}, [mcpTools])
576576

577-
const { data: mcpServers = [], isLoading: mcpServersLoading } = useMcpServers(workspaceId)
577+
const { data: mcpServers = [], isLoading: mcpServersLoading } = useMcpToolServers(workspaceId)
578578
const { data: storedMcpTools = [] } = useStoredMcpTools(workspaceId)
579579
const forceRefreshMcpTools = useForceRefreshMcpTools().mutate
580580
const { navigateToSettings } = useSettingsNavigation()

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,7 @@ import { useKnowledgeBase } from '@/hooks/kb/use-knowledge'
108108
import { useCustomTools } from '@/hooks/queries/custom-tools'
109109
import { useDeployWorkflow } from '@/hooks/queries/deployments'
110110
import { useDynamicSubBlockOptionDisplayName } from '@/hooks/queries/dynamic-subblock-options'
111-
import { useMcpServers, useMcpToolsQuery } from '@/hooks/queries/mcp'
111+
import { useMcpToolServers, useMcpToolsQuery } from '@/hooks/queries/mcp'
112112
import { useCredentialName } from '@/hooks/queries/oauth/oauth-credentials'
113113
import { useSandboxes } from '@/hooks/queries/sandboxes'
114114
import { useReactivateSchedule, useScheduleInfo } from '@/hooks/queries/schedules'
@@ -470,7 +470,7 @@ const SubBlockRow = memo(function SubBlockRow({
470470
)
471471
}, [workflowMapForLookup, workflowMapLoaded, workflowMapIsPlaceholder, subBlock, rawValue])
472472

473-
const { data: mcpServers = [] } = useMcpServers(workspaceId || '')
473+
const { data: mcpServers = [] } = useMcpToolServers(workspaceId || '')
474474
const mcpServerDisplayName = useMemo(() => {
475475
if (subBlock?.type !== 'mcp-server-selector' || typeof rawValue !== 'string') {
476476
return null

0 commit comments

Comments
 (0)