Skip to content

Commit e1a6e22

Browse files
fix(credential-groups): harden managed MCP lifecycle
1 parent d1b9c58 commit e1a6e22

23 files changed

Lines changed: 369 additions & 131 deletions

File tree

apps/sim/app/api/credential-groups/enroll/[token]/mcp/[mcpServerId]/route.ts

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,15 @@ import {
1111
enforceCredentialGroupEnrollmentOAuthRateLimit,
1212
enforcePublicCredentialGroupOAuthStartIpRateLimit,
1313
} from '@/lib/credential-groups/rate-limit'
14+
import { makeTimedStep } from '@/lib/mcp/oauth'
1415
import { createCredentialGroupEnrollmentRedirect } from '@/app/api/credential-groups/enrollment-redirect'
1516

1617
export const dynamic = 'force-dynamic'
1718
export const runtime = 'nodejs'
1819

1920
const logger = createLogger('CredentialGroupMcpOAuthStartAPI')
21+
const timedStep = makeTimedStep(logger)
22+
const MANAGED_MCP_OAUTH_START_TIMEOUT_MS = 26_000
2023

2124
export const GET = withRouteHandler(
2225
async (
@@ -41,11 +44,16 @@ export const GET = withRouteHandler(
4144
}
4245

4346
try {
44-
const { authorizationUrl } = await startPublicCredentialGroupMcpOAuth.execute({
45-
principal,
46-
input: { invitationToken: token, mcpServerId },
47-
request,
48-
})
47+
const { authorizationUrl } = await timedStep(
48+
'startPublicCredentialGroupMcpOAuth',
49+
MANAGED_MCP_OAUTH_START_TIMEOUT_MS,
50+
() =>
51+
startPublicCredentialGroupMcpOAuth.execute({
52+
principal,
53+
input: { invitationToken: token, mcpServerId },
54+
request,
55+
})
56+
)
4957
const response = NextResponse.redirect(authorizationUrl)
5058
response.headers.set('Cache-Control', 'no-store')
5159
response.headers.set('Referrer-Policy', 'no-referrer')

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

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,13 @@ const {
1616
mockCompleteManagedMcpOAuth,
1717
mockConsumeManagedAttempt,
1818
mockDiscoverServerTools,
19+
mockEnforceCallbackRateLimit,
1920
} = vi.hoisted(() => ({
2021
mockAuthenticateEnrollment: vi.fn(),
2122
mockCompleteManagedMcpOAuth: vi.fn(),
2223
mockConsumeManagedAttempt: vi.fn(),
2324
mockDiscoverServerTools: vi.fn(),
25+
mockEnforceCallbackRateLimit: vi.fn(),
2426
}))
2527

2628
vi.mock('@/lib/mcp/oauth', () => mcpOauthMock)
@@ -37,6 +39,9 @@ vi.mock('@/lib/credential-groups/mcp-oauth-state', () => ({
3739
consumeCredentialGroupMcpOAuthAttempt: mockConsumeManagedAttempt,
3840
isCredentialGroupMcpOAuthState: (state: string) => state.startsWith('mcp_cg_'),
3941
}))
42+
vi.mock('@/lib/credential-groups/rate-limit', () => ({
43+
enforcePublicCredentialGroupIpRateLimit: mockEnforceCallbackRateLimit,
44+
}))
4045

4146
import { GET } from './route'
4247

@@ -82,6 +87,7 @@ describe('MCP OAuth callback route', () => {
8287
connectionId: 'mcp-cg-connection-1',
8388
mcpServerId: 'server-1',
8489
})
90+
mockEnforceCallbackRateLimit.mockResolvedValue(null)
8591
})
8692

8793
it('performs the token exchange through the SSRF-guarded mcpAuthGuarded wrapper', async () => {
@@ -151,6 +157,7 @@ describe('MCP OAuth callback route', () => {
151157

152158
const response = await GET(request)
153159

160+
expect(mockEnforceCallbackRateLimit).toHaveBeenCalledWith(request, 'oauth-callback')
154161
expect(mockConsumeManagedAttempt).toHaveBeenCalledWith('mcp_cg_state-1')
155162
expect(mockAuthenticateEnrollment).toHaveBeenCalledWith('invitation-token')
156163
expect(mockCompleteManagedMcpOAuth).toHaveBeenCalledWith(
@@ -166,4 +173,18 @@ describe('MCP OAuth callback route', () => {
166173
'/credential-groups/enroll/invitation-token?mcp=connected&mcpServerId=server-1'
167174
)
168175
})
176+
177+
it('rate limits a managed callback before consuming its one-time state', async () => {
178+
const limitedResponse = new Response('rate limited', { status: 429 })
179+
mockEnforceCallbackRateLimit.mockResolvedValueOnce(limitedResponse)
180+
const request = new NextRequest(
181+
'http://localhost:3000/api/mcp/oauth/callback?state=mcp_cg_state-1&code=auth-code-1'
182+
)
183+
184+
const response = await GET(request)
185+
186+
expect(response.status).toBe(429)
187+
expect(mockConsumeManagedAttempt).not.toHaveBeenCalled()
188+
expect(mockCompleteManagedMcpOAuth).not.toHaveBeenCalled()
189+
})
169190
})

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
consumeCredentialGroupMcpOAuthAttempt,
1616
isCredentialGroupMcpOAuthState,
1717
} from '@/lib/credential-groups/mcp-oauth-state'
18+
import { enforcePublicCredentialGroupIpRateLimit } from '@/lib/credential-groups/rate-limit'
1819
import {
1920
assertSafeOauthServerUrl,
2021
clearState,
@@ -125,6 +126,8 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
125126
const { state, code, error: errorParam } = parsed.data.query
126127

127128
if (state && isCredentialGroupMcpOAuthState(state)) {
129+
const limited = await enforcePublicCredentialGroupIpRateLimit(request, 'oauth-callback')
130+
if (limited) return limited
128131
return completeManagedMcpCallback({ request, state, code, error: errorParam })
129132
}
130133

apps/sim/app/credential-groups/enroll/[token]/oauth-toast.test.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ describe('CredentialGroupOAuthToast', () => {
4444
expect(mockSuccess).toHaveBeenCalledOnce()
4545
expect(mockSuccess).toHaveBeenCalledWith('Gmail connected successfully.')
4646
expect(mockSetOAuthStatus).toHaveBeenCalledWith(
47-
{ connected: null, oauth: null, submitted: null },
47+
{ connected: null, mcp: null, mcpServerId: null, oauth: null, submitted: null },
4848
{ history: 'replace', scroll: false }
4949
)
5050
act(() => root.unmount())
@@ -55,7 +55,7 @@ describe('CredentialGroupOAuthToast', () => {
5555

5656
expect(mockError).toHaveBeenCalledWith('Authorization was canceled.')
5757
expect(mockSetOAuthStatus).toHaveBeenCalledWith(
58-
{ connected: null, oauth: null, submitted: null },
58+
{ connected: null, mcp: null, mcpServerId: null, oauth: null, submitted: null },
5959
{ history: 'replace', scroll: false }
6060
)
6161
act(() => root.unmount())
@@ -66,7 +66,7 @@ describe('CredentialGroupOAuthToast', () => {
6666

6767
expect(mockSuccess).toHaveBeenCalledWith('Accounts submitted successfully.')
6868
expect(mockSetOAuthStatus).toHaveBeenCalledWith(
69-
{ connected: null, oauth: null, submitted: null },
69+
{ connected: null, mcp: null, mcpServerId: null, oauth: null, submitted: null },
7070
{ history: 'replace', scroll: false }
7171
)
7272
act(() => root.unmount())

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ export function CredentialGroupOAuthToast({ message, variant }: CredentialGroupO
2929
else toast.error(message)
3030

3131
void setOAuthStatus(
32-
{ connected: null, oauth: null, submitted: null },
32+
{ connected: null, mcp: null, mcpServerId: null, oauth: null, submitted: null },
3333
{ history: 'replace', scroll: false }
3434
)
3535
}, [message, setOAuthStatus, toast, variant])

apps/sim/app/credential-groups/enroll/[token]/search-params.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ import { parseAsString } from 'nuqs/server'
33
/** One-shot OAuth result signals are nullable because absence means no toast. */
44
export const credentialGroupEnrollmentStatusParsers = {
55
connected: parseAsString,
6+
mcp: parseAsString,
7+
mcpServerId: parseAsString,
68
oauth: parseAsString,
79
submitted: parseAsString,
810
} as const

apps/sim/ee/credential-groups/components/credential-group-detail.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -137,7 +137,11 @@ export function CredentialGroupDetail({
137137
: null
138138
const configurationReady =
139139
Boolean(
140-
credentialGroup && (credentialGroup.options.length || credentialGroup.mcpServers.length)
140+
credentialGroup &&
141+
(credentialGroup.options.length ||
142+
credentialGroup.mcpServers.some(
143+
(server) => server.enabled && server.authType === 'oauth'
144+
))
141145
) &&
142146
credentialGroup?.options.every(
143147
(option) =>

apps/sim/hooks/queries/mcp.test.tsx

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,24 @@ describe('useMcpToolsQuery', () => {
193193
hook.unmount()
194194
})
195195

196+
it('surfaces a shared server-list failure when the managed catalog is empty', async () => {
197+
const serverListError = new Error('server list failed')
198+
mockRequestJson.mockImplementation(async (contract) => {
199+
if (contract === listMcpServersContract) throw serverListError
200+
if (contract === listManagedMcpCatalogContract) return { servers: [], tools: [] }
201+
throw new Error('Unexpected MCP request')
202+
})
203+
204+
const hook = renderHookWithClient(() => useMcpToolsQuery(WORKSPACE_ID))
205+
await flush()
206+
207+
expect(hook.getResult().data).toEqual([])
208+
expect(hook.getResult().error).toBe(serverListError)
209+
expect(hook.getResult().isLoading).toBe(false)
210+
211+
hook.unmount()
212+
})
213+
196214
it('defers detail and form metadata queries while their surfaces are closed', async () => {
197215
mockRequestJson.mockImplementation(async (contract) => {
198216
if (contract === listStoredMcpToolsContract || contract === getAllowedMcpDomainsContract) {

apps/sim/hooks/queries/mcp.ts

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -190,7 +190,11 @@ function isServerEligibleForDiscovery(server: McpServer, workspaceId: string): b
190190
*/
191191
export function useMcpToolsQuery(workspaceId: string) {
192192
const queryClient = useQueryClient()
193-
const { data: servers, isLoading: serversLoading } = useMcpServers(workspaceId)
193+
const {
194+
data: servers,
195+
isLoading: serversLoading,
196+
error: serversError,
197+
} = useMcpServers(workspaceId)
194198
const managedCatalog = useManagedMcpCatalog(workspaceId)
195199
// Push is intrinsic to consuming the tools query: every surface that reads tools (settings,
196200
// tool picker, dynamic args, tool selector, canvas block) gets real-time `list_changed`
@@ -236,10 +240,14 @@ export function useMcpToolsQuery(workspaceId: string) {
236240

237241
return useMemo(() => {
238242
const tools: McpTool[] = [...(managedCatalog.data?.tools ?? [])]
239-
let hasData = Boolean(managedCatalog.data)
243+
let hasData = Boolean(managedCatalog.data?.tools.length)
240244
let anyServerLoading = false
241245
let firstError: Error | null =
242-
managedCatalog.error instanceof Error ? managedCatalog.error : null
246+
managedCatalog.error instanceof Error
247+
? managedCatalog.error
248+
: serversError instanceof Error
249+
? serversError
250+
: null
243251
const statusById = new Map(
244252
[...(servers ?? []), ...(managedCatalog.data?.servers ?? [])].map((server) => [
245253
server.id,
@@ -282,7 +290,7 @@ export function useMcpToolsQuery(workspaceId: string) {
282290
error: hasData ? null : firstError,
283291
toolsStateByServer,
284292
}
285-
}, [results, serversLoading, serverIds, servers, managedCatalog])
293+
}, [results, serversLoading, serversError, serverIds, servers, managedCatalog])
286294
}
287295

288296
export function useForceRefreshMcpTools() {

apps/sim/lib/api/contracts/credential-groups.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,8 +74,10 @@ export const credentialGroupOptionUpdateInputSchema = z.discriminatedUnion('prov
7474

7575
export const credentialGroupMcpServerSchema = z.object({
7676
id: z.string().min(1).max(128),
77-
name: z.string().min(1).max(255),
77+
name: z.string().min(1),
7878
description: z.string().nullable(),
79+
authType: z.string().min(1),
80+
enabled: z.boolean(),
7981
})
8082

8183
export const credentialGroupSchema = z.object({

0 commit comments

Comments
 (0)