Skip to content

Commit 1628ba9

Browse files
committed
merge: staging into feat/permission-aware-knowledge
Staging's 0318 (managed MCP connections) lands first; the knowledge migration becomes 0319 with its snapshot regenerated from the merged schema, and the credential-group limits move to their renamed module.
2 parents b930ead + 65a58a8 commit 1628ba9

155 files changed

Lines changed: 29161 additions & 1326 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.

apps/docs/openapi-v2-workflows.json

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6215,6 +6215,9 @@
62156215
},
62166216
{
62176217
"$ref": "#/components/schemas/AgentMcpTool"
6218+
},
6219+
{
6220+
"$ref": "#/components/schemas/AgentMcpServerAdvanced"
62186221
}
62196222
],
62206223
"title": "Agent tool",
@@ -6227,7 +6230,7 @@
62276230
"type": "string",
62286231
"minLength": 1,
62296232
"maxLength": 255,
6230-
"pattern": "^(?!(?:custom-tool|mcp)$).+$",
6233+
"pattern": "^(?!(?:custom-tool|mcp|mcp-server-advanced)$).+$",
62316234
"description": "Catalog block id, such as `cloudwatch` or `slack`. Use the block id, never an underlying tool id."
62326235
},
62336236
"operation": {
@@ -6439,6 +6442,50 @@
64396442
}
64406443
]
64416444
},
6445+
"AgentMcpServerAdvanced": {
6446+
"type": "object",
6447+
"properties": {
6448+
"type": {
6449+
"type": "string",
6450+
"const": "mcp-server-advanced",
6451+
"description": "Server-wide MCP binding discriminator."
6452+
},
6453+
"params": {
6454+
"type": "object",
6455+
"properties": {
6456+
"serverId": {
6457+
"type": "string",
6458+
"minLength": 1,
6459+
"maxLength": 128,
6460+
"description": "Workspace MCP server ID or explicit credential-group managed MCP connection ID."
6461+
}
6462+
},
6463+
"required": ["serverId"],
6464+
"additionalProperties": false,
6465+
"description": "Server identity for discovering and invoking every available MCP tool."
6466+
},
6467+
"usageControl": {
6468+
"type": "string",
6469+
"enum": ["auto", "force", "none"],
6470+
"description": "When the Agent may call the tool: `auto` lets the model decide, `force` requires a call, and `none` disables it. Omitted means `auto`."
6471+
}
6472+
},
6473+
"required": ["type", "params"],
6474+
"additionalProperties": {
6475+
"description": "Forward-compatible MCP server metadata preserved by the workflow editor."
6476+
},
6477+
"title": "Agent MCP server (advanced)",
6478+
"description": "All tools available to the executing subject from one MCP server.",
6479+
"examples": [
6480+
{
6481+
"type": "mcp-server-advanced",
6482+
"params": {
6483+
"serverId": "mcp_01J9X2ABCDEF"
6484+
},
6485+
"usageControl": "auto"
6486+
}
6487+
]
6488+
},
64426489
"ApplyWorkflowOperationsRequest": {
64436490
"type": "object",
64446491
"properties": {
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
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 { makeTimedStep } from '@/lib/mcp/oauth'
15+
import { createCredentialGroupEnrollmentRedirect } from '@/app/api/credential-groups/enrollment-redirect'
16+
17+
export const dynamic = 'force-dynamic'
18+
export const runtime = 'nodejs'
19+
20+
const logger = createLogger('CredentialGroupMcpOAuthStartAPI')
21+
const timedStep = makeTimedStep(logger)
22+
const MANAGED_MCP_OAUTH_START_TIMEOUT_MS = 26_000
23+
24+
export const GET = withRouteHandler(
25+
async (
26+
request: NextRequest,
27+
context: { params: Promise<{ token: string; mcpServerId: string }> }
28+
) => {
29+
const limited = await enforcePublicCredentialGroupOAuthStartIpRateLimit(request)
30+
const parsed = await parseRequest(startCredentialGroupMcpOAuthContract, request, context)
31+
if (!parsed.success) return limited ?? parsed.response
32+
const { token, mcpServerId } = parsed.data.params
33+
if (limited) return createCredentialGroupEnrollmentRedirect(token, { oauth: 'rate_limited' })
34+
35+
const principal = await authenticateCredentialGroupEnrollment(token)
36+
if (!principal) {
37+
return createCredentialGroupEnrollmentRedirect(token, { oauth: 'unavailable' })
38+
}
39+
const enrollmentLimited = await enforceCredentialGroupEnrollmentOAuthRateLimit(
40+
principal.enrollmentId
41+
)
42+
if (enrollmentLimited) {
43+
return createCredentialGroupEnrollmentRedirect(token, { oauth: 'rate_limited' })
44+
}
45+
46+
try {
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+
)
57+
const response = NextResponse.redirect(authorizationUrl)
58+
response.headers.set('Cache-Control', 'no-store')
59+
response.headers.set('Referrer-Policy', 'no-referrer')
60+
return response
61+
} catch (error) {
62+
logger.error('Failed to start managed MCP OAuth authorization', {
63+
error: getErrorMessage(error),
64+
})
65+
return createCredentialGroupEnrollmentRedirect(token, { oauth: 'unavailable' })
66+
}
67+
}
68+
)
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: 84 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,14 +11,37 @@ 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+
mockEnforceCallbackRateLimit,
20+
} = vi.hoisted(() => ({
21+
mockAuthenticateEnrollment: vi.fn(),
22+
mockCompleteManagedMcpOAuth: vi.fn(),
23+
mockConsumeManagedAttempt: vi.fn(),
1524
mockDiscoverServerTools: vi.fn(),
25+
mockEnforceCallbackRateLimit: vi.fn(),
1626
}))
1727

1828
vi.mock('@/lib/mcp/oauth', () => mcpOauthMock)
1929
vi.mock('@/lib/mcp/service', () => ({
2030
mcpService: { discoverServerTools: mockDiscoverServerTools },
2131
}))
32+
vi.mock('@/lib/credential-groups/application/enrollment-auth', () => ({
33+
authenticateCredentialGroupEnrollment: mockAuthenticateEnrollment,
34+
}))
35+
vi.mock('@/lib/credential-groups/application/public-enrollment', () => ({
36+
completePublicCredentialGroupMcpOAuth: { execute: mockCompleteManagedMcpOAuth },
37+
}))
38+
vi.mock('@/lib/credential-groups/mcp-oauth-state', () => ({
39+
consumeCredentialGroupMcpOAuthAttempt: mockConsumeManagedAttempt,
40+
isCredentialGroupMcpOAuthState: (state: string) => state.startsWith('mcp_cg_'),
41+
}))
42+
vi.mock('@/lib/credential-groups/rate-limit', () => ({
43+
enforcePublicCredentialGroupIpRateLimit: mockEnforceCallbackRateLimit,
44+
}))
2245

2346
import { GET } from './route'
2447

@@ -43,6 +66,28 @@ describe('MCP OAuth callback route', () => {
4366
mcpOauthMockFns.mockLoadPreregisteredClient.mockResolvedValue(undefined)
4467
mcpOauthMockFns.mockMcpAuthGuarded.mockResolvedValue('AUTHORIZED')
4568
mockDiscoverServerTools.mockResolvedValue(undefined)
69+
mockConsumeManagedAttempt.mockResolvedValue({
70+
state: 'mcp_cg_state-1',
71+
enrollmentId: 'enrollment-1',
72+
credentialGroupId: 'group-1',
73+
mcpServerId: 'server-1',
74+
codeVerifier: 'code-verifier',
75+
invitationToken: 'invitation-token',
76+
createdAt: Date.now(),
77+
})
78+
mockAuthenticateEnrollment.mockResolvedValue({
79+
kind: 'credential_group_enrollment',
80+
workspaceId: 'workspace-1',
81+
credentialGroupId: 'group-1',
82+
enrollmentId: 'enrollment-1',
83+
email: 'invitee@example.com',
84+
invitationTokenHash: 'token-hash',
85+
})
86+
mockCompleteManagedMcpOAuth.mockResolvedValue({
87+
connectionId: 'mcp-cg-connection-1',
88+
mcpServerId: 'server-1',
89+
})
90+
mockEnforceCallbackRateLimit.mockResolvedValue(null)
4691
})
4792

4893
it('performs the token exchange through the SSRF-guarded mcpAuthGuarded wrapper', async () => {
@@ -104,4 +149,42 @@ describe('MCP OAuth callback route', () => {
104149
expect(body).toContain('"state-1"')
105150
expect(body).toContain('serverId: undefined')
106151
})
152+
153+
it('completes a managed grant from one-time invitation state without a Sim session', async () => {
154+
const request = new NextRequest(
155+
'http://localhost:3000/api/mcp/oauth/callback?state=mcp_cg_state-1&code=auth-code-1'
156+
)
157+
158+
const response = await GET(request)
159+
160+
expect(mockEnforceCallbackRateLimit).toHaveBeenCalledWith(request, 'oauth-callback')
161+
expect(mockConsumeManagedAttempt).toHaveBeenCalledWith('mcp_cg_state-1')
162+
expect(mockAuthenticateEnrollment).toHaveBeenCalledWith('invitation-token')
163+
expect(mockCompleteManagedMcpOAuth).toHaveBeenCalledWith(
164+
expect.objectContaining({
165+
input: expect.objectContaining({
166+
code: 'auth-code-1',
167+
attempt: expect.objectContaining({ mcpServerId: 'server-1' }),
168+
}),
169+
})
170+
)
171+
expect(authMockFns.mockGetSession).not.toHaveBeenCalled()
172+
expect(response.headers.get('location')).toContain(
173+
'/credential-groups/enroll/invitation-token?mcp=connected&mcpServerId=server-1'
174+
)
175+
})
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+
})
107190
})

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

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,13 @@ 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'
18+
import { enforcePublicCredentialGroupIpRateLimit } from '@/lib/credential-groups/rate-limit'
1219
import {
1320
assertSafeOauthServerUrl,
1421
clearState,
@@ -21,6 +28,7 @@ import {
2128
SimMcpOauthProvider,
2229
} from '@/lib/mcp/oauth'
2330
import { mcpService } from '@/lib/mcp/service'
31+
import { createCredentialGroupEnrollmentRedirect } from '@/app/api/credential-groups/enrollment-redirect'
2432

2533
const logger = createLogger('McpOauthCallbackAPI')
2634
const timedStep = makeTimedStep(logger)
@@ -70,13 +78,59 @@ function htmlClose(
7078
})
7179
}
7280

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

128+
if (state && isCredentialGroupMcpOAuthState(state)) {
129+
const limited = await enforcePublicCredentialGroupIpRateLimit(request, 'oauth-callback')
130+
if (limited) return limited
131+
return completeManagedMcpCallback({ request, state, code, error: errorParam })
132+
}
133+
80134
// Echo the flow's `state` on every result so the opener can correlate a broadcast back to
81135
// the exact flow it started — including failures (e.g. `invalid_state`) that never resolve
82136
// 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
})

0 commit comments

Comments
 (0)