From 3508ab0c37280d51cb72c3e896c2fbe2d4678741 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Tue, 1 Sep 2026 00:29:07 -0700 Subject: [PATCH 01/10] feat(credential-groups): add personal MCP OAuth connections --- .../enroll/[token]/mcp/[mcpServerId]/route.ts | 60 + .../app/api/mcp/managed-connections/route.ts | 20 + .../app/api/mcp/oauth/callback/route.test.ts | 64 +- apps/sim/app/api/mcp/oauth/callback/route.ts | 51 + .../enrollments/[enrollmentId]/route.ts | 1 + .../[id]/credential-groups/[groupId]/route.ts | 2 + .../credential-groups/enroll/[token]/page.tsx | 43 +- .../mcp-server-modal/mcp-server-selector.tsx | 4 +- .../components/tool-input/tool-input.tsx | 4 +- .../workflow-block/workflow-block.tsx | 4 +- .../components/credential-group-access.tsx | 2 +- .../components/credential-group-detail.tsx | 23 +- .../components/credential-group-details.tsx | 91 + apps/sim/hooks/mcp/use-mcp-oauth-popup.ts | 1 + apps/sim/hooks/queries/credential-groups.ts | 19 + apps/sim/hooks/queries/mcp.test.tsx | 45 +- apps/sim/hooks/queries/mcp.ts | 61 +- .../hooks/queries/workflow-search-replace.ts | 97 +- .../api/contracts/credential-groups.test.ts | 6 +- .../lib/api/contracts/credential-groups.ts | 65 +- apps/sim/lib/api/contracts/mcp.ts | 17 + .../application/enrollment-operations.ts | 12 + .../application/manage-access.test.ts | 2 +- .../application/manage-access.ts | 2 +- .../application/manage-enrollments.ts | 10 +- .../application/manage-groups.test.ts | 1 + .../application/manage-groups.ts | 29 +- .../application/public-enrollment.test.ts | 39 + .../application/public-enrollment.ts | 63 + .../application/validation.ts | 21 + .../workflow-access-policy.test.ts | 2 +- .../application/workflow-access-policy.ts | 2 +- .../lib/credential-groups/enrollments.test.ts | 6 +- apps/sim/lib/credential-groups/enrollments.ts | 238 +- .../{workflow-access-limits.ts => limits.ts} | 1 + .../credential-groups/mcp-oauth-state.test.ts | 106 + .../lib/credential-groups/mcp-oauth-state.ts | 124 + apps/sim/lib/credential-groups/mcp-oauth.ts | 103 + .../sim/lib/credential-groups/service.test.ts | 49 +- apps/sim/lib/credential-groups/service.ts | 228 +- apps/sim/lib/credential-groups/types.ts | 16 + apps/sim/lib/credentials/access.ts | 17 +- .../credentials/application/authorization.ts | 10 + .../application/credential-crud.ts | 8 +- .../application/credential-members.ts | 5 +- .../lib/credentials/application/operations.ts | 11 + .../credentials/application/presentation.ts | 1 + .../application/service-account.ts | 4 +- apps/sim/lib/credentials/managed-mcp.ts | 350 + apps/sim/lib/credentials/members.test.ts | 7 +- apps/sim/lib/credentials/members.ts | 9 +- .../orchestration/credential-create.ts | 4 +- apps/sim/lib/credentials/queries.test.ts | 14 +- apps/sim/lib/credentials/queries.ts | 64 +- apps/sim/lib/internal/mcp/execute-tool.ts | 95 +- .../application/execute-managed-tool.test.ts | 115 + .../mcp/application/execute-managed-tool.ts | 116 + apps/sim/lib/mcp/application/execute-tool.ts | 6 +- .../mcp/application/managed-connections.ts | 100 + apps/sim/lib/mcp/application/operations.ts | 6 + apps/sim/lib/mcp/connection-pool.ts | 5 + apps/sim/lib/mcp/oauth/managed-provider.ts | 128 + apps/sim/lib/mcp/oauth/storage.ts | 6 +- apps/sim/lib/mcp/service.ts | 94 +- apps/sim/lib/mcp/utils.test.ts | 28 + apps/sim/lib/mcp/utils.ts | 33 + .../0315_credential_group_managed_mcp.sql | 3 + ...edential_group_managed_mcp_constraints.sql | 47 + .../0317_managed_mcp_external_identity.sql | 3 + .../db/migrations/meta/0317_snapshot.json | 20896 ++++++++++++++++ packages/db/migrations/meta/_journal.json | 7 + packages/db/schema.ts | 57 +- packages/testing/src/mocks/schema.mock.ts | 8 + 73 files changed, 23802 insertions(+), 189 deletions(-) create mode 100644 apps/sim/app/api/credential-groups/enroll/[token]/mcp/[mcpServerId]/route.ts create mode 100644 apps/sim/app/api/mcp/managed-connections/route.ts rename apps/sim/lib/credential-groups/{workflow-access-limits.ts => limits.ts} (77%) create mode 100644 apps/sim/lib/credential-groups/mcp-oauth-state.test.ts create mode 100644 apps/sim/lib/credential-groups/mcp-oauth-state.ts create mode 100644 apps/sim/lib/credential-groups/mcp-oauth.ts create mode 100644 apps/sim/lib/credentials/managed-mcp.ts create mode 100644 apps/sim/lib/mcp/application/execute-managed-tool.test.ts create mode 100644 apps/sim/lib/mcp/application/execute-managed-tool.ts create mode 100644 apps/sim/lib/mcp/application/managed-connections.ts create mode 100644 apps/sim/lib/mcp/oauth/managed-provider.ts create mode 100644 packages/db/migrations/0315_credential_group_managed_mcp.sql create mode 100644 packages/db/migrations/0316_credential_group_managed_mcp_constraints.sql create mode 100644 packages/db/migrations/0317_managed_mcp_external_identity.sql create mode 100644 packages/db/migrations/meta/0317_snapshot.json diff --git a/apps/sim/app/api/credential-groups/enroll/[token]/mcp/[mcpServerId]/route.ts b/apps/sim/app/api/credential-groups/enroll/[token]/mcp/[mcpServerId]/route.ts new file mode 100644 index 00000000000..dc63ed8cd90 --- /dev/null +++ b/apps/sim/app/api/credential-groups/enroll/[token]/mcp/[mcpServerId]/route.ts @@ -0,0 +1,60 @@ +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 { createCredentialGroupEnrollmentRedirect } from '@/app/api/credential-groups/enrollment-redirect' + +export const dynamic = 'force-dynamic' +export const runtime = 'nodejs' + +const logger = createLogger('CredentialGroupMcpOAuthStartAPI') + +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 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' }) + } + } +) diff --git a/apps/sim/app/api/mcp/managed-connections/route.ts b/apps/sim/app/api/mcp/managed-connections/route.ts new file mode 100644 index 00000000000..c2690f97715 --- /dev/null +++ b/apps/sim/app/api/mcp/managed-connections/route.ts @@ -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, +}) diff --git a/apps/sim/app/api/mcp/oauth/callback/route.test.ts b/apps/sim/app/api/mcp/oauth/callback/route.test.ts index 1cad45541bd..449d6e0ac68 100644 --- a/apps/sim/app/api/mcp/oauth/callback/route.test.ts +++ b/apps/sim/app/api/mcp/oauth/callback/route.test.ts @@ -11,7 +11,15 @@ import { import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { mockDiscoverServerTools } = vi.hoisted(() => ({ +const { + mockAuthenticateEnrollment, + mockCompleteManagedMcpOAuth, + mockConsumeManagedAttempt, + mockDiscoverServerTools, +} = vi.hoisted(() => ({ + mockAuthenticateEnrollment: vi.fn(), + mockCompleteManagedMcpOAuth: vi.fn(), + mockConsumeManagedAttempt: vi.fn(), mockDiscoverServerTools: vi.fn(), })) @@ -19,6 +27,16 @@ 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_'), +})) import { GET } from './route' @@ -43,6 +61,27 @@ 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', + }) }) it('performs the token exchange through the SSRF-guarded mcpAuthGuarded wrapper', async () => { @@ -104,4 +143,27 @@ 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(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' + ) + }) }) diff --git a/apps/sim/app/api/mcp/oauth/callback/route.ts b/apps/sim/app/api/mcp/oauth/callback/route.ts index ff76af5b2a3..c97248fd2a6 100644 --- a/apps/sim/app/api/mcp/oauth/callback/route.ts +++ b/apps/sim/app/api/mcp/oauth/callback/route.ts @@ -9,6 +9,12 @@ 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 { assertSafeOauthServerUrl, clearState, @@ -21,6 +27,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) @@ -70,6 +77,46 @@ function htmlClose( }) } +async function completeManagedMcpCallback(params: { + request: NextRequest + state: string + code?: string + error?: string +}): Promise { + const attempt = await consumeCredentialGroupMcpOAuthAttempt(params.state) + 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) { @@ -77,6 +124,10 @@ export const GET = withRouteHandler(async (request: NextRequest) => { } const { state, code, error: errorParam } = parsed.data.query + if (state && isCredentialGroupMcpOAuthState(state)) { + 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…". diff --git a/apps/sim/app/api/workspaces/[id]/credential-groups/[groupId]/enrollments/[enrollmentId]/route.ts b/apps/sim/app/api/workspaces/[id]/credential-groups/[groupId]/enrollments/[enrollmentId]/route.ts index 6ccbd8882a0..2763fc5318f 100644 --- a/apps/sim/app/api/workspaces/[id]/credential-groups/[groupId]/enrollments/[enrollmentId]/route.ts +++ b/apps/sim/app/api/workspaces/[id]/credential-groups/[groupId]/enrollments/[enrollmentId]/route.ts @@ -24,4 +24,5 @@ export const DELETE = defineInternalJsonRoute({ enrollmentId: params.enrollmentId, }), useCase: deleteCredentialGroupEnrollmentSettings, + present: ({ credentialGroupEnrollment }) => ({ credentialGroupEnrollment }), }) diff --git a/apps/sim/app/api/workspaces/[id]/credential-groups/[groupId]/route.ts b/apps/sim/app/api/workspaces/[id]/credential-groups/[groupId]/route.ts index 66a6c609dc8..d47add6d93a 100644 --- a/apps/sim/app/api/workspaces/[id]/credential-groups/[groupId]/route.ts +++ b/apps/sim/app/api/workspaces/[id]/credential-groups/[groupId]/route.ts @@ -47,6 +47,7 @@ export const PATCH = defineInternalJsonRoute({ update: body, }), useCase: updateCredentialGroupSettings, + present: ({ credentialGroup }) => ({ credentialGroup }), }) export const DELETE = defineInternalJsonRoute({ @@ -60,4 +61,5 @@ export const DELETE = defineInternalJsonRoute({ credentialGroupId: params.groupId, }), useCase: deleteCredentialGroupSettings, + present: () => ({ success: true as const }), }) diff --git a/apps/sim/app/credential-groups/enroll/[token]/page.tsx b/apps/sim/app/credential-groups/enroll/[token]/page.tsx index 3336b84ad1b..0dd2dc8653e 100644 --- a/apps/sim/app/credential-groups/enroll/[token]/page.tsx +++ b/apps/sim/app/credential-groups/enroll/[token]/page.tsx @@ -2,6 +2,7 @@ import { type ReactNode, Suspense } from 'react' import { Chip } from '@sim/emcn' import type { Metadata } from 'next' import { headers } from 'next/headers' +import { McpIcon } from '@/components/icons' import { asOrchestrationError } from '@/lib/core/orchestration/types' import { authenticateCredentialGroupEnrollment } from '@/lib/credential-groups/application/enrollment-auth' import { readPublicCredentialGroupEnrollment } from '@/lib/credential-groups/application/public-enrollment' @@ -106,6 +107,10 @@ export default async function CredentialGroupEnrollmentPage({ const resolvedSearchParams = await searchParams const oauthStatus = getSearchParam(resolvedSearchParams, 'oauth') const connectedOptionId = getSearchParam(resolvedSearchParams, 'connected') + const connectedMcpServerId = + getSearchParam(resolvedSearchParams, 'mcp') === 'connected' + ? getSearchParam(resolvedSearchParams, 'mcpServerId') + : undefined const oauthMessage = oauthStatus && oauthStatus in OAUTH_MESSAGES ? OAUTH_MESSAGES[oauthStatus as keyof typeof OAUTH_MESSAGES] @@ -114,14 +119,22 @@ export default async function CredentialGroupEnrollmentPage({ const connectedOption = connectedOptionId ? activeOptions.find((option) => option.id === connectedOptionId) : undefined - const notification = connectedOptionId + const connectedMcpServer = connectedMcpServerId + ? enrollment.mcpServers.find((server) => server.id === connectedMcpServerId) + : undefined + const notification = connectedMcpServerId ? { - message: `${connectedOption ? getCredentialGroupProviderService(connectedOption.provider).name : 'Account'} connected successfully.`, + message: `${connectedMcpServer?.name ?? 'MCP server'} connected successfully.`, variant: 'success' as const, } - : oauthMessage - ? { message: oauthMessage, variant: 'error' as const } - : null + : connectedOptionId + ? { + message: `${connectedOption ? getCredentialGroupProviderService(connectedOption.provider).name : 'Account'} connected successfully.`, + variant: 'success' as const, + } + : oauthMessage + ? { message: oauthMessage, variant: 'error' as const } + : null return ( {notification && ( @@ -168,6 +181,26 @@ export default async function CredentialGroupEnrollmentPage({ /> ) })} + {enrollment.mcpServers.map((server) => ( + } + title={server.name} + description={ + server.connection?.status === 'connected' + ? 'Connected' + : server.connection + ? 'Reconnect required' + : server.description || 'Not connected' + } + trailing={ + + } + /> + ))}
s.enabled && !s.deletedAt) const [storeValue, setStoreValue] = useSubBlockValue(blockId, subBlock.id) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/tool-input.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/tool-input.tsx index 6a78d2ccf23..e7a14bf6f76 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/tool-input.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/tool-input.tsx @@ -69,7 +69,7 @@ import { useAllowedMcpDomains, useCreateMcpServer, useForceRefreshMcpTools, - useMcpServers, + useMcpToolServers, useStoredMcpTools, } from '@/hooks/queries/mcp' import { useWorkflows } from '@/hooks/queries/workflows' @@ -455,7 +455,7 @@ export const ToolInput = memo(function ToolInput({ return names }, [mcpTools]) - const { data: mcpServers = [], isLoading: mcpServersLoading } = useMcpServers(workspaceId) + const { data: mcpServers = [], isLoading: mcpServersLoading } = useMcpToolServers(workspaceId) const { data: storedMcpTools = [] } = useStoredMcpTools(workspaceId) const forceRefreshMcpTools = useForceRefreshMcpTools().mutate const { navigateToSettings } = useSettingsNavigation() diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx index 3e813fe26d5..81494015754 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx @@ -108,7 +108,7 @@ import { useKnowledgeBase } from '@/hooks/kb/use-knowledge' import { useCustomTools } from '@/hooks/queries/custom-tools' import { useDeployWorkflow } from '@/hooks/queries/deployments' import { useDynamicSubBlockOptionDisplayName } from '@/hooks/queries/dynamic-subblock-options' -import { useMcpServers, useMcpToolsQuery } from '@/hooks/queries/mcp' +import { useMcpToolServers, useMcpToolsQuery } from '@/hooks/queries/mcp' import { useCredentialName } from '@/hooks/queries/oauth/oauth-credentials' import { useSandboxes } from '@/hooks/queries/sandboxes' import { useReactivateSchedule, useScheduleInfo } from '@/hooks/queries/schedules' @@ -470,7 +470,7 @@ const SubBlockRow = memo(function SubBlockRow({ ) }, [workflowMapForLookup, workflowMapLoaded, workflowMapIsPlaceholder, subBlock, rawValue]) - const { data: mcpServers = [] } = useMcpServers(workspaceId || '') + const { data: mcpServers = [] } = useMcpToolServers(workspaceId || '') const mcpServerDisplayName = useMemo(() => { if (subBlock?.type !== 'mcp-server-selector' || typeof rawValue !== 'string') { return null diff --git a/apps/sim/ee/credential-groups/components/credential-group-access.tsx b/apps/sim/ee/credential-groups/components/credential-group-access.tsx index 5d061f2ac4c..915171b5b86 100644 --- a/apps/sim/ee/credential-groups/components/credential-group-access.tsx +++ b/apps/sim/ee/credential-groups/components/credential-group-access.tsx @@ -5,7 +5,7 @@ import { Chip, toast } from '@sim/emcn' import { Workflow } from '@sim/emcn/icons' import { getErrorMessage } from '@sim/utils/errors' import type { CredentialGroupAccessResponse } from '@/lib/api/contracts/credential-groups' -import { CREDENTIAL_GROUP_WORKFLOW_ACCESS_LIMIT } from '@/lib/credential-groups/workflow-access-limits' +import { CREDENTIAL_GROUP_WORKFLOW_ACCESS_LIMIT } from '@/lib/credential-groups/limits' import { RowActionsMenu } from '@/app/workspace/[workspaceId]/settings/components/row-actions-menu' import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' import { diff --git a/apps/sim/ee/credential-groups/components/credential-group-detail.tsx b/apps/sim/ee/credential-groups/components/credential-group-detail.tsx index a6a76bf9ab4..72c96200241 100644 --- a/apps/sim/ee/credential-groups/components/credential-group-detail.tsx +++ b/apps/sim/ee/credential-groups/components/credential-group-detail.tsx @@ -5,10 +5,12 @@ import { Chip, ChipConfirmModal, ChipModalTabs, toast } from '@sim/emcn' import { ArrowLeft, Plus, User } from '@sim/emcn/icons' import { getErrorMessage } from '@sim/utils/errors' import { useQueryState } from 'nuqs' +import { McpIcon } from '@/components/icons' import { saveDiscardActions } from '@/components/settings/save-discard-actions' import type { CredentialGroupEnrollment, CredentialGroupEnrollmentConnection, + CredentialGroupEnrollmentMcpConnection, } from '@/lib/api/contracts/credential-groups' import type { CredentialGroupProvider } from '@/lib/credential-groups/providers' import { getCredentialGroupProviderService } from '@/lib/credential-groups/providers' @@ -62,6 +64,7 @@ const CREDENTIAL_GROUP_TABS = [ interface EnrollmentConnectionsProps { connections: CredentialGroupEnrollmentConnection[] + mcpConnections: CredentialGroupEnrollmentMcpConnection[] } interface CredentialProviderIconProps { @@ -73,9 +76,11 @@ function CredentialProviderIcon({ provider }: CredentialProviderIconProps) { return } -function EnrollmentConnections({ connections }: EnrollmentConnectionsProps) { +function EnrollmentConnections({ connections, mcpConnections }: EnrollmentConnectionsProps) { const connected = connections.filter((connection) => connection.status === 'active') - const count = connected.reduce((total, connection) => total + connection.count, 0) + const connectedMcp = mcpConnections.filter((connection) => connection.status === 'active') + const count = + connected.reduce((total, connection) => total + connection.count, 0) + connectedMcp.length const providers = [...new Set(connected.map((connection) => connection.provider))] return ( @@ -83,8 +88,9 @@ function EnrollmentConnections({ connections }: EnrollmentConnectionsProps) { {providers.map((provider) => { return })} + {connectedMcp.length > 0 ? : null} - {count} connected {count === 1 ? 'account' : 'accounts'} + {count} connected {count === 1 ? 'connection' : 'connections'} ) @@ -130,7 +136,9 @@ export function CredentialGroupDetail({ ? (enrollments.find((enrollment) => enrollment.id === deletingEnrollmentId) ?? null) : null const configurationReady = - Boolean(credentialGroup?.options.length) && + Boolean( + credentialGroup && (credentialGroup.options.length || credentialGroup.mcpServers.length) + ) && credentialGroup?.options.every( (option) => option.provider !== 'slack' || @@ -275,7 +283,7 @@ export function CredentialGroupDetail({ ? { value: providerSearch, onChange: setProviderSearch, - placeholder: 'Search account types...', + placeholder: 'Search accounts and MCP servers...', disabled: detail.isPending, } : undefined @@ -332,7 +340,10 @@ export function CredentialGroupDetail({ iconFilled title={enrollment.email} description={ - + } trailing={ { + try { + await updateGroup.mutateAsync({ + workspaceId, + groupId: credentialGroup.id, + body: { mcpServerIds }, + }) + toast.success(successMessage) + } catch (error) { + toast.error(getErrorMessage(error, 'Could not update MCP servers')) + } + } + const addProvider = async (provider: CredentialGroupStandardOAuthProvider) => { const service = getCredentialGroupProviderService(provider) const existing = credentialGroup.options.map(toOptionUpdateInput) @@ -161,6 +177,23 @@ export function CredentialGroupDetails({ if (!providerQuery) return true return getCredentialGroupProviderService(provider).name.toLowerCase().includes(providerQuery) }) + const linkedMcpServerIds = new Set(credentialGroup.mcpServers.map((server) => server.id)) + const mcpOwnershipReady = credentialGroups.isSuccess + const mcpServerOwnerById = new Map( + credentialGroups.data?.credentialGroups.flatMap((group) => + group.mcpServers.map((server) => [server.id, group] as const) + ) ?? [] + ) + const shownMcpServers = (mcpServers.data ?? []).filter((server) => { + const linked = linkedMcpServerIds.has(server.id) + if (!linked && (server.authType !== 'oauth' || !server.enabled || server.deletedAt)) + return false + if (!providerQuery) return true + return ( + server.name.toLowerCase().includes(providerQuery) || + server.description?.toLowerCase().includes(providerQuery) + ) + }) return ( <> @@ -283,6 +316,64 @@ export function CredentialGroupDetails({ + + {mcpServers.isPending ? ( + Loading OAuth MCP servers... + ) : shownMcpServers.length === 0 ? ( + + {providerSearch.trim() + ? `No OAuth MCP servers found matching "${providerSearch}"` + : 'No OAuth MCP servers are available. Add one in MCP settings first.'} + + ) : null} +
+ {shownMcpServers.map((server) => { + const linked = linkedMcpServerIds.has(server.id) + const owner = mcpServerOwnerById.get(server.id) + const assignedElsewhere = owner && owner.id !== credentialGroup.id + return ( + } + title={server.name} + description={ + assignedElsewhere + ? `Used by ${owner.name}` + : server.description || 'Each invited person connects their own OAuth account' + } + badge={ + linked ? ( + Added + ) : assignedElsewhere ? ( + Unavailable + ) : undefined + } + trailing={ + + void updateMcpServers( + linked + ? credentialGroup.mcpServers + .filter((candidate) => candidate.id !== server.id) + .map((candidate) => candidate.id) + : [ + ...credentialGroup.mcpServers.map((candidate) => candidate.id), + server.id, + ], + linked ? `${server.name} removed` : `${server.name} added` + ) + } + > + {linked ? 'Remove' : 'Add'} + + } + /> + ) + })} +
+
+ { queryClient.invalidateQueries({ queryKey: mcpKeys.serversList(workspaceId) }) + queryClient.invalidateQueries({ queryKey: mcpKeys.managedCatalogList(workspaceId) }) queryClient.invalidateQueries({ queryKey: mcpKeys.serverToolsList(workspaceId, serverId) }) queryClient.invalidateQueries({ queryKey: mcpKeys.storedToolsList(workspaceId) }) }, diff --git a/apps/sim/hooks/queries/credential-groups.ts b/apps/sim/hooks/queries/credential-groups.ts index 868dc0156b9..4dd3c41077e 100644 --- a/apps/sim/hooks/queries/credential-groups.ts +++ b/apps/sim/hooks/queries/credential-groups.ts @@ -17,6 +17,7 @@ import { updateCredentialGroupContract, } from '@/lib/api/contracts/credential-groups' import type { ContractJsonResponse } from '@/lib/api/contracts/types' +import { mcpKeys } from '@/hooks/queries/mcp' import { CREDENTIAL_GROUP_ACCESS_STALE_TIME, CREDENTIAL_GROUP_DETAIL_STALE_TIME, @@ -145,6 +146,9 @@ export function useCreateCredentialGroup() { queryClient.invalidateQueries({ queryKey: credentialGroupKeys.list(variables.workspaceId), }), + queryClient.invalidateQueries({ + queryKey: mcpKeys.managedCatalogList(variables.workspaceId), + }), invalidateSelectorQueries(queryClient), ]), }) @@ -165,6 +169,9 @@ export function useDeleteCredentialGroup() { queryClient.invalidateQueries({ queryKey: credentialGroupKeys.list(variables.workspaceId), }), + queryClient.invalidateQueries({ + queryKey: mcpKeys.managedCatalogList(variables.workspaceId), + }), invalidateSelectorQueries(queryClient), ]) }, @@ -198,6 +205,9 @@ export function useUpdateCredentialGroup() { queryClient.invalidateQueries({ queryKey: credentialGroupKeys.detail(variables.workspaceId, variables.groupId), }), + queryClient.invalidateQueries({ + queryKey: mcpKeys.managedCatalogList(variables.workspaceId), + }), invalidateSelectorQueries(queryClient), ]), }) @@ -242,6 +252,9 @@ export function useInviteCredentialGroupEnrollments() { queryClient.invalidateQueries({ queryKey: credentialGroupKeys.detail(variables.workspaceId, variables.groupId), }), + queryClient.invalidateQueries({ + queryKey: mcpKeys.managedCatalogList(variables.workspaceId), + }), invalidateSelectorQueries(queryClient), ]), }) @@ -267,6 +280,9 @@ export function useResendCredentialGroupEnrollment() { queryClient.invalidateQueries({ queryKey: credentialGroupKeys.detail(variables.workspaceId, variables.groupId), }), + queryClient.invalidateQueries({ + queryKey: mcpKeys.managedCatalogList(variables.workspaceId), + }), invalidateSelectorQueries(queryClient), ]), }) @@ -292,6 +308,9 @@ export function useDeleteCredentialGroupEnrollment() { queryClient.invalidateQueries({ queryKey: credentialGroupKeys.detail(variables.workspaceId, variables.groupId), }), + queryClient.invalidateQueries({ + queryKey: mcpKeys.managedCatalogList(variables.workspaceId), + }), invalidateSelectorQueries(queryClient), ]), }) diff --git a/apps/sim/hooks/queries/mcp.test.tsx b/apps/sim/hooks/queries/mcp.test.tsx index 638172515eb..c5847e7d062 100644 --- a/apps/sim/hooks/queries/mcp.test.tsx +++ b/apps/sim/hooks/queries/mcp.test.tsx @@ -18,6 +18,7 @@ vi.mock('@/lib/api/client/request', () => ({ import { discoverMcpToolsContract, getAllowedMcpDomainsContract, + listManagedMcpCatalogContract, listMcpServersContract, listStoredMcpToolsContract, type McpServer, @@ -103,6 +104,7 @@ function mockServers(servers: McpServer[]) { if (contract === discoverMcpToolsContract) { return { success: true, data: { tools: [], totalCount: 0, byServer: {} } } } + if (contract === listManagedMcpCatalogContract) return { servers: [], tools: [] } throw new Error('Unexpected MCP request') }) } @@ -141,7 +143,7 @@ describe('useMcpToolsQuery', () => { const { unmount } = renderHookWithClient(() => useMcpToolsQuery(WORKSPACE_ID)) await flush() - expect(mockRequestJson).toHaveBeenCalledTimes(1) + expect(mockRequestJson).toHaveBeenCalledTimes(2) expect(mockRequestJson).toHaveBeenCalledWith( listMcpServersContract, expect.objectContaining({ query: { workspaceId: WORKSPACE_ID } }) @@ -150,6 +152,47 @@ describe('useMcpToolsQuery', () => { unmount() }) + it('includes managed Credential Group connection snapshots without upstream discovery', async () => { + const managedServer = server('mcp-cg-123456789012345678901', { + name: 'Fireflies — alex@example.com', + authType: 'oauth', + url: undefined, + }) + mockRequestJson.mockImplementation(async (contract) => { + if (contract === listMcpServersContract) { + return { success: true, data: { servers: [] } } + } + if (contract === listManagedMcpCatalogContract) { + return { + servers: [managedServer], + tools: [ + { + name: 'search_transcripts', + description: 'Search transcripts', + inputSchema: { type: 'object', properties: {} }, + serverId: managedServer.id, + serverName: managedServer.name, + }, + ], + } + } + throw new Error('Managed MCP snapshots must not trigger discovery') + }) + + const hook = renderHookWithClient(() => useMcpToolsQuery(WORKSPACE_ID)) + await flush() + + expect(hook.getResult().data).toEqual([ + expect.objectContaining({ + name: 'search_transcripts', + serverId: managedServer.id, + }), + ]) + expect(mockRequestJson).toHaveBeenCalledTimes(2) + + hook.unmount() + }) + it('defers detail and form metadata queries while their surfaces are closed', async () => { mockRequestJson.mockImplementation(async (contract) => { if (contract === listStoredMcpToolsContract || contract === getAllowedMcpDomainsContract) { diff --git a/apps/sim/hooks/queries/mcp.ts b/apps/sim/hooks/queries/mcp.ts index e3134e3f8d2..8d53053b4df 100644 --- a/apps/sim/hooks/queries/mcp.ts +++ b/apps/sim/hooks/queries/mcp.ts @@ -16,8 +16,10 @@ import { deleteMcpServerContract, discoverMcpToolsContract, getAllowedMcpDomainsContract, + listManagedMcpCatalogContract, listMcpServersContract, listStoredMcpToolsContract, + type ManagedMcpCatalog, type McpServer, type McpServerTestBody, type McpServerTestResult, @@ -52,6 +54,9 @@ export const mcpKeys = { all: ['mcp'] as const, servers: () => [...mcpKeys.all, 'servers'] as const, serversList: (workspaceId?: string) => [...mcpKeys.servers(), workspaceId ?? ''] as const, + managedCatalog: () => [...mcpKeys.all, 'managedCatalog'] as const, + managedCatalogList: (workspaceId?: string) => + [...mcpKeys.managedCatalog(), workspaceId ?? ''] as const, serverTools: () => [...mcpKeys.all, 'serverTools'] as const, serverToolsWorkspace: (workspaceId?: string) => [...mcpKeys.serverTools(), workspaceId ?? ''] as const, @@ -114,6 +119,39 @@ export function useMcpServers(workspaceId: string) { }) } +async function fetchManagedMcpCatalog( + workspaceId: string, + signal?: AbortSignal +): Promise { + return requestJson(listManagedMcpCatalogContract, { + query: { workspaceId }, + signal, + }) +} + +export function useManagedMcpCatalog(workspaceId: string) { + return useQuery({ + queryKey: mcpKeys.managedCatalogList(workspaceId), + queryFn: ({ signal }) => fetchManagedMcpCatalog(workspaceId, signal), + enabled: Boolean(workspaceId), + retry: false, + staleTime: MCP_SERVER_LIST_STALE_TIME, + }) +} + +export function useMcpToolServers(workspaceId: string) { + const shared = useMcpServers(workspaceId) + const managed = useManagedMcpCatalog(workspaceId) + return useMemo( + () => ({ + data: [...(shared.data ?? []), ...(managed.data?.servers ?? [])], + isLoading: shared.isLoading || managed.isLoading, + error: shared.error ?? managed.error, + }), + [shared.data, shared.error, shared.isLoading, managed.data, managed.error, managed.isLoading] + ) +} + async function fetchMcpTools( workspaceId: string, forceRefresh = false, @@ -153,6 +191,7 @@ function isServerEligibleForDiscovery(server: McpServer, workspaceId: string): b export function useMcpToolsQuery(workspaceId: string) { const queryClient = useQueryClient() const { data: servers, isLoading: serversLoading } = useMcpServers(workspaceId) + const managedCatalog = useManagedMcpCatalog(workspaceId) // Push is intrinsic to consuming the tools query: every surface that reads tools (settings, // tool picker, dynamic args, tool selector, canvas block) gets real-time `list_changed` // refresh via the shared, reference-counted subscription — so the 5-min stale time is always @@ -196,11 +235,17 @@ export function useMcpToolsQuery(workspaceId: string) { }) return useMemo(() => { - const tools: McpTool[] = [] - let hasData = false + const tools: McpTool[] = [...(managedCatalog.data?.tools ?? [])] + let hasData = Boolean(managedCatalog.data) let anyServerLoading = false - let firstError: Error | null = null - const statusById = new Map(servers?.map((s) => [s.id, s.connectionStatus])) + let firstError: Error | null = + managedCatalog.error instanceof Error ? managedCatalog.error : null + const statusById = new Map( + [...(servers ?? []), ...(managedCatalog.data?.servers ?? [])].map((server) => [ + server.id, + server.connectionStatus, + ]) + ) const toolsStateByServer = new Map< string, { isLoading: boolean; isFetching: boolean; error: Error | null } @@ -231,13 +276,13 @@ export function useMcpToolsQuery(workspaceId: string) { } return { data: tools, - isLoading: (serversLoading || anyServerLoading) && !hasData, - isFetching: serversLoading || results.some((r) => r.isFetching), + isLoading: (serversLoading || managedCatalog.isLoading || anyServerLoading) && !hasData, + isFetching: serversLoading || managedCatalog.isFetching || results.some((r) => r.isFetching), // Suppress when any healthy server rendered; per-server errors live in `toolsStateByServer`. error: hasData ? null : firstError, toolsStateByServer, } - }, [results, serversLoading, serverIds, servers]) + }, [results, serversLoading, serverIds, servers, managedCatalog]) } export function useForceRefreshMcpTools() { @@ -273,6 +318,7 @@ export function useForceRefreshMcpTools() { }, onSettled: (_data, _error, workspaceId) => { queryClient.invalidateQueries({ queryKey: mcpKeys.serversList(workspaceId) }) + queryClient.invalidateQueries({ queryKey: mcpKeys.managedCatalogList(workspaceId) }) queryClient.invalidateQueries({ queryKey: mcpKeys.storedToolsList(workspaceId) }) }, }) @@ -576,6 +622,7 @@ export function useMcpToolsEvents(workspaceId: string) { queryClient.invalidateQueries({ queryKey: mcpKeys.serverToolsWorkspace(workspaceId) }) } queryClient.invalidateQueries({ queryKey: mcpKeys.serversList(workspaceId) }) + queryClient.invalidateQueries({ queryKey: mcpKeys.managedCatalogList(workspaceId) }) queryClient.invalidateQueries({ queryKey: mcpKeys.storedToolsList(workspaceId) }) queryClient.invalidateQueries({ queryKey: workflowMcpServerKeys.all }) } diff --git a/apps/sim/hooks/queries/workflow-search-replace.ts b/apps/sim/hooks/queries/workflow-search-replace.ts index 46feacf004a..b425ddc1db2 100644 --- a/apps/sim/hooks/queries/workflow-search-replace.ts +++ b/apps/sim/hooks/queries/workflow-search-replace.ts @@ -6,6 +6,7 @@ import { type DiscoverMcpToolsResponse, discoverMcpToolsContract, type ListMcpServersResponse, + listManagedMcpCatalogContract, listMcpServersContract, } from '@/lib/api/contracts/mcp' import { @@ -394,11 +395,19 @@ export function useWorkflowSearchMcpServerDetails( const serversQuery = useQuery({ queryKey: workflowSearchReplaceKeys.mcpServerListDetails(workspaceId), - queryFn: ({ signal }: { signal: AbortSignal }) => - requestJson(listMcpServersContract, { - query: { workspaceId: workspaceId as string }, - signal, - }), + queryFn: async ({ signal }: { signal: AbortSignal }) => { + const [shared, managed] = await Promise.all([ + requestJson(listMcpServersContract, { + query: { workspaceId: workspaceId as string }, + signal, + }), + requestJson(listManagedMcpCatalogContract, { + query: { workspaceId: workspaceId as string }, + signal, + }), + ]) + return [...shared.data.servers, ...managed.servers] + }, enabled: Boolean(workspaceId && serverMatches.length > 0), staleTime: WORKFLOW_SEARCH_MCP_SERVER_LIST_STALE_TIME, }) @@ -406,7 +415,7 @@ export function useWorkflowSearchMcpServerDetails( return useMemo( () => serverMatches.map((match) => { - const server = serversQuery.data?.data.servers.find((item) => item.id === match.rawValue) + const server = serversQuery.data?.find((item) => item.id === match.rawValue) return { data: serversQuery.data ? { @@ -431,11 +440,19 @@ export function useWorkflowSearchMcpToolDetails( const toolsQuery = useQuery({ queryKey: workflowSearchReplaceKeys.mcpToolListDetails(workspaceId), - queryFn: ({ signal }: { signal: AbortSignal }) => - requestJson(discoverMcpToolsContract, { - query: { workspaceId: workspaceId as string }, - signal, - }), + queryFn: async ({ signal }: { signal: AbortSignal }) => { + const [shared, managed] = await Promise.all([ + requestJson(discoverMcpToolsContract, { + query: { workspaceId: workspaceId as string }, + signal, + }), + requestJson(listManagedMcpCatalogContract, { + query: { workspaceId: workspaceId as string }, + signal, + }), + ]) + return [...shared.data.tools, ...managed.tools] + }, enabled: Boolean(workspaceId && toolMatches.length > 0), staleTime: WORKFLOW_SEARCH_MCP_TOOL_LIST_STALE_TIME, }) @@ -443,7 +460,7 @@ export function useWorkflowSearchMcpToolDetails( return useMemo( () => toolMatches.map((match) => { - const tool = toolsQuery.data?.data.tools.find( + const tool = toolsQuery.data?.find( (item) => createMcpToolId(item.serverId, item.name) === match.rawValue ) return { @@ -687,16 +704,30 @@ export function useWorkflowSearchMcpServerReplacementOptions( queries: [ { queryKey: workflowSearchReplaceKeys.mcpServerReplacementOptions(workspaceId), - queryFn: ({ signal }: { signal: AbortSignal }) => - requestJson(listMcpServersContract, { - query: { workspaceId: workspaceId as string }, - signal, - }), + queryFn: async ({ + signal, + }: { + signal: AbortSignal + }): Promise => { + const [shared, managed] = await Promise.all([ + requestJson(listMcpServersContract, { + query: { workspaceId: workspaceId as string }, + signal, + }), + requestJson(listManagedMcpCatalogContract, { + query: { workspaceId: workspaceId as string }, + signal, + }), + ]) + return [...shared.data.servers, ...managed.servers] + }, enabled: Boolean(workspaceId && serverGroups.length > 0), staleTime: WORKFLOW_SEARCH_MCP_SERVER_REPLACEMENT_STALE_TIME, - select: (response: ListMcpServersResponse): WorkflowSearchReplacementOption[] => + select: ( + servers: ListMcpServersResponse['data']['servers'] + ): WorkflowSearchReplacementOption[] => serverGroups.flatMap((match) => - response.data.servers.map((server) => ({ + servers.map((server) => ({ kind: 'mcp-server', value: server.id, label: server.name, @@ -735,15 +766,29 @@ export function useWorkflowSearchMcpToolReplacementOptions( queries: [ { queryKey: workflowSearchReplaceKeys.mcpToolReplacementOptions(workspaceId), - queryFn: ({ signal }: { signal: AbortSignal }) => - requestJson(discoverMcpToolsContract, { - query: { workspaceId: workspaceId as string }, - signal, - }), + queryFn: async ({ + signal, + }: { + signal: AbortSignal + }): Promise => { + const [shared, managed] = await Promise.all([ + requestJson(discoverMcpToolsContract, { + query: { workspaceId: workspaceId as string }, + signal, + }), + requestJson(listManagedMcpCatalogContract, { + query: { workspaceId: workspaceId as string }, + signal, + }), + ]) + return [...shared.data.tools, ...managed.tools] + }, enabled: Boolean(workspaceId && toolGroups.length > 0), staleTime: WORKFLOW_SEARCH_MCP_TOOL_REPLACEMENT_STALE_TIME, - select: (response: DiscoverMcpToolsResponse): WorkflowSearchReplacementOption[] => - buildWorkflowSearchMcpToolReplacementOptions(toolGroups, response.data.tools), + select: ( + tools: DiscoverMcpToolsResponse['data']['tools'] + ): WorkflowSearchReplacementOption[] => + buildWorkflowSearchMcpToolReplacementOptions(toolGroups, tools), }, ], }) diff --git a/apps/sim/lib/api/contracts/credential-groups.test.ts b/apps/sim/lib/api/contracts/credential-groups.test.ts index 5f7edf584e3..7ca0cd7b232 100644 --- a/apps/sim/lib/api/contracts/credential-groups.test.ts +++ b/apps/sim/lib/api/contracts/credential-groups.test.ts @@ -14,7 +14,7 @@ import { import { CREDENTIAL_GROUP_WORKFLOW_ACCESS_LIMIT, CREDENTIAL_GROUP_WORKFLOW_CATALOG_LIMIT, -} from '@/lib/credential-groups/workflow-access-limits' +} from '@/lib/credential-groups/limits' describe('credential group contracts', () => { it('describes the shared managed OAuth callback as a redirect', () => { @@ -204,9 +204,13 @@ describe('credential group contracts', () => { createdAt: '2026-08-11T12:00:00.000Z', updatedAt: '2026-08-11T12:05:00.000Z', connections: [{ provider: 'gmail', status: 'active', count: 2 }], + mcpConnections: [{ mcpServerId: 'mcp-server-1', name: 'Fireflies', status: 'active' }], }) expect(result.connections).toEqual([{ provider: 'gmail', status: 'active', count: 2 }]) + expect(result.mcpConnections).toEqual([ + { mcpServerId: 'mcp-server-1', name: 'Fireflies', status: 'active' }, + ]) }) it('accepts a bounded unique workflow access selection', () => { diff --git a/apps/sim/lib/api/contracts/credential-groups.ts b/apps/sim/lib/api/contracts/credential-groups.ts index 94fba1f1e92..dfcc3fea2cd 100644 --- a/apps/sim/lib/api/contracts/credential-groups.ts +++ b/apps/sim/lib/api/contracts/credential-groups.ts @@ -2,14 +2,15 @@ import { z } from 'zod' import { workflowIdSchema, workspaceIdSchema } from '@/lib/api/contracts/primitives' import { defineRouteContract } from '@/lib/api/contracts/types' import { - CREDENTIAL_GROUP_PROVIDER_IDS, - CREDENTIAL_GROUP_STANDARD_OAUTH_PROVIDER_IDS, -} from '@/lib/credential-groups/providers' -import { + CREDENTIAL_GROUP_MCP_SERVER_LIMIT, CREDENTIAL_GROUP_WORKFLOW_ACCESS_LIMIT, CREDENTIAL_GROUP_WORKFLOW_CATALOG_LIMIT, CREDENTIAL_GROUP_WORKFLOW_NAME_MAX_LENGTH, -} from '@/lib/credential-groups/workflow-access-limits' +} from '@/lib/credential-groups/limits' +import { + CREDENTIAL_GROUP_PROVIDER_IDS, + CREDENTIAL_GROUP_STANDARD_OAUTH_PROVIDER_IDS, +} from '@/lib/credential-groups/providers' export const credentialGroupProviderSchema = z.enum(CREDENTIAL_GROUP_PROVIDER_IDS) export const credentialGroupStatusSchema = z.enum(['active', 'disabled']) @@ -71,12 +72,19 @@ export const credentialGroupOptionUpdateInputSchema = z.discriminatedUnion('prov slackCredentialGroupOptionInputSchema.extend({ id: z.string().min(1).max(128).optional() }), ]) +export const credentialGroupMcpServerSchema = z.object({ + id: z.string().min(1).max(128), + name: z.string().min(1).max(255), + description: z.string().nullable(), +}) + export const credentialGroupSchema = z.object({ id: z.string(), workspaceId: z.string(), name: z.string(), description: z.string().nullable(), options: z.array(credentialGroupOptionSchema).max(CREDENTIAL_GROUP_PROVIDER_IDS.length), + mcpServers: z.array(credentialGroupMcpServerSchema).max(CREDENTIAL_GROUP_MCP_SERVER_LIMIT), status: credentialGroupStatusSchema, createdAt: z.string(), updatedAt: z.string(), @@ -85,6 +93,7 @@ export const credentialGroupSchema = z.object({ export type CredentialGroup = z.output export type CredentialGroupOption = z.output export type CredentialGroupOptionInput = z.input +export type CredentialGroupMcpServer = z.output export const credentialGroupEnrollmentSchema = z.object({ id: z.string(), @@ -109,17 +118,49 @@ export const credentialGroupEnrollmentConnectionSchema = z.object({ count: z.number().int().positive(), }) +export const credentialGroupEnrollmentMcpConnectionSchema = z.object({ + mcpServerId: z.string().min(1).max(128), + name: z.string().min(1).max(255), + status: z.enum(['active', 'needs_reauth', 'revoked']), +}) + export const credentialGroupEnrollmentDetailSchema = credentialGroupEnrollmentSchema.extend({ connections: z .array(credentialGroupEnrollmentConnectionSchema) .max(CREDENTIAL_GROUP_PROVIDER_IDS.length * 3), + mcpConnections: z + .array(credentialGroupEnrollmentMcpConnectionSchema) + .max(CREDENTIAL_GROUP_MCP_SERVER_LIMIT), }) export type CredentialGroupEnrollmentConnection = z.output< typeof credentialGroupEnrollmentConnectionSchema > +export type CredentialGroupEnrollmentMcpConnection = z.output< + typeof credentialGroupEnrollmentMcpConnectionSchema +> export type CredentialGroupEnrollmentDetail = z.output +const credentialGroupMcpServerIdsSchema = z + .array(z.string().min(1, 'MCP server ID is required').max(128)) + .max( + CREDENTIAL_GROUP_MCP_SERVER_LIMIT, + `Select at most ${CREDENTIAL_GROUP_MCP_SERVER_LIMIT} MCP servers` + ) + .superRefine((serverIds, ctx) => { + const seen = new Set() + for (const [index, serverId] of serverIds.entries()) { + if (seen.has(serverId)) { + ctx.addIssue({ + code: 'custom', + path: [index], + message: 'MCP server selections must be unique', + }) + } + seen.add(serverId) + } + }) + export const credentialGroupAccessPolicySchema = z .object({ revision: z.number().int().positive(), @@ -196,6 +237,11 @@ export const startCredentialGroupOAuthParamsSchema = optionId: z.string().min(1, 'Credential option ID is required').max(128), }) +export const startCredentialGroupMcpOAuthParamsSchema = + publicCredentialGroupEnrollmentParamsSchema.extend({ + mcpServerId: z.string().min(1, 'MCP server ID is required').max(128), + }) + export const credentialGroupOAuthCallbackQuerySchema = z .object({ state: z.string().min(1, 'OAuth state is required').max(512), @@ -272,6 +318,7 @@ export const createCredentialGroupBodySchema = z name: z.string().trim().min(1, 'Name is required').max(100), description: z.string().trim().max(500).optional(), options: z.array(credentialGroupOptionInputSchema).max(CREDENTIAL_GROUP_PROVIDER_IDS.length), + mcpServerIds: credentialGroupMcpServerIdsSchema.default([]), }) .strict() .superRefine((body, ctx) => { @@ -315,6 +362,7 @@ export const updateCredentialGroupBodySchema = z .array(credentialGroupOptionUpdateInputSchema) .max(CREDENTIAL_GROUP_PROVIDER_IDS.length) .optional(), + mcpServerIds: credentialGroupMcpServerIdsSchema.optional(), status: credentialGroupStatusSchema.optional(), }) .strict() @@ -501,6 +549,13 @@ export const startCredentialGroupOAuthContract = defineRouteContract({ response: { mode: 'empty' }, }) +export const startCredentialGroupMcpOAuthContract = defineRouteContract({ + method: 'GET', + path: '/api/credential-groups/enroll/[token]/mcp/[mcpServerId]', + params: startCredentialGroupMcpOAuthParamsSchema, + response: { mode: 'empty' }, +}) + export const completeCredentialGroupEnrollmentContract = defineRouteContract({ method: 'POST', path: '/api/credential-groups/enroll/[token]/complete', diff --git a/apps/sim/lib/api/contracts/mcp.ts b/apps/sim/lib/api/contracts/mcp.ts index d720802b55c..3e1225a2336 100644 --- a/apps/sim/lib/api/contracts/mcp.ts +++ b/apps/sim/lib/api/contracts/mcp.ts @@ -147,6 +147,13 @@ export const mcpServerSchema = z .passthrough() export type McpServer = z.output +export const managedMcpCatalogSchema = z.object({ + servers: z.array(mcpServerSchema).max(500), + tools: z.array(mcpToolSchema).max(500_000), +}) + +export type ManagedMcpCatalog = z.output + export const mcpWorkspaceQuerySchema = z.object({ workspaceId: z.string().min(1), }) @@ -317,6 +324,16 @@ export const listMcpServersContract = defineRouteContract({ ), }, }) + +export const listManagedMcpCatalogContract = defineRouteContract({ + method: 'GET', + path: '/api/mcp/managed-connections', + query: mcpWorkspaceQuerySchema, + response: { + mode: 'json', + schema: managedMcpCatalogSchema, + }, +}) export type ListMcpServersResponse = ContractJsonResponse export const createMcpServerContract = defineRouteContract({ diff --git a/apps/sim/lib/credential-groups/application/enrollment-operations.ts b/apps/sim/lib/credential-groups/application/enrollment-operations.ts index eb17a758bca..c34ed537056 100644 --- a/apps/sim/lib/credential-groups/application/enrollment-operations.ts +++ b/apps/sim/lib/credential-groups/application/enrollment-operations.ts @@ -34,6 +34,18 @@ export const credentialGroupEnrollmentOperations = { principalKind: 'credential_group_enrollment', }), // permission-group-exempt: the enrollment principal is a one-time credential-connect token, not a workspace member, so no permission group governs it + startMcpOAuth: defineCredentialGroupEnrollmentOperation({ + id: 'credential_groups.enrollment.mcp_oauth.start', + capability: 'none', + principalKind: 'credential_group_enrollment', + }), + // permission-group-exempt: the enrollment principal is a one-time credential-connect token, not a workspace member, so no permission group governs it + completeMcpOAuth: defineCredentialGroupEnrollmentOperation({ + id: 'credential_groups.enrollment.mcp_oauth.complete', + capability: 'none', + principalKind: 'credential_group_enrollment', + }), + // permission-group-exempt: the enrollment principal is a one-time credential-connect token, not a workspace member, so no permission group governs it complete: defineCredentialGroupEnrollmentOperation({ id: 'credential_groups.enrollment.complete', capability: 'none', diff --git a/apps/sim/lib/credential-groups/application/manage-access.test.ts b/apps/sim/lib/credential-groups/application/manage-access.test.ts index b333ef02b0c..f5cceaadf7d 100644 --- a/apps/sim/lib/credential-groups/application/manage-access.test.ts +++ b/apps/sim/lib/credential-groups/application/manage-access.test.ts @@ -10,7 +10,7 @@ import { credentialGroupWorkflowAccessPolicyCodec, decodeCredentialGroupWorkflowAccessPolicy, } from '@/lib/credential-groups/application/workflow-access-policy' -import { CREDENTIAL_GROUP_WORKFLOW_CATALOG_LIMIT } from '@/lib/credential-groups/workflow-access-limits' +import { CREDENTIAL_GROUP_WORKFLOW_CATALOG_LIMIT } from '@/lib/credential-groups/limits' const mocks = vi.hoisted(() => ({ requirePolicy: vi.fn(), diff --git a/apps/sim/lib/credential-groups/application/manage-access.ts b/apps/sim/lib/credential-groups/application/manage-access.ts index dbbbcd63c69..cdb3b5e03f1 100644 --- a/apps/sim/lib/credential-groups/application/manage-access.ts +++ b/apps/sim/lib/credential-groups/application/manage-access.ts @@ -17,7 +17,7 @@ import { import { CREDENTIAL_GROUP_WORKFLOW_CATALOG_LIMIT, CREDENTIAL_GROUP_WORKFLOW_NAME_MAX_LENGTH, -} from '@/lib/credential-groups/workflow-access-limits' +} from '@/lib/credential-groups/limits' import { ResourcePolicyRevisionConflictError, requireResourcePolicy, diff --git a/apps/sim/lib/credential-groups/application/manage-enrollments.ts b/apps/sim/lib/credential-groups/application/manage-enrollments.ts index b4220f4a18c..970a98d74b3 100644 --- a/apps/sim/lib/credential-groups/application/manage-enrollments.ts +++ b/apps/sim/lib/credential-groups/application/manage-enrollments.ts @@ -14,6 +14,7 @@ import { loadCredentialGroupInviterIdentity, resendCredentialGroupEnrollment, } from '@/lib/credential-groups/enrollments' +import { mcpService } from '@/lib/mcp/service' interface CredentialGroupEnrollmentSettingsInput { assertedWorkspaceId: string @@ -122,12 +123,11 @@ export const deleteCredentialGroupEnrollmentSettings = defineAuthorizedWorkspace async execute({ input, context }) { await requireCredentialGroupSettingsAvailable(context.workspaceId) try { - const credentialGroupEnrollment = await deleteCredentialGroupEnrollment( + return await deleteCredentialGroupEnrollment( context.workspaceId, context.credentialGroupId, input.enrollmentId ) - return { credentialGroupEnrollment } } catch (error) { normalizeEnrollmentError(error) } @@ -140,4 +140,10 @@ export const deleteCredentialGroupEnrollmentSettings = defineAuthorizedWorkspace description: `Deleted ${result.credentialGroupEnrollment.email} from the Credential Group`, metadata: { enrollmentId: result.credentialGroupEnrollment.id }, }), + afterSuccess: ({ result }) => + Promise.all( + result.retiredMcpConnectionIds.map((connectionId) => + mcpService.evictServerConnections(connectionId, 'credential_group_enrollment_deleted') + ) + ).then(() => undefined), }) diff --git a/apps/sim/lib/credential-groups/application/manage-groups.test.ts b/apps/sim/lib/credential-groups/application/manage-groups.test.ts index d871461244b..d83cd2ffb5f 100644 --- a/apps/sim/lib/credential-groups/application/manage-groups.test.ts +++ b/apps/sim/lib/credential-groups/application/manage-groups.test.ts @@ -140,6 +140,7 @@ describe('Credential Group Settings application operations', () => { expect(mocks.create).toHaveBeenCalledWith('workspace-1', 'admin-1', { name: 'Support', options: [], + mcpServerIds: [], }) }) diff --git a/apps/sim/lib/credential-groups/application/manage-groups.ts b/apps/sim/lib/credential-groups/application/manage-groups.ts index 3825bf52b69..435d296c346 100644 --- a/apps/sim/lib/credential-groups/application/manage-groups.ts +++ b/apps/sim/lib/credential-groups/application/manage-groups.ts @@ -19,6 +19,7 @@ import { } from '@/lib/credential-groups/enrollments' import { listConfiguredCredentialGroupProviders } from '@/lib/credential-groups/provider-availability' import { + CredentialGroupMcpServerError, createCredentialGroup, deleteCredentialGroup, getCredentialGroup, @@ -29,8 +30,12 @@ import type { CreateCredentialGroupInput, UpdateCredentialGroupInput, } from '@/lib/credential-groups/types' +import { evictMcpServerConnections } from '@/lib/mcp/connection-pool' function throwCredentialGroupConflict(error: unknown): never { + if (error instanceof CredentialGroupMcpServerError) { + throw new OrchestrationError(error.code, error.message) + } if (getPostgresErrorCode(error) === '23505') { throw new OrchestrationError('conflict', 'A credential group with this name already exists') } @@ -137,15 +142,15 @@ export const updateCredentialGroupSettings = defineAuthorizedWorkspaceUseCase({ async execute({ input, context }) { await requireCredentialGroupSettingsAvailable(context.workspaceId) try { - const credentialGroup = await updateCredentialGroup( + const result = await updateCredentialGroup( context.workspaceId, context.credentialGroupId, validateUpdateCredentialGroupInput(input.update) ) - if (!credentialGroup) { + if (!result) { throw new OrchestrationError('not_found', 'Credential group not found') } - return { credentialGroup } + return result } catch (error) { throwCredentialGroupConflict(error) } @@ -157,6 +162,12 @@ export const updateCredentialGroupSettings = defineAuthorizedWorkspaceUseCase({ resourceName: result.credentialGroup.name, description: 'Updated a Credential Group', }), + afterSuccess: ({ result }) => + Promise.all( + result.retiredMcpConnectionIds.map((connectionId) => + evictMcpServerConnections(connectionId, 'credential_group_mcp_unlinked') + ) + ).then(() => undefined), }) export const deleteCredentialGroupSettings = defineAuthorizedWorkspaceUseCase({ @@ -166,9 +177,9 @@ export const deleteCredentialGroupSettings = defineAuthorizedWorkspaceUseCase({ authorizationOptions: {}, async execute({ context }) { await requireCredentialGroupSettingsAvailable(context.workspaceId) - const deleted = await deleteCredentialGroup(context.workspaceId, context.credentialGroupId) - if (!deleted) throw new OrchestrationError('not_found', 'Credential group not found') - return { success: true as const } + const result = await deleteCredentialGroup(context.workspaceId, context.credentialGroupId) + if (!result.deleted) throw new OrchestrationError('not_found', 'Credential group not found') + return { success: true as const, retiredMcpConnectionIds: result.retiredMcpConnectionIds } }, projectAudit: ({ context }) => ({ action: AuditAction.CREDENTIAL_GROUP_UPDATED, @@ -177,4 +188,10 @@ export const deleteCredentialGroupSettings = defineAuthorizedWorkspaceUseCase({ resourceName: context.name, description: 'Deleted a Credential Group', }), + afterSuccess: ({ result }) => + Promise.all( + result.retiredMcpConnectionIds.map((connectionId) => + evictMcpServerConnections(connectionId, 'credential_group_deleted') + ) + ).then(() => undefined), }) diff --git a/apps/sim/lib/credential-groups/application/public-enrollment.test.ts b/apps/sim/lib/credential-groups/application/public-enrollment.test.ts index 9fae003f2de..2c413f79629 100644 --- a/apps/sim/lib/credential-groups/application/public-enrollment.test.ts +++ b/apps/sim/lib/credential-groups/application/public-enrollment.test.ts @@ -8,16 +8,24 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => ({ completeEnrollment: vi.fn(), getEnrollment: vi.fn(), + getMcpOAuthContext: vi.fn(), getOAuthContext: vi.fn(), + startMcpOAuth: vi.fn(), startOAuth: vi.fn(), })) vi.mock('@/lib/credential-groups/enrollments', () => ({ completeAuthorizedCredentialGroupEnrollment: mocks.completeEnrollment, + getAuthorizedCredentialGroupMcpOAuthContext: mocks.getMcpOAuthContext, getAuthorizedCredentialGroupOAuthContext: mocks.getOAuthContext, getAuthorizedPublicCredentialGroupEnrollment: mocks.getEnrollment, })) +vi.mock('@/lib/credential-groups/mcp-oauth', () => ({ + completeCredentialGroupMcpOAuth: vi.fn(), + startCredentialGroupMcpOAuth: mocks.startMcpOAuth, +})) + vi.mock('@/lib/credential-groups/oauth', () => ({ completeCredentialGroupOAuth: vi.fn(), startCredentialGroupOAuth: mocks.startOAuth, @@ -25,6 +33,7 @@ vi.mock('@/lib/credential-groups/oauth', () => ({ import { readPublicCredentialGroupEnrollment, + startPublicCredentialGroupMcpOAuth, startPublicCredentialGroupOAuth, } from '@/lib/credential-groups/application/public-enrollment' @@ -54,7 +63,13 @@ describe('public Credential Group enrollment application operations', () => { credentialGroupId: 'group-1', option: { id: 'option-1' }, }) + mocks.getMcpOAuthContext.mockResolvedValue({ + enrollmentId: 'enrollment-1', + credentialGroupId: 'group-1', + server: { id: 'mcp-server-1' }, + }) mocks.startOAuth.mockResolvedValue('https://accounts.example/authorize') + mocks.startMcpOAuth.mockResolvedValue('https://mcp.example/authorize') }) it('rejects a workspace session before resolving invitation data', async () => { @@ -104,4 +119,28 @@ describe('public Credential Group enrollment application operations', () => { expect(mocks.getOAuthContext).toHaveBeenCalledWith(identity, 'option-1') expect(result).toEqual({ authorizationUrl: 'https://accounts.example/authorize' }) }) + + it('rejects a substituted bearer before creating managed MCP state', async () => { + await expect( + startPublicCredentialGroupMcpOAuth.execute({ + principal, + input: { invitationToken: 'different-token', mcpServerId: 'mcp-server-1' }, + }) + ).rejects.toMatchObject({ code: 'not_found' }) + expect(mocks.startMcpOAuth).not.toHaveBeenCalled() + }) + + it('starts managed MCP OAuth only for a server linked to the current invitation', async () => { + const result = await startPublicCredentialGroupMcpOAuth.execute({ + principal, + input: { invitationToken, mcpServerId: 'mcp-server-1' }, + }) + + expect(mocks.getMcpOAuthContext).toHaveBeenCalledWith(identity, 'mcp-server-1') + expect(mocks.startMcpOAuth).toHaveBeenCalledWith( + expect.objectContaining({ server: { id: 'mcp-server-1' } }), + invitationToken + ) + expect(result).toEqual({ authorizationUrl: 'https://mcp.example/authorize' }) + }) }) diff --git a/apps/sim/lib/credential-groups/application/public-enrollment.ts b/apps/sim/lib/credential-groups/application/public-enrollment.ts index ccda73d5984..d81a0480634 100644 --- a/apps/sim/lib/credential-groups/application/public-enrollment.ts +++ b/apps/sim/lib/credential-groups/application/public-enrollment.ts @@ -6,10 +6,16 @@ import { OrchestrationError } from '@/lib/core/orchestration/types' import { credentialGroupEnrollmentOperations } from '@/lib/credential-groups/application/enrollment-operations' import { completeAuthorizedCredentialGroupEnrollment, + getAuthorizedCredentialGroupMcpOAuthContext, getAuthorizedCredentialGroupOAuthContext, getAuthorizedPublicCredentialGroupEnrollment, type PublicCredentialGroupEnrollmentIdentity, } from '@/lib/credential-groups/enrollments' +import { + completeCredentialGroupMcpOAuth, + startCredentialGroupMcpOAuth, +} from '@/lib/credential-groups/mcp-oauth' +import type { CredentialGroupMcpOAuthAttempt } from '@/lib/credential-groups/mcp-oauth-state' import { completeCredentialGroupOAuth, startCredentialGroupOAuth, @@ -186,3 +192,60 @@ export const completePublicCredentialGroupOAuth = defineAuthorizedCredentialGrou return { connectedOptionId: context.oauth.option.id } }, }) + +interface PublicCredentialGroupMcpOAuthInput { + invitationToken: string + mcpServerId: string +} + +interface PublicCredentialGroupMcpOAuthContext extends PublicCredentialGroupEnrollmentIdentity { + oauth: NonNullable>> +} + +async function resolvePublicMcpOAuthContext( + principal: CredentialGroupEnrollmentPrincipal, + mcpServerId: string +): Promise { + const identity = identityFromPrincipal(principal) + const oauth = await getAuthorizedCredentialGroupMcpOAuthContext(identity, mcpServerId) + if (!oauth) throw new OrchestrationError('not_found', 'Invitation is invalid or expired') + return { ...identity, oauth } +} + +export const startPublicCredentialGroupMcpOAuth = defineAuthorizedCredentialGroupEnrollmentUseCase({ + operation: credentialGroupEnrollmentOperations.startMcpOAuth, + resolveContext: ({ + principal, + input, + }: { + principal: CredentialGroupEnrollmentPrincipal + input: PublicCredentialGroupMcpOAuthInput + }) => resolvePublicMcpOAuthContext(principal, input.mcpServerId), + async execute({ principal, input, context }) { + requireInvitationToken(principal, input.invitationToken) + return { + authorizationUrl: await startCredentialGroupMcpOAuth(context.oauth, input.invitationToken), + } + }, +}) + +interface CompletePublicCredentialGroupMcpOAuthInput { + attempt: CredentialGroupMcpOAuthAttempt + code: string +} + +export const completePublicCredentialGroupMcpOAuth = + defineAuthorizedCredentialGroupEnrollmentUseCase({ + operation: credentialGroupEnrollmentOperations.completeMcpOAuth, + resolveContext: ({ + principal, + input, + }: { + principal: CredentialGroupEnrollmentPrincipal + input: CompletePublicCredentialGroupMcpOAuthInput + }) => resolvePublicMcpOAuthContext(principal, input.attempt.mcpServerId), + async execute({ principal, input, context }) { + requireInvitationToken(principal, input.attempt.invitationToken) + return completeCredentialGroupMcpOAuth(context.oauth, input.attempt.codeVerifier, input.code) + }, + }) diff --git a/apps/sim/lib/credential-groups/application/validation.ts b/apps/sim/lib/credential-groups/application/validation.ts index 2b2c6eb24ab..f6f849f5cd4 100644 --- a/apps/sim/lib/credential-groups/application/validation.ts +++ b/apps/sim/lib/credential-groups/application/validation.ts @@ -1,5 +1,6 @@ import { isValidEmailSyntax, normalizeEmail } from '@sim/utils/string' import { OrchestrationError } from '@/lib/core/orchestration/types' +import { CREDENTIAL_GROUP_MCP_SERVER_LIMIT } from '@/lib/credential-groups/limits' import { CREDENTIAL_GROUP_PROVIDER_IDS, isCredentialGroupProvider, @@ -64,6 +65,23 @@ function normalizeOption CREDENTIAL_GROUP_MCP_SERVER_LIMIT) { + throw new OrchestrationError( + 'validation', + `Select at most ${CREDENTIAL_GROUP_MCP_SERVER_LIMIT} MCP servers` + ) + } + const normalized = serverIds.map((serverId) => serverId.trim()) + if (normalized.some((serverId) => !serverId || serverId.length > 128)) { + throw new OrchestrationError('validation', 'Every MCP server ID must be valid') + } + if (new Set(normalized).size !== normalized.length) { + throw new OrchestrationError('validation', 'MCP server selections must be unique') + } + return normalized +} + export function validateCreateCredentialGroupInput( input: CreateCredentialGroupInput ): CreateCredentialGroupInput { @@ -86,6 +104,7 @@ export function validateCreateCredentialGroupInput( name, ...(description ? { description } : {}), options: input.options.map(normalizeOption), + mcpServerIds: validateMcpServerIds(input.mcpServerIds ?? []), } } @@ -104,10 +123,12 @@ export function validateUpdateCredentialGroupInput( throw new OrchestrationError('validation', 'Description must be at most 500 characters') } if (input.options) validateOptions(input.options) + const mcpServerIds = input.mcpServerIds ? validateMcpServerIds(input.mcpServerIds) : undefined return { ...(name ? { name } : {}), ...(input.description !== undefined ? { description: description || null } : {}), ...(input.options ? { options: input.options.map(normalizeOption) } : {}), + ...(mcpServerIds ? { mcpServerIds } : {}), ...(input.status ? { status: input.status } : {}), } } diff --git a/apps/sim/lib/credential-groups/application/workflow-access-policy.test.ts b/apps/sim/lib/credential-groups/application/workflow-access-policy.test.ts index abd5feb5c50..151608c4f6c 100644 --- a/apps/sim/lib/credential-groups/application/workflow-access-policy.test.ts +++ b/apps/sim/lib/credential-groups/application/workflow-access-policy.test.ts @@ -10,7 +10,7 @@ import { evaluateCredentialGroupWorkflowAccess, requireDefaultCredentialGroupWorkflowAccessPolicy, } from '@/lib/credential-groups/application/workflow-access-policy' -import { CREDENTIAL_GROUP_WORKFLOW_ACCESS_LIMIT } from '@/lib/credential-groups/workflow-access-limits' +import { CREDENTIAL_GROUP_WORKFLOW_ACCESS_LIMIT } from '@/lib/credential-groups/limits' import type { ResourcePolicyBindingFor } from '@/lib/resource-policies/registry' const GROUP_ID = 'group-1' diff --git a/apps/sim/lib/credential-groups/application/workflow-access-policy.ts b/apps/sim/lib/credential-groups/application/workflow-access-policy.ts index f0e3248ceac..29faac88e46 100644 --- a/apps/sim/lib/credential-groups/application/workflow-access-policy.ts +++ b/apps/sim/lib/credential-groups/application/workflow-access-policy.ts @@ -1,6 +1,6 @@ import type { WorkflowExecutionAuthority } from '@sim/auth/principal' import { z } from 'zod' -import { CREDENTIAL_GROUP_WORKFLOW_ACCESS_LIMIT } from '@/lib/credential-groups/workflow-access-limits' +import { CREDENTIAL_GROUP_WORKFLOW_ACCESS_LIMIT } from '@/lib/credential-groups/limits' import { CREDENTIAL_GROUP_ACTOR_OWNS_CREDENTIAL_CONDITION_KEY } from '@/lib/resource-policies/conditions' import { WORKFLOW_MODE_RESOURCE_POLICY_CONDITION_KEY } from '@/lib/resource-policies/conditions/workflow-mode' import { diff --git a/apps/sim/lib/credential-groups/enrollments.test.ts b/apps/sim/lib/credential-groups/enrollments.test.ts index 48657895ca4..6424f0e1c17 100644 --- a/apps/sim/lib/credential-groups/enrollments.test.ts +++ b/apps/sim/lib/credential-groups/enrollments.test.ts @@ -331,12 +331,14 @@ describe('deleteCredentialGroupEnrollment', () => { }) it('deletes the enrollment and lets its foreign-key cascade remove managed credentials', async () => { - dbChainMockFns.limit.mockResolvedValueOnce([{ email: ENROLLMENT.email }]) + queueTableRows(schemaMock.credentialGroupEnrollment, [{ email: ENROLLMENT.email }]) + queueTableRows(schemaMock.credential, [{ id: 'mcp-cg-connection-1' }]) dbChainMockFns.returning.mockResolvedValueOnce([ENROLLMENT]) const result = await deleteCredentialGroupEnrollment('workspace-1', 'group-1', ENROLLMENT.id) - expect(result.id).toBe(ENROLLMENT.id) + expect(result.credentialGroupEnrollment.id).toBe(ENROLLMENT.id) + expect(result.retiredMcpConnectionIds).toEqual(['mcp-cg-connection-1']) expect(dbChainMockFns.update).not.toHaveBeenCalled() expect(dbChainMockFns.delete).toHaveBeenCalledOnce() expect(dbChainMockFns.delete).toHaveBeenCalledWith(schemaMock.credentialGroupEnrollment) diff --git a/apps/sim/lib/credential-groups/enrollments.ts b/apps/sim/lib/credential-groups/enrollments.ts index 3f99519ab2f..fea1d15d3b4 100644 --- a/apps/sim/lib/credential-groups/enrollments.ts +++ b/apps/sim/lib/credential-groups/enrollments.ts @@ -4,6 +4,7 @@ import { credential, credentialGroup, credentialGroupEnrollment, + mcpServers, user, workspace, } from '@sim/db/schema' @@ -11,7 +12,7 @@ import { sha256Hex } from '@sim/security/hash' import { getErrorMessage } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import { normalizeEmail, truncate } from '@sim/utils/string' -import { and, count, desc, eq, inArray, lt, or, sql } from 'drizzle-orm' +import { and, asc, count, desc, eq, inArray, isNull, lt, or, sql } from 'drizzle-orm' import { renderCredentialGroupInvitationEmail } from '@/components/emails/credential-groups/render' import { getCredentialGroupInvitationSubject } from '@/components/emails/subjects' import { getWorkspaceOwnerSubscriptionAccess } from '@/lib/billing/core/workspace-access' @@ -27,6 +28,7 @@ import { import type { CredentialGroupEnrollmentConnection, CredentialGroupEnrollmentDetail, + CredentialGroupEnrollmentMcpConnection, CredentialGroupEnrollmentRecord, InviteCredentialGroupEnrollmentsInput, } from '@/lib/credential-groups/types' @@ -88,6 +90,16 @@ export interface PublicCredentialGroupEnrollment { }> } > + mcpServers: Array<{ + id: string + name: string + description: string | null + connection: { + id: string + status: 'connected' | 'needs_reauth' | 'revoked' + grantedAt: string + } | null + }> status: CredentialGroupEnrollmentRecord['status'] } @@ -103,6 +115,19 @@ export interface CredentialGroupOAuthContext { options: CredentialGroupOptionConfig[] } +export interface CredentialGroupMcpOAuthContext { + enrollmentId: string + credentialGroupId: string + workspaceId: string + email: string + enrollmentStatus: EnrollmentRow['status'] + server: { + id: string + name: string + url: string + } +} + export interface PublicCredentialGroupEnrollmentIdentity { enrollmentId: string credentialGroupId: string @@ -324,7 +349,25 @@ async function getInvitationContext( throw new CredentialGroupEnrollmentError('Credential group is disabled', 409) } if (!row.options.some((option) => option.status === 'active')) { - throw new CredentialGroupEnrollmentError('Add an account type before inviting people', 409) + const [linkedMcpServer] = await db + .select({ id: mcpServers.id }) + .from(mcpServers) + .where( + and( + eq(mcpServers.workspaceId, workspaceId), + eq(mcpServers.credentialGroupId, groupId), + eq(mcpServers.authType, 'oauth'), + eq(mcpServers.enabled, true), + isNull(mcpServers.deletedAt) + ) + ) + .limit(1) + if (!linkedMcpServer) { + throw new CredentialGroupEnrollmentError( + 'Add an account type or OAuth MCP server before inviting people', + 409 + ) + } } return row } @@ -520,6 +563,19 @@ export async function listCredentialGroupEnrollments( const activeOptionIds = group.options .filter((option) => option.status === 'active') .map((option) => option.id) + const activeMcpServers = await db + .select({ id: mcpServers.id, name: mcpServers.name }) + .from(mcpServers) + .where( + and( + eq(mcpServers.workspaceId, workspaceId), + eq(mcpServers.credentialGroupId, groupId), + eq(mcpServers.authType, 'oauth'), + eq(mcpServers.enabled, true), + isNull(mcpServers.deletedAt) + ) + ) + const activeMcpServerById = new Map(activeMcpServers.map((server) => [server.id, server])) const cursorPosition = cursor ? decodeCredentialGroupEnrollmentCursor(cursor) : undefined @@ -579,6 +635,31 @@ export async function listCredentialGroupEnrollments( if (connectionRows.length > connectionSummaryLimit) { throw new Error('Managed credential connection summaries exceed the supported provider states') } + const mcpConnectionSummaryLimit = enrollmentIds.length * activeMcpServers.length + const mcpConnectionRows = + enrollmentIds.length === 0 || activeMcpServers.length === 0 + ? [] + : await db + .select({ + enrollmentId: credential.credentialGroupEnrollmentId, + mcpServerId: credential.mcpServerId, + status: credential.managedOauthStatus, + }) + .from(credential) + .where( + and( + eq(credential.type, 'managed_mcp'), + inArray(credential.credentialGroupEnrollmentId, enrollmentIds), + inArray( + credential.mcpServerId, + activeMcpServers.map((server) => server.id) + ) + ) + ) + .limit(mcpConnectionSummaryLimit + 1) + if (mcpConnectionRows.length > mcpConnectionSummaryLimit) { + throw new Error('Managed MCP connection summaries exceed the linked server limit') + } const connectionsByEnrollment = new Map() for (const connection of connectionRows) { if (!connection.enrollmentId) { @@ -593,6 +674,22 @@ export async function listCredentialGroupEnrollments( if (current) current.push(summary) else connectionsByEnrollment.set(connection.enrollmentId, [summary]) } + const mcpConnectionsByEnrollment = new Map() + for (const connection of mcpConnectionRows) { + if (!connection.enrollmentId || !connection.mcpServerId) { + throw new Error('Managed MCP credential source is missing') + } + const server = activeMcpServerById.get(connection.mcpServerId) + if (!server) throw new Error('Managed MCP credential references an unlinked server') + const summary: CredentialGroupEnrollmentMcpConnection = { + mcpServerId: server.id, + name: server.name, + status: toCredentialGroupConnectionStatus(connection.status), + } + const current = mcpConnectionsByEnrollment.get(connection.enrollmentId) + if (current) current.push(summary) + else mcpConnectionsByEnrollment.set(connection.enrollmentId, [summary]) + } const nextCursorEnrollment = hasNextPage ? pageRows.at(-1)?.enrollment : undefined if (hasNextPage && !nextCursorEnrollment) { throw new Error('Credential group enrollment page is missing its cursor boundary') @@ -601,6 +698,7 @@ export async function listCredentialGroupEnrollments( enrollments: pageRows.map(({ enrollment }) => ({ ...toCredentialGroupEnrollment(enrollment), connections: connectionsByEnrollment.get(enrollment.id) ?? [], + mcpConnections: mcpConnectionsByEnrollment.get(enrollment.id) ?? [], })), nextCursor: nextCursorEnrollment ? encodeCredentialGroupEnrollmentCursor(nextCursorEnrollment) @@ -721,7 +819,10 @@ export async function deleteCredentialGroupEnrollment( workspaceId: string, groupId: string, enrollmentId: string -): Promise { +): Promise<{ + credentialGroupEnrollment: CredentialGroupEnrollmentRecord + retiredMcpConnectionIds: string[] +}> { const [existing] = await db .select({ email: credentialGroupEnrollment.email }) .from(credentialGroupEnrollment) @@ -739,6 +840,15 @@ export async function deleteCredentialGroupEnrollment( return db.transaction(async (tx) => { await lockCredentialGroupInvitationTarget(tx, groupId, existing.email) await lockCredentialGroupEnrollmentLifecycle(tx, enrollmentId) + const managedMcpConnections = await tx + .select({ id: credential.id }) + .from(credential) + .where( + and( + eq(credential.type, 'managed_mcp'), + eq(credential.credentialGroupEnrollmentId, enrollmentId) + ) + ) const [deleted] = await tx .delete(credentialGroupEnrollment) .where( @@ -749,7 +859,10 @@ export async function deleteCredentialGroupEnrollment( ) .returning() if (!deleted) throw new CredentialGroupEnrollmentError('Enrollment not found', 404) - return toCredentialGroupEnrollment(deleted) + return { + credentialGroupEnrollment: toCredentialGroupEnrollment(deleted), + retiredMcpConnectionIds: managedMcpConnections.map((row) => row.id), + } }) } @@ -774,24 +887,59 @@ export async function getAuthorizedPublicCredentialGroupEnrollment( async function buildPublicCredentialGroupEnrollment( row: NonNullable>> ): Promise { - const connectionRows = await db - .select({ - optionId: credential.credentialGroupOptionId, - status: credential.managedOauthStatus, - scopeVersion: credential.managedOauthScopeVersion, - authorizationAppId: credential.authorizationAppId, - grantedScopes: credential.grantedScopes, - displayName: credential.displayName, - metadata: credential.providerMetadata, - grantedAt: credential.grantedAt, - }) - .from(credential) - .where( - and( - eq(credential.type, 'managed_oauth'), - eq(credential.credentialGroupEnrollmentId, row.enrollment.id) + const [connectionRows, linkedMcpServers, mcpConnectionRows] = await Promise.all([ + db + .select({ + optionId: credential.credentialGroupOptionId, + status: credential.managedOauthStatus, + scopeVersion: credential.managedOauthScopeVersion, + authorizationAppId: credential.authorizationAppId, + grantedScopes: credential.grantedScopes, + displayName: credential.displayName, + metadata: credential.providerMetadata, + grantedAt: credential.grantedAt, + }) + .from(credential) + .where( + and( + eq(credential.type, 'managed_oauth'), + eq(credential.credentialGroupEnrollmentId, row.enrollment.id) + ) + ), + db + .select({ id: mcpServers.id, name: mcpServers.name, description: mcpServers.description }) + .from(mcpServers) + .where( + and( + eq(mcpServers.workspaceId, row.workspaceId), + eq(mcpServers.credentialGroupId, row.groupId), + eq(mcpServers.authType, 'oauth'), + eq(mcpServers.enabled, true), + isNull(mcpServers.deletedAt) + ) ) - ) + .orderBy(asc(mcpServers.name), asc(mcpServers.id)), + db + .select({ + id: credential.id, + mcpServerId: credential.mcpServerId, + status: credential.managedOauthStatus, + grantedAt: credential.grantedAt, + }) + .from(credential) + .where( + and( + eq(credential.type, 'managed_mcp'), + eq(credential.credentialGroupEnrollmentId, row.enrollment.id) + ) + ), + ]) + const mcpConnectionByServerId = new Map( + mcpConnectionRows.map((connection) => { + if (!connection.mcpServerId) throw new Error('Managed MCP credential has no server') + return [connection.mcpServerId, connection] as const + }) + ) return { inviterName: row.inviterName, @@ -844,6 +992,23 @@ async function buildPublicCredentialGroupEnrollment( } }) ), + mcpServers: linkedMcpServers.map((server) => { + const connection = mcpConnectionByServerId.get(server.id) + if (!connection?.grantedAt) return { ...server, connection: null } + return { + ...server, + connection: { + id: connection.id, + status: + connection.status === 'active' + ? ('connected' as const) + : connection.status === 'revoked' + ? ('revoked' as const) + : ('needs_reauth' as const), + grantedAt: connection.grantedAt.toISOString(), + }, + } + }), status: row.enrollment.status, } } @@ -946,6 +1111,37 @@ export async function getAuthorizedCredentialGroupOAuthContext( return credentialGroupOAuthContextFromRow(row, option) } +export async function getAuthorizedCredentialGroupMcpOAuthContext( + identity: PublicCredentialGroupEnrollmentIdentity, + mcpServerId: string +): Promise { + const row = await resolveAuthorizedPublicEnrollmentRow(identity) + if (!row) return null + const [server] = await db + .select({ id: mcpServers.id, name: mcpServers.name, url: mcpServers.url }) + .from(mcpServers) + .where( + and( + eq(mcpServers.id, mcpServerId), + eq(mcpServers.workspaceId, row.workspaceId), + eq(mcpServers.credentialGroupId, row.groupId), + eq(mcpServers.authType, 'oauth'), + eq(mcpServers.enabled, true), + isNull(mcpServers.deletedAt) + ) + ) + .limit(1) + if (!server?.url) return null + return { + enrollmentId: row.enrollment.id, + credentialGroupId: row.groupId, + workspaceId: row.workspaceId, + email: row.enrollment.email, + enrollmentStatus: row.enrollment.status, + server: { id: server.id, name: server.name, url: server.url }, + } +} + function credentialGroupOAuthContextFromRow( row: NonNullable>>, option: CredentialGroupOptionConfig diff --git a/apps/sim/lib/credential-groups/workflow-access-limits.ts b/apps/sim/lib/credential-groups/limits.ts similarity index 77% rename from apps/sim/lib/credential-groups/workflow-access-limits.ts rename to apps/sim/lib/credential-groups/limits.ts index 50c7c24949e..93e3a889f59 100644 --- a/apps/sim/lib/credential-groups/workflow-access-limits.ts +++ b/apps/sim/lib/credential-groups/limits.ts @@ -1,3 +1,4 @@ +export const CREDENTIAL_GROUP_MCP_SERVER_LIMIT = 50 export const CREDENTIAL_GROUP_WORKFLOW_ACCESS_LIMIT = 50 export const CREDENTIAL_GROUP_WORKFLOW_CATALOG_LIMIT = 500 export const CREDENTIAL_GROUP_WORKFLOW_NAME_MAX_LENGTH = 255 diff --git a/apps/sim/lib/credential-groups/mcp-oauth-state.test.ts b/apps/sim/lib/credential-groups/mcp-oauth-state.test.ts new file mode 100644 index 00000000000..013e289bd3f --- /dev/null +++ b/apps/sim/lib/credential-groups/mcp-oauth-state.test.ts @@ -0,0 +1,106 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockRedis, values } = vi.hoisted(() => { + const values = new Map() + return { + values, + mockRedis: { + set: vi.fn(async (key: string, value: string) => { + if (values.has(key)) return null + values.set(key, value) + return 'OK' + }), + eval: vi.fn(async (_script: string, _keyCount: number, key: string) => { + const value = values.get(key) ?? null + values.delete(key) + return value + }), + }, + } +}) + +vi.mock('@/lib/core/config/redis', () => ({ + getRedisClient: vi.fn(() => mockRedis), +})) + +vi.mock('@/lib/core/security/encryption', () => ({ + encryptSecret: vi.fn(async (value: string) => ({ + encrypted: `encrypted:${Buffer.from(value).toString('base64')}`, + })), + decryptSecret: vi.fn(async (value: string) => ({ + decrypted: Buffer.from(value.replace(/^encrypted:/, ''), 'base64').toString(), + })), +})) + +import { getRedisClient } from '@/lib/core/config/redis' +import { + consumeCredentialGroupMcpOAuthAttempt, + createCredentialGroupMcpOAuthAttempt, + isCredentialGroupMcpOAuthState, +} from '@/lib/credential-groups/mcp-oauth-state' + +describe('Credential Group MCP OAuth state', () => { + beforeEach(() => { + vi.clearAllMocks() + values.clear() + vi.mocked(getRedisClient).mockReturnValue(mockRedis as never) + }) + + it('encrypts bearer material and consumes an attempt exactly once', async () => { + const state = 'mcp_cg_state-1' + await createCredentialGroupMcpOAuthAttempt({ + state, + enrollmentId: 'enrollment-1', + credentialGroupId: 'group-1', + mcpServerId: 'mcp-server-1', + codeVerifier: 'code-verifier', + invitationToken: 'invitation-token', + }) + + const stored = [...values.values()][0] + expect(isCredentialGroupMcpOAuthState(state)).toBe(true) + expect(stored).not.toContain('code-verifier') + expect(stored).not.toContain('invitation-token') + await expect(consumeCredentialGroupMcpOAuthAttempt(state)).resolves.toMatchObject({ + state, + enrollmentId: 'enrollment-1', + credentialGroupId: 'group-1', + mcpServerId: 'mcp-server-1', + codeVerifier: 'code-verifier', + invitationToken: 'invitation-token', + }) + await expect(consumeCredentialGroupMcpOAuthAttempt(state)).resolves.toBeNull() + }) + + it('fails closed when Redis is unavailable', async () => { + vi.mocked(getRedisClient).mockReturnValue(null) + + await expect( + createCredentialGroupMcpOAuthAttempt({ + state: 'mcp_cg_state-1', + enrollmentId: 'enrollment-1', + credentialGroupId: 'group-1', + mcpServerId: 'mcp-server-1', + codeVerifier: 'code-verifier', + invitationToken: 'invitation-token', + }) + ).rejects.toThrow('Credential Group MCP OAuth requires Redis') + }) + + it('rejects state outside the managed MCP namespace', async () => { + await expect( + createCredentialGroupMcpOAuthAttempt({ + state: 'ordinary-state', + enrollmentId: 'enrollment-1', + credentialGroupId: 'group-1', + mcpServerId: 'mcp-server-1', + codeVerifier: 'code-verifier', + invitationToken: 'invitation-token', + }) + ).rejects.toThrow('invalid prefix') + expect(mockRedis.set).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/credential-groups/mcp-oauth-state.ts b/apps/sim/lib/credential-groups/mcp-oauth-state.ts new file mode 100644 index 00000000000..c9a12ffb78f --- /dev/null +++ b/apps/sim/lib/credential-groups/mcp-oauth-state.ts @@ -0,0 +1,124 @@ +import { sha256Hex } from '@sim/security/hash' +import { getRedisClient } from '@/lib/core/config/redis' +import { decryptSecret, encryptSecret } from '@/lib/core/security/encryption' + +const MCP_OAUTH_ATTEMPT_TTL_MS = 10 * 60 * 1000 +const MCP_OAUTH_ATTEMPT_VERSION = 1 as const +const MCP_OAUTH_STATE_PREFIX = 'mcp_cg_' + +const CONSUME_SCRIPT = ` +local value = redis.call('GET', KEYS[1]) +if not value then + return nil +end +redis.call('DEL', KEYS[1]) +return value +` + +interface StoredCredentialGroupMcpOAuthAttempt { + version: typeof MCP_OAUTH_ATTEMPT_VERSION + enrollmentId: string + credentialGroupId: string + mcpServerId: string + encryptedCodeVerifier: string + encryptedInvitationToken: string + createdAt: number +} + +export interface CredentialGroupMcpOAuthAttempt { + state: string + enrollmentId: string + credentialGroupId: string + mcpServerId: string + codeVerifier: string + invitationToken: string + createdAt: number +} + +function requireRedis() { + const redis = getRedisClient() + if (!redis) throw new Error('Credential Group MCP OAuth requires Redis') + return redis +} + +function attemptKey(state: string): string { + return `credential-group:mcp-oauth-attempt:${sha256Hex(state)}` +} + +function isStoredAttempt(value: unknown): value is StoredCredentialGroupMcpOAuthAttempt { + if (!value || typeof value !== 'object') return false + const candidate = value as Record + return ( + candidate.version === MCP_OAUTH_ATTEMPT_VERSION && + typeof candidate.enrollmentId === 'string' && + typeof candidate.credentialGroupId === 'string' && + typeof candidate.mcpServerId === 'string' && + typeof candidate.encryptedCodeVerifier === 'string' && + typeof candidate.encryptedInvitationToken === 'string' && + typeof candidate.createdAt === 'number' + ) +} + +export function isCredentialGroupMcpOAuthState(state: string): boolean { + return state.startsWith(MCP_OAUTH_STATE_PREFIX) +} + +export async function createCredentialGroupMcpOAuthAttempt(params: { + state: string + enrollmentId: string + credentialGroupId: string + mcpServerId: string + codeVerifier: string + invitationToken: string +}): Promise { + if (!isCredentialGroupMcpOAuthState(params.state)) { + throw new Error('Managed MCP OAuth state has an invalid prefix') + } + const redis = requireRedis() + const [codeVerifier, invitationToken] = await Promise.all([ + encryptSecret(params.codeVerifier), + encryptSecret(params.invitationToken), + ]) + const attempt: StoredCredentialGroupMcpOAuthAttempt = { + version: MCP_OAUTH_ATTEMPT_VERSION, + enrollmentId: params.enrollmentId, + credentialGroupId: params.credentialGroupId, + mcpServerId: params.mcpServerId, + encryptedCodeVerifier: codeVerifier.encrypted, + encryptedInvitationToken: invitationToken.encrypted, + createdAt: Date.now(), + } + const stored = await redis.set( + attemptKey(params.state), + JSON.stringify(attempt), + 'PX', + MCP_OAUTH_ATTEMPT_TTL_MS, + 'NX' + ) + if (stored !== 'OK') throw new Error('Credential Group MCP OAuth state collision') +} + +export async function consumeCredentialGroupMcpOAuthAttempt( + state: string +): Promise { + if (!isCredentialGroupMcpOAuthState(state)) return null + const raw = await requireRedis().eval(CONSUME_SCRIPT, 1, attemptKey(state)) + if (raw === null) return null + if (typeof raw !== 'string') throw new Error('Credential Group MCP OAuth state is malformed') + const parsed: unknown = JSON.parse(raw) + if (!isStoredAttempt(parsed)) throw new Error('Credential Group MCP OAuth state is malformed') + if (Date.now() - parsed.createdAt > MCP_OAUTH_ATTEMPT_TTL_MS) return null + const [codeVerifier, invitationToken] = await Promise.all([ + decryptSecret(parsed.encryptedCodeVerifier), + decryptSecret(parsed.encryptedInvitationToken), + ]) + return { + state, + enrollmentId: parsed.enrollmentId, + credentialGroupId: parsed.credentialGroupId, + mcpServerId: parsed.mcpServerId, + codeVerifier: codeVerifier.decrypted, + invitationToken: invitationToken.decrypted, + createdAt: parsed.createdAt, + } +} diff --git a/apps/sim/lib/credential-groups/mcp-oauth.ts b/apps/sim/lib/credential-groups/mcp-oauth.ts new file mode 100644 index 00000000000..9b8d6d1972d --- /dev/null +++ b/apps/sim/lib/credential-groups/mcp-oauth.ts @@ -0,0 +1,103 @@ +import type { OAuthTokens } from '@modelcontextprotocol/sdk/shared/auth.js' +import type { CredentialGroupMcpOAuthContext } from '@/lib/credential-groups/enrollments' +import { createCredentialGroupMcpOAuthAttempt } from '@/lib/credential-groups/mcp-oauth-state' +import { encryptManagedMcpTokens, persistManagedMcpCredential } from '@/lib/credentials/managed-mcp' +import { + assertSafeOauthServerUrl, + getOrCreateOauthRow, + loadPreregisteredClient, + McpOauthRedirectRequired, + mcpAuthGuarded, +} from '@/lib/mcp/oauth' +import { ManagedMcpOauthProvider } from '@/lib/mcp/oauth/managed-provider' +import { mcpService } from '@/lib/mcp/service' + +export async function startCredentialGroupMcpOAuth( + context: CredentialGroupMcpOAuthContext, + invitationToken: string +): Promise { + assertSafeOauthServerUrl(context.server.url) + const clientRow = await getOrCreateOauthRow({ + mcpServerId: context.server.id, + workspaceId: context.workspaceId, + }) + const preregistered = await loadPreregisteredClient(context.server.id) + const provider = new ManagedMcpOauthProvider({ + clientRow, + preregistered, + async onSaveTokens() { + throw new Error('Managed MCP OAuth start cannot persist grant tokens') + }, + }) + + try { + const result = await mcpAuthGuarded(provider, { serverUrl: context.server.url }) + if (result === 'AUTHORIZED') { + throw new Error('Managed MCP OAuth unexpectedly authorized without an enrollment grant') + } + throw new Error('Managed MCP OAuth did not produce an authorization redirect') + } catch (error) { + if (!(error instanceof McpOauthRedirectRequired)) throw error + const attempt = provider.requireAuthorizationAttempt() + await createCredentialGroupMcpOAuthAttempt({ + ...attempt, + enrollmentId: context.enrollmentId, + credentialGroupId: context.credentialGroupId, + mcpServerId: context.server.id, + invitationToken, + }) + return error.authorizationUrl + } +} + +export async function completeCredentialGroupMcpOAuth( + context: CredentialGroupMcpOAuthContext, + codeVerifier: string, + authorizationCode: string +): Promise<{ connectionId: string; mcpServerId: string }> { + assertSafeOauthServerUrl(context.server.url) + const clientRow = await getOrCreateOauthRow({ + mcpServerId: context.server.id, + workspaceId: context.workspaceId, + }) + const preregistered = await loadPreregisteredClient(context.server.id) + let grantedTokens: OAuthTokens | undefined + const provider = new ManagedMcpOauthProvider({ + clientRow, + preregistered, + codeVerifier, + async onSaveTokens(tokens) { + if (!tokens) { + grantedTokens = undefined + return + } + await encryptManagedMcpTokens(tokens) + grantedTokens = tokens + }, + }) + const result = await mcpAuthGuarded(provider, { + serverUrl: context.server.url, + authorizationCode, + }) + if (result !== 'AUTHORIZED' || !grantedTokens) { + throw new Error('Managed MCP OAuth token exchange did not return usable tokens') + } + const tools = await mcpService.discoverManagedMcpTools( + context.server.id, + context.workspaceId, + provider + ) + const connectionId = await persistManagedMcpCredential({ + enrollmentId: context.enrollmentId, + workspaceId: context.workspaceId, + mcpServerId: context.server.id, + mcpServerName: context.server.name, + tokens: grantedTokens, + tools: tools.map((tool) => ({ + name: tool.name, + ...(tool.description ? { description: tool.description } : {}), + inputSchema: tool.inputSchema, + })), + }) + return { connectionId, mcpServerId: context.server.id } +} diff --git a/apps/sim/lib/credential-groups/service.test.ts b/apps/sim/lib/credential-groups/service.test.ts index 32badce9ee4..af6979af2ca 100644 --- a/apps/sim/lib/credential-groups/service.test.ts +++ b/apps/sim/lib/credential-groups/service.test.ts @@ -19,6 +19,7 @@ vi.mock('@/lib/credential-groups/provider-registry', () => ({ })) import { + CredentialGroupMcpServerError, createCredentialGroup, deleteCredentialGroup, updateCredentialGroup, @@ -79,7 +80,7 @@ describe('Credential Group service', () => { }, ], }) - ).resolves.toMatchObject({ id: 'group-1' }) + ).resolves.toMatchObject({ credentialGroup: { id: 'group-1' } }) expect(mockGetPolicy).toHaveBeenCalledWith( expect.objectContaining({ slackBotCredentialId: 'bot-1' }), @@ -169,15 +170,59 @@ describe('Credential Group service', () => { ).rejects.toThrow('Required resource policy is missing') }) + it('rejects an OAuth MCP server already held by another Credential Group', async () => { + const existing = { + id: 'group-1', + workspaceId: 'workspace-1', + publicId: 'public-1', + name: 'Support accounts', + description: null, + options: [], + encryptedProviderConfiguration: null, + status: 'active' as const, + createdBy: 'user-1', + createdAt: new Date('2026-08-20T00:00:00.000Z'), + updatedAt: new Date('2026-08-20T00:00:00.000Z'), + } + queueTableRows(schemaMock.credentialGroup, [existing]) + queueTableRows(schemaMock.mcpServers, [ + { + id: 'mcp-server-1', + credentialGroupId: 'group-2', + authType: 'oauth', + enabled: true, + deletedAt: null, + }, + ]) + + await expect( + updateCredentialGroup('workspace-1', 'group-1', { + mcpServerIds: ['mcp-server-1'], + }) + ).rejects.toEqual( + new CredentialGroupMcpServerError( + 'MCP server mcp-server-1 is already assigned to another Credential Group', + 'conflict' + ) + ) + }) + it('deletes the policy and group in one locked transaction', async () => { queueTableRows(schemaMock.credentialGroup, [{ id: 'group-1' }]) + queueTableRows(schemaMock.credential, [{ id: 'mcp-cg-connection-1' }]) dbChainMockFns.returning .mockResolvedValueOnce([{ id: 'policy-1' }]) .mockResolvedValueOnce([{ id: 'group-1' }]) - await expect(deleteCredentialGroup('workspace-1', 'group-1')).resolves.toBe(true) + await expect(deleteCredentialGroup('workspace-1', 'group-1')).resolves.toEqual({ + deleted: true, + retiredMcpConnectionIds: ['mcp-cg-connection-1'], + }) expect(dbChainMockFns.for).toHaveBeenCalledWith('update') + expect(dbChainMockFns.set).toHaveBeenCalledWith( + expect.objectContaining({ credentialGroupId: null }) + ) expect(dbChainMockFns.delete).toHaveBeenCalledTimes(2) expect(dbChainMockFns.transaction).toHaveBeenCalledOnce() }) diff --git a/apps/sim/lib/credential-groups/service.ts b/apps/sim/lib/credential-groups/service.ts index 6bff6ee700d..bbac2c2ea5e 100644 --- a/apps/sim/lib/credential-groups/service.ts +++ b/apps/sim/lib/credential-groups/service.ts @@ -4,9 +4,10 @@ import { credential, credentialGroup, credentialGroupEnrollment, + mcpServers, } from '@sim/db/schema' import { generateId } from '@sim/utils/id' -import { and, desc, eq, inArray } from 'drizzle-orm' +import { and, asc, desc, eq, inArray, isNull, or } from 'drizzle-orm' import { credentialGroupWorkflowAccessPolicyCodec, requireDefaultCredentialGroupWorkflowAccessPolicy, @@ -18,6 +19,7 @@ import { isCredentialGroupProvider } from '@/lib/credential-groups/providers' import { SLACK_MANAGED_USER_SCOPES } from '@/lib/credential-groups/slack-managed-user-scopes' import type { CreateCredentialGroupInput, + CredentialGroupMcpServer, CredentialGroupOptionInput, CredentialGroupRecord, UpdateCredentialGroupInput, @@ -28,6 +30,148 @@ import { requireResourcePolicy, } from '@/lib/resource-policies/repository' +export class CredentialGroupMcpServerError extends Error { + constructor( + message: string, + readonly code: 'validation' | 'conflict' + ) { + super(message) + this.name = 'CredentialGroupMcpServerError' + } +} + +interface CredentialGroupMutationResult { + credentialGroup: CredentialGroupRecord + retiredMcpConnectionIds: string[] +} + +interface DeleteCredentialGroupResult { + deleted: boolean + retiredMcpConnectionIds: string[] +} + +async function listLinkedMcpServers( + credentialGroupId: string, + executor: DbOrTx = db +): Promise { + return executor + .select({ id: mcpServers.id, name: mcpServers.name, description: mcpServers.description }) + .from(mcpServers) + .where(and(eq(mcpServers.credentialGroupId, credentialGroupId), isNull(mcpServers.deletedAt))) + .orderBy(asc(mcpServers.name), asc(mcpServers.id)) +} + +async function syncCredentialGroupMcpServers( + workspaceId: string, + credentialGroupId: string, + requestedServerIds: string[], + executor: DbOrTx +): Promise { + const requestedScope = + requestedServerIds.length > 0 + ? or( + eq(mcpServers.credentialGroupId, credentialGroupId), + inArray(mcpServers.id, requestedServerIds) + ) + : eq(mcpServers.credentialGroupId, credentialGroupId) + const rows = await executor + .select({ + id: mcpServers.id, + credentialGroupId: mcpServers.credentialGroupId, + authType: mcpServers.authType, + enabled: mcpServers.enabled, + deletedAt: mcpServers.deletedAt, + }) + .from(mcpServers) + .where(and(eq(mcpServers.workspaceId, workspaceId), requestedScope)) + .for('update') + + const rowsById = new Map(rows.map((row) => [row.id, row])) + for (const serverId of requestedServerIds) { + const server = rowsById.get(serverId) + if (!server || server.deletedAt || !server.enabled) { + throw new CredentialGroupMcpServerError(`MCP server ${serverId} is unavailable`, 'validation') + } + if (server.authType !== 'oauth') { + throw new CredentialGroupMcpServerError(`MCP server ${serverId} must use OAuth`, 'validation') + } + if (server.credentialGroupId && server.credentialGroupId !== credentialGroupId) { + throw new CredentialGroupMcpServerError( + `MCP server ${serverId} is already assigned to another Credential Group`, + 'conflict' + ) + } + } + + const requested = new Set(requestedServerIds) + const removedServerIds = rows + .filter((row) => row.credentialGroupId === credentialGroupId && !requested.has(row.id)) + .map((row) => row.id) + const addedServerIds = requestedServerIds.filter( + (serverId) => rowsById.get(serverId)?.credentialGroupId !== credentialGroupId + ) + + let retiredMcpConnectionIds: string[] = [] + if (removedServerIds.length > 0) { + const enrollmentIds = executor + .select({ id: credentialGroupEnrollment.id }) + .from(credentialGroupEnrollment) + .where(eq(credentialGroupEnrollment.credentialGroupId, credentialGroupId)) + const retired = await executor + .update(credential) + .set({ + managedOauthStatus: 'revoked', + encryptedOauthTokenSet: null, + mcpTools: null, + mcpToolsRefreshedAt: null, + revokedAt: new Date(), + updatedAt: new Date(), + }) + .where( + and( + eq(credential.type, 'managed_mcp'), + inArray(credential.credentialGroupEnrollmentId, enrollmentIds), + inArray(credential.mcpServerId, removedServerIds) + ) + ) + .returning({ id: credential.id }) + retiredMcpConnectionIds = retired.map((row) => row.id) + await executor + .update(mcpServers) + .set({ credentialGroupId: null, updatedAt: new Date() }) + .where( + and( + eq(mcpServers.workspaceId, workspaceId), + eq(mcpServers.credentialGroupId, credentialGroupId), + inArray(mcpServers.id, removedServerIds) + ) + ) + } + + if (addedServerIds.length > 0) { + const assigned = await executor + .update(mcpServers) + .set({ credentialGroupId, updatedAt: new Date() }) + .where( + and( + eq(mcpServers.workspaceId, workspaceId), + inArray(mcpServers.id, addedServerIds), + isNull(mcpServers.credentialGroupId), + isNull(mcpServers.deletedAt) + ) + ) + .returning({ id: mcpServers.id }) + if (assigned.length !== addedServerIds.length) { + throw new CredentialGroupMcpServerError( + 'An MCP server was assigned to another Credential Group', + 'conflict' + ) + } + } + + return retiredMcpConnectionIds +} + function scopesEqual(left: string[], right: string[]): boolean { const normalizedLeft = [...new Set(left)].sort() const normalizedRight = [...new Set(right)].sort() @@ -97,7 +241,8 @@ async function updateOptions( } async function toCredentialGroup( - row: typeof credentialGroup.$inferSelect + row: typeof credentialGroup.$inferSelect, + linkedMcpServers: CredentialGroupMcpServer[] ): Promise { const providerConfiguration = await decryptCredentialGroupProviderConfiguration( row.encryptedProviderConfiguration @@ -140,6 +285,7 @@ async function toCredentialGroup( : ('ready' as const), } }), + mcpServers: linkedMcpServers, status: row.status, createdAt: row.createdAt.toISOString(), updatedAt: row.updatedAt.toISOString(), @@ -147,12 +293,32 @@ async function toCredentialGroup( } export async function listCredentialGroups(workspaceId: string): Promise { - const rows = await db - .select() - .from(credentialGroup) - .where(eq(credentialGroup.workspaceId, workspaceId)) - .orderBy(desc(credentialGroup.createdAt)) - return Promise.all(rows.map(toCredentialGroup)) + const [rows, serverRows] = await Promise.all([ + db + .select() + .from(credentialGroup) + .where(eq(credentialGroup.workspaceId, workspaceId)) + .orderBy(desc(credentialGroup.createdAt)), + db + .select({ + id: mcpServers.id, + name: mcpServers.name, + description: mcpServers.description, + credentialGroupId: mcpServers.credentialGroupId, + }) + .from(mcpServers) + .where(and(eq(mcpServers.workspaceId, workspaceId), isNull(mcpServers.deletedAt))) + .orderBy(asc(mcpServers.name), asc(mcpServers.id)), + ]) + const serversByGroupId = new Map() + for (const server of serverRows) { + if (!server.credentialGroupId) continue + const summary = { id: server.id, name: server.name, description: server.description } + const current = serversByGroupId.get(server.credentialGroupId) + if (current) current.push(summary) + else serversByGroupId.set(server.credentialGroupId, [summary]) + } + return Promise.all(rows.map((row) => toCredentialGroup(row, serversByGroupId.get(row.id) ?? []))) } export async function getCredentialGroup( @@ -164,7 +330,7 @@ export async function getCredentialGroup( .from(credentialGroup) .where(and(eq(credentialGroup.id, groupId), eq(credentialGroup.workspaceId, workspaceId))) .limit(1) - return row ? toCredentialGroup(row) : null + return row ? toCredentialGroup(row, await listLinkedMcpServers(row.id)) : null } export async function createCredentialGroup( @@ -206,14 +372,15 @@ export async function createCredentialGroup( document: policy.document, credentialGroupId: created.id, }) - return toCredentialGroup(created) + await syncCredentialGroupMcpServers(workspaceId, created.id, body.mcpServerIds ?? [], tx) + return toCredentialGroup(created, await listLinkedMcpServers(created.id, tx)) }) } export async function deleteCredentialGroup( workspaceId: string, groupId: string -): Promise { +): Promise { return db.transaction(async (tx) => { const [existing] = await tx .select({ id: credentialGroup.id }) @@ -221,7 +388,27 @@ export async function deleteCredentialGroup( .where(and(eq(credentialGroup.id, groupId), eq(credentialGroup.workspaceId, workspaceId))) .limit(1) .for('update') - if (!existing) return false + if (!existing) return { deleted: false, retiredMcpConnectionIds: [] } + + const retiredMcpConnections = await tx + .select({ id: credential.id }) + .from(credential) + .innerJoin( + credentialGroupEnrollment, + eq(credentialGroupEnrollment.id, credential.credentialGroupEnrollmentId) + ) + .where( + and( + eq(credential.type, 'managed_mcp'), + eq(credentialGroupEnrollment.credentialGroupId, groupId) + ) + ) + await tx + .update(mcpServers) + .set({ credentialGroupId: null, updatedAt: new Date() }) + .where( + and(eq(mcpServers.workspaceId, workspaceId), eq(mcpServers.credentialGroupId, groupId)) + ) await deleteResourcePolicyForResource( { workspaceId, resourceType: 'credential_group', resourceId: groupId }, @@ -232,7 +419,10 @@ export async function deleteCredentialGroup( .where(and(eq(credentialGroup.id, groupId), eq(credentialGroup.workspaceId, workspaceId))) .returning({ id: credentialGroup.id }) if (deleted.length !== 1) throw new Error('Locked Credential Group delete returned no row') - return true + return { + deleted: true, + retiredMcpConnectionIds: retiredMcpConnections.map((row) => row.id), + } }) } @@ -240,7 +430,7 @@ export async function updateCredentialGroup( workspaceId: string, groupId: string, body: UpdateCredentialGroupInput -): Promise { +): Promise { return db.transaction(async (tx) => { const [existing] = await tx .select() @@ -250,6 +440,11 @@ export async function updateCredentialGroup( .for('update') if (!existing) return null + const retiredMcpConnectionIds = + body.mcpServerIds === undefined + ? [] + : await syncCredentialGroupMcpServers(workspaceId, groupId, body.mcpServerIds, tx) + const nextOptions = body.options !== undefined ? await updateOptions(workspaceId, groupId, body.options, existing.options, tx) @@ -302,6 +497,9 @@ export async function updateCredentialGroup( ) ) } - return toCredentialGroup(updated) + return { + credentialGroup: await toCredentialGroup(updated, await listLinkedMcpServers(updated.id, tx)), + retiredMcpConnectionIds, + } }) } diff --git a/apps/sim/lib/credential-groups/types.ts b/apps/sim/lib/credential-groups/types.ts index a9c39dc5ebe..a204949891c 100644 --- a/apps/sim/lib/credential-groups/types.ts +++ b/apps/sim/lib/credential-groups/types.ts @@ -20,15 +20,23 @@ export interface CreateCredentialGroupInput { name: string description?: string options: CredentialGroupOptionInput[] + mcpServerIds?: string[] } export interface UpdateCredentialGroupInput { name?: string description?: string | null options?: CredentialGroupOptionUpdateInput[] + mcpServerIds?: string[] status?: 'active' | 'disabled' } +export interface CredentialGroupMcpServer { + id: string + name: string + description: string | null +} + interface CredentialGroupOptionBase { id: string label: string @@ -53,6 +61,7 @@ export interface CredentialGroupRecord { name: string description: string | null options: CredentialGroupOption[] + mcpServers: CredentialGroupMcpServer[] status: 'active' | 'disabled' createdAt: string updatedAt: string @@ -86,8 +95,15 @@ export interface CredentialGroupEnrollmentConnection { count: number } +export interface CredentialGroupEnrollmentMcpConnection { + mcpServerId: string + name: string + status: 'active' | 'needs_reauth' | 'revoked' +} + export interface CredentialGroupEnrollmentDetail extends CredentialGroupEnrollmentRecord { connections: CredentialGroupEnrollmentConnection[] + mcpConnections: CredentialGroupEnrollmentMcpConnection[] } export interface InviteCredentialGroupEnrollmentsInput { diff --git a/apps/sim/lib/credentials/access.ts b/apps/sim/lib/credentials/access.ts index 6f7bc25515e..9f1da73fa52 100644 --- a/apps/sim/lib/credentials/access.ts +++ b/apps/sim/lib/credentials/access.ts @@ -12,12 +12,23 @@ type ActiveCredentialMember = typeof credentialMember.$inferSelect type CredentialRecord = typeof credential.$inferSelect export type CredentialType = (typeof credentialTypeEnum.enumValues)[number] -export type OrdinaryCredentialType = Exclude +export type ManagedCredentialType = Extract + +export const MANAGED_CREDENTIAL_TYPES: readonly ManagedCredentialType[] = [ + 'managed_oauth', + 'managed_mcp', +] + +export function isManagedCredentialType(type: CredentialType): type is ManagedCredentialType { + return MANAGED_CREDENTIAL_TYPES.some((managed) => managed === type) +} + +export type OrdinaryCredentialType = Exclude /** Narrows credentials exposed through ordinary user-managed credential surfaces. */ export function requireOrdinaryCredentialType(type: CredentialType): OrdinaryCredentialType { - if (type === 'managed_oauth') { - throw new Error('Managed OAuth credential reached an ordinary credential surface') + if (isManagedCredentialType(type)) { + throw new Error('Managed credential reached an ordinary credential surface') } return type } diff --git a/apps/sim/lib/credentials/application/authorization.ts b/apps/sim/lib/credentials/application/authorization.ts index ef384c6b346..acee7eea975 100644 --- a/apps/sim/lib/credentials/application/authorization.ts +++ b/apps/sim/lib/credentials/application/authorization.ts @@ -1,10 +1,12 @@ import { type Principal, resolvePrincipalExecutionActorUserId } from '@sim/auth/principal' import type { WorkspaceDelegationPolicy } from '@/lib/core/application' import { OrchestrationError } from '@/lib/core/orchestration/types' +import type { ManagedMcpCredentialApplicationContext } from '@/lib/credentials/managed-mcp' import type { ManagedOAuthCredentialApplicationContext } from '@/lib/credentials/managed-oauth' export const CREDENTIAL_DELEGATION_AUDIENCE = 'sim:credentials' export const MANAGED_OAUTH_DELEGATION_AUDIENCE = 'sim:managed-oauth-credentials' +export const MANAGED_MCP_DELEGATION_AUDIENCE = 'sim:managed-mcp-credentials' export const credentialDelegationPolicy = { audience: CREDENTIAL_DELEGATION_AUDIENCE, @@ -23,6 +25,14 @@ export const managedOAuthCredentialDelegationPolicy = { ) => principal.resourceScope?.credentialId === context.credentialId, } satisfies WorkspaceDelegationPolicy +export const managedMcpCredentialDelegationPolicy = { + audience: MANAGED_MCP_DELEGATION_AUDIENCE, + isWithinScope: ( + principal: Extract, + context: ManagedMcpCredentialApplicationContext + ) => principal.resourceScope?.credentialId === context.credentialId, +} satisfies WorkspaceDelegationPolicy + /** * Resolves the user whose credential grants an operation evaluates. * diff --git a/apps/sim/lib/credentials/application/credential-crud.ts b/apps/sim/lib/credentials/application/credential-crud.ts index e0ba3090979..13938cb9abd 100644 --- a/apps/sim/lib/credentials/application/credential-crud.ts +++ b/apps/sim/lib/credentials/application/credential-crud.ts @@ -3,7 +3,11 @@ import { requirePrincipalSubjectUserId } from '@sim/auth/principal' import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application' import { getBlockVisibility } from '@/lib/core/config/block-visibility' import { OrchestrationError } from '@/lib/core/orchestration/types' -import { canUseCredential, getCredentialActorContext } from '@/lib/credentials/access' +import { + canUseCredential, + getCredentialActorContext, + requireOrdinaryCredentialType, +} from '@/lib/credentials/access' import { defineAuthorizedCredentialUseCase, requireCredentialAccess, @@ -241,7 +245,7 @@ export const createWorkspaceCredential = defineAuthorizedWorkspaceUseCase({ requirePrincipalSubjectUserId(principal), 'credential_connected', { - credential_type: result.credential.type, + credential_type: requireOrdinaryCredentialType(result.credential.type), provider_id: result.credential.providerId ?? result.credential.type, workspace_id: context.workspaceId, }, diff --git a/apps/sim/lib/credentials/application/credential-members.ts b/apps/sim/lib/credentials/application/credential-members.ts index 58a2262ce60..48736ffceae 100644 --- a/apps/sim/lib/credentials/application/credential-members.ts +++ b/apps/sim/lib/credentials/application/credential-members.ts @@ -1,6 +1,7 @@ import { AuditAction, AuditResourceType } from '@sim/audit' import { requirePrincipalSubjectUserId, type SessionPrincipal } from '@sim/auth/principal' import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application' +import { requireOrdinaryCredentialType } from '@/lib/credentials/access' import { defineAuthorizedCredentialUseCase } from '@/lib/credentials/application/authorized-credential-use-case' import { defineAuthorizedCredentialUserUseCase } from '@/lib/credentials/application/authorized-user-use-case' import { resolveCredentialApplicationContext } from '@/lib/credentials/application/credential-context' @@ -86,7 +87,7 @@ export const upsertCredentialMemberUseCase = defineAuthorizedCredentialUseCase({ afterSuccess: ({ principal, context, result }) => { if (!result.created) return captureServerEvent(requirePrincipalSubjectUserId(principal), 'credential_shared', { - credential_type: context.credential.type, + credential_type: requireOrdinaryCredentialType(context.credential.type), role: result.role, workspace_id: context.workspaceId, }) @@ -119,7 +120,7 @@ export const removeCredentialMemberUseCase = defineAuthorizedCredentialUseCase({ }), afterSuccess: ({ principal, context }) => { captureServerEvent(requirePrincipalSubjectUserId(principal), 'credential_unshared', { - credential_type: context.credential.type, + credential_type: requireOrdinaryCredentialType(context.credential.type), workspace_id: context.workspaceId, }) }, diff --git a/apps/sim/lib/credentials/application/operations.ts b/apps/sim/lib/credentials/application/operations.ts index 4bda143e590..be9b7766dd8 100644 --- a/apps/sim/lib/credentials/application/operations.ts +++ b/apps/sim/lib/credentials/application/operations.ts @@ -183,6 +183,17 @@ export const credentialOperations = { action: CREDENTIAL_GROUP_CREDENTIAL_USE_ACTION, }, }), + useManagedMcp: defineWorkspaceOperation({ + id: 'credentials.managed_mcp.use', + minimumRole: 'read', + workspaceApiKey: 'deny', + principalKinds: ['delegated'], + delegatedServices: ['executor'], + resourcePolicy: { + resourceType: 'credential_group', + action: CREDENTIAL_GROUP_CREDENTIAL_USE_ACTION, + }, + }), } as const /** diff --git a/apps/sim/lib/credentials/application/presentation.ts b/apps/sim/lib/credentials/application/presentation.ts index 3770f2ab7f7..a7a9305580d 100644 --- a/apps/sim/lib/credentials/application/presentation.ts +++ b/apps/sim/lib/credentials/application/presentation.ts @@ -36,6 +36,7 @@ export function toWorkspaceCredential( access?: CredentialActorContext ): WorkspaceCredential { const type = requireOrdinaryCredentialType(row.type) + if (!row.createdBy) throw new Error(`Credential ${row.id} has no creator`) const role = access?.isAdmin ? 'admin' : (access?.member?.role ?? ('role' in row ? row.role : undefined)) diff --git a/apps/sim/lib/credentials/application/service-account.ts b/apps/sim/lib/credentials/application/service-account.ts index 6112b39a99d..6061034e068 100644 --- a/apps/sim/lib/credentials/application/service-account.ts +++ b/apps/sim/lib/credentials/application/service-account.ts @@ -4,7 +4,7 @@ import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application' import { ForbiddenOperationError } from '@/lib/core/application/forbidden' import { OrchestrationError } from '@/lib/core/orchestration/types' import { HttpError } from '@/lib/core/utils/http-error' -import { getCredentialActorContext } from '@/lib/credentials/access' +import { getCredentialActorContext, requireOrdinaryCredentialType } from '@/lib/credentials/access' import { defineAuthorizedCredentialUseCase, requireManageableCredentialType, @@ -183,7 +183,7 @@ export const deleteCredentialUseCase = defineAuthorizedCredentialUseCase({ requirePrincipalSubjectUserId(principal), 'credential_deleted', { - credential_type: result.credential.type, + credential_type: requireOrdinaryCredentialType(result.credential.type), provider_id: result.credential.providerId ?? result.credential.envKey ?? result.credential.id, workspace_id: context.workspaceId, diff --git a/apps/sim/lib/credentials/managed-mcp.ts b/apps/sim/lib/credentials/managed-mcp.ts new file mode 100644 index 00000000000..c47b5eeb097 --- /dev/null +++ b/apps/sim/lib/credentials/managed-mcp.ts @@ -0,0 +1,350 @@ +import type { OAuthTokens } from '@modelcontextprotocol/sdk/shared/auth.js' +import { db } from '@sim/db' +import { + credential, + credentialGroup, + credentialGroupEnrollment, + type ManagedMcpToolSnapshot, + mcpServers, +} from '@sim/db/schema' +import { getErrorMessage } from '@sim/utils/errors' +import { and, eq, inArray, isNull, ne } from 'drizzle-orm' +import { getWorkspaceOwnerSubscriptionAccess } from '@/lib/billing/core/workspace-access' +import type { WorkspaceAuthorizationContext } from '@/lib/core/application' +import { decryptSecret, encryptSecret } from '@/lib/core/security/encryption' +import { isCredentialGroupsAvailable } from '@/lib/credential-groups/availability' +import { lockCredentialGroupEnrollmentLifecycle } from '@/lib/credential-groups/enrollments' +import { generateManagedMcpConnectionId } from '@/lib/mcp/utils' +import { loadActiveWorkspaceApplicationContext } from '@/lib/workspaces/application/workspace-context' + +const MANAGED_MCP_TOKEN_SET_TYPE = 'managed-mcp-oauth-token-set' as const +const MANAGED_MCP_TOKEN_SET_VERSION = 1 as const + +interface ManagedMcpTokenEnvelope { + type: typeof MANAGED_MCP_TOKEN_SET_TYPE + version: typeof MANAGED_MCP_TOKEN_SET_VERSION + tokens: OAuthTokens +} + +export interface ManagedMcpCredentialApplicationContext extends WorkspaceAuthorizationContext { + credentialId: string + credentialGroupId: string + credentialGroupEnrollmentId: string + mcpServerId: string + mcpServerName: string +} + +export interface ManagedMcpRuntimeCredential { + credentialId: string + mcpServerId: string + mcpServerName: string + workspaceId: string + tokens: OAuthTokens + tools: ManagedMcpToolSnapshot[] +} + +export class ManagedMcpCredentialError extends Error { + constructor( + message: string, + readonly statusCode: 401 | 403 | 404 | 500 + ) { + super(message) + this.name = 'ManagedMcpCredentialError' + } +} + +function isOAuthTokens(value: unknown): value is OAuthTokens { + if (!value || typeof value !== 'object') return false + const candidate = value as Record + return ( + typeof candidate.access_token === 'string' && + candidate.access_token.length > 0 && + (candidate.refresh_token === undefined || typeof candidate.refresh_token === 'string') && + (candidate.token_type === undefined || typeof candidate.token_type === 'string') && + (candidate.expires_in === undefined || typeof candidate.expires_in === 'number') + ) +} + +export async function encryptManagedMcpTokens(tokens: OAuthTokens): Promise { + if (!isOAuthTokens(tokens)) throw new ManagedMcpCredentialError('Invalid MCP OAuth tokens', 500) + const envelope: ManagedMcpTokenEnvelope = { + type: MANAGED_MCP_TOKEN_SET_TYPE, + version: MANAGED_MCP_TOKEN_SET_VERSION, + tokens, + } + return (await encryptSecret(JSON.stringify(envelope))).encrypted +} + +export async function decryptManagedMcpTokens(encrypted: string): Promise { + try { + const { decrypted } = await decryptSecret(encrypted) + const parsed: unknown = JSON.parse(decrypted) + if (!parsed || typeof parsed !== 'object') throw new Error('Invalid token envelope') + const envelope = parsed as Record + if ( + envelope.type !== MANAGED_MCP_TOKEN_SET_TYPE || + envelope.version !== MANAGED_MCP_TOKEN_SET_VERSION || + !isOAuthTokens(envelope.tokens) + ) { + throw new Error('Invalid token envelope') + } + return envelope.tokens + } catch (error) { + throw new ManagedMcpCredentialError( + `Managed MCP credential token data is invalid: ${getErrorMessage(error)}`, + 500 + ) + } +} + +export async function loadManagedMcpCredentialApplicationContext( + credentialId: string +): Promise { + const [row] = await db + .select({ + credentialId: credential.id, + workspaceId: credential.workspaceId, + credentialGroupId: credentialGroup.id, + credentialGroupEnrollmentId: credentialGroupEnrollment.id, + mcpServerId: mcpServers.id, + mcpServerName: mcpServers.name, + }) + .from(credential) + .innerJoin( + credentialGroupEnrollment, + eq(credentialGroupEnrollment.id, credential.credentialGroupEnrollmentId) + ) + .innerJoin(credentialGroup, eq(credentialGroup.id, credentialGroupEnrollment.credentialGroupId)) + .innerJoin(mcpServers, eq(mcpServers.id, credential.mcpServerId)) + .where(and(eq(credential.id, credentialId), eq(credential.type, 'managed_mcp'))) + .limit(1) + if (!row) return null + const workspaceContext = await loadActiveWorkspaceApplicationContext(row.workspaceId) + return workspaceContext ? { ...workspaceContext, ...row } : null +} + +export async function loadManagedMcpRuntimeCredential( + credentialId: string, + workspaceId: string +): Promise { + const ownerBilling = await getWorkspaceOwnerSubscriptionAccess(workspaceId) + if (!(await isCredentialGroupsAvailable({ workspaceId, ownerBilling }))) { + throw new ManagedMcpCredentialError( + 'Managed MCP credentials are not available for this workspace', + 403 + ) + } + + const [row] = await db + .select({ + credentialId: credential.id, + workspaceId: credential.workspaceId, + status: credential.managedOauthStatus, + encryptedTokens: credential.encryptedOauthTokenSet, + tools: credential.mcpTools, + enrollmentStatus: credentialGroupEnrollment.status, + groupStatus: credentialGroup.status, + credentialGroupId: credentialGroup.id, + linkedCredentialGroupId: mcpServers.credentialGroupId, + mcpServerId: mcpServers.id, + mcpServerName: mcpServers.name, + }) + .from(credential) + .innerJoin( + credentialGroupEnrollment, + eq(credentialGroupEnrollment.id, credential.credentialGroupEnrollmentId) + ) + .innerJoin(credentialGroup, eq(credentialGroup.id, credentialGroupEnrollment.credentialGroupId)) + .innerJoin(mcpServers, eq(mcpServers.id, credential.mcpServerId)) + .where( + and( + eq(credential.id, credentialId), + eq(credential.workspaceId, workspaceId), + eq(credential.type, 'managed_mcp'), + eq(mcpServers.workspaceId, workspaceId), + eq(mcpServers.authType, 'oauth'), + eq(mcpServers.enabled, true), + isNull(mcpServers.deletedAt) + ) + ) + .limit(1) + if (!row) throw new ManagedMcpCredentialError('Managed MCP credential not found', 404) + if ( + row.status !== 'active' || + row.groupStatus !== 'active' || + !['in_progress', 'completed'].includes(row.enrollmentStatus) || + row.linkedCredentialGroupId !== row.credentialGroupId + ) { + throw new ManagedMcpCredentialError('Managed MCP credential needs authorization', 401) + } + if (!row.encryptedTokens) { + throw new ManagedMcpCredentialError('Managed MCP credential token data is missing', 500) + } + if (!row.tools) throw new ManagedMcpCredentialError('Managed MCP tool metadata is missing', 500) + return { + credentialId: row.credentialId, + workspaceId: row.workspaceId, + mcpServerId: row.mcpServerId, + mcpServerName: row.mcpServerName, + tokens: await decryptManagedMcpTokens(row.encryptedTokens), + tools: row.tools, + } +} + +export async function persistManagedMcpCredential(params: { + enrollmentId: string + workspaceId: string + mcpServerId: string + mcpServerName: string + tokens: OAuthTokens + tools: Array<{ name: string; description?: string; inputSchema: Record }> +}): Promise { + const encryptedOauthTokenSet = await encryptManagedMcpTokens(params.tokens) + const now = new Date() + const accessTokenExpiresAt = + typeof params.tokens.expires_in === 'number' + ? new Date(now.getTime() + params.tokens.expires_in * 1000) + : null + return db.transaction(async (tx) => { + await lockCredentialGroupEnrollmentLifecycle(tx, params.enrollmentId) + const [source] = await tx + .select({ + enrollmentStatus: credentialGroupEnrollment.status, + credentialGroupId: credentialGroupEnrollment.credentialGroupId, + groupStatus: credentialGroup.status, + linkedCredentialGroupId: mcpServers.credentialGroupId, + }) + .from(credentialGroupEnrollment) + .innerJoin( + credentialGroup, + eq(credentialGroup.id, credentialGroupEnrollment.credentialGroupId) + ) + .innerJoin(mcpServers, eq(mcpServers.id, params.mcpServerId)) + .where( + and( + eq(credentialGroupEnrollment.id, params.enrollmentId), + eq(credentialGroup.workspaceId, params.workspaceId), + eq(mcpServers.workspaceId, params.workspaceId), + eq(mcpServers.authType, 'oauth'), + eq(mcpServers.enabled, true), + isNull(mcpServers.deletedAt) + ) + ) + .limit(1) + .for('update') + if ( + !source || + source.groupStatus !== 'active' || + !['invited', 'in_progress', 'completed'].includes(source.enrollmentStatus) || + source.linkedCredentialGroupId !== source.credentialGroupId + ) { + throw new ManagedMcpCredentialError('Managed MCP connection is no longer available', 404) + } + + const [existing] = await tx + .select({ id: credential.id }) + .from(credential) + .where( + and( + eq(credential.type, 'managed_mcp'), + eq(credential.credentialGroupEnrollmentId, params.enrollmentId), + eq(credential.mcpServerId, params.mcpServerId) + ) + ) + .limit(1) + .for('update') + const values = { + displayName: params.mcpServerName, + managedOauthStatus: 'active' as const, + encryptedOauthTokenSet, + accessTokenExpiresAt, + mcpTools: params.tools, + mcpToolsRefreshedAt: now, + grantedAt: now, + revokedAt: null, + updatedAt: now, + } + let connectionId: string + if (existing) { + const [updated] = await tx + .update(credential) + .set(values) + .where(and(eq(credential.id, existing.id), eq(credential.type, 'managed_mcp'))) + .returning({ id: credential.id }) + if (!updated) throw new Error('Managed MCP credential update returned no row') + connectionId = updated.id + } else { + const id = generateManagedMcpConnectionId() + const insert: typeof credential.$inferInsert = { + id, + workspaceId: params.workspaceId, + type: 'managed_mcp', + createdBy: null, + credentialGroupEnrollmentId: params.enrollmentId, + mcpServerId: params.mcpServerId, + ...values, + createdAt: now, + } + const [created] = await tx.insert(credential).values(insert).returning({ id: credential.id }) + if (!created) throw new Error('Managed MCP credential insert returned no row') + connectionId = created.id + } + + const [updatedEnrollment] = await tx + .update(credentialGroupEnrollment) + .set({ + status: source.enrollmentStatus === 'completed' ? 'completed' : 'in_progress', + ...(source.enrollmentStatus === 'completed' ? {} : { completedAt: null }), + updatedAt: now, + }) + .where( + and( + eq(credentialGroupEnrollment.id, params.enrollmentId), + ne(credentialGroupEnrollment.status, 'revoked') + ) + ) + .returning({ id: credentialGroupEnrollment.id }) + if (!updatedEnrollment) { + throw new ManagedMcpCredentialError('Managed MCP enrollment is no longer available', 404) + } + return connectionId + }) +} + +export async function saveManagedMcpRuntimeTokens( + credentialId: string, + tokens: OAuthTokens | null +): Promise { + const now = new Date() + const updated = await db + .update(credential) + .set( + tokens + ? { + encryptedOauthTokenSet: await encryptManagedMcpTokens(tokens), + managedOauthStatus: 'active', + accessTokenExpiresAt: + typeof tokens.expires_in === 'number' + ? new Date(now.getTime() + tokens.expires_in * 1000) + : null, + updatedAt: now, + } + : { + encryptedOauthTokenSet: null, + managedOauthStatus: 'needs_reauth', + accessTokenExpiresAt: null, + updatedAt: now, + } + ) + .where( + and( + eq(credential.id, credentialId), + eq(credential.type, 'managed_mcp'), + inArray(credential.managedOauthStatus, ['active', 'needs_reauth']) + ) + ) + .returning({ id: credential.id }) + if (updated.length !== 1) { + throw new ManagedMcpCredentialError('Managed MCP credential is no longer active', 401) + } +} diff --git a/apps/sim/lib/credentials/members.test.ts b/apps/sim/lib/credentials/members.test.ts index fae51df0525..074b49de7f2 100644 --- a/apps/sim/lib/credentials/members.test.ts +++ b/apps/sim/lib/credentials/members.test.ts @@ -10,11 +10,14 @@ describe('listCredentialMembershipsForUser', () => { resetDbChainMock() }) - it('excludes managed OAuth credentials from ordinary memberships', async () => { + it('excludes managed credentials from ordinary memberships', async () => { dbChainMockFns.where.mockResolvedValue([]) await listCredentialMembershipsForUser('user-1') - expect(drizzleOrmMock.ne).toHaveBeenCalledWith(schemaMock.credential.type, 'managed_oauth') + expect(drizzleOrmMock.notInArray).toHaveBeenCalledWith(schemaMock.credential.type, [ + 'managed_oauth', + 'managed_mcp', + ]) }) }) diff --git a/apps/sim/lib/credentials/members.ts b/apps/sim/lib/credentials/members.ts index 7c3f68047d8..4c0de62ec25 100644 --- a/apps/sim/lib/credentials/members.ts +++ b/apps/sim/lib/credentials/members.ts @@ -1,7 +1,7 @@ import { db } from '@sim/db' import { credential, credentialMember, user } from '@sim/db/schema' import { generateId } from '@sim/utils/id' -import { and, eq, ne } from 'drizzle-orm' +import { and, eq, notInArray } from 'drizzle-orm' import { OrchestrationError } from '@/lib/core/orchestration/types' import { isSharedCredentialType, requireOrdinaryCredentialType } from '@/lib/credentials/access' import type { CredentialRow } from '@/lib/credentials/queries' @@ -218,7 +218,12 @@ export async function listCredentialMembershipsForUser(userId: string) { }) .from(credentialMember) .innerJoin(credential, eq(credentialMember.credentialId, credential.id)) - .where(and(eq(credentialMember.userId, userId), ne(credential.type, 'managed_oauth'))) + .where( + and( + eq(credentialMember.userId, userId), + notInArray(credential.type, ['managed_oauth', 'managed_mcp']) + ) + ) return rows.map((row) => ({ ...row, type: requireOrdinaryCredentialType(row.type) })) } diff --git a/apps/sim/lib/credentials/orchestration/credential-create.ts b/apps/sim/lib/credentials/orchestration/credential-create.ts index 8c3af20c6bb..3eb5903815c 100644 --- a/apps/sim/lib/credentials/orchestration/credential-create.ts +++ b/apps/sim/lib/credentials/orchestration/credential-create.ts @@ -10,7 +10,7 @@ import { normalizeCredentialEnvKey } from '@/lib/api/contracts/credentials' import { acquireOrganizationUserMutationLocks } from '@/lib/billing/organizations/membership' import type { OrchestrationRequestContext } from '@/lib/core/orchestration/types' import { decryptSecret } from '@/lib/core/security/encryption' -import { getCredentialActorContext } from '@/lib/credentials/access' +import { getCredentialActorContext, requireOrdinaryCredentialType } from '@/lib/credentials/access' import { AtlassianValidationError } from '@/lib/credentials/atlassian-service-account' import { getCredentialCreationWorkspaceContext } from '@/lib/credentials/environment' import type { CredentialOrchestrationErrorCode } from '@/lib/credentials/orchestration' @@ -608,7 +608,7 @@ export async function performCreateCredential( params.userId, 'credential_connected', { - credential_type: result.credential.type, + credential_type: requireOrdinaryCredentialType(result.credential.type), provider_id: result.credential.providerId ?? result.credential.type, workspace_id: result.credential.workspaceId, }, diff --git a/apps/sim/lib/credentials/queries.test.ts b/apps/sim/lib/credentials/queries.test.ts index efe7459628e..de3cc280dae 100644 --- a/apps/sim/lib/credentials/queries.test.ts +++ b/apps/sim/lib/credentials/queries.test.ts @@ -16,7 +16,7 @@ describe('listVisibleWorkspaceCredentials', () => { resetDbChainMock() }) - it('always excludes managed OAuth credentials from selector-backed listings', async () => { + it('always excludes managed credentials from selector-backed listings', async () => { dbChainMockFns.orderBy.mockResolvedValueOnce([]) await listVisibleWorkspaceCredentials({ @@ -25,7 +25,10 @@ describe('listVisibleWorkspaceCredentials', () => { workspaceAccess: { canAdmin: true }, }) - expect(drizzleOrmMock.ne).toHaveBeenCalledWith(schemaMock.credential.type, 'managed_oauth') + expect(drizzleOrmMock.notInArray).toHaveBeenCalledWith(schemaMock.credential.type, [ + 'managed_oauth', + 'managed_mcp', + ]) }) it('does not expose Credential Group configuration on a custom Slack bot', async () => { @@ -153,11 +156,14 @@ describe('ordinary credential lookups', () => { credentialId: 'credential-1', }), ], - ])('excludes managed OAuth from the %s path', async (_name, lookup) => { + ])('excludes managed credentials from the %s path', async (_name, lookup) => { dbChainMockFns.limit.mockResolvedValue([]) await lookup() - expect(drizzleOrmMock.ne).toHaveBeenCalledWith(schemaMock.credential.type, 'managed_oauth') + expect(drizzleOrmMock.notInArray).toHaveBeenCalledWith(schemaMock.credential.type, [ + 'managed_oauth', + 'managed_mcp', + ]) }) }) diff --git a/apps/sim/lib/credentials/queries.ts b/apps/sim/lib/credentials/queries.ts index 1fe8d5f02e4..c89a3975a38 100644 --- a/apps/sim/lib/credentials/queries.ts +++ b/apps/sim/lib/credentials/queries.ts @@ -1,6 +1,6 @@ import { db } from '@sim/db' import { credential, credentialMember } from '@sim/db/schema' -import { and, eq, inArray, isNotNull, ne, or, sql } from 'drizzle-orm' +import { and, eq, inArray, isNotNull, notInArray, or, sql } from 'drizzle-orm' import type { V2CredentialSortBy } from '@/lib/api/contracts/v2/credentials' import { type CursorKey, @@ -135,7 +135,8 @@ export async function listVisibleWorkspaceCredentials(params: { const whereClauses = [ eq(credential.workspaceId, workspaceId), - ne(credential.type, 'managed_oauth'), + notInArray(credential.type, ['managed_oauth', 'managed_mcp']), + isNotNull(credential.createdBy), ] if (types?.length) whereClauses.push(inArray(credential.type, types)) if (providerId) whereClauses.push(eq(credential.providerId, providerId)) @@ -198,19 +199,23 @@ export async function listVisibleWorkspaceCredentials(params: { const rows = await (limit === undefined ? query : query.limit(limit + 1)) - const mapped = rows.map(({ memberRole, encryptedServiceAccountKey, ...rest }) => ({ - ...rest, - hasServiceAccountKey: Boolean(encryptedServiceAccountKey), - /** - * An `env_personal` credential's own env owner administers it regardless of - * workspace role — otherwise the owner of a personal secret can't manage it. - */ - role: - (rest.type === 'env_personal' && rest.envOwnerUserId === userId) || - (isWorkspaceAdmin && isSharedCredentialType(rest.type)) - ? ('admin' as const) - : (memberRole ?? ('member' as const)), - })) + const mapped = rows.map(({ memberRole, encryptedServiceAccountKey, ...rest }) => { + if (!rest.createdBy) throw new Error(`Credential ${rest.id} has no creator`) + return { + ...rest, + createdBy: rest.createdBy, + hasServiceAccountKey: Boolean(encryptedServiceAccountKey), + /** + * An `env_personal` credential's own env owner administers it regardless of + * workspace role — otherwise the owner of a personal secret can't manage it. + */ + role: + (rest.type === 'env_personal' && rest.envOwnerUserId === userId) || + (isWorkspaceAdmin && isSharedCredentialType(rest.type)) + ? ('admin' as const) + : (memberRole ?? ('member' as const)), + } + }) return keysetPage(keys, mapped, limit) } @@ -271,12 +276,16 @@ export async function listWorkspacePrincipalCredentials(params: { const rows = await query.limit(limit + 1) - const mapped = rows.map((row) => ({ - ...row, - envKey: null, - envOwnerUserId: null, - role: 'member' as const, - })) + const mapped = rows.map((row) => { + if (!row.createdBy) throw new Error(`Credential ${row.id} has no creator`) + return { + ...row, + createdBy: row.createdBy, + envKey: null, + envOwnerUserId: null, + role: 'member' as const, + } + }) return keysetPage(keys, mapped, limit) } @@ -296,7 +305,7 @@ export async function getWorkspaceCredential(params: { and( eq(credential.id, params.credentialId), eq(credential.workspaceId, params.workspaceId), - ne(credential.type, 'managed_oauth') + notInArray(credential.type, ['managed_oauth', 'managed_mcp']) ) ) .limit(1) @@ -321,7 +330,7 @@ export async function findWorkspaceCredentialLookup(params: { and( eq(credential.id, params.credentialId), eq(credential.workspaceId, params.workspaceId), - ne(credential.type, 'managed_oauth') + notInArray(credential.type, ['managed_oauth', 'managed_mcp']) ) ) .limit(1) @@ -334,7 +343,7 @@ export async function findWorkspaceCredentialLookup(params: { and( eq(credential.accountId, params.credentialId), eq(credential.workspaceId, params.workspaceId), - ne(credential.type, 'managed_oauth') + notInArray(credential.type, ['managed_oauth', 'managed_mcp']) ) ) .limit(1) @@ -348,7 +357,12 @@ export async function getCredentialById(credentialId: string): Promise { request.signal?.throwIfAborted() - let serverId: string - let toolName: string + let target: ReturnType try { - ;({ serverId, toolName } = parseMcpToolId(request.toolId)) + target = parseMcpToolTarget(request.toolId) } catch (error) { return Response.json( { success: false, error: getErrorMessage(error, 'Invalid MCP tool ID') }, { status: 400 } ) } + const toolName = target.toolName + const targetId = target.kind === 'shared_server' ? target.serverId : target.credentialId if (!request.context.workspaceId) { return Response.json( @@ -126,7 +130,13 @@ export const executeMcpTool: InternalToolOperationHandler = async (request) => { try { const principal = await createExecutorPrincipalFromExecutionContext({ context: request.context, - audience: MCP_SERVER_DELEGATION_AUDIENCE, + audience: + target.kind === 'shared_server' + ? MCP_SERVER_DELEGATION_AUDIENCE + : MANAGED_MCP_DELEGATION_AUDIENCE, + ...(target.kind === 'managed_connection' + ? { resourceScope: { credentialId: target.credentialId } } + : {}), }) request.signal?.throwIfAborted() const subject = resolvePrincipalSubject(principal) @@ -144,21 +154,35 @@ export const executeMcpTool: InternalToolOperationHandler = async (request) => { policyTimeoutMs, getRemainingExecutionMs(request.signal) ) - const result = await executeMcpToolUseCase.execute({ - principal, - input: { - workspaceId: request.context.workspaceId, - serverId, - toolName, - arguments: args, - callChain: request.context.callChain, - timeoutMs, - signal: request.signal, - onResolvedSecretTraceProvenance: provenance - ? (value) => provenance?.record(value) - : undefined, - }, - }) + const result = + target.kind === 'shared_server' + ? await executeMcpToolUseCase.execute({ + principal, + input: { + workspaceId: request.context.workspaceId, + serverId: target.serverId, + toolName, + arguments: args, + callChain: request.context.callChain, + timeoutMs, + signal: request.signal, + onResolvedSecretTraceProvenance: provenance + ? (value) => provenance?.record(value) + : undefined, + }, + }) + : await executeManagedMcpToolUseCase.execute({ + principal, + input: { + workspaceId: request.context.workspaceId, + credentialId: target.credentialId, + toolName, + arguments: args, + callChain: request.context.callChain, + timeoutMs, + signal: request.signal, + }, + }) request.signal?.throwIfAborted() const body = result.success ? { success: true, data: { success: true, output: result.output } } @@ -188,13 +212,27 @@ export const executeMcpTool: InternalToolOperationHandler = async (request) => { request.toolId ) } + if (error instanceof ManagedMcpCredentialError && error.statusCode === 401) { + return createResponse( + { + success: false, + error: 'OAuth re-authorization required', + code: 'reauth_required', + serverId: targetId, + }, + 401, + provenance, + request.context.resolvedSecretTraceRegistry, + request.toolId + ) + } if ( error instanceof McpOauthAuthorizationRequiredError || error instanceof McpOauthRedirectRequired || error instanceof UnauthorizedError ) { const oauthServerId = - error instanceof McpOauthAuthorizationRequiredError ? error.serverId : serverId + error instanceof McpOauthAuthorizationRequiredError ? error.serverId : targetId return createResponse( { success: false, @@ -209,6 +247,19 @@ export const executeMcpTool: InternalToolOperationHandler = async (request) => { ) } + if (error instanceof ManagedMcpCredentialError) { + return createResponse( + { + success: false, + error: error.statusCode === 404 ? 'Resource not found' : 'Managed MCP connection failed', + }, + error.statusCode, + provenance, + request.context.resolvedSecretTraceRegistry, + request.toolId + ) + } + const orchestrationError = asOrchestrationError(error) if (orchestrationError) { const message = @@ -230,7 +281,7 @@ export const executeMcpTool: InternalToolOperationHandler = async (request) => { logger.error('MCP tool execution failed', { error: getErrorMessage(error), requestId: request.requestId, - serverId, + serverId: targetId, toolName, }) return createResponse( diff --git a/apps/sim/lib/mcp/application/execute-managed-tool.test.ts b/apps/sim/lib/mcp/application/execute-managed-tool.test.ts new file mode 100644 index 00000000000..cfcf471d23a --- /dev/null +++ b/apps/sim/lib/mcp/application/execute-managed-tool.test.ts @@ -0,0 +1,115 @@ +/** + * @vitest-environment node + */ +import type { WorkflowExecutionDelegatedPrincipal } from '@sim/auth/principal' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + executeTool: vi.fn(), + loadContext: vi.fn(), + loadRuntime: vi.fn(), + requireCredentialAccess: vi.fn(), + resolvePermission: vi.fn(), + saveTokens: vi.fn(), +})) + +vi.mock('@/lib/credentials/managed-mcp', () => ({ + loadManagedMcpCredentialApplicationContext: mocks.loadContext, + loadManagedMcpRuntimeCredential: mocks.loadRuntime, + saveManagedMcpRuntimeTokens: mocks.saveTokens, +})) + +vi.mock('@/lib/credential-groups/application/authorization', () => ({ + requireCredentialGroupCredentialAccess: mocks.requireCredentialAccess, +})) + +vi.mock('@/lib/mcp/service', () => ({ + mcpService: { executeManagedMcpTool: mocks.executeTool }, +})) + +vi.mock('@/lib/mcp/oauth', () => ({ + getOrCreateOauthRow: vi.fn(), + loadPreregisteredClient: vi.fn(), +})) + +vi.mock('@/lib/mcp/oauth/managed-provider', () => ({ + ManagedMcpOauthProvider: class ManagedMcpOauthProvider {}, +})) + +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: (permission: string | null, required: string) => + permission === 'admin' || permission === 'write' || permission === required, + resolveEffectiveWorkspacePermission: mocks.resolvePermission, +})) + +import { executeManagedMcpToolUseCase } from '@/lib/mcp/application/execute-managed-tool' + +const context = { + credentialId: 'mcp-cg-123456789012345678901', + credentialGroupId: 'group-1', + credentialGroupEnrollmentId: 'enrollment-1', + mcpServerId: 'mcp-server-1', + mcpServerName: 'Fireflies', + workspaceId: 'workspace-1', + workspaceOrganizationId: null, + allowPersonalApiKeys: true, +} + +const principal: WorkflowExecutionDelegatedPrincipal = { + kind: 'delegated', + serviceId: 'executor', + subjectUserId: 'user-1', + workspaceId: 'workspace-1', + delegationId: 'delegation-1', + audience: 'sim:managed-mcp-credentials', + issuedAt: new Date(Date.now() - 1_000), + expiresAt: new Date(Date.now() + 60_000), + resourceScope: { credentialId: context.credentialId }, + delegationContext: { + kind: 'workflow_execution', + workflowId: 'workflow-1', + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + currentWorkflow: { + workflowId: 'workflow-1', + mode: 'deployment', + deploymentVersionId: 'version-1', + }, + }, +} + +describe('executeManagedMcpToolUseCase', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.loadContext.mockResolvedValue(context) + mocks.resolvePermission.mockResolvedValue('read') + }) + + it('does not load token material when Credential Group policy denies execution', async () => { + mocks.requireCredentialAccess.mockRejectedValueOnce({ + code: 'forbidden', + message: 'Credential Group credential access denied', + }) + + await expect( + executeManagedMcpToolUseCase.execute({ + principal, + input: { + workspaceId: 'workspace-1', + credentialId: context.credentialId, + toolName: 'search_transcripts', + arguments: {}, + }, + }) + ).rejects.toMatchObject({ + code: 'forbidden', + message: 'Credential Group credential access denied', + }) + + expect(mocks.requireCredentialAccess).toHaveBeenCalledWith(principal, context, { + resourceType: 'credential_group', + action: 'credential_groups.credentials.use', + }) + expect(mocks.loadRuntime).not.toHaveBeenCalled() + expect(mocks.executeTool).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/mcp/application/execute-managed-tool.ts b/apps/sim/lib/mcp/application/execute-managed-tool.ts new file mode 100644 index 00000000000..3ed2809fd28 --- /dev/null +++ b/apps/sim/lib/mcp/application/execute-managed-tool.ts @@ -0,0 +1,116 @@ +import { AuditAction, AuditResourceType } from '@sim/audit' +import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { requireCredentialGroupCredentialAccess } from '@/lib/credential-groups/application/authorization' +import { managedMcpCredentialDelegationPolicy } from '@/lib/credentials/application/authorization' +import { credentialOperations } from '@/lib/credentials/application/operations' +import { + loadManagedMcpCredentialApplicationContext, + loadManagedMcpRuntimeCredential, + saveManagedMcpRuntimeTokens, +} from '@/lib/credentials/managed-mcp' +import { SIM_VIA_HEADER, serializeCallChain } from '@/lib/execution/call-chain' +import { + coerceToolArguments, + type ExecuteMcpToolResult, + transformToolResult, + validateToolArguments, +} from '@/lib/mcp/application/execute-tool' +import { getOrCreateOauthRow, loadPreregisteredClient } from '@/lib/mcp/oauth' +import { ManagedMcpOauthProvider } from '@/lib/mcp/oauth/managed-provider' +import { mcpService } from '@/lib/mcp/service' +import type { McpTool, McpToolCall, McpToolSchema } from '@/lib/mcp/types' + +export interface ExecuteManagedMcpToolInput { + workspaceId: string + credentialId: string + toolName: string + arguments?: Record + callChain?: string[] + timeoutMs?: number + signal?: AbortSignal +} + +function requireToolSchema(value: Record): McpToolSchema { + if (value.type !== 'object') { + throw new OrchestrationError('validation', 'Managed MCP tool schema is invalid') + } + return value as McpToolSchema +} + +export const executeManagedMcpToolUseCase = defineAuthorizedWorkspaceUseCase({ + operation: credentialOperations.useManagedMcp, + resolveContext: async ({ input }: { input: ExecuteManagedMcpToolInput }) => { + const context = await loadManagedMcpCredentialApplicationContext(input.credentialId) + if (!context) throw new OrchestrationError('not_found', 'Managed MCP connection not found') + if (context.workspaceId !== input.workspaceId) { + throw new OrchestrationError('not_found', 'Managed MCP connection not found') + } + return context + }, + authorizationOptions: { delegation: managedMcpCredentialDelegationPolicy }, + async authorizeResource({ principal, context, resourcePolicy }) { + await requireCredentialGroupCredentialAccess(principal, context, resourcePolicy) + }, + async execute({ input, context }): Promise { + input.signal?.throwIfAborted() + const runtime = await loadManagedMcpRuntimeCredential(context.credentialId, context.workspaceId) + const snapshot = runtime.tools.find((tool) => tool.name === input.toolName) + if (!snapshot) { + throw new OrchestrationError('not_found', 'Tool not found on the managed MCP connection') + } + const tool: McpTool = { + name: snapshot.name, + ...(snapshot.description ? { description: snapshot.description } : {}), + inputSchema: requireToolSchema(snapshot.inputSchema), + serverId: runtime.credentialId, + serverName: runtime.mcpServerName, + } + const args = coerceToolArguments(tool, { ...input.arguments }) + validateToolArguments(tool, args) + const toolCall: McpToolCall = { name: input.toolName, arguments: args } + const extraHeaders = + input.callChain && input.callChain.length > 0 + ? { [SIM_VIA_HEADER]: serializeCallChain(input.callChain) } + : undefined + const providerResult = await mcpService.executeManagedMcpTool({ + connectionId: runtime.credentialId, + serverId: runtime.mcpServerId, + workspaceId: runtime.workspaceId, + toolCall, + extraHeaders, + signal: input.signal, + timeoutMs: input.timeoutMs, + async loadAuthProvider() { + const current = await loadManagedMcpRuntimeCredential( + context.credentialId, + context.workspaceId + ) + const clientRow = await getOrCreateOauthRow({ + mcpServerId: current.mcpServerId, + workspaceId: current.workspaceId, + }) + const preregistered = await loadPreregisteredClient(current.mcpServerId) + return new ManagedMcpOauthProvider({ + clientRow, + preregistered, + tokens: current.tokens, + onSaveTokens: (tokens) => saveManagedMcpRuntimeTokens(current.credentialId, tokens), + }) + }, + }) + input.signal?.throwIfAborted() + return transformToolResult(providerResult) + }, + projectAudit: ({ input, context }) => ({ + action: AuditAction.CREDENTIAL_ACCESSED, + resourceType: AuditResourceType.CREDENTIAL, + resourceId: context.credentialId, + description: `Executed managed MCP tool ${input.toolName}`, + metadata: { + credentialType: 'managed_mcp', + mcpServerId: context.mcpServerId, + toolName: input.toolName, + }, + }), +}) diff --git a/apps/sim/lib/mcp/application/execute-tool.ts b/apps/sim/lib/mcp/application/execute-tool.ts index 60dba14a253..186a6a8d67f 100644 --- a/apps/sim/lib/mcp/application/execute-tool.ts +++ b/apps/sim/lib/mcp/application/execute-tool.ts @@ -42,7 +42,7 @@ function hasType(value: unknown): value is SchemaProperty { return typeof value === 'object' && value !== null && 'type' in value } -function coerceToolArguments( +export function coerceToolArguments( tool: McpTool, input: Record ): Record { @@ -88,7 +88,7 @@ function coerceToolArguments( return result } -function validateToolArguments(tool: McpTool, args: Record): void { +export function validateToolArguments(tool: McpTool, args: Record): void { const schema = tool.inputSchema if (!schema) return @@ -115,7 +115,7 @@ function validateToolArguments(tool: McpTool, args: Record): vo } } -function transformToolResult(result: McpToolResult): ExecuteMcpToolResult { +export function transformToolResult(result: McpToolResult): ExecuteMcpToolResult { if (!result.isError) return { success: true, output: result } const firstContent = Array.isArray(result.content) ? result.content[0] : undefined const errorText = diff --git a/apps/sim/lib/mcp/application/managed-connections.ts b/apps/sim/lib/mcp/application/managed-connections.ts new file mode 100644 index 00000000000..157ffcce69c --- /dev/null +++ b/apps/sim/lib/mcp/application/managed-connections.ts @@ -0,0 +1,100 @@ +import { db } from '@sim/db' +import { credential, credentialGroup, credentialGroupEnrollment, mcpServers } from '@sim/db/schema' +import { and, asc, eq, inArray, isNull, sql } from 'drizzle-orm' +import { getWorkspaceOwnerSubscriptionAccess } from '@/lib/billing/core/workspace-access' +import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application' +import { isCredentialGroupsAvailable } from '@/lib/credential-groups/availability' +import { resolveMcpWorkspaceContext } from '@/lib/mcp/application/context' +import { mcpServerOperations } from '@/lib/mcp/application/operations' +import type { McpToolSchema } from '@/lib/mcp/types' + +const MAX_MANAGED_MCP_CONNECTIONS = 500 + +function requireMcpToolSchema(inputSchema: Record): McpToolSchema { + if (inputSchema.type !== 'object') { + throw new Error('Managed MCP tool snapshot must have an object input schema') + } + return inputSchema as McpToolSchema +} + +export const listManagedMcpConnectionsUseCase = defineAuthorizedWorkspaceUseCase({ + operation: mcpServerOperations.listManagedConnections, + resolveContext: ({ input }: { input: { workspaceId: string } }) => + resolveMcpWorkspaceContext(input.workspaceId), + authorizationOptions: {}, + async execute({ context }) { + const ownerBilling = await getWorkspaceOwnerSubscriptionAccess(context.workspaceId) + if (!(await isCredentialGroupsAvailable({ workspaceId: context.workspaceId, ownerBilling }))) { + return { servers: [], tools: [] } + } + const rows = await db + .select({ + id: credential.id, + serverId: mcpServers.id, + serverName: mcpServers.name, + serverDescription: mcpServers.description, + email: credentialGroupEnrollment.email, + tools: credential.mcpTools, + createdAt: credential.createdAt, + updatedAt: credential.updatedAt, + }) + .from(credential) + .innerJoin( + credentialGroupEnrollment, + eq(credentialGroupEnrollment.id, credential.credentialGroupEnrollmentId) + ) + .innerJoin( + credentialGroup, + eq(credentialGroup.id, credentialGroupEnrollment.credentialGroupId) + ) + .innerJoin(mcpServers, eq(mcpServers.id, credential.mcpServerId)) + .where( + and( + eq(credential.workspaceId, context.workspaceId), + eq(credential.type, 'managed_mcp'), + eq(credential.managedOauthStatus, 'active'), + eq(credentialGroup.status, 'active'), + inArray(credentialGroupEnrollment.status, ['in_progress', 'completed']), + eq(mcpServers.workspaceId, context.workspaceId), + eq(mcpServers.authType, 'oauth'), + eq(mcpServers.enabled, true), + isNull(mcpServers.deletedAt), + sql`${mcpServers.credentialGroupId} = ${credentialGroup.id}` + ) + ) + .orderBy(asc(mcpServers.name), asc(credentialGroupEnrollment.email), asc(credential.id)) + .limit(MAX_MANAGED_MCP_CONNECTIONS + 1) + + if (rows.length > MAX_MANAGED_MCP_CONNECTIONS) { + throw new Error( + `Managed MCP catalog exceeds the ${MAX_MANAGED_MCP_CONNECTIONS}-connection limit` + ) + } + + return { + servers: rows.map((row) => ({ + id: row.id, + workspaceId: context.workspaceId, + name: `${row.serverName} — ${row.email}`, + ...(row.serverDescription ? { description: row.serverDescription } : {}), + transport: 'streamable-http' as const, + authType: 'oauth' as const, + enabled: true, + connectionStatus: 'connected' as const, + toolCount: row.tools?.length ?? 0, + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + })), + tools: rows.flatMap((row) => { + if (!row.tools) throw new Error(`Managed MCP connection ${row.id} has no tool snapshot`) + return row.tools.map((tool) => ({ + name: tool.name, + description: tool.description, + inputSchema: requireMcpToolSchema(tool.inputSchema), + serverId: row.id, + serverName: `${row.serverName} — ${row.email}`, + })) + }), + } + }, +}) diff --git a/apps/sim/lib/mcp/application/operations.ts b/apps/sim/lib/mcp/application/operations.ts index e5cba22f155..ab392808cbe 100644 --- a/apps/sim/lib/mcp/application/operations.ts +++ b/apps/sim/lib/mcp/application/operations.ts @@ -39,6 +39,12 @@ export const mcpServerOperations = { capability: 'mcp_tools.use', ...ALL_PRINCIPAL_POLICY, }), + listManagedConnections: defineWorkspaceOperation({ + id: 'mcp_servers.managed_connections.list', + minimumRole: 'read', + workspaceApiKey: 'deny', + principalKinds: ['session'], + }), discoverTools: defineWorkspaceOperation({ id: 'mcp_servers.tools.discover', minimumRole: 'read', diff --git a/apps/sim/lib/mcp/connection-pool.ts b/apps/sim/lib/mcp/connection-pool.ts index 8eda72cbd3e..1a12fc5b27e 100644 --- a/apps/sim/lib/mcp/connection-pool.ts +++ b/apps/sim/lib/mcp/connection-pool.ts @@ -320,3 +320,8 @@ if (!('_mcpConnectionPool' in _g)) { } export const mcpConnectionPool: McpConnectionPool | null = _g._mcpConnectionPool ?? null + +/** Evicts every warm connection for a server without importing the full MCP service. */ +export async function evictMcpServerConnections(serverId: string, reason: string): Promise { + await mcpConnectionPool?.evictServer(serverId, reason) +} diff --git a/apps/sim/lib/mcp/oauth/managed-provider.ts b/apps/sim/lib/mcp/oauth/managed-provider.ts new file mode 100644 index 00000000000..1ca319716de --- /dev/null +++ b/apps/sim/lib/mcp/oauth/managed-provider.ts @@ -0,0 +1,128 @@ +import type { OAuthClientProvider } from '@modelcontextprotocol/sdk/client/auth.js' +import type { + OAuthClientInformationMixed, + OAuthClientMetadata, + OAuthTokens, +} from '@modelcontextprotocol/sdk/shared/auth.js' +import { generateId } from '@sim/utils/id' +import { getBaseUrl } from '@/lib/core/utils/urls' +import { McpOauthRedirectRequired, type PreregisteredClient } from '@/lib/mcp/oauth/provider' +import { clearClient, type McpOauthRow, saveClientInformation } from '@/lib/mcp/oauth/storage' + +interface ManagedMcpOauthProviderInit { + clientRow: McpOauthRow + preregistered?: PreregisteredClient + tokens?: OAuthTokens + codeVerifier?: string + onSaveTokens: (tokens: OAuthTokens | null) => Promise +} + +/** Shares server client registration while keeping grant tokens scoped to one enrollment. */ +export class ManagedMcpOauthProvider implements OAuthClientProvider { + private readonly clientRow: McpOauthRow + private readonly preregistered?: PreregisteredClient + private readonly onSaveTokens: (tokens: OAuthTokens | null) => Promise + private currentTokens?: OAuthTokens + private currentState?: string + private currentCodeVerifier?: string + + constructor({ + clientRow, + preregistered, + tokens, + codeVerifier, + onSaveTokens, + }: ManagedMcpOauthProviderInit) { + this.clientRow = clientRow + this.preregistered = preregistered + this.currentTokens = tokens + this.currentCodeVerifier = codeVerifier + this.onSaveTokens = onSaveTokens + } + + get redirectUrl(): string { + return `${getBaseUrl().replace(/\/$/, '')}/api/mcp/oauth/callback` + } + + get clientMetadata(): OAuthClientMetadata { + return { + client_name: 'Sim', + redirect_uris: [this.redirectUrl], + grant_types: ['authorization_code', 'refresh_token'], + response_types: ['code'], + token_endpoint_auth_method: this.preregistered?.clientSecret ? 'client_secret_post' : 'none', + } + } + + async state(): Promise { + this.currentState = `mcp_cg_${generateId()}` + return this.currentState + } + + clientInformation(): OAuthClientInformationMixed | undefined { + if (this.clientRow.clientInformation) return this.clientRow.clientInformation + if (!this.preregistered) return undefined + return { + client_id: this.preregistered.clientId, + client_secret: this.preregistered.clientSecret, + redirect_uris: [this.redirectUrl], + grant_types: ['authorization_code', 'refresh_token'], + response_types: ['code'], + token_endpoint_auth_method: this.preregistered.clientSecret ? 'client_secret_post' : 'none', + } + } + + async saveClientInformation(info: OAuthClientInformationMixed): Promise { + if (this.preregistered) return + await saveClientInformation(this.clientRow.id, info) + this.clientRow.clientInformation = info + } + + tokens(): OAuthTokens | undefined { + return this.currentTokens + } + + async saveTokens(tokens: OAuthTokens): Promise { + await this.onSaveTokens(tokens) + this.currentTokens = tokens + } + + async redirectToAuthorization(authorizationUrl: URL): Promise { + throw new McpOauthRedirectRequired(authorizationUrl.toString()) + } + + async saveCodeVerifier(codeVerifier: string): Promise { + this.currentCodeVerifier = codeVerifier + } + + async codeVerifier(): Promise { + if (!this.currentCodeVerifier) { + throw new Error('No PKCE code verifier saved for this managed MCP OAuth session') + } + return this.currentCodeVerifier + } + + async invalidateCredentials( + scope: 'all' | 'client' | 'tokens' | 'verifier' | 'discovery' + ): Promise { + if (scope === 'all' || scope === 'client') { + await clearClient(this.clientRow.id) + this.clientRow.clientInformation = null + } + if (scope === 'all' || scope === 'tokens') { + await this.onSaveTokens(null) + this.currentTokens = undefined + } + if (scope === 'all' || scope === 'verifier') { + this.currentState = undefined + this.currentCodeVerifier = undefined + } + } + + requireAuthorizationAttempt(): { state: string; codeVerifier: string } { + if (!this.currentState || !this.currentCodeVerifier) { + throw new Error('Managed MCP OAuth provider did not produce state and PKCE verifier') + } + return { state: this.currentState, codeVerifier: this.currentCodeVerifier } + } +} diff --git a/apps/sim/lib/mcp/oauth/storage.ts b/apps/sim/lib/mcp/oauth/storage.ts index 63a567faaf8..c2e54c2e951 100644 --- a/apps/sim/lib/mcp/oauth/storage.ts +++ b/apps/sim/lib/mcp/oauth/storage.ts @@ -71,7 +71,7 @@ async function safeDecrypt( export async function getOrCreateOauthRow(params: { mcpServerId: string - userId: string + userId?: string | null workspaceId: string }): Promise { const existing = await loadOauthRow(params) @@ -82,7 +82,7 @@ export async function getOrCreateOauthRow(params: { await db.insert(mcpServerOauth).values({ id, mcpServerId: params.mcpServerId, - userId: params.userId, + userId: params.userId ?? null, workspaceId: params.workspaceId, }) } catch (error) { @@ -94,7 +94,7 @@ export async function getOrCreateOauthRow(params: { return { id, mcpServerId: params.mcpServerId, - userId: params.userId, + userId: params.userId ?? null, workspaceId: params.workspaceId, clientInformation: null, tokens: null, diff --git a/apps/sim/lib/mcp/service.ts b/apps/sim/lib/mcp/service.ts index bb5914156c8..51f30a2bbcf 100644 --- a/apps/sim/lib/mcp/service.ts +++ b/apps/sim/lib/mcp/service.ts @@ -1,4 +1,7 @@ -import { UnauthorizedError } from '@modelcontextprotocol/sdk/client/auth.js' +import { + type OAuthClientProvider, + UnauthorizedError, +} from '@modelcontextprotocol/sdk/client/auth.js' import { StreamableHTTPError } from '@modelcontextprotocol/sdk/client/streamableHttp.js' import { ErrorCode, McpError } from '@modelcontextprotocol/sdk/types.js' import { db } from '@sim/db' @@ -12,7 +15,7 @@ import { and, eq, isNull, lte, or, sql } from 'drizzle-orm' import { generateRequestId } from '@/lib/core/utils/request' import { McpClient } from '@/lib/mcp/client' import { mcpConnectionManager } from '@/lib/mcp/connection-manager' -import { mcpConnectionPool } from '@/lib/mcp/connection-pool' +import { evictMcpServerConnections, mcpConnectionPool } from '@/lib/mcp/connection-pool' import { MAX_MCP_LAST_ERROR_LENGTH } from '@/lib/mcp/constants' import { isMcpDomainAllowed, @@ -43,6 +46,7 @@ import { type McpTransport, } from '@/lib/mcp/types' import { MCP_CLIENT_CONSTANTS, MCP_CONSTANTS } from '@/lib/mcp/utils' +import { createEnvVarPattern } from '@/executor/utils/reference-validation' import { isResolvedSecretTraceProvenanceV1, type ResolvedSecretTraceProvenanceV1, @@ -454,6 +458,90 @@ class McpService { }) } + private async createManagedOauthClient( + config: McpServerConfig, + authProvider: OAuthClientProvider, + signal?: AbortSignal + ): Promise { + if (config.authType !== 'oauth' || !config.url) { + throw new Error('Managed MCP connection requires an OAuth HTTP server') + } + if ( + [config.url, ...Object.values(config.headers ?? {})].some((value) => + createEnvVarPattern().test(value) + ) + ) { + throw new Error('Credential Group MCP servers cannot use personal environment references') + } + validateMcpDomain(config.url) + const resolvedIP = await validateMcpServerSsrf(config.url) + const client = new McpClient({ + config, + securityPolicy: { + requireConsent: true, + auditLevel: 'basic', + maxToolExecutionsPerHour: 1000, + allowedOrigins: [new URL(config.url).origin], + }, + authProvider, + resolvedIP: resolvedIP ?? undefined, + }) + await client.connect({ signal }) + return client + } + + async discoverManagedMcpTools( + serverId: string, + workspaceId: string, + authProvider: OAuthClientProvider, + signal?: AbortSignal + ): Promise { + const config = await this.getServerConfig(serverId, workspaceId) + if (!config) throw new Error('Managed MCP server is unavailable') + return this.withServerClient( + { key: '', serverId, allowPool: false }, + () => this.createManagedOauthClient(config, authProvider, signal), + (client) => client.listTools(signal) + ) + } + + async executeManagedMcpTool(params: { + connectionId: string + serverId: string + workspaceId: string + toolCall: McpToolCall + loadAuthProvider: () => Promise + extraHeaders?: Record + signal?: AbortSignal + timeoutMs?: number + }): Promise { + const config = await this.getServerConfig(params.serverId, params.workspaceId) + if (!config) throw new Error('Managed MCP server is unavailable') + const effectiveConfig = params.extraHeaders + ? { ...config, headers: { ...config.headers, ...params.extraHeaders } } + : config + return this.withServerClient( + { + key: this.poolKey(params.connectionId, params.workspaceId, params.connectionId), + serverId: params.connectionId, + allowPool: !params.extraHeaders, + }, + () => + withMcpOauthRefreshLock(params.connectionId, async () => + this.createManagedOauthClient( + effectiveConfig, + await params.loadAuthProvider(), + params.signal + ) + ), + (client) => + client.callTool(params.toolCall, { + signal: params.signal, + timeoutMs: params.timeoutMs, + }) + ) + } + /** Auth-scoped pool key: a server's resolved credentials depend on the (user, workspace) env. */ private poolKey( serverId: string, @@ -1265,7 +1353,7 @@ class McpService { /** Evict a single server's warm pooled connections (all users) — call on config change/delete. */ async evictServerConnections(serverId: string, reason: string): Promise { - await mcpConnectionPool?.evictServer(serverId, reason) + await evictMcpServerConnections(serverId, reason) } } diff --git a/apps/sim/lib/mcp/utils.test.ts b/apps/sim/lib/mcp/utils.test.ts index 3fa3d61806c..5eb5ec2913e 100644 --- a/apps/sim/lib/mcp/utils.test.ts +++ b/apps/sim/lib/mcp/utils.test.ts @@ -9,10 +9,12 @@ import { import { categorizeError, createMcpToolId, + generateManagedMcpConnectionId, generateMcpServerId, MCP_CLIENT_CONSTANTS, MCP_CONSTANTS, parseMcpToolId, + parseMcpToolTarget, validateRequiredFields, validateStringParam, } from './utils' @@ -431,3 +433,29 @@ describe('parseMcpToolId', () => { expect(result.toolName).toBe('tool-with-many-parts') }) }) + +describe('parseMcpToolTarget', () => { + it('preserves a managed connection ID even when its random segment contains hyphens', () => { + const credentialId = generateManagedMcpConnectionId() + const result = parseMcpToolTarget(`${credentialId}-fireflies-search-transcripts`) + + expect(result).toEqual({ + kind: 'managed_connection', + credentialId, + toolName: 'fireflies-search-transcripts', + }) + }) + + it('keeps existing shared MCP tool IDs unchanged', () => { + expect(parseMcpToolTarget('mcp-12345678-search-transcripts')).toEqual({ + kind: 'shared_server', + serverId: 'mcp-12345678', + toolName: 'search-transcripts', + }) + }) + + it('rejects a managed connection ID without a tool name', () => { + const credentialId = generateManagedMcpConnectionId() + expect(() => parseMcpToolTarget(credentialId)).toThrow('Invalid managed MCP tool ID format') + }) +}) diff --git a/apps/sim/lib/mcp/utils.ts b/apps/sim/lib/mcp/utils.ts index 5f29e46acf8..f5d499ac91d 100644 --- a/apps/sim/lib/mcp/utils.ts +++ b/apps/sim/lib/mcp/utils.ts @@ -1,4 +1,5 @@ import { UnauthorizedError } from '@modelcontextprotocol/sdk/client/auth.js' +import { generateShortId } from '@sim/utils/id' import { NextResponse } from 'next/server' import { DEFAULT_EXECUTION_TIMEOUT_MS } from '@/lib/core/execution-limits' import { @@ -24,6 +25,15 @@ export const MCP_CONSTANTS = { */ export const MCP_TOOL_CORE_PARAMS = new Set(['serverId', 'serverUrl', 'toolName', 'serverName']) +export const MANAGED_MCP_CONNECTION_PREFIX = 'mcp-cg-' +const MANAGED_MCP_RANDOM_ID_LENGTH = 21 +const MANAGED_MCP_CONNECTION_ID_LENGTH = + MANAGED_MCP_CONNECTION_PREFIX.length + MANAGED_MCP_RANDOM_ID_LENGTH + +export function generateManagedMcpConnectionId(): string { + return `${MANAGED_MCP_CONNECTION_PREFIX}${generateShortId(MANAGED_MCP_RANDOM_ID_LENGTH)}` +} + /** * Sanitizes a string by removing invisible Unicode characters that cause HTTP header errors. * Handles characters like U+2028 (Line Separator) that can be introduced via copy-paste. @@ -220,6 +230,29 @@ export function parseMcpToolId(toolId: string): { serverId: string; toolName: st return { serverId, toolName } } +export type ParsedMcpToolTarget = + | { kind: 'shared_server'; serverId: string; toolName: string } + | { kind: 'managed_connection'; credentialId: string; toolName: string } + +export function parseMcpToolTarget(toolId: string): ParsedMcpToolTarget { + if (toolId.startsWith(MANAGED_MCP_CONNECTION_PREFIX)) { + if ( + toolId.length <= MANAGED_MCP_CONNECTION_ID_LENGTH || + toolId[MANAGED_MCP_CONNECTION_ID_LENGTH] !== '-' + ) { + throw new Error( + `Invalid managed MCP tool ID format: ${toolId}. Expected: mcp-cg-connectionId-toolName` + ) + } + const credentialId = toolId.slice(0, MANAGED_MCP_CONNECTION_ID_LENGTH) + const toolName = toolId.slice(MANAGED_MCP_CONNECTION_ID_LENGTH + 1) + if (!toolName) throw new Error(`Invalid managed MCP tool ID format: ${toolId}`) + return { kind: 'managed_connection', credentialId, toolName } + } + const { serverId, toolName } = parseMcpToolId(toolId) + return { kind: 'shared_server', serverId, toolName } +} + /** * Generate a deterministic MCP server ID based on workspace and URL. * diff --git a/packages/db/migrations/0315_credential_group_managed_mcp.sql b/packages/db/migrations/0315_credential_group_managed_mcp.sql new file mode 100644 index 00000000000..a82114bc95b --- /dev/null +++ b/packages/db/migrations/0315_credential_group_managed_mcp.sql @@ -0,0 +1,3 @@ +-- Adds the managed MCP credential type separately from the storage migration. PostgreSQL cannot +-- use a new enum label in an index predicate until the transaction that introduced it commits. +ALTER TYPE "public"."credential_type" ADD VALUE IF NOT EXISTS 'managed_mcp' BEFORE 'env_workspace'; diff --git a/packages/db/migrations/0316_credential_group_managed_mcp_constraints.sql b/packages/db/migrations/0316_credential_group_managed_mcp_constraints.sql new file mode 100644 index 00000000000..fc6b36c1c77 --- /dev/null +++ b/packages/db/migrations/0316_credential_group_managed_mcp_constraints.sql @@ -0,0 +1,47 @@ +-- Pure expand: every new column is nullable, and no managed_mcp row can predate 0315. +ALTER TABLE "credential" ADD COLUMN IF NOT EXISTS "mcp_server_id" text;--> statement-breakpoint +ALTER TABLE "credential" ADD COLUMN IF NOT EXISTS "mcp_tools" jsonb;--> statement-breakpoint +ALTER TABLE "credential" ADD COLUMN IF NOT EXISTS "mcp_tools_refreshed_at" timestamp;--> statement-breakpoint +ALTER TABLE "mcp_servers" ADD COLUMN IF NOT EXISTS "credential_group_id" text;--> statement-breakpoint + +-- NOT VALID avoids scanning existing tables while the foreign keys are installed. The new +-- nullable columns contain no values, so validation is immediate and safe. +ALTER TABLE "credential" ADD CONSTRAINT "credential_mcp_server_id_mcp_servers_id_fk" FOREIGN KEY ("mcp_server_id") REFERENCES "public"."mcp_servers"("id") ON DELETE cascade ON UPDATE no action NOT VALID;--> statement-breakpoint +ALTER TABLE "credential" VALIDATE CONSTRAINT "credential_mcp_server_id_mcp_servers_id_fk";--> statement-breakpoint +ALTER TABLE "mcp_servers" ADD CONSTRAINT "mcp_servers_credential_group_id_credential_group_id_fk" FOREIGN KEY ("credential_group_id") REFERENCES "public"."credential_group"("id") ON DELETE set null ON UPDATE no action NOT VALID;--> statement-breakpoint +ALTER TABLE "mcp_servers" VALIDATE CONSTRAINT "mcp_servers_credential_group_id_credential_group_id_fk";--> statement-breakpoint + +ALTER TABLE "credential" ADD CONSTRAINT "credential_managed_mcp_source_check" CHECK ((type::text <> 'managed_mcp') OR ( + id LIKE 'mcp-cg-%' + AND account_id IS NULL + AND provider_id IS NULL + AND authorization_app_id IS NULL + AND credential_group_enrollment_id IS NOT NULL + AND credential_group_option_id IS NULL + AND mcp_server_id IS NOT NULL + AND managed_oauth_status IS NOT NULL + AND (managed_oauth_status <> 'active' OR ( + encrypted_oauth_token_set IS NOT NULL + AND mcp_tools IS NOT NULL + )) + AND granted_at IS NOT NULL + AND managed_oauth_scope_version IS NULL + AND provider_subject_id IS NULL + AND provider_tenant_id IS NULL + AND granted_scopes IS NULL + AND provider_metadata IS NULL + AND created_by IS NULL + AND env_key IS NULL + AND env_owner_user_id IS NULL + AND encrypted_service_account_key IS NULL + AND unredacted = false + )) NOT VALID;--> statement-breakpoint +ALTER TABLE "credential" VALIDATE CONSTRAINT "credential_managed_mcp_source_check";--> statement-breakpoint + +-- Concurrent index operations cannot run inside the migration runner's transaction. +COMMIT;--> statement-breakpoint +SET lock_timeout = 0;--> statement-breakpoint +CREATE INDEX CONCURRENTLY IF NOT EXISTS "credential_mcp_server_idx" ON "credential" USING btree ("mcp_server_id");--> statement-breakpoint +CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS "credential_managed_mcp_enrollment_server_unique" ON "credential" USING btree ("credential_group_enrollment_id","mcp_server_id") WHERE "credential"."type" = 'managed_mcp';--> statement-breakpoint +CREATE INDEX CONCURRENTLY IF NOT EXISTS "mcp_servers_credential_group_idx" ON "mcp_servers" USING btree ("credential_group_id");--> statement-breakpoint +SET lock_timeout = '5s'; diff --git a/packages/db/migrations/0317_managed_mcp_external_identity.sql b/packages/db/migrations/0317_managed_mcp_external_identity.sql new file mode 100644 index 00000000000..d23e1570122 --- /dev/null +++ b/packages/db/migrations/0317_managed_mcp_external_identity.sql @@ -0,0 +1,3 @@ +ALTER TABLE "credential" ADD CONSTRAINT "credential_creator_source_check" CHECK ((type::text = 'managed_mcp') OR created_by IS NOT NULL) NOT VALID;--> statement-breakpoint +ALTER TABLE "credential" VALIDATE CONSTRAINT "credential_creator_source_check";--> statement-breakpoint +ALTER TABLE "credential" ALTER COLUMN "created_by" DROP NOT NULL; diff --git a/packages/db/migrations/meta/0317_snapshot.json b/packages/db/migrations/meta/0317_snapshot.json new file mode 100644 index 00000000000..408a786621e --- /dev/null +++ b/packages/db/migrations/meta/0317_snapshot.json @@ -0,0 +1,20896 @@ +{ + "id": "3e774f5a-401e-40b7-a59e-c97238a721b9", + "prevId": "cfa8e676-67d0-48f3-97ea-c5a3c3dfa594", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.academy_certificate": { + "name": "academy_certificate", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "course_id": { + "name": "course_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "academy_cert_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "issued_at": { + "name": "issued_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "certificate_number": { + "name": "certificate_number", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "academy_certificate_user_id_idx": { + "name": "academy_certificate_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "academy_certificate_course_id_idx": { + "name": "academy_certificate_course_id_idx", + "columns": [ + { + "expression": "course_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "academy_certificate_user_course_unique": { + "name": "academy_certificate_user_course_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "course_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "academy_certificate_status_idx": { + "name": "academy_certificate_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "academy_certificate_user_id_user_id_fk": { + "name": "academy_certificate_user_id_user_id_fk", + "tableFrom": "academy_certificate", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "academy_certificate_certificate_number_unique": { + "name": "academy_certificate_certificate_number_unique", + "nullsNotDistinct": false, + "columns": ["certificate_number"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "account_user_id_idx": { + "name": "account_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_account_on_account_id_provider_id": { + "name": "idx_account_on_account_id_provider_id", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.api_key": { + "name": "api_key", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key_hash": { + "name": "key_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'personal'" + }, + "last_used": { + "name": "last_used", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "api_key_workspace_type_idx": { + "name": "api_key_workspace_type_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "api_key_user_type_idx": { + "name": "api_key_user_type_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "api_key_key_hash_idx": { + "name": "api_key_key_hash_idx", + "columns": [ + { + "expression": "key_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "api_key_user_id_user_id_fk": { + "name": "api_key_user_id_user_id_fk", + "tableFrom": "api_key", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "api_key_workspace_id_workspace_id_fk": { + "name": "api_key_workspace_id_workspace_id_fk", + "tableFrom": "api_key", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "api_key_created_by_user_id_fk": { + "name": "api_key_created_by_user_id_fk", + "tableFrom": "api_key", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "api_key_key_unique": { + "name": "api_key_key_unique", + "nullsNotDistinct": false, + "columns": ["key"] + } + }, + "policies": {}, + "checkConstraints": { + "workspace_type_check": { + "name": "workspace_type_check", + "value": "(type = 'workspace' AND workspace_id IS NOT NULL) OR (type = 'personal' AND workspace_id IS NULL)" + } + }, + "isRLSEnabled": false + }, + "public.async_jobs": { + "name": "async_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "run_at": { + "name": "run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "max_attempts": { + "name": "max_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 3 + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "output": { + "name": "output", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "async_jobs_status_started_at_idx": { + "name": "async_jobs_status_started_at_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "async_jobs_status_completed_at_idx": { + "name": "async_jobs_status_completed_at_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "completed_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "async_jobs_schedule_pending_run_at_idx": { + "name": "async_jobs_schedule_pending_run_at_idx", + "columns": [ + { + "expression": "run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"async_jobs\".\"type\" = 'schedule-execution' AND \"async_jobs\".\"status\" = 'pending'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "async_jobs_schedule_processing_started_at_idx": { + "name": "async_jobs_schedule_processing_started_at_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"async_jobs\".\"type\" = 'schedule-execution' AND \"async_jobs\".\"status\" = 'processing'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "async_jobs_schedule_unreconciled_terminal_idx": { + "name": "async_jobs_schedule_unreconciled_terminal_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"async_jobs\".\"type\" = 'schedule-execution' AND \"async_jobs\".\"status\" IN ('completed', 'failed', 'cancelled') AND COALESCE(\"async_jobs\".\"metadata\" ->> 'scheduleReconciled', 'false') <> 'true'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audit_log": { + "name": "audit_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_name": { + "name": "actor_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_email": { + "name": "actor_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resource_name": { + "name": "resource_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "audit_log_workspace_created_idx": { + "name": "audit_log_workspace_created_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_workspace_created_at_id_idx": { + "name": "audit_log_workspace_created_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"created_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_actor_created_idx": { + "name": "audit_log_actor_created_idx", + "columns": [ + { + "expression": "actor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_resource_idx": { + "name": "audit_log_resource_idx", + "columns": [ + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_action_idx": { + "name": "audit_log_action_idx", + "columns": [ + { + "expression": "action", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "audit_log_workspace_id_workspace_id_fk": { + "name": "audit_log_workspace_id_workspace_id_fk", + "tableFrom": "audit_log", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "audit_log_actor_id_user_id_fk": { + "name": "audit_log_actor_id_user_id_fk", + "tableFrom": "audit_log", + "tableTo": "user", + "columnsFrom": ["actor_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.background_work_status": { + "name": "background_work_status", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "kind": { + "name": "kind", + "type": "background_work_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "background_work_status_value", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "background_work_status_workspace_status_idx": { + "name": "background_work_status_workspace_status_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "background_work_status_workflow_status_idx": { + "name": "background_work_status_workflow_status_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "background_work_status_meta_child_ws_idx": { + "name": "background_work_status_meta_child_ws_idx", + "columns": [ + { + "expression": "(\"metadata\" ->> 'childWorkspaceId')", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "background_work_status_meta_other_ws_idx": { + "name": "background_work_status_meta_other_ws_idx", + "columns": [ + { + "expression": "(\"metadata\" ->> 'otherWorkspaceId')", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "background_work_status_workspace_id_workspace_id_fk": { + "name": "background_work_status_workspace_id_workspace_id_fk", + "tableFrom": "background_work_status", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "background_work_status_workflow_id_workflow_id_fk": { + "name": "background_work_status_workflow_id_workflow_id_fk", + "tableFrom": "background_work_status", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat": { + "name": "chat", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "customizations": { + "name": "customizations", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'public'" + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "allowed_emails": { + "name": "allowed_emails", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'[]'" + }, + "output_configs": { + "name": "output_configs", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'[]'" + }, + "include_thinking": { + "name": "include_thinking", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "include_tool_calls": { + "name": "include_tool_calls", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "identifier_idx": { + "name": "identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"chat\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "chat_archived_at_partial_idx": { + "name": "chat_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"chat\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_chat_on_workflow_id_archived_at": { + "name": "idx_chat_on_workflow_id_archived_at", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "chat_workflow_id_workflow_id_fk": { + "name": "chat_workflow_id_workflow_id_fk", + "tableFrom": "chat", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "chat_user_id_user_id_fk": { + "name": "chat_user_id_user_id_fk", + "tableFrom": "chat", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_async_tool_calls": { + "name": "copilot_async_tool_calls", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "checkpoint_id": { + "name": "checkpoint_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "tool_call_id": { + "name": "tool_call_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_name": { + "name": "tool_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "args": { + "name": "args", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "status": { + "name": "status", + "type": "copilot_async_tool_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "result": { + "name": "result", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "permission_decision": { + "name": "permission_decision", + "type": "copilot_tool_permission_decision", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "permission_decided_at": { + "name": "permission_decided_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "claimed_by": { + "name": "claimed_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_async_tool_calls_run_id_idx": { + "name": "copilot_async_tool_calls_run_id_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_checkpoint_id_idx": { + "name": "copilot_async_tool_calls_checkpoint_id_idx", + "columns": [ + { + "expression": "checkpoint_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_status_idx": { + "name": "copilot_async_tool_calls_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_run_status_idx": { + "name": "copilot_async_tool_calls_run_status_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_tool_call_id_unique": { + "name": "copilot_async_tool_calls_tool_call_id_unique", + "columns": [ + { + "expression": "tool_call_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_async_tool_calls_run_id_copilot_runs_id_fk": { + "name": "copilot_async_tool_calls_run_id_copilot_runs_id_fk", + "tableFrom": "copilot_async_tool_calls", + "tableTo": "copilot_runs", + "columnsFrom": ["run_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_async_tool_calls_checkpoint_id_copilot_run_checkpoints_id_fk": { + "name": "copilot_async_tool_calls_checkpoint_id_copilot_run_checkpoints_id_fk", + "tableFrom": "copilot_async_tool_calls", + "tableTo": "copilot_run_checkpoints", + "columnsFrom": ["checkpoint_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_chats": { + "name": "copilot_chats", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "chat_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'copilot'" + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'claude-3-7-sonnet-latest'" + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "preview_yaml": { + "name": "preview_yaml", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "plan_artifact": { + "name": "plan_artifact", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "resources": { + "name": "resources", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "auto_allowed_tools": { + "name": "auto_allowed_tools", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "pinned": { + "name": "pinned", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_chats_user_id_idx": { + "name": "copilot_chats_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_workflow_id_idx": { + "name": "copilot_chats_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_user_workflow_idx": { + "name": "copilot_chats_user_workflow_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_user_workspace_idx": { + "name": "copilot_chats_user_workspace_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_created_at_idx": { + "name": "copilot_chats_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_updated_at_idx": { + "name": "copilot_chats_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_workspace_created_at_id_idx": { + "name": "copilot_chats_workspace_created_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"created_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_user_workspace_deleted_partial_idx": { + "name": "copilot_chats_user_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_chats\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_chats_user_id_user_id_fk": { + "name": "copilot_chats_user_id_user_id_fk", + "tableFrom": "copilot_chats", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_chats_workflow_id_workflow_id_fk": { + "name": "copilot_chats_workflow_id_workflow_id_fk", + "tableFrom": "copilot_chats", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_chats_workspace_id_workspace_id_fk": { + "name": "copilot_chats_workspace_id_workspace_id_fk", + "tableFrom": "copilot_chats", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_feedback": { + "name": "copilot_feedback", + "schema": "", + "columns": { + "feedback_id": { + "name": "feedback_id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_query": { + "name": "user_query", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_response": { + "name": "agent_response", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_positive": { + "name": "is_positive", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "feedback": { + "name": "feedback", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_yaml": { + "name": "workflow_yaml", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_feedback_user_id_idx": { + "name": "copilot_feedback_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_chat_id_idx": { + "name": "copilot_feedback_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_user_chat_idx": { + "name": "copilot_feedback_user_chat_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_is_positive_idx": { + "name": "copilot_feedback_is_positive_idx", + "columns": [ + { + "expression": "is_positive", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_created_at_idx": { + "name": "copilot_feedback_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_feedback_user_id_user_id_fk": { + "name": "copilot_feedback_user_id_user_id_fk", + "tableFrom": "copilot_feedback", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_feedback_chat_id_copilot_chats_id_fk": { + "name": "copilot_feedback_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_feedback", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_messages": { + "name": "copilot_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "stream_id": { + "name": "stream_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parent_message_id": { + "name": "parent_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tokens_in": { + "name": "tokens_in", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "tokens_out": { + "name": "tokens_out", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "seq": { + "name": "seq", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_messages_chat_message_unique": { + "name": "copilot_messages_chat_message_unique", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_messages_chat_created_at_idx": { + "name": "copilot_messages_chat_created_at_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_messages\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_messages_chat_seq_idx": { + "name": "copilot_messages_chat_seq_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "seq", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_messages\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_messages_chat_stream_idx": { + "name": "copilot_messages_chat_stream_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "stream_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_messages\".\"stream_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_messages_user_created_at_idx": { + "name": "copilot_messages_user_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_messages\".\"role\" = 'user' AND \"copilot_messages\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_messages_chat_id_copilot_chats_id_fk": { + "name": "copilot_messages_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_messages", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_run_checkpoints": { + "name": "copilot_run_checkpoints", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "pending_tool_call_id": { + "name": "pending_tool_call_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "conversation_snapshot": { + "name": "conversation_snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "agent_state": { + "name": "agent_state", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "provider_request": { + "name": "provider_request", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_run_checkpoints_run_id_idx": { + "name": "copilot_run_checkpoints_run_id_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_run_checkpoints_pending_tool_call_id_idx": { + "name": "copilot_run_checkpoints_pending_tool_call_id_idx", + "columns": [ + { + "expression": "pending_tool_call_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_run_checkpoints_run_pending_tool_unique": { + "name": "copilot_run_checkpoints_run_pending_tool_unique", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "pending_tool_call_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_run_checkpoints_run_id_copilot_runs_id_fk": { + "name": "copilot_run_checkpoints_run_id_copilot_runs_id_fk", + "tableFrom": "copilot_run_checkpoints", + "tableTo": "copilot_runs", + "columnsFrom": ["run_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_runs": { + "name": "copilot_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_run_id": { + "name": "parent_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stream_id": { + "name": "stream_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent": { + "name": "agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "copilot_run_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "request_context": { + "name": "request_context", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "copilot_runs_execution_id_idx": { + "name": "copilot_runs_execution_id_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_parent_run_id_idx": { + "name": "copilot_runs_parent_run_id_idx", + "columns": [ + { + "expression": "parent_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_chat_id_idx": { + "name": "copilot_runs_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_user_id_idx": { + "name": "copilot_runs_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_workflow_id_idx": { + "name": "copilot_runs_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_workspace_id_idx": { + "name": "copilot_runs_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_status_idx": { + "name": "copilot_runs_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_chat_execution_idx": { + "name": "copilot_runs_chat_execution_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_execution_started_at_idx": { + "name": "copilot_runs_execution_started_at_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_workspace_completed_at_id_idx": { + "name": "copilot_runs_workspace_completed_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"completed_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_stream_id_unique": { + "name": "copilot_runs_stream_id_unique", + "columns": [ + { + "expression": "stream_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_runs_chat_id_copilot_chats_id_fk": { + "name": "copilot_runs_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_runs", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_runs_user_id_user_id_fk": { + "name": "copilot_runs_user_id_user_id_fk", + "tableFrom": "copilot_runs", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_runs_workflow_id_workflow_id_fk": { + "name": "copilot_runs_workflow_id_workflow_id_fk", + "tableFrom": "copilot_runs", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_runs_workspace_id_workspace_id_fk": { + "name": "copilot_runs_workspace_id_workspace_id_fk", + "tableFrom": "copilot_runs", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_workflow_read_hashes": { + "name": "copilot_workflow_read_hashes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_workflow_read_hashes_chat_id_idx": { + "name": "copilot_workflow_read_hashes_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_workflow_read_hashes_workflow_id_idx": { + "name": "copilot_workflow_read_hashes_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_workflow_read_hashes_chat_workflow_unique": { + "name": "copilot_workflow_read_hashes_chat_workflow_unique", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_workflow_read_hashes_chat_id_copilot_chats_id_fk": { + "name": "copilot_workflow_read_hashes_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_workflow_read_hashes", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_workflow_read_hashes_workflow_id_workflow_id_fk": { + "name": "copilot_workflow_read_hashes_workflow_id_workflow_id_fk", + "tableFrom": "copilot_workflow_read_hashes", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.credential": { + "name": "credential", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "credential_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "unredacted": { + "name": "unredacted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "env_key": { + "name": "env_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "env_owner_user_id": { + "name": "env_owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_service_account_key": { + "name": "encrypted_service_account_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "authorization_app_id": { + "name": "authorization_app_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "credential_group_enrollment_id": { + "name": "credential_group_enrollment_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "credential_group_option_id": { + "name": "credential_group_option_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "mcp_server_id": { + "name": "mcp_server_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "managed_oauth_scope_version": { + "name": "managed_oauth_scope_version", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "provider_subject_id": { + "name": "provider_subject_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_tenant_id": { + "name": "provider_tenant_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "managed_oauth_status": { + "name": "managed_oauth_status", + "type": "managed_oauth_credential_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "granted_scopes": { + "name": "granted_scopes", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "provider_metadata": { + "name": "provider_metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "encrypted_oauth_token_set": { + "name": "encrypted_oauth_token_set", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "mcp_tools": { + "name": "mcp_tools", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "mcp_tools_refreshed_at": { + "name": "mcp_tools_refreshed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "granted_at": { + "name": "granted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_refreshed_at": { + "name": "last_refreshed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "credential_workspace_id_idx": { + "name": "credential_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_type_idx": { + "name": "credential_type_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_provider_id_idx": { + "name": "credential_provider_id_idx", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_account_id_idx": { + "name": "credential_account_id_idx", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_env_owner_user_id_idx": { + "name": "credential_env_owner_user_id_idx", + "columns": [ + { + "expression": "env_owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_group_enrollment_idx": { + "name": "credential_group_enrollment_idx", + "columns": [ + { + "expression": "credential_group_enrollment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_mcp_server_idx": { + "name": "credential_mcp_server_idx", + "columns": [ + { + "expression": "mcp_server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_group_option_unique": { + "name": "credential_group_option_unique", + "columns": [ + { + "expression": "credential_group_enrollment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "credential_group_option_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"credential\".\"type\" = 'managed_oauth'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_managed_mcp_enrollment_server_unique": { + "name": "credential_managed_mcp_enrollment_server_unique", + "columns": [ + { + "expression": "credential_group_enrollment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "mcp_server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"credential\".\"type\" = 'managed_mcp'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_workspace_account_unique": { + "name": "credential_workspace_account_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "account_id IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_workspace_env_unique": { + "name": "credential_workspace_env_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "type = 'env_workspace'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_workspace_personal_env_unique": { + "name": "credential_workspace_personal_env_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env_owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "type = 'env_personal'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "credential_workspace_id_workspace_id_fk": { + "name": "credential_workspace_id_workspace_id_fk", + "tableFrom": "credential", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_account_id_account_id_fk": { + "name": "credential_account_id_account_id_fk", + "tableFrom": "credential", + "tableTo": "account", + "columnsFrom": ["account_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_env_owner_user_id_user_id_fk": { + "name": "credential_env_owner_user_id_user_id_fk", + "tableFrom": "credential", + "tableTo": "user", + "columnsFrom": ["env_owner_user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_credential_group_enrollment_id_credential_group_enrollment_id_fk": { + "name": "credential_credential_group_enrollment_id_credential_group_enrollment_id_fk", + "tableFrom": "credential", + "tableTo": "credential_group_enrollment", + "columnsFrom": ["credential_group_enrollment_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_mcp_server_id_mcp_servers_id_fk": { + "name": "credential_mcp_server_id_mcp_servers_id_fk", + "tableFrom": "credential", + "tableTo": "mcp_servers", + "columnsFrom": ["mcp_server_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_created_by_user_id_fk": { + "name": "credential_created_by_user_id_fk", + "tableFrom": "credential", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "credential_oauth_source_check": { + "name": "credential_oauth_source_check", + "value": "(type <> 'oauth') OR (account_id IS NOT NULL AND provider_id IS NOT NULL)" + }, + "credential_managed_oauth_source_check": { + "name": "credential_managed_oauth_source_check", + "value": "(type::text <> 'managed_oauth') OR (\n account_id IS NULL\n AND provider_id IS NOT NULL\n AND authorization_app_id IS NOT NULL\n AND provider_subject_id IS NOT NULL\n AND managed_oauth_status IS NOT NULL\n AND granted_scopes IS NOT NULL\n AND cardinality(granted_scopes) > 0\n AND encrypted_oauth_token_set IS NOT NULL\n AND granted_at IS NOT NULL\n )" + }, + "credential_managed_oauth_group_binding_check": { + "name": "credential_managed_oauth_group_binding_check", + "value": "(type::text <> 'managed_oauth') OR (\n credential_group_enrollment_id IS NOT NULL\n AND credential_group_option_id IS NOT NULL\n AND managed_oauth_scope_version IS NOT NULL\n AND managed_oauth_scope_version > 0\n )" + }, + "credential_managed_mcp_source_check": { + "name": "credential_managed_mcp_source_check", + "value": "(type::text <> 'managed_mcp') OR (\n id LIKE 'mcp-cg-%'\n AND account_id IS NULL\n AND provider_id IS NULL\n AND authorization_app_id IS NULL\n AND credential_group_enrollment_id IS NOT NULL\n AND credential_group_option_id IS NULL\n AND mcp_server_id IS NOT NULL\n AND managed_oauth_status IS NOT NULL\n AND (managed_oauth_status <> 'active' OR (\n encrypted_oauth_token_set IS NOT NULL\n AND mcp_tools IS NOT NULL\n ))\n AND granted_at IS NOT NULL\n AND managed_oauth_scope_version IS NULL\n AND provider_subject_id IS NULL\n AND provider_tenant_id IS NULL\n AND granted_scopes IS NULL\n AND provider_metadata IS NULL\n AND created_by IS NULL\n AND env_key IS NULL\n AND env_owner_user_id IS NULL\n AND encrypted_service_account_key IS NULL\n AND unredacted = false\n )" + }, + "credential_creator_source_check": { + "name": "credential_creator_source_check", + "value": "(type::text = 'managed_mcp') OR created_by IS NOT NULL" + }, + "credential_workspace_env_source_check": { + "name": "credential_workspace_env_source_check", + "value": "(type <> 'env_workspace') OR (env_key IS NOT NULL AND env_owner_user_id IS NULL)" + }, + "credential_personal_env_source_check": { + "name": "credential_personal_env_source_check", + "value": "(type <> 'env_personal') OR (env_key IS NOT NULL AND env_owner_user_id IS NOT NULL)" + } + }, + "isRLSEnabled": false + }, + "public.credential_group": { + "name": "credential_group", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "public_id": { + "name": "public_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "options": { + "name": "options", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "encrypted_provider_configuration": { + "name": "encrypted_provider_configuration", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "credential_group_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "credential_group_public_id_unique": { + "name": "credential_group_public_id_unique", + "columns": [ + { + "expression": "public_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_group_workspace_status_idx": { + "name": "credential_group_workspace_status_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_group_workspace_name_unique": { + "name": "credential_group_workspace_name_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lower(\"name\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "credential_group_workspace_id_workspace_id_fk": { + "name": "credential_group_workspace_id_workspace_id_fk", + "tableFrom": "credential_group", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_group_created_by_user_id_fk": { + "name": "credential_group_created_by_user_id_fk", + "tableFrom": "credential_group", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.credential_group_enrollment": { + "name": "credential_group_enrollment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "credential_group_id": { + "name": "credential_group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "credential_group_enrollment_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'invited'" + }, + "invitation_token_hash": { + "name": "invitation_token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "invitation_expires_at": { + "name": "invitation_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "invited_at": { + "name": "invited_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "sent_at": { + "name": "sent_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_delivery_error": { + "name": "last_delivery_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "credential_group_enrollment_group_email_unique": { + "name": "credential_group_enrollment_group_email_unique", + "columns": [ + { + "expression": "credential_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_group_enrollment_invitation_token_hash_unique": { + "name": "credential_group_enrollment_invitation_token_hash_unique", + "columns": [ + { + "expression": "invitation_token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_group_enrollment_group_status_idx": { + "name": "credential_group_enrollment_group_status_idx", + "columns": [ + { + "expression": "credential_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_group_enrollment_group_invited_at_id_idx": { + "name": "credential_group_enrollment_group_invited_at_id_idx", + "columns": [ + { + "expression": "credential_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "invited_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "credential_group_enrollment_credential_group_id_credential_group_id_fk": { + "name": "credential_group_enrollment_credential_group_id_credential_group_id_fk", + "tableFrom": "credential_group_enrollment", + "tableTo": "credential_group", + "columnsFrom": ["credential_group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_group_enrollment_created_by_user_id_fk": { + "name": "credential_group_enrollment_created_by_user_id_fk", + "tableFrom": "credential_group_enrollment", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "credential_group_enrollment_normalized_email_check": { + "name": "credential_group_enrollment_normalized_email_check", + "value": "\"credential_group_enrollment\".\"email\" = lower(btrim(\"credential_group_enrollment\".\"email\")) AND length(\"credential_group_enrollment\".\"email\") BETWEEN 3 AND 320" + }, + "credential_group_enrollment_invitation_token_hash_length_check": { + "name": "credential_group_enrollment_invitation_token_hash_length_check", + "value": "length(\"credential_group_enrollment\".\"invitation_token_hash\") = 64" + } + }, + "isRLSEnabled": false + }, + "public.credential_member": { + "name": "credential_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "credential_member_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "status": { + "name": "status", + "type": "credential_member_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "joined_at": { + "name": "joined_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "invited_by": { + "name": "invited_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "credential_member_user_id_idx": { + "name": "credential_member_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_member_role_idx": { + "name": "credential_member_role_idx", + "columns": [ + { + "expression": "role", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_member_status_idx": { + "name": "credential_member_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_member_unique": { + "name": "credential_member_unique", + "columns": [ + { + "expression": "credential_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "credential_member_credential_id_credential_id_fk": { + "name": "credential_member_credential_id_credential_id_fk", + "tableFrom": "credential_member", + "tableTo": "credential", + "columnsFrom": ["credential_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_member_user_id_user_id_fk": { + "name": "credential_member_user_id_user_id_fk", + "tableFrom": "credential_member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_member_invited_by_user_id_fk": { + "name": "credential_member_invited_by_user_id_fk", + "tableFrom": "credential_member", + "tableTo": "user", + "columnsFrom": ["invited_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.custom_block": { + "name": "custom_block", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "icon_url": { + "name": "icon_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "inputs": { + "name": "inputs", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "outputs": { + "name": "outputs", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "trace_child_runs": { + "name": "trace_child_runs", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "custom_block_organization_id_idx": { + "name": "custom_block_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "custom_block_workflow_id_idx": { + "name": "custom_block_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "custom_block_organization_type_unique": { + "name": "custom_block_organization_type_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "custom_block_organization_id_organization_id_fk": { + "name": "custom_block_organization_id_organization_id_fk", + "tableFrom": "custom_block", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "custom_block_workflow_id_workflow_id_fk": { + "name": "custom_block_workflow_id_workflow_id_fk", + "tableFrom": "custom_block", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "custom_block_created_by_user_id_fk": { + "name": "custom_block_created_by_user_id_fk", + "tableFrom": "custom_block", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.custom_tools": { + "name": "custom_tools", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "schema": { + "name": "schema", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "custom_tools_workspace_id_idx": { + "name": "custom_tools_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "custom_tools_workspace_title_unique": { + "name": "custom_tools_workspace_title_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "title", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "custom_tools_workspace_id_workspace_id_fk": { + "name": "custom_tools_workspace_id_workspace_id_fk", + "tableFrom": "custom_tools", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "custom_tools_user_id_user_id_fk": { + "name": "custom_tools_user_id_user_id_fk", + "tableFrom": "custom_tools", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.data_drain_runs": { + "name": "data_drain_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "drain_id": { + "name": "drain_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "data_drain_run_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "trigger": { + "name": "trigger", + "type": "data_drain_run_trigger", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "rows_exported": { + "name": "rows_exported", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "bytes_written": { + "name": "bytes_written", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "cursor_before": { + "name": "cursor_before", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cursor_after": { + "name": "cursor_after", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "locators": { + "name": "locators", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + } + }, + "indexes": { + "data_drain_runs_drain_started_idx": { + "name": "data_drain_runs_drain_started_idx", + "columns": [ + { + "expression": "drain_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "data_drain_runs_drain_id_data_drains_id_fk": { + "name": "data_drain_runs_drain_id_data_drains_id_fk", + "tableFrom": "data_drain_runs", + "tableTo": "data_drains", + "columnsFrom": ["drain_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.data_drains": { + "name": "data_drains", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "data_drain_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "destination_type": { + "name": "destination_type", + "type": "data_drain_destination", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "destination_config": { + "name": "destination_config", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "destination_credentials": { + "name": "destination_credentials", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "schedule_cadence": { + "name": "schedule_cadence", + "type": "data_drain_cadence", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "cursor": { + "name": "cursor", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_run_at": { + "name": "last_run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_success_at": { + "name": "last_success_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "data_drains_org_idx": { + "name": "data_drains_org_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "data_drains_due_idx": { + "name": "data_drains_due_idx", + "columns": [ + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "data_drains_org_name_unique": { + "name": "data_drains_org_name_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "data_drains_organization_id_organization_id_fk": { + "name": "data_drains_organization_id_organization_id_fk", + "tableFrom": "data_drains", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "data_drains_created_by_user_id_fk": { + "name": "data_drains_created_by_user_id_fk", + "tableFrom": "data_drains", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.docs_embeddings": { + "name": "docs_embeddings", + "schema": "", + "columns": { + "chunk_id": { + "name": "chunk_id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "chunk_text": { + "name": "chunk_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_document": { + "name": "source_document", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_link": { + "name": "source_link", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "header_text": { + "name": "header_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "header_level": { + "name": "header_level", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "embedding": { + "name": "embedding", + "type": "vector(1536)", + "primaryKey": false, + "notNull": true + }, + "embedding_model": { + "name": "embedding_model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text-embedding-3-small'" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "chunk_text_tsv": { + "name": "chunk_text_tsv", + "type": "tsvector", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "to_tsvector('english', \"docs_embeddings\".\"chunk_text\")", + "type": "stored" + } + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "docs_emb_source_document_idx": { + "name": "docs_emb_source_document_idx", + "columns": [ + { + "expression": "source_document", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_header_level_idx": { + "name": "docs_emb_header_level_idx", + "columns": [ + { + "expression": "header_level", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_source_header_idx": { + "name": "docs_emb_source_header_idx", + "columns": [ + { + "expression": "source_document", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "header_level", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_model_idx": { + "name": "docs_emb_model_idx", + "columns": [ + { + "expression": "embedding_model", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_created_at_idx": { + "name": "docs_emb_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_embedding_vector_hnsw_idx": { + "name": "docs_embedding_vector_hnsw_idx", + "columns": [ + { + "expression": "embedding", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "vector_cosine_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hnsw", + "with": { + "m": 16, + "ef_construction": 64 + } + }, + "docs_emb_metadata_gin_idx": { + "name": "docs_emb_metadata_gin_idx", + "columns": [ + { + "expression": "metadata", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "docs_emb_chunk_text_fts_idx": { + "name": "docs_emb_chunk_text_fts_idx", + "columns": [ + { + "expression": "chunk_text_tsv", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "docs_embedding_not_null_check": { + "name": "docs_embedding_not_null_check", + "value": "\"embedding\" IS NOT NULL" + }, + "docs_header_level_check": { + "name": "docs_header_level_check", + "value": "\"header_level\" >= 1 AND \"header_level\" <= 6" + } + }, + "isRLSEnabled": false + }, + "public.document": { + "name": "document", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "filename": { + "name": "filename", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_url": { + "name": "file_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "file_size": { + "name": "file_size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chunk_count": { + "name": "chunk_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "character_count": { + "name": "character_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "processing_status": { + "name": "processing_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "processing_attempts": { + "name": "processing_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "processing_queued_at": { + "name": "processing_queued_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "processing_queue_token": { + "name": "processing_queue_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "processing_started_at": { + "name": "processing_started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "processing_deferred_until": { + "name": "processing_deferred_until", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "processing_completed_at": { + "name": "processing_completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "processing_error": { + "name": "processing_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "user_excluded": { + "name": "user_excluded", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "tag1": { + "name": "tag1", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag2": { + "name": "tag2", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag3": { + "name": "tag3", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag4": { + "name": "tag4", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag5": { + "name": "tag5", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag6": { + "name": "tag6", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag7": { + "name": "tag7", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "number1": { + "name": "number1", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number2": { + "name": "number2", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number3": { + "name": "number3", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number4": { + "name": "number4", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number5": { + "name": "number5", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "date1": { + "name": "date1", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "date2": { + "name": "date2", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "boolean1": { + "name": "boolean1", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "boolean2": { + "name": "boolean2", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "boolean3": { + "name": "boolean3", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "connector_id": { + "name": "connector_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content_hash": { + "name": "content_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_url": { + "name": "source_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "secret_provenance_version": { + "name": "secret_provenance_version", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "uploaded_by": { + "name": "uploaded_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "uploaded_at": { + "name": "uploaded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "doc_kb_id_idx": { + "name": "doc_kb_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_filename_idx": { + "name": "doc_filename_idx", + "columns": [ + { + "expression": "filename", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_processing_status_idx": { + "name": "doc_processing_status_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "processing_status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_connector_external_id_idx": { + "name": "doc_connector_external_id_idx", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"document\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_connector_id_idx": { + "name": "doc_connector_id_idx", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_active_kb_token_count_idx": { + "name": "doc_active_kb_token_count_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "token_count", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"document\".\"user_excluded\" = false AND \"document\".\"archived_at\" IS NULL AND \"document\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_storage_key_idx": { + "name": "doc_storage_key_idx", + "columns": [ + { + "expression": "storage_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"document\".\"storage_key\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_archived_at_partial_idx": { + "name": "doc_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"document\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_deleted_at_partial_idx": { + "name": "doc_deleted_at_partial_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"document\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag1_idx": { + "name": "doc_tag1_idx", + "columns": [ + { + "expression": "tag1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag2_idx": { + "name": "doc_tag2_idx", + "columns": [ + { + "expression": "tag2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag3_idx": { + "name": "doc_tag3_idx", + "columns": [ + { + "expression": "tag3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag4_idx": { + "name": "doc_tag4_idx", + "columns": [ + { + "expression": "tag4", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag5_idx": { + "name": "doc_tag5_idx", + "columns": [ + { + "expression": "tag5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag6_idx": { + "name": "doc_tag6_idx", + "columns": [ + { + "expression": "tag6", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag7_idx": { + "name": "doc_tag7_idx", + "columns": [ + { + "expression": "tag7", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number1_idx": { + "name": "doc_number1_idx", + "columns": [ + { + "expression": "number1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number2_idx": { + "name": "doc_number2_idx", + "columns": [ + { + "expression": "number2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number3_idx": { + "name": "doc_number3_idx", + "columns": [ + { + "expression": "number3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number4_idx": { + "name": "doc_number4_idx", + "columns": [ + { + "expression": "number4", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number5_idx": { + "name": "doc_number5_idx", + "columns": [ + { + "expression": "number5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_date1_idx": { + "name": "doc_date1_idx", + "columns": [ + { + "expression": "date1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_date2_idx": { + "name": "doc_date2_idx", + "columns": [ + { + "expression": "date2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_boolean1_idx": { + "name": "doc_boolean1_idx", + "columns": [ + { + "expression": "boolean1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_boolean2_idx": { + "name": "doc_boolean2_idx", + "columns": [ + { + "expression": "boolean2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_boolean3_idx": { + "name": "doc_boolean3_idx", + "columns": [ + { + "expression": "boolean3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "document_knowledge_base_id_knowledge_base_id_fk": { + "name": "document_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "document", + "tableTo": "knowledge_base", + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "document_connector_id_knowledge_connector_id_fk": { + "name": "document_connector_id_knowledge_connector_id_fk", + "tableFrom": "document", + "tableTo": "knowledge_connector", + "columnsFrom": ["connector_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "document_uploaded_by_user_id_fk": { + "name": "document_uploaded_by_user_id_fk", + "tableFrom": "document", + "tableTo": "user", + "columnsFrom": ["uploaded_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.document_secret_provenance": { + "name": "document_secret_provenance", + "schema": "", + "columns": { + "document_id": { + "name": "document_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "source_hash": { + "name": "source_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entries": { + "name": "entries", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "document_secret_provenance_document_id_document_id_fk": { + "name": "document_secret_provenance_document_id_document_id_fk", + "tableFrom": "document_secret_provenance", + "tableTo": "document", + "columnsFrom": ["document_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "document_secret_provenance_status_check": { + "name": "document_secret_provenance_status_check", + "value": "\"document_secret_provenance\".\"status\" IN ('exact', 'unknown')" + } + }, + "isRLSEnabled": false + }, + "public.embedding": { + "name": "embedding", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "document_id": { + "name": "document_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chunk_index": { + "name": "chunk_index", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "chunk_hash": { + "name": "chunk_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "secret_provenance_version": { + "name": "secret_provenance_version", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "content_length": { + "name": "content_length", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "embedding": { + "name": "embedding", + "type": "vector(1536)", + "primaryKey": false, + "notNull": false + }, + "embedding_model": { + "name": "embedding_model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text-embedding-3-small'" + }, + "start_offset": { + "name": "start_offset", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "end_offset": { + "name": "end_offset", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "tag1": { + "name": "tag1", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag2": { + "name": "tag2", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag3": { + "name": "tag3", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag4": { + "name": "tag4", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag5": { + "name": "tag5", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag6": { + "name": "tag6", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag7": { + "name": "tag7", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "number1": { + "name": "number1", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number2": { + "name": "number2", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number3": { + "name": "number3", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number4": { + "name": "number4", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number5": { + "name": "number5", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "date1": { + "name": "date1", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "date2": { + "name": "date2", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "boolean1": { + "name": "boolean1", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "boolean2": { + "name": "boolean2", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "boolean3": { + "name": "boolean3", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "content_tsv": { + "name": "content_tsv", + "type": "tsvector", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "to_tsvector('english', \"embedding\".\"content\")", + "type": "stored" + } + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "emb_kb_id_idx": { + "name": "emb_kb_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_doc_id_idx": { + "name": "emb_doc_id_idx", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_doc_chunk_idx": { + "name": "emb_doc_chunk_idx", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chunk_index", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_kb_model_idx": { + "name": "emb_kb_model_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "embedding_model", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_kb_enabled_idx": { + "name": "emb_kb_enabled_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_doc_enabled_idx": { + "name": "emb_doc_enabled_idx", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "embedding_vector_hnsw_idx": { + "name": "embedding_vector_hnsw_idx", + "columns": [ + { + "expression": "embedding", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "vector_cosine_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hnsw", + "with": { + "m": 16, + "ef_construction": 64 + } + }, + "emb_tag1_idx": { + "name": "emb_tag1_idx", + "columns": [ + { + "expression": "tag1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag2_idx": { + "name": "emb_tag2_idx", + "columns": [ + { + "expression": "tag2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag3_idx": { + "name": "emb_tag3_idx", + "columns": [ + { + "expression": "tag3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag4_idx": { + "name": "emb_tag4_idx", + "columns": [ + { + "expression": "tag4", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag5_idx": { + "name": "emb_tag5_idx", + "columns": [ + { + "expression": "tag5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag6_idx": { + "name": "emb_tag6_idx", + "columns": [ + { + "expression": "tag6", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag7_idx": { + "name": "emb_tag7_idx", + "columns": [ + { + "expression": "tag7", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number1_idx": { + "name": "emb_number1_idx", + "columns": [ + { + "expression": "number1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number2_idx": { + "name": "emb_number2_idx", + "columns": [ + { + "expression": "number2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number3_idx": { + "name": "emb_number3_idx", + "columns": [ + { + "expression": "number3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number4_idx": { + "name": "emb_number4_idx", + "columns": [ + { + "expression": "number4", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number5_idx": { + "name": "emb_number5_idx", + "columns": [ + { + "expression": "number5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_date1_idx": { + "name": "emb_date1_idx", + "columns": [ + { + "expression": "date1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_date2_idx": { + "name": "emb_date2_idx", + "columns": [ + { + "expression": "date2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_boolean1_idx": { + "name": "emb_boolean1_idx", + "columns": [ + { + "expression": "boolean1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_boolean2_idx": { + "name": "emb_boolean2_idx", + "columns": [ + { + "expression": "boolean2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_boolean3_idx": { + "name": "emb_boolean3_idx", + "columns": [ + { + "expression": "boolean3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_content_fts_idx": { + "name": "emb_content_fts_idx", + "columns": [ + { + "expression": "content_tsv", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": { + "embedding_knowledge_base_id_knowledge_base_id_fk": { + "name": "embedding_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "embedding", + "tableTo": "knowledge_base", + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "embedding_document_id_document_id_fk": { + "name": "embedding_document_id_document_id_fk", + "tableFrom": "embedding", + "tableTo": "document", + "columnsFrom": ["document_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "embedding_not_null_check": { + "name": "embedding_not_null_check", + "value": "\"embedding\" IS NOT NULL" + } + }, + "isRLSEnabled": false + }, + "public.embedding_secret_provenance": { + "name": "embedding_secret_provenance", + "schema": "", + "columns": { + "embedding_id": { + "name": "embedding_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "content_hash": { + "name": "content_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entries": { + "name": "entries", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "embedding_secret_provenance_embedding_id_embedding_id_fk": { + "name": "embedding_secret_provenance_embedding_id_embedding_id_fk", + "tableFrom": "embedding_secret_provenance", + "tableTo": "embedding", + "columnsFrom": ["embedding_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "embedding_secret_provenance_status_check": { + "name": "embedding_secret_provenance_status_check", + "value": "\"embedding_secret_provenance\".\"status\" IN ('exact', 'unknown')" + } + }, + "isRLSEnabled": false + }, + "public.environment": { + "name": "environment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "variables": { + "name": "variables", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "environment_user_id_user_id_fk": { + "name": "environment_user_id_user_id_fk", + "tableFrom": "environment", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "environment_user_id_unique": { + "name": "environment_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.execution_large_value_dependencies": { + "name": "execution_large_value_dependencies", + "schema": "", + "columns": { + "parent_key": { + "name": "parent_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_key": { + "name": "child_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "execution_large_value_dependencies_workspace_parent_key_idx": { + "name": "execution_large_value_dependencies_workspace_parent_key_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_large_value_dependencies_workspace_child_key_idx": { + "name": "execution_large_value_dependencies_workspace_child_key_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "child_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "execution_large_value_dependencies_workspace_id_workspace_id_fk": { + "name": "execution_large_value_dependencies_workspace_id_workspace_id_fk", + "tableFrom": "execution_large_value_dependencies", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "execution_large_value_dependencies_parent_key_child_key_pk": { + "name": "execution_large_value_dependencies_parent_key_child_key_pk", + "columns": ["parent_key", "child_key"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.execution_large_value_references": { + "name": "execution_large_value_references", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "execution_large_value_reference_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "execution_large_value_references_workspace_execution_source_idx": { + "name": "execution_large_value_references_workspace_execution_source_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_large_value_references_workflow_id_idx": { + "name": "execution_large_value_references_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "execution_large_value_references_workspace_id_workspace_id_fk": { + "name": "execution_large_value_references_workspace_id_workspace_id_fk", + "tableFrom": "execution_large_value_references", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "execution_large_value_references_workflow_id_workflow_id_fk": { + "name": "execution_large_value_references_workflow_id_workflow_id_fk", + "tableFrom": "execution_large_value_references", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "execution_large_value_references_key_execution_id_source_pk": { + "name": "execution_large_value_references_key_execution_id_source_pk", + "columns": ["key", "execution_id", "source"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.execution_large_values": { + "name": "execution_large_values", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "owner_execution_id": { + "name": "owner_execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "execution_large_values_owner_execution_id_idx": { + "name": "execution_large_values_owner_execution_id_idx", + "columns": [ + { + "expression": "owner_execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_large_values_cleanup_idx": { + "name": "execution_large_values_cleanup_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"execution_large_values\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_large_values_tombstone_cleanup_idx": { + "name": "execution_large_values_tombstone_cleanup_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"execution_large_values\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_large_values_workflow_id_idx": { + "name": "execution_large_values_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "execution_large_values_workspace_id_workspace_id_fk": { + "name": "execution_large_values_workspace_id_workspace_id_fk", + "tableFrom": "execution_large_values", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "execution_large_values_workflow_id_workflow_id_fk": { + "name": "execution_large_values_workflow_id_workflow_id_fk", + "tableFrom": "execution_large_values", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.folder": { + "name": "folder", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "folder_resource_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_id": { + "name": "parent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "locked": { + "name": "locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "folder_user_idx": { + "name": "folder_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "folder_workspace_resource_parent_idx": { + "name": "folder_workspace_resource_parent_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "folder_parent_sort_idx": { + "name": "folder_parent_sort_idx", + "columns": [ + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "folder_deleted_at_idx": { + "name": "folder_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "folder_workspace_deleted_partial_idx": { + "name": "folder_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"folder\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "folder_workspace_resource_parent_name_active_unique": { + "name": "folder_workspace_resource_parent_name_active_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"parent_id\", '')", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"folder\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "folder_user_id_user_id_fk": { + "name": "folder_user_id_user_id_fk", + "tableFrom": "folder", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "folder_workspace_id_workspace_id_fk": { + "name": "folder_workspace_id_workspace_id_fk", + "tableFrom": "folder", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "folder_parent_id_folder_id_fk": { + "name": "folder_parent_id_folder_id_fk", + "tableFrom": "folder", + "tableTo": "folder", + "columnsFrom": ["parent_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.idempotency_key": { + "name": "idempotency_key", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "result": { + "name": "result", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idempotency_key_created_at_idx": { + "name": "idempotency_key_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invitation": { + "name": "invitation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "invitation_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'organization'" + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "inviter_id": { + "name": "inviter_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "membership_intent": { + "name": "membership_intent", + "type": "invitation_membership_intent", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'internal'" + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "invitation_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "invitation_email_idx": { + "name": "invitation_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_organization_id_idx": { + "name": "invitation_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_status_idx": { + "name": "invitation_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_pending_email_org_unique": { + "name": "invitation_pending_email_org_unique", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"invitation\".\"status\" = 'pending' AND \"invitation\".\"organization_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invitation_inviter_id_user_id_fk": { + "name": "invitation_inviter_id_user_id_fk", + "tableFrom": "invitation", + "tableTo": "user", + "columnsFrom": ["inviter_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitation_organization_id_organization_id_fk": { + "name": "invitation_organization_id_organization_id_fk", + "tableFrom": "invitation", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "invitation_token_unique": { + "name": "invitation_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invitation_workspace_grant": { + "name": "invitation_workspace_grant", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "invitation_id": { + "name": "invitation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permission": { + "name": "permission", + "type": "permission_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "invitation_workspace_grant_unique": { + "name": "invitation_workspace_grant_unique", + "columns": [ + { + "expression": "invitation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_workspace_grant_workspace_id_idx": { + "name": "invitation_workspace_grant_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invitation_workspace_grant_invitation_id_invitation_id_fk": { + "name": "invitation_workspace_grant_invitation_id_invitation_id_fk", + "tableFrom": "invitation_workspace_grant", + "tableTo": "invitation", + "columnsFrom": ["invitation_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitation_workspace_grant_workspace_id_workspace_id_fk": { + "name": "invitation_workspace_grant_workspace_id_workspace_id_fk", + "tableFrom": "invitation_workspace_grant", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.job_execution_logs": { + "name": "job_execution_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "schedule_id": { + "name": "schedule_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "level": { + "name": "level", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "trigger": { + "name": "trigger", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "total_duration_ms": { + "name": "total_duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "execution_data": { + "name": "execution_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "cost": { + "name": "cost", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "job_execution_logs_schedule_id_idx": { + "name": "job_execution_logs_schedule_id_idx", + "columns": [ + { + "expression": "schedule_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "job_execution_logs_workspace_started_at_idx": { + "name": "job_execution_logs_workspace_started_at_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "job_execution_logs_workspace_ended_at_id_idx": { + "name": "job_execution_logs_workspace_ended_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"ended_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "job_execution_logs_execution_id_unique": { + "name": "job_execution_logs_execution_id_unique", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "job_execution_logs_trigger_idx": { + "name": "job_execution_logs_trigger_idx", + "columns": [ + { + "expression": "trigger", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "job_execution_logs_schedule_id_workflow_schedule_id_fk": { + "name": "job_execution_logs_schedule_id_workflow_schedule_id_fk", + "tableFrom": "job_execution_logs", + "tableTo": "workflow_schedule", + "columnsFrom": ["schedule_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "job_execution_logs_workspace_id_workspace_id_fk": { + "name": "job_execution_logs_workspace_id_workspace_id_fk", + "tableFrom": "job_execution_logs", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_base": { + "name": "knowledge_base", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "folder_id": { + "name": "folder_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "embedding_model": { + "name": "embedding_model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text-embedding-3-small'" + }, + "embedding_dimension": { + "name": "embedding_dimension", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1536 + }, + "chunking_config": { + "name": "chunking_config", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{\"maxSize\": 1024, \"minSize\": 1, \"overlap\": 200}'" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "kb_user_id_idx": { + "name": "kb_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_workspace_id_idx": { + "name": "kb_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_user_workspace_idx": { + "name": "kb_user_workspace_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_folder_id_idx": { + "name": "kb_folder_id_idx", + "columns": [ + { + "expression": "folder_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_deleted_at_idx": { + "name": "kb_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_workspace_deleted_partial_idx": { + "name": "kb_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"knowledge_base\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_workspace_name_active_unique": { + "name": "kb_workspace_name_active_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"knowledge_base\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_base_user_id_user_id_fk": { + "name": "knowledge_base_user_id_user_id_fk", + "tableFrom": "knowledge_base", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "knowledge_base_workspace_id_workspace_id_fk": { + "name": "knowledge_base_workspace_id_workspace_id_fk", + "tableFrom": "knowledge_base", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "knowledge_base_folder_id_folder_id_fk": { + "name": "knowledge_base_folder_id_folder_id_fk", + "tableFrom": "knowledge_base", + "tableTo": "folder", + "columnsFrom": ["folder_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_base_tag_definitions": { + "name": "knowledge_base_tag_definitions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tag_slot": { + "name": "tag_slot", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "field_type": { + "name": "field_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "kb_tag_definitions_kb_slot_idx": { + "name": "kb_tag_definitions_kb_slot_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "tag_slot", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_tag_definitions_kb_display_name_idx": { + "name": "kb_tag_definitions_kb_display_name_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "display_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_tag_definitions_kb_id_idx": { + "name": "kb_tag_definitions_kb_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_base_tag_definitions_knowledge_base_id_knowledge_base_id_fk": { + "name": "knowledge_base_tag_definitions_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "knowledge_base_tag_definitions", + "tableTo": "knowledge_base", + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_connector": { + "name": "knowledge_connector", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connector_type": { + "name": "connector_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_api_key": { + "name": "encrypted_api_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_config": { + "name": "source_config", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "sync_mode": { + "name": "sync_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'full'" + }, + "sync_interval_minutes": { + "name": "sync_interval_minutes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1440 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "last_sync_at": { + "name": "last_sync_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_sync_error": { + "name": "last_sync_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_sync_doc_count": { + "name": "last_sync_doc_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "next_sync_at": { + "name": "next_sync_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "consecutive_failures": { + "name": "consecutive_failures", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "sync_lock_token": { + "name": "sync_lock_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sync_lock_lease_at": { + "name": "sync_lock_lease_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "kc_knowledge_base_id_idx": { + "name": "kc_knowledge_base_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kc_status_next_sync_idx": { + "name": "kc_status_next_sync_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "next_sync_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kc_archived_at_partial_idx": { + "name": "kc_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"knowledge_connector\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "kc_deleted_at_partial_idx": { + "name": "kc_deleted_at_partial_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"knowledge_connector\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_connector_knowledge_base_id_knowledge_base_id_fk": { + "name": "knowledge_connector_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "knowledge_connector", + "tableTo": "knowledge_base", + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_connector_sync_log": { + "name": "knowledge_connector_sync_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connector_id": { + "name": "connector_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "docs_added": { + "name": "docs_added", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_updated": { + "name": "docs_updated", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_deleted": { + "name": "docs_deleted", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_unchanged": { + "name": "docs_unchanged", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_skipped": { + "name": "docs_skipped", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_failed": { + "name": "docs_failed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "kcsl_connector_started_at_idx": { + "name": "kcsl_connector_started_at_idx", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"started_at\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kcsl_started_at_partial_idx": { + "name": "kcsl_started_at_partial_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"knowledge_connector_sync_log\".\"status\" = 'started'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_connector_sync_log_connector_id_knowledge_connector_id_fk": { + "name": "knowledge_connector_sync_log_connector_id_knowledge_connector_id_fk", + "tableFrom": "knowledge_connector_sync_log", + "tableTo": "knowledge_connector", + "columnsFrom": ["connector_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_server_oauth": { + "name": "mcp_server_oauth", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "mcp_server_id": { + "name": "mcp_server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_information": { + "name": "client_information", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tokens": { + "name": "tokens", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "code_verifier": { + "name": "code_verifier", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state_created_at": { + "name": "state_created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_refreshed_at": { + "name": "last_refreshed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mcp_server_oauth_server_unique": { + "name": "mcp_server_oauth_server_unique", + "columns": [ + { + "expression": "mcp_server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcp_server_oauth_state_idx": { + "name": "mcp_server_oauth_state_idx", + "columns": [ + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcp_server_oauth_mcp_server_id_mcp_servers_id_fk": { + "name": "mcp_server_oauth_mcp_server_id_mcp_servers_id_fk", + "tableFrom": "mcp_server_oauth", + "tableTo": "mcp_servers", + "columnsFrom": ["mcp_server_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_server_oauth_user_id_user_id_fk": { + "name": "mcp_server_oauth_user_id_user_id_fk", + "tableFrom": "mcp_server_oauth", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "mcp_server_oauth_workspace_id_workspace_id_fk": { + "name": "mcp_server_oauth_workspace_id_workspace_id_fk", + "tableFrom": "mcp_server_oauth", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_servers": { + "name": "mcp_servers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_group_id": { + "name": "credential_group_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "transport": { + "name": "transport", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'headers'" + }, + "oauth_client_id": { + "name": "oauth_client_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "oauth_client_secret": { + "name": "oauth_client_secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "headers": { + "name": "headers", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "timeout": { + "name": "timeout", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 30000 + }, + "retries": { + "name": "retries", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 3 + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "last_connected": { + "name": "last_connected", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "connection_status": { + "name": "connection_status", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'disconnected'" + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status_config": { + "name": "status_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "tool_count": { + "name": "tool_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "last_tools_refresh": { + "name": "last_tools_refresh", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "total_requests": { + "name": "total_requests", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "last_used": { + "name": "last_used", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mcp_servers_workspace_enabled_idx": { + "name": "mcp_servers_workspace_enabled_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcp_servers_credential_group_idx": { + "name": "mcp_servers_credential_group_idx", + "columns": [ + { + "expression": "credential_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcp_servers_workspace_deleted_partial_idx": { + "name": "mcp_servers_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"mcp_servers\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcp_servers_workspace_id_workspace_id_fk": { + "name": "mcp_servers_workspace_id_workspace_id_fk", + "tableFrom": "mcp_servers", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_servers_credential_group_id_credential_group_id_fk": { + "name": "mcp_servers_credential_group_id_credential_group_id_fk", + "tableFrom": "mcp_servers", + "tableTo": "credential_group", + "columnsFrom": ["credential_group_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "mcp_servers_created_by_user_id_fk": { + "name": "mcp_servers_created_by_user_id_fk", + "tableFrom": "mcp_servers", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.member": { + "name": "member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "member_user_id_unique": { + "name": "member_user_id_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "member_organization_id_idx": { + "name": "member_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "member_user_id_user_id_fk": { + "name": "member_user_id_user_id_fk", + "tableFrom": "member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "member_organization_id_organization_id_fk": { + "name": "member_organization_id_organization_id_fk", + "tableFrom": "member", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.memory": { + "name": "memory", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "secret_provenance_version": { + "name": "secret_provenance_version", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "memory_key_idx": { + "name": "memory_key_idx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "memory_workspace_idx": { + "name": "memory_workspace_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "memory_workspace_key_idx": { + "name": "memory_workspace_key_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "memory_workspace_deleted_partial_idx": { + "name": "memory_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"memory\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "memory_workspace_id_workspace_id_fk": { + "name": "memory_workspace_id_workspace_id_fk", + "tableFrom": "memory", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.memory_secret_provenance": { + "name": "memory_secret_provenance", + "schema": "", + "columns": { + "memory_id": { + "name": "memory_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "content_hash": { + "name": "content_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entries": { + "name": "entries", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "memory_secret_provenance_memory_id_memory_id_fk": { + "name": "memory_secret_provenance_memory_id_memory_id_fk", + "tableFrom": "memory_secret_provenance", + "tableTo": "memory", + "columnsFrom": ["memory_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "memory_secret_provenance_status_check": { + "name": "memory_secret_provenance_status_check", + "value": "\"memory_secret_provenance\".\"status\" IN ('exact', 'unknown')" + } + }, + "isRLSEnabled": false + }, + "public.mothership_inbox_allowed_sender": { + "name": "mothership_inbox_allowed_sender", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "added_by": { + "name": "added_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "inbox_sender_ws_email_idx": { + "name": "inbox_sender_ws_email_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mothership_inbox_allowed_sender_workspace_id_workspace_id_fk": { + "name": "mothership_inbox_allowed_sender_workspace_id_workspace_id_fk", + "tableFrom": "mothership_inbox_allowed_sender", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mothership_inbox_allowed_sender_added_by_user_id_fk": { + "name": "mothership_inbox_allowed_sender_added_by_user_id_fk", + "tableFrom": "mothership_inbox_allowed_sender", + "tableTo": "user", + "columnsFrom": ["added_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mothership_inbox_task": { + "name": "mothership_inbox_task", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "from_email": { + "name": "from_email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "from_name": { + "name": "from_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "body_preview": { + "name": "body_preview", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "body_text": { + "name": "body_text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "body_html": { + "name": "body_html", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_message_id": { + "name": "email_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "in_reply_to": { + "name": "in_reply_to", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "response_message_id": { + "name": "response_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "agentmail_message_id": { + "name": "agentmail_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'received'" + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "trigger_job_id": { + "name": "trigger_job_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "result_summary": { + "name": "result_summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "rejection_reason": { + "name": "rejection_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "has_attachments": { + "name": "has_attachments", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "cc_recipients": { + "name": "cc_recipients", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "processing_started_at": { + "name": "processing_started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "inbox_task_ws_created_at_idx": { + "name": "inbox_task_ws_created_at_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "inbox_task_ws_status_idx": { + "name": "inbox_task_ws_status_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "inbox_task_response_msg_id_idx": { + "name": "inbox_task_response_msg_id_idx", + "columns": [ + { + "expression": "response_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "inbox_task_email_msg_id_idx": { + "name": "inbox_task_email_msg_id_idx", + "columns": [ + { + "expression": "email_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mothership_inbox_task_workspace_id_workspace_id_fk": { + "name": "mothership_inbox_task_workspace_id_workspace_id_fk", + "tableFrom": "mothership_inbox_task", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mothership_inbox_task_chat_id_copilot_chats_id_fk": { + "name": "mothership_inbox_task_chat_id_copilot_chats_id_fk", + "tableFrom": "mothership_inbox_task", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mothership_inbox_webhook": { + "name": "mothership_inbox_webhook", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "webhook_id": { + "name": "webhook_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "secret": { + "name": "secret", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "mothership_inbox_webhook_workspace_id_workspace_id_fk": { + "name": "mothership_inbox_webhook_workspace_id_workspace_id_fk", + "tableFrom": "mothership_inbox_webhook", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "mothership_inbox_webhook_workspace_id_unique": { + "name": "mothership_inbox_webhook_workspace_id_unique", + "nullsNotDistinct": false, + "columns": ["workspace_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mothership_settings": { + "name": "mothership_settings", + "schema": "", + "columns": { + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "mcp_tool_refs": { + "name": "mcp_tool_refs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "custom_tool_refs": { + "name": "custom_tool_refs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "skill_refs": { + "name": "skill_refs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "mothership_settings_workspace_id_workspace_id_fk": { + "name": "mothership_settings_workspace_id_workspace_id_fk", + "tableFrom": "mothership_settings", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization": { + "name": "organization", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "logo": { + "name": "logo", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "session_policy_settings": { + "name": "session_policy_settings", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "security_policy_version": { + "name": "security_policy_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "whitelabel_settings": { + "name": "whitelabel_settings", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "data_retention_settings": { + "name": "data_retention_settings", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "org_usage_limit": { + "name": "org_usage_limit", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "storage_used_bytes": { + "name": "storage_used_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "limit_notifications": { + "name": "limit_notifications", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "departed_member_usage": { + "name": "departed_member_usage", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "credit_balance": { + "name": "credit_balance", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization_byok_keys": { + "name": "organization_byok_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_api_key": { + "name": "encrypted_api_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "organization_byok_organization_provider_idx": { + "name": "organization_byok_organization_provider_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "organization_byok_keys_organization_id_organization_id_fk": { + "name": "organization_byok_keys_organization_id_organization_id_fk", + "tableFrom": "organization_byok_keys", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "organization_byok_keys_created_by_user_id_fk": { + "name": "organization_byok_keys_created_by_user_id_fk", + "tableFrom": "organization_byok_keys", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization_member_usage_limit": { + "name": "organization_member_usage_limit", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "usage_limit": { + "name": "usage_limit", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "set_by": { + "name": "set_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "org_member_usage_limit_org_user_unique": { + "name": "org_member_usage_limit_org_user_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "org_member_usage_limit_organization_id_idx": { + "name": "org_member_usage_limit_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "organization_member_usage_limit_organization_id_organization_id_fk": { + "name": "organization_member_usage_limit_organization_id_organization_id_fk", + "tableFrom": "organization_member_usage_limit", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "organization_member_usage_limit_user_id_user_id_fk": { + "name": "organization_member_usage_limit_user_id_user_id_fk", + "tableFrom": "organization_member_usage_limit", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "organization_member_usage_limit_set_by_user_id_fk": { + "name": "organization_member_usage_limit_set_by_user_id_fk", + "tableFrom": "organization_member_usage_limit", + "tableTo": "user", + "columnsFrom": ["set_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.outbox_event": { + "name": "outbox_event", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "max_attempts": { + "name": "max_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 10 + }, + "available_at": { + "name": "available_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "locked_at": { + "name": "locked_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "processed_at": { + "name": "processed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "outbox_event_status_available_idx": { + "name": "outbox_event_status_available_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "available_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "outbox_event_locked_at_idx": { + "name": "outbox_event_locked_at_idx", + "columns": [ + { + "expression": "locked_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "outbox_event_type_created_idx": { + "name": "outbox_event_type_created_idx", + "columns": [ + { + "expression": "event_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.paused_executions": { + "name": "paused_executions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_snapshot": { + "name": "execution_snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "pause_points": { + "name": "pause_points", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "total_pause_count": { + "name": "total_pause_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "resumed_count": { + "name": "resumed_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "automatic_resume_retry_count": { + "name": "automatic_resume_retry_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'paused'" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "paused_at": { + "name": "paused_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "next_resume_at": { + "name": "next_resume_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "paused_executions_workflow_id_idx": { + "name": "paused_executions_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "paused_executions_status_idx": { + "name": "paused_executions_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "paused_executions_execution_id_unique": { + "name": "paused_executions_execution_id_unique", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "paused_executions_next_resume_at_idx": { + "name": "paused_executions_next_resume_at_idx", + "columns": [ + { + "expression": "next_resume_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "status = 'paused' AND next_resume_at IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "paused_executions_workflow_id_workflow_id_fk": { + "name": "paused_executions_workflow_id_workflow_id_fk", + "tableFrom": "paused_executions", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pending_credential_draft": { + "name": "pending_credential_draft", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "pending_draft_user_provider_ws": { + "name": "pending_draft_user_provider_ws", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pending_credential_draft_user_id_user_id_fk": { + "name": "pending_credential_draft_user_id_user_id_fk", + "tableFrom": "pending_credential_draft", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pending_credential_draft_workspace_id_workspace_id_fk": { + "name": "pending_credential_draft_workspace_id_workspace_id_fk", + "tableFrom": "pending_credential_draft", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pending_credential_draft_credential_id_credential_id_fk": { + "name": "pending_credential_draft_credential_id_credential_id_fk", + "tableFrom": "pending_credential_draft", + "tableTo": "credential", + "columnsFrom": ["credential_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permission_group": { + "name": "permission_group", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": { + "permission_group_created_by_idx": { + "name": "permission_group_created_by_idx", + "columns": [ + { + "expression": "created_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_organization_name_unique": { + "name": "permission_group_organization_name_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_organization_default_unique": { + "name": "permission_group_organization_default_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "is_default = true", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permission_group_organization_id_organization_id_fk": { + "name": "permission_group_organization_id_organization_id_fk", + "tableFrom": "permission_group", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_created_by_user_id_fk": { + "name": "permission_group_created_by_user_id_fk", + "tableFrom": "permission_group", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permission_group_member": { + "name": "permission_group_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "permission_group_id": { + "name": "permission_group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "assigned_by": { + "name": "assigned_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "assigned_at": { + "name": "assigned_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "permission_group_member_group_id_idx": { + "name": "permission_group_member_group_id_idx", + "columns": [ + { + "expression": "permission_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_member_group_user_unique": { + "name": "permission_group_member_group_user_unique", + "columns": [ + { + "expression": "permission_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_member_organization_user_idx": { + "name": "permission_group_member_organization_user_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permission_group_member_permission_group_id_permission_group_id_fk": { + "name": "permission_group_member_permission_group_id_permission_group_id_fk", + "tableFrom": "permission_group_member", + "tableTo": "permission_group", + "columnsFrom": ["permission_group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_member_organization_id_organization_id_fk": { + "name": "permission_group_member_organization_id_organization_id_fk", + "tableFrom": "permission_group_member", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_member_user_id_user_id_fk": { + "name": "permission_group_member_user_id_user_id_fk", + "tableFrom": "permission_group_member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_member_assigned_by_user_id_fk": { + "name": "permission_group_member_assigned_by_user_id_fk", + "tableFrom": "permission_group_member", + "tableTo": "user", + "columnsFrom": ["assigned_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permission_group_workspace": { + "name": "permission_group_workspace", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "permission_group_id": { + "name": "permission_group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "permission_group_workspace_workspace_id_idx": { + "name": "permission_group_workspace_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_workspace_group_workspace_unique": { + "name": "permission_group_workspace_group_workspace_unique", + "columns": [ + { + "expression": "permission_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permission_group_workspace_permission_group_id_permission_group_id_fk": { + "name": "permission_group_workspace_permission_group_id_permission_group_id_fk", + "tableFrom": "permission_group_workspace", + "tableTo": "permission_group", + "columnsFrom": ["permission_group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_workspace_workspace_id_workspace_id_fk": { + "name": "permission_group_workspace_workspace_id_workspace_id_fk", + "tableFrom": "permission_group_workspace", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_workspace_organization_id_organization_id_fk": { + "name": "permission_group_workspace_organization_id_organization_id_fk", + "tableFrom": "permission_group_workspace", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permissions": { + "name": "permissions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permission_type": { + "name": "permission_type", + "type": "permission_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "permissions_user_id_idx": { + "name": "permissions_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_entity_idx": { + "name": "permissions_entity_idx", + "columns": [ + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_user_entity_type_idx": { + "name": "permissions_user_entity_type_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_user_entity_permission_idx": { + "name": "permissions_user_entity_permission_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "permission_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_unique_constraint": { + "name": "permissions_unique_constraint", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permissions_user_id_user_id_fk": { + "name": "permissions_user_id_user_id_fk", + "tableFrom": "permissions", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pinned_item": { + "name": "pinned_item", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pinned_at": { + "name": "pinned_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "pinned_item_user_workspace_idx": { + "name": "pinned_item_user_workspace_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pinned_item_resource_idx": { + "name": "pinned_item_resource_idx", + "columns": [ + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pinned_item_user_resource_unique": { + "name": "pinned_item_user_resource_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pinned_item_user_id_user_id_fk": { + "name": "pinned_item_user_id_user_id_fk", + "tableFrom": "pinned_item", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pinned_item_workspace_id_workspace_id_fk": { + "name": "pinned_item_workspace_id_workspace_id_fk", + "tableFrom": "pinned_item", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.public_share": { + "name": "public_share", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'public'" + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "allowed_emails": { + "name": "allowed_emails", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'[]'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "public_share_token_unique": { + "name": "public_share_token_unique", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "public_share_resource_unique": { + "name": "public_share_resource_unique", + "columns": [ + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "public_share_resource_id_idx": { + "name": "public_share_resource_id_idx", + "columns": [ + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "public_share_workspace_id_idx": { + "name": "public_share_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "public_share_workspace_id_workspace_id_fk": { + "name": "public_share_workspace_id_workspace_id_fk", + "tableFrom": "public_share", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "public_share_created_by_user_id_fk": { + "name": "public_share_created_by_user_id_fk", + "tableFrom": "public_share", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.rate_limit_bucket": { + "name": "rate_limit_bucket", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tokens": { + "name": "tokens", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "last_refill_at": { + "name": "last_refill_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.resource_policy": { + "name": "resource_policy", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "document": { + "name": "document", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "resource_policy_resource_unique": { + "name": "resource_policy_resource_unique", + "columns": [ + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "resource_policy_workspace_id_idx": { + "name": "resource_policy_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "resource_policy_workspace_id_workspace_id_fk": { + "name": "resource_policy_workspace_id_workspace_id_fk", + "tableFrom": "resource_policy", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "resource_policy_created_by_user_id_fk": { + "name": "resource_policy_created_by_user_id_fk", + "tableFrom": "resource_policy", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "resource_policy_updated_by_user_id_fk": { + "name": "resource_policy_updated_by_user_id_fk", + "tableFrom": "resource_policy", + "tableTo": "user", + "columnsFrom": ["updated_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.resume_queue": { + "name": "resume_queue", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "paused_execution_id": { + "name": "paused_execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_execution_id": { + "name": "parent_execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "new_execution_id": { + "name": "new_execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "context_id": { + "name": "context_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resume_input": { + "name": "resume_input", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "queued_at": { + "name": "queued_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "failure_reason": { + "name": "failure_reason", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "resume_queue_parent_status_idx": { + "name": "resume_queue_parent_status_idx", + "columns": [ + { + "expression": "parent_execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "queued_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "resume_queue_new_execution_idx": { + "name": "resume_queue_new_execution_idx", + "columns": [ + { + "expression": "new_execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "resume_queue_paused_execution_id_paused_executions_id_fk": { + "name": "resume_queue_paused_execution_id_paused_executions_id_fk", + "tableFrom": "resume_queue", + "tableTo": "paused_executions", + "columnsFrom": ["paused_execution_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sandbox_image": { + "name": "sandbox_image", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "spec_hash": { + "name": "spec_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "spec": { + "name": "spec", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "sandbox_image_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "image_ref": { + "name": "image_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_image_id": { + "name": "provider_image_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "build_id": { + "name": "build_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "materialization_generation": { + "name": "materialization_generation", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "error_code": { + "name": "error_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_detail": { + "name": "error_detail", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "sandbox_image_provider_spec_unique": { + "name": "sandbox_image_provider_spec_unique", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "spec_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sandbox_image_status_idx": { + "name": "sandbox_image_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sandbox_image_last_used_idx": { + "name": "sandbox_image_last_used_idx", + "columns": [ + { + "expression": "last_used_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.secret_usage": { + "name": "secret_usage", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "secret_name": { + "name": "secret_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "secret_scope": { + "name": "secret_scope", + "type": "secret_usage_scope", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "secret_owner_user_id": { + "name": "secret_owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "source": { + "name": "source", + "type": "secret_usage_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "actor_user_id": { + "name": "actor_user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "usage_date": { + "name": "usage_date", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "use_count": { + "name": "use_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "last_execution_id": { + "name": "last_execution_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_trigger": { + "name": "last_trigger", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "secret_usage_bucket_unique": { + "name": "secret_usage_bucket_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "secret_name", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "secret_scope", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "secret_owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "actor_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "usage_date", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "secret_usage_secret_recent_idx": { + "name": "secret_usage_secret_recent_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "secret_name", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "secret_scope", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "secret_owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_used_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "secret_usage_workspace_id_workspace_id_fk": { + "name": "secret_usage_workspace_id_workspace_id_fk", + "tableFrom": "secret_usage", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "active_organization_id": { + "name": "active_organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "impersonated_by": { + "name": "impersonated_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "session_user_id_idx": { + "name": "session_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "session_active_organization_id_organization_id_fk": { + "name": "session_active_organization_id_organization_id_fk", + "tableFrom": "session", + "tableTo": "organization", + "columnsFrom": ["active_organization_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.settings": { + "name": "settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "theme": { + "name": "theme", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'system'" + }, + "auto_connect": { + "name": "auto_connect", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "telemetry_enabled": { + "name": "telemetry_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "email_preferences": { + "name": "email_preferences", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "billing_usage_notifications_enabled": { + "name": "billing_usage_notifications_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "show_training_controls": { + "name": "show_training_controls", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "super_user_mode_enabled": { + "name": "super_user_mode_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "mothership_environment": { + "name": "mothership_environment", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'default'" + }, + "error_notifications_enabled": { + "name": "error_notifications_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "snap_to_grid_size": { + "name": "snap_to_grid_size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "show_action_bar": { + "name": "show_action_bar", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "auto_focus_on_click": { + "name": "auto_focus_on_click", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "copilot_enabled_models": { + "name": "copilot_enabled_models", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "copilot_auto_allowed_tools": { + "name": "copilot_auto_allowed_tools", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "last_active_workspace_id": { + "name": "last_active_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "settings_user_id_user_id_fk": { + "name": "settings_user_id_user_id_fk", + "tableFrom": "settings", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "settings_user_id_unique": { + "name": "settings_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sim_trigger_state": { + "name": "sim_trigger_state", + "schema": "", + "columns": { + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "block_id": { + "name": "block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope_key": { + "name": "scope_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "last_fired_at": { + "name": "last_fired_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "sim_trigger_state_workflow_id_workflow_id_fk": { + "name": "sim_trigger_state_workflow_id_workflow_id_fk", + "tableFrom": "sim_trigger_state", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "sim_trigger_state_workflow_id_block_id_scope_key_pk": { + "name": "sim_trigger_state_workflow_id_block_id_scope_key_pk", + "columns": ["workflow_id", "block_id", "scope_key"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.skill": { + "name": "skill", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "skill_workspace_name_unique": { + "name": "skill_workspace_name_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "skill_workspace_id_workspace_id_fk": { + "name": "skill_workspace_id_workspace_id_fk", + "tableFrom": "skill", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "skill_user_id_user_id_fk": { + "name": "skill_user_id_user_id_fk", + "tableFrom": "skill", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.skill_member": { + "name": "skill_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "skill_id": { + "name": "skill_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "invited_by": { + "name": "invited_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "skill_member_user_id_idx": { + "name": "skill_member_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "skill_member_unique": { + "name": "skill_member_unique", + "columns": [ + { + "expression": "skill_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "skill_member_skill_id_skill_id_fk": { + "name": "skill_member_skill_id_skill_id_fk", + "tableFrom": "skill_member", + "tableTo": "skill", + "columnsFrom": ["skill_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "skill_member_user_id_user_id_fk": { + "name": "skill_member_user_id_user_id_fk", + "tableFrom": "skill_member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "skill_member_invited_by_user_id_fk": { + "name": "skill_member_invited_by_user_id_fk", + "tableFrom": "skill_member", + "tableTo": "user", + "columnsFrom": ["invited_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sso_domain": { + "name": "sso_domain", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "verification_token": { + "name": "verification_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "verified_at": { + "name": "verified_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "sso_domain_organization_id_idx": { + "name": "sso_domain_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_domain_domain_idx": { + "name": "sso_domain_domain_idx", + "columns": [ + { + "expression": "domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_domain_org_domain_unique": { + "name": "sso_domain_org_domain_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_domain_verified_unique": { + "name": "sso_domain_verified_unique", + "columns": [ + { + "expression": "domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "status = 'verified'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sso_domain_organization_id_organization_id_fk": { + "name": "sso_domain_organization_id_organization_id_fk", + "tableFrom": "sso_domain", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "sso_domain_created_by_user_id_fk": { + "name": "sso_domain_created_by_user_id_fk", + "tableFrom": "sso_domain", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sso_provider": { + "name": "sso_provider", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "issuer": { + "name": "issuer", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "oidc_config": { + "name": "oidc_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "saml_config": { + "name": "saml_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "domain_verified": { + "name": "domain_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "jit_provisioning_enabled": { + "name": "jit_provisioning_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + } + }, + "indexes": { + "sso_provider_provider_id_unique": { + "name": "sso_provider_provider_id_unique", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_provider_domain_idx": { + "name": "sso_provider_domain_idx", + "columns": [ + { + "expression": "domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_provider_user_id_idx": { + "name": "sso_provider_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_provider_organization_id_idx": { + "name": "sso_provider_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sso_provider_user_id_user_id_fk": { + "name": "sso_provider_user_id_user_id_fk", + "tableFrom": "sso_provider", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "sso_provider_organization_id_organization_id_fk": { + "name": "sso_provider_organization_id_organization_id_fk", + "tableFrom": "sso_provider", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.subscription": { + "name": "subscription", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "plan": { + "name": "plan", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_subscription_id": { + "name": "stripe_subscription_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "period_start": { + "name": "period_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "period_end": { + "name": "period_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "cancel_at_period_end": { + "name": "cancel_at_period_end", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "cancel_at": { + "name": "cancel_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "canceled_at": { + "name": "canceled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "seats": { + "name": "seats", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "trial_start": { + "name": "trial_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "trial_end": { + "name": "trial_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "billing_interval": { + "name": "billing_interval", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_schedule_id": { + "name": "stripe_schedule_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "last_closed_period_start": { + "name": "last_closed_period_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "subscription_reference_status_idx": { + "name": "subscription_reference_status_idx", + "columns": [ + { + "expression": "reference_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "subscription_cycle_close_lagging_idx": { + "name": "subscription_cycle_close_lagging_idx", + "columns": [ + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"subscription\".\"status\" in ('active', 'past_due') and \"subscription\".\"period_start\" is not null and (\"subscription\".\"last_closed_period_start\" is null or \"subscription\".\"last_closed_period_start\" < \"subscription\".\"period_start\")", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "check_enterprise_metadata": { + "name": "check_enterprise_metadata", + "value": "plan != 'enterprise' OR metadata IS NOT NULL" + } + }, + "isRLSEnabled": false + }, + "public.table_jobs": { + "name": "table_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "rows_processed": { + "name": "rows_processed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "table_jobs_one_active_per_table": { + "name": "table_jobs_one_active_per_table", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"table_jobs\".\"status\" = 'running' AND \"table_jobs\".\"type\" <> 'export'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_jobs_watchdog_idx": { + "name": "table_jobs_watchdog_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_jobs_table_started_idx": { + "name": "table_jobs_table_started_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "table_jobs_table_id_user_table_definitions_id_fk": { + "name": "table_jobs_table_id_user_table_definitions_id_fk", + "tableFrom": "table_jobs", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_jobs_workspace_id_workspace_id_fk": { + "name": "table_jobs_workspace_id_workspace_id_fk", + "tableFrom": "table_jobs", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.table_row_executions": { + "name": "table_row_executions", + "schema": "", + "columns": { + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "group_id": { + "name": "group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "job_id": { + "name": "job_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "running_block_ids": { + "name": "running_block_ids", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'::text[]" + }, + "block_errors": { + "name": "block_errors", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "cancelled_at": { + "name": "cancelled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "enrichment_details": { + "name": "enrichment_details", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "table_row_executions_table_status_idx": { + "name": "table_row_executions_table_status_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"table_row_executions\".\"status\" IN ('queued', 'running', 'pending')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_row_executions_execution_id_idx": { + "name": "table_row_executions_execution_id_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"table_row_executions\".\"execution_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_row_executions_table_group_idx": { + "name": "table_row_executions_table_group_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "table_row_executions_table_id_user_table_definitions_id_fk": { + "name": "table_row_executions_table_id_user_table_definitions_id_fk", + "tableFrom": "table_row_executions", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_row_executions_row_id_user_table_rows_id_fk": { + "name": "table_row_executions_row_id_user_table_rows_id_fk", + "tableFrom": "table_row_executions", + "tableTo": "user_table_rows", + "columnsFrom": ["row_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "table_row_executions_row_id_group_id_pk": { + "name": "table_row_executions_row_id_group_id_pk", + "columns": ["row_id", "group_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.table_run_dispatches": { + "name": "table_run_dispatches", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "request_id": { + "name": "request_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "cursor": { + "name": "cursor", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "limit": { + "name": "limit", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "processed_count": { + "name": "processed_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "is_manual_run": { + "name": "is_manual_run", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "triggered_by_user_id": { + "name": "triggered_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "requested_at": { + "name": "requested_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "heartbeat_at": { + "name": "heartbeat_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "cancelled_at": { + "name": "cancelled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "table_run_dispatches_active_idx": { + "name": "table_run_dispatches_active_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_run_dispatches_watchdog_idx": { + "name": "table_run_dispatches_watchdog_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "requested_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "table_run_dispatches_table_id_user_table_definitions_id_fk": { + "name": "table_run_dispatches_table_id_user_table_definitions_id_fk", + "tableFrom": "table_run_dispatches", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_run_dispatches_workspace_id_workspace_id_fk": { + "name": "table_run_dispatches_workspace_id_workspace_id_fk", + "tableFrom": "table_run_dispatches", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_run_dispatches_triggered_by_user_id_user_id_fk": { + "name": "table_run_dispatches_triggered_by_user_id_user_id_fk", + "tableFrom": "table_run_dispatches", + "tableTo": "user", + "columnsFrom": ["triggered_by_user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.table_views": { + "name": "table_views", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "table_views_table_created_idx": { + "name": "table_views_table_created_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_views_workspace_created_idx": { + "name": "table_views_workspace_created_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_views_table_default_unique": { + "name": "table_views_table_default_unique", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "is_default = true", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "table_views_table_id_user_table_definitions_id_fk": { + "name": "table_views_table_id_user_table_definitions_id_fk", + "tableFrom": "table_views", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_views_workspace_id_workspace_id_fk": { + "name": "table_views_workspace_id_workspace_id_fk", + "tableFrom": "table_views", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_views_created_by_user_id_fk": { + "name": "table_views_created_by_user_id_fk", + "tableFrom": "table_views", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.upload_session": { + "name": "upload_session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "purpose": { + "name": "purpose", + "type": "upload_session_purpose", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "method": { + "name": "method", + "type": "upload_session_method", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "storage_context": { + "name": "storage_context", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "final_key": { + "name": "final_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_provider": { + "name": "storage_provider", + "type": "upload_session_provider", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "provider_upload_id": { + "name": "provider_upload_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_object_version": { + "name": "provider_object_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "file_name": { + "name": "file_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_size": { + "name": "file_size", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "part_size": { + "name": "part_size", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "part_count": { + "name": "part_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "upload_session_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'uploading'" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "processing_lease_id": { + "name": "processing_lease_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "processing_lease_expires_at": { + "name": "processing_lease_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_file_id": { + "name": "completed_file_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "upload_session_token_hash_unique": { + "name": "upload_session_token_hash_unique", + "columns": [ + { + "expression": "token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "upload_session_final_key_unique": { + "name": "upload_session_final_key_unique", + "columns": [ + { + "expression": "final_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "upload_session_status_expires_at_idx": { + "name": "upload_session_status_expires_at_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.usage_log": { + "name": "usage_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "category": { + "name": "category", + "type": "usage_log_category", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "usage_log_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "cost": { + "name": "cost", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "event_key": { + "name": "event_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "billing_entity_type": { + "name": "billing_entity_type", + "type": "billing_entity_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "billing_entity_id": { + "name": "billing_entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "billing_period_start": { + "name": "billing_period_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "billing_period_end": { + "name": "billing_period_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "usage_log_user_created_at_idx": { + "name": "usage_log_user_created_at_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_source_idx": { + "name": "usage_log_source_idx", + "columns": [ + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_workspace_id_idx": { + "name": "usage_log_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_workflow_id_idx": { + "name": "usage_log_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_event_key_unique": { + "name": "usage_log_event_key_unique", + "columns": [ + { + "expression": "event_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"usage_log\".\"event_key\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_billing_entity_period_idx": { + "name": "usage_log_billing_entity_period_idx", + "columns": [ + { + "expression": "billing_entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_period_start", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_period_end", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"usage_log\".\"billing_entity_type\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_billing_period_cost_idx": { + "name": "usage_log_billing_period_cost_idx", + "columns": [ + { + "expression": "billing_entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_period_start", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_period_end", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cost", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"usage_log\".\"billing_entity_type\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_billing_entity_created_at_cost_idx": { + "name": "usage_log_billing_entity_created_at_cost_idx", + "columns": [ + { + "expression": "billing_entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cost", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"usage_log\".\"billing_entity_type\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_workspace_created_at_idx": { + "name": "usage_log_workspace_created_at_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_execution_id_idx": { + "name": "usage_log_execution_id_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "usage_log_user_id_user_id_fk": { + "name": "usage_log_user_id_user_id_fk", + "tableFrom": "usage_log", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "usage_log_workspace_id_workspace_id_fk": { + "name": "usage_log_workspace_id_workspace_id_fk", + "tableFrom": "usage_log", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "usage_log_workflow_id_workflow_id_fk": { + "name": "usage_log_workflow_id_workflow_id_fk", + "tableFrom": "usage_log", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "usage_log_billing_scope_all_or_none": { + "name": "usage_log_billing_scope_all_or_none", + "value": "(\n (\"usage_log\".\"billing_entity_type\" IS NULL AND \"usage_log\".\"billing_entity_id\" IS NULL AND \"usage_log\".\"billing_period_start\" IS NULL AND \"usage_log\".\"billing_period_end\" IS NULL)\n OR\n (\"usage_log\".\"billing_entity_type\" IS NOT NULL AND \"usage_log\".\"billing_entity_id\" IS NOT NULL AND \"usage_log\".\"billing_period_start\" IS NOT NULL AND \"usage_log\".\"billing_period_end\" IS NOT NULL AND \"usage_log\".\"billing_period_start\" < \"usage_log\".\"billing_period_end\")\n )" + } + }, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "normalized_email": { + "name": "normalized_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'user'" + }, + "banned": { + "name": "banned", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "ban_reason": { + "name": "ban_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ban_expires": { + "name": "ban_expires", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + }, + "user_normalized_email_unique": { + "name": "user_normalized_email_unique", + "nullsNotDistinct": false, + "columns": ["normalized_email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_stats": { + "name": "user_stats", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "total_manual_executions": { + "name": "total_manual_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_api_calls": { + "name": "total_api_calls", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_webhook_triggers": { + "name": "total_webhook_triggers", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_scheduled_executions": { + "name": "total_scheduled_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_chat_executions": { + "name": "total_chat_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_mcp_executions": { + "name": "total_mcp_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_tokens_used": { + "name": "total_tokens_used", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_cost": { + "name": "total_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "current_usage_limit": { + "name": "current_usage_limit", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'5'" + }, + "usage_limit_updated_at": { + "name": "usage_limit_updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "current_period_cost": { + "name": "current_period_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "last_period_cost": { + "name": "last_period_cost", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "billed_overage_this_period": { + "name": "billed_overage_this_period", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "pro_period_cost_snapshot": { + "name": "pro_period_cost_snapshot", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "pro_period_cost_snapshot_at": { + "name": "pro_period_cost_snapshot_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "credit_balance": { + "name": "credit_balance", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "total_copilot_cost": { + "name": "total_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "current_period_copilot_cost": { + "name": "current_period_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "last_period_copilot_cost": { + "name": "last_period_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "total_copilot_tokens": { + "name": "total_copilot_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_copilot_calls": { + "name": "total_copilot_calls", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_mcp_copilot_calls": { + "name": "total_mcp_copilot_calls", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_mcp_copilot_cost": { + "name": "total_mcp_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "current_period_mcp_copilot_cost": { + "name": "current_period_mcp_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "storage_used_bytes": { + "name": "storage_used_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_active": { + "name": "last_active", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "billing_blocked": { + "name": "billing_blocked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "billing_blocked_reason": { + "name": "billing_blocked_reason", + "type": "billing_blocked_reason", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "limit_notifications": { + "name": "limit_notifications", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + } + }, + "indexes": {}, + "foreignKeys": { + "user_stats_user_id_user_id_fk": { + "name": "user_stats_user_id_user_id_fk", + "tableFrom": "user_stats", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_stats_user_id_unique": { + "name": "user_stats_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_table_definitions": { + "name": "user_table_definitions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "folder_id": { + "name": "folder_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "schema": { + "name": "schema", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "max_rows": { + "name": "max_rows", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 10000 + }, + "row_count": { + "name": "row_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "rows_version": { + "name": "rows_version", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "schema_locked": { + "name": "schema_locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "insert_locked": { + "name": "insert_locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "update_locked": { + "name": "update_locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "delete_locked": { + "name": "delete_locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "user_table_def_workspace_id_idx": { + "name": "user_table_def_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_def_folder_id_idx": { + "name": "user_table_def_folder_id_idx", + "columns": [ + { + "expression": "folder_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_def_workspace_name_unique": { + "name": "user_table_def_workspace_name_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"user_table_definitions\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_def_archived_at_idx": { + "name": "user_table_def_archived_at_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_def_workspace_archived_partial_idx": { + "name": "user_table_def_workspace_archived_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"user_table_definitions\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_table_definitions_workspace_id_workspace_id_fk": { + "name": "user_table_definitions_workspace_id_workspace_id_fk", + "tableFrom": "user_table_definitions", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_table_definitions_folder_id_folder_id_fk": { + "name": "user_table_definitions_folder_id_folder_id_fk", + "tableFrom": "user_table_definitions", + "tableTo": "folder", + "columnsFrom": ["folder_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "user_table_definitions_created_by_user_id_fk": { + "name": "user_table_definitions_created_by_user_id_fk", + "tableFrom": "user_table_definitions", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_table_row_secret_provenance": { + "name": "user_table_row_secret_provenance", + "schema": "", + "columns": { + "row_id": { + "name": "row_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "content_updated_at": { + "name": "content_updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entries": { + "name": "entries", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_table_row_secret_provenance_row_id_user_table_rows_id_fk": { + "name": "user_table_row_secret_provenance_row_id_user_table_rows_id_fk", + "tableFrom": "user_table_row_secret_provenance", + "tableTo": "user_table_rows", + "columnsFrom": ["row_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "user_table_row_secret_provenance_status_check": { + "name": "user_table_row_secret_provenance_status_check", + "value": "\"user_table_row_secret_provenance\".\"status\" IN ('exact', 'unknown')" + } + }, + "isRLSEnabled": false + }, + "public.user_table_rows": { + "name": "user_table_rows", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "order_key": { + "name": "order_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "secret_provenance_version": { + "name": "secret_provenance_version", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "user_table_rows_tenant_data_gin_idx": { + "name": "user_table_rows_tenant_data_gin_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"data\" jsonb_path_ops", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "user_table_rows_workspace_table_idx": { + "name": "user_table_rows_workspace_table_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_rows_table_position_idx": { + "name": "user_table_rows_table_position_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "position", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_rows_table_order_key_idx": { + "name": "user_table_rows_table_order_key_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "order_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_rows_table_created_id_idx": { + "name": "user_table_rows_table_created_id_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_rows_table_id_id_idx": { + "name": "user_table_rows_table_id_id_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_table_rows_table_id_user_table_definitions_id_fk": { + "name": "user_table_rows_table_id_user_table_definitions_id_fk", + "tableFrom": "user_table_rows", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_table_rows_workspace_id_workspace_id_fk": { + "name": "user_table_rows_workspace_id_workspace_id_fk", + "tableFrom": "user_table_rows", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_table_rows_created_by_user_id_fk": { + "name": "user_table_rows_created_by_user_id_fk", + "tableFrom": "user_table_rows", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "verification_expires_at_idx": { + "name": "verification_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.waitlist": { + "name": "waitlist", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "waitlist_email_unique": { + "name": "waitlist_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.webhook": { + "name": "webhook", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deployment_version_id": { + "name": "deployment_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "registration_status": { + "name": "registration_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "registration_generation": { + "name": "registration_generation", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "config_fingerprint": { + "name": "config_fingerprint", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "prepared_at": { + "name": "prepared_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "block_id": { + "name": "block_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "routing_key": { + "name": "routing_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_config": { + "name": "provider_config", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "failed_count": { + "name": "failed_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "last_failed_at": { + "name": "last_failed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "path_deployment_unique": { + "name": "path_deployment_unique", + "columns": [ + { + "expression": "path", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"webhook\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_workflow_deployment_idx": { + "name": "webhook_workflow_deployment_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_routing_key_active_idx": { + "name": "webhook_routing_key_active_idx", + "columns": [ + { + "expression": "routing_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"webhook\".\"archived_at\" IS NULL AND \"webhook\".\"routing_key\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_archived_at_partial_idx": { + "name": "webhook_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"webhook\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_webhook_on_provider_is_active_workflow_id_deploym_bdeed5468": { + "name": "idx_webhook_on_provider_is_active_workflow_id_deploym_bdeed5468", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_webhook_on_workflow_id_block_id_updated_at_desc": { + "name": "idx_webhook_on_workflow_id_block_id_updated_at_desc", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_active_registration_unique": { + "name": "webhook_active_registration_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"webhook\".\"registration_status\" = 'active' AND \"webhook\".\"block_id\" IS NOT NULL AND \"webhook\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_candidate_registration_unique": { + "name": "webhook_candidate_registration_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"webhook\".\"registration_status\" = 'candidate' AND \"webhook\".\"block_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_registration_status_generation_idx": { + "name": "webhook_registration_status_generation_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "registration_status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "registration_generation", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "webhook_workflow_id_workflow_id_fk": { + "name": "webhook_workflow_id_workflow_id_fk", + "tableFrom": "webhook", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "webhook_deployment_version_id_workflow_deployment_version_id_fk": { + "name": "webhook_deployment_version_id_workflow_deployment_version_id_fk", + "tableFrom": "webhook", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["deployment_version_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "webhook_registration_status_check": { + "name": "webhook_registration_status_check", + "value": "\"webhook\".\"registration_status\" IS NULL OR \"webhook\".\"registration_status\" IN ('active', 'candidate', 'retired', 'orphaned')" + }, + "webhook_registration_generation_check": { + "name": "webhook_registration_generation_check", + "value": "\"webhook\".\"registration_generation\" IS NULL OR \"webhook\".\"registration_generation\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.webhook_path_claim": { + "name": "webhook_path_claim", + "schema": "", + "columns": { + "path": { + "name": "path", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "generation": { + "name": "generation", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "webhook_path_claim_workflow_idx": { + "name": "webhook_path_claim_workflow_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "webhook_path_claim_workflow_id_workflow_id_fk": { + "name": "webhook_path_claim_workflow_id_workflow_id_fk", + "tableFrom": "webhook_path_claim", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "webhook_path_claim_generation_check": { + "name": "webhook_path_claim_generation_check", + "value": "\"webhook_path_claim\".\"generation\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.workflow": { + "name": "workflow", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "folder_id": { + "name": "folder_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_synced": { + "name": "last_synced", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "is_deployed": { + "name": "is_deployed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "deployed_at": { + "name": "deployed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "is_public_api": { + "name": "is_public_api", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "locked": { + "name": "locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "fork_sync_excluded": { + "name": "fork_sync_excluded", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "run_count": { + "name": "run_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_run_at": { + "name": "last_run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "variables": { + "name": "variables", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "workflow_user_id_idx": { + "name": "workflow_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_workspace_id_idx": { + "name": "workflow_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_user_workspace_idx": { + "name": "workflow_user_workspace_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_workspace_folder_name_active_unique": { + "name": "workflow_workspace_folder_name_active_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"folder_id\", '')", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_folder_sort_idx": { + "name": "workflow_folder_sort_idx", + "columns": [ + { + "expression": "folder_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_active_workspace_sort_idx": { + "name": "workflow_active_workspace_sort_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_archived_at_idx": { + "name": "workflow_archived_at_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_workspace_archived_partial_idx": { + "name": "workflow_workspace_archived_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_user_id_user_id_fk": { + "name": "workflow_user_id_user_id_fk", + "tableFrom": "workflow", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_workspace_id_workspace_id_fk": { + "name": "workflow_workspace_id_workspace_id_fk", + "tableFrom": "workflow", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_folder_id_folder_id_fk": { + "name": "workflow_folder_id_folder_id_fk", + "tableFrom": "workflow", + "tableTo": "folder", + "columnsFrom": ["folder_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_blocks": { + "name": "workflow_blocks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "position_x": { + "name": "position_x", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "position_y": { + "name": "position_y", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "horizontal_handles": { + "name": "horizontal_handles", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "is_wide": { + "name": "is_wide", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "advanced_mode": { + "name": "advanced_mode", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "trigger_mode": { + "name": "trigger_mode", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "error_enabled": { + "name": "error_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "retry": { + "name": "retry", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "locked": { + "name": "locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "height": { + "name": "height", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "sub_blocks": { + "name": "sub_blocks", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "outputs": { + "name": "outputs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_blocks_workflow_id_idx": { + "name": "workflow_blocks_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_blocks_type_idx": { + "name": "workflow_blocks_type_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_blocks_workflow_id_workflow_id_fk": { + "name": "workflow_blocks_workflow_id_workflow_id_fk", + "tableFrom": "workflow_blocks", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_checkpoints": { + "name": "workflow_checkpoints", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_state": { + "name": "workflow_state", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_checkpoints_user_id_idx": { + "name": "workflow_checkpoints_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_workflow_id_idx": { + "name": "workflow_checkpoints_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_chat_id_idx": { + "name": "workflow_checkpoints_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_message_id_idx": { + "name": "workflow_checkpoints_message_id_idx", + "columns": [ + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_user_workflow_idx": { + "name": "workflow_checkpoints_user_workflow_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_workflow_chat_idx": { + "name": "workflow_checkpoints_workflow_chat_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_created_at_idx": { + "name": "workflow_checkpoints_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_chat_created_at_idx": { + "name": "workflow_checkpoints_chat_created_at_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_checkpoints_user_id_user_id_fk": { + "name": "workflow_checkpoints_user_id_user_id_fk", + "tableFrom": "workflow_checkpoints", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_checkpoints_workflow_id_workflow_id_fk": { + "name": "workflow_checkpoints_workflow_id_workflow_id_fk", + "tableFrom": "workflow_checkpoints", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_checkpoints_chat_id_copilot_chats_id_fk": { + "name": "workflow_checkpoints_chat_id_copilot_chats_id_fk", + "tableFrom": "workflow_checkpoints", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_deployment_operation": { + "name": "workflow_deployment_operation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deployment_version_id": { + "name": "deployment_version_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "previous_active_version_id": { + "name": "previous_active_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "protocol_version": { + "name": "protocol_version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "generation": { + "name": "generation", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'preparing'" + }, + "component_readiness": { + "name": "component_readiness", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "error_code": { + "name": "error_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "request_hash": { + "name": "request_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_deployment_operation_workflow_generation_unique": { + "name": "workflow_deployment_operation_workflow_generation_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "generation", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_operation_workflow_idempotency_unique": { + "name": "workflow_deployment_operation_workflow_idempotency_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow_deployment_operation\".\"idempotency_key\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_operation_workflow_in_flight_unique": { + "name": "workflow_deployment_operation_workflow_in_flight_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow_deployment_operation\".\"status\" IN ('preparing', 'activating')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_operation_workflow_status_idx": { + "name": "workflow_deployment_operation_workflow_status_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_operation_deployment_version_idx": { + "name": "workflow_deployment_operation_deployment_version_idx", + "columns": [ + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_operation_workflow_version_generation_idx": { + "name": "workflow_deployment_operation_workflow_version_generation_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "generation", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_deployment_operation_workflow_id_workflow_id_fk": { + "name": "workflow_deployment_operation_workflow_id_workflow_id_fk", + "tableFrom": "workflow_deployment_operation", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_deployment_operation_deployment_version_id_workflow_deployment_version_id_fk": { + "name": "workflow_deployment_operation_deployment_version_id_workflow_deployment_version_id_fk", + "tableFrom": "workflow_deployment_operation", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["deployment_version_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_deployment_operation_previous_active_version_id_workflow_deployment_version_id_fk": { + "name": "workflow_deployment_operation_previous_active_version_id_workflow_deployment_version_id_fk", + "tableFrom": "workflow_deployment_operation", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["previous_active_version_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "workflow_deployment_operation_action_check": { + "name": "workflow_deployment_operation_action_check", + "value": "\"workflow_deployment_operation\".\"action\" IN ('deploy', 'activate')" + }, + "workflow_deployment_operation_status_check": { + "name": "workflow_deployment_operation_status_check", + "value": "\"workflow_deployment_operation\".\"status\" IN ('preparing', 'activating', 'active', 'failed', 'superseded')" + }, + "workflow_deployment_operation_generation_check": { + "name": "workflow_deployment_operation_generation_check", + "value": "\"workflow_deployment_operation\".\"generation\" > 0" + }, + "workflow_deployment_operation_protocol_version_check": { + "name": "workflow_deployment_operation_protocol_version_check", + "value": "\"workflow_deployment_operation\".\"protocol_version\" > 0" + } + }, + "isRLSEnabled": false + }, + "public.workflow_deployment_version": { + "name": "workflow_deployment_version", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "workflow_deployment_version_workflow_version_unique": { + "name": "workflow_deployment_version_workflow_version_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_version_workflow_active_idx": { + "name": "workflow_deployment_version_workflow_active_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_version_created_at_idx": { + "name": "workflow_deployment_version_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_deployment_version_workflow_id_workflow_id_fk": { + "name": "workflow_deployment_version_workflow_id_workflow_id_fk", + "tableFrom": "workflow_deployment_version", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_edges": { + "name": "workflow_edges", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_block_id": { + "name": "source_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_block_id": { + "name": "target_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_handle": { + "name": "source_handle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "target_handle": { + "name": "target_handle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_edges_workflow_id_idx": { + "name": "workflow_edges_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_edges_workflow_source_idx": { + "name": "workflow_edges_workflow_source_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_edges_workflow_target_idx": { + "name": "workflow_edges_workflow_target_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_edges_workflow_id_workflow_id_fk": { + "name": "workflow_edges_workflow_id_workflow_id_fk", + "tableFrom": "workflow_edges", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_edges_source_block_id_workflow_blocks_id_fk": { + "name": "workflow_edges_source_block_id_workflow_blocks_id_fk", + "tableFrom": "workflow_edges", + "tableTo": "workflow_blocks", + "columnsFrom": ["source_block_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_edges_target_block_id_workflow_blocks_id_fk": { + "name": "workflow_edges_target_block_id_workflow_blocks_id_fk", + "tableFrom": "workflow_edges", + "tableTo": "workflow_blocks", + "columnsFrom": ["target_block_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_execution_logs": { + "name": "workflow_execution_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state_snapshot_id": { + "name": "state_snapshot_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deployment_version_id": { + "name": "deployment_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "level": { + "name": "level", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "trigger": { + "name": "trigger", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "execution_deadline_at": { + "name": "execution_deadline_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "total_duration_ms": { + "name": "total_duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "execution_data": { + "name": "execution_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "cost": { + "name": "cost", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "cost_total": { + "name": "cost_total", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "models_used": { + "name": "models_used", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "files": { + "name": "files", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_execution_logs_workflow_id_idx": { + "name": "workflow_execution_logs_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_state_snapshot_id_idx": { + "name": "workflow_execution_logs_state_snapshot_id_idx", + "columns": [ + { + "expression": "state_snapshot_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_deployment_version_id_idx": { + "name": "workflow_execution_logs_deployment_version_id_idx", + "columns": [ + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_trigger_idx": { + "name": "workflow_execution_logs_trigger_idx", + "columns": [ + { + "expression": "trigger", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_level_idx": { + "name": "workflow_execution_logs_level_idx", + "columns": [ + { + "expression": "level", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_started_at_idx": { + "name": "workflow_execution_logs_started_at_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_execution_id_unique": { + "name": "workflow_execution_logs_execution_id_unique", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_workflow_started_at_idx": { + "name": "workflow_execution_logs_workflow_started_at_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_workspace_started_at_idx": { + "name": "workflow_execution_logs_workspace_started_at_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_workspace_started_at_id_desc_idx": { + "name": "workflow_execution_logs_workspace_started_at_id_desc_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"started_at\" DESC NULLS LAST", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "\"id\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_workspace_cost_total_idx": { + "name": "workflow_execution_logs_workspace_cost_total_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cost_total", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_models_used_idx": { + "name": "workflow_execution_logs_models_used_idx", + "columns": [ + { + "expression": "models_used", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "workflow_execution_logs_workspace_ended_at_id_idx": { + "name": "workflow_execution_logs_workspace_ended_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"ended_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_running_started_at_idx": { + "name": "workflow_execution_logs_running_started_at_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "status = 'running'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_running_deadline_idx": { + "name": "workflow_execution_logs_running_deadline_idx", + "columns": [ + { + "expression": "execution_deadline_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_execution_logs\".\"status\" = 'running' AND \"workflow_execution_logs\".\"execution_deadline_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_redacting_started_at_idx": { + "name": "workflow_execution_logs_redacting_started_at_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "status = 'redacting'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_redacting_deadline_idx": { + "name": "workflow_execution_logs_redacting_deadline_idx", + "columns": [ + { + "expression": "execution_deadline_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_execution_logs\".\"status\" = 'redacting' AND \"workflow_execution_logs\".\"execution_deadline_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_completed_ended_at_idx": { + "name": "workflow_execution_logs_completed_ended_at_idx", + "columns": [ + { + "expression": "ended_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_execution_logs\".\"status\" = 'completed' AND \"workflow_execution_logs\".\"level\" = 'info' AND \"workflow_execution_logs\".\"ended_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_execution_logs_workflow_id_workflow_id_fk": { + "name": "workflow_execution_logs_workflow_id_workflow_id_fk", + "tableFrom": "workflow_execution_logs", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workflow_execution_logs_workspace_id_workspace_id_fk": { + "name": "workflow_execution_logs_workspace_id_workspace_id_fk", + "tableFrom": "workflow_execution_logs", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_execution_logs_state_snapshot_id_workflow_execution_snapshots_id_fk": { + "name": "workflow_execution_logs_state_snapshot_id_workflow_execution_snapshots_id_fk", + "tableFrom": "workflow_execution_logs", + "tableTo": "workflow_execution_snapshots", + "columnsFrom": ["state_snapshot_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "workflow_execution_logs_deployment_version_id_workflow_deployment_version_id_fk": { + "name": "workflow_execution_logs_deployment_version_id_workflow_deployment_version_id_fk", + "tableFrom": "workflow_execution_logs", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["deployment_version_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_execution_snapshots": { + "name": "workflow_execution_snapshots", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state_hash": { + "name": "state_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state_data": { + "name": "state_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_snapshots_workflow_id_idx": { + "name": "workflow_snapshots_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_snapshots_hash_idx": { + "name": "workflow_snapshots_hash_idx", + "columns": [ + { + "expression": "state_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_snapshots_workflow_hash_idx": { + "name": "workflow_snapshots_workflow_hash_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "state_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_snapshots_created_at_idx": { + "name": "workflow_snapshots_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_execution_snapshots_workflow_id_workflow_id_fk": { + "name": "workflow_execution_snapshots_workflow_id_workflow_id_fk", + "tableFrom": "workflow_execution_snapshots", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_mcp_server": { + "name": "workflow_mcp_server", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_public": { + "name": "is_public", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_mcp_server_workspace_id_idx": { + "name": "workflow_mcp_server_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_server_created_by_idx": { + "name": "workflow_mcp_server_created_by_idx", + "columns": [ + { + "expression": "created_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_server_deleted_at_idx": { + "name": "workflow_mcp_server_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_server_workspace_deleted_partial_idx": { + "name": "workflow_mcp_server_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_mcp_server\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_mcp_server_workspace_id_workspace_id_fk": { + "name": "workflow_mcp_server_workspace_id_workspace_id_fk", + "tableFrom": "workflow_mcp_server", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_mcp_server_created_by_user_id_fk": { + "name": "workflow_mcp_server_created_by_user_id_fk", + "tableFrom": "workflow_mcp_server", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_mcp_tool": { + "name": "workflow_mcp_tool", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_name": { + "name": "tool_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_description": { + "name": "tool_description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parameter_schema": { + "name": "parameter_schema", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "parameter_description_overrides": { + "name": "parameter_description_overrides", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'::json" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_mcp_tool_server_id_idx": { + "name": "workflow_mcp_tool_server_id_idx", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_tool_workflow_id_idx": { + "name": "workflow_mcp_tool_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_tool_server_workflow_unique": { + "name": "workflow_mcp_tool_server_workflow_unique", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow_mcp_tool\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_tool_archived_at_partial_idx": { + "name": "workflow_mcp_tool_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_mcp_tool\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_mcp_tool_server_id_workflow_mcp_server_id_fk": { + "name": "workflow_mcp_tool_server_id_workflow_mcp_server_id_fk", + "tableFrom": "workflow_mcp_tool", + "tableTo": "workflow_mcp_server", + "columnsFrom": ["server_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_mcp_tool_workflow_id_workflow_id_fk": { + "name": "workflow_mcp_tool_workflow_id_workflow_id_fk", + "tableFrom": "workflow_mcp_tool", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_schedule": { + "name": "workflow_schedule", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deployment_version_id": { + "name": "deployment_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deployment_operation_id": { + "name": "deployment_operation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "block_id": { + "name": "block_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cron_expression": { + "name": "cron_expression", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "next_run_at": { + "name": "next_run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_ran_at": { + "name": "last_ran_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_queued_at": { + "name": "last_queued_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "trigger_type": { + "name": "trigger_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'UTC'" + }, + "failed_count": { + "name": "failed_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "infra_retry_count": { + "name": "infra_retry_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "last_failed_at": { + "name": "last_failed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "source_type": { + "name": "source_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'workflow'" + }, + "job_title": { + "name": "job_title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "prompt": { + "name": "prompt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lifecycle": { + "name": "lifecycle", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'persistent'" + }, + "success_condition": { + "name": "success_condition", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "max_runs": { + "name": "max_runs", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "run_count": { + "name": "run_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "source_chat_id": { + "name": "source_chat_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_task_name": { + "name": "source_task_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_user_id": { + "name": "source_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_workspace_id": { + "name": "source_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "secret_scope": { + "name": "secret_scope", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'all'" + }, + "mounted_secrets": { + "name": "mounted_secrets", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "job_history": { + "name": "job_history", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "contexts": { + "name": "contexts", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "excluded_dates": { + "name": "excluded_dates", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "ends_at": { + "name": "ends_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_schedule_workflow_block_deployment_unique": { + "name": "workflow_schedule_workflow_block_deployment_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow_schedule\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_schedule_workflow_deployment_idx": { + "name": "workflow_schedule_workflow_deployment_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_schedule_archived_at_partial_idx": { + "name": "workflow_schedule_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_schedule\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_workflow_schedule_on_source_workspace_id_source_t_c07f3bba6": { + "name": "idx_workflow_schedule_on_source_workspace_id_source_t_c07f3bba6", + "columns": [ + { + "expression": "source_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_schedule_due_workflow_idx": { + "name": "workflow_schedule_due_workflow_idx", + "columns": [ + { + "expression": "next_run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_queued_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_schedule\".\"archived_at\" IS NULL AND \"workflow_schedule\".\"status\" NOT IN ('disabled', 'completed') AND (\"workflow_schedule\".\"source_type\" = 'workflow' OR \"workflow_schedule\".\"source_type\" IS NULL)", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_schedule_due_job_idx": { + "name": "workflow_schedule_due_job_idx", + "columns": [ + { + "expression": "next_run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_queued_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_schedule\".\"archived_at\" IS NULL AND \"workflow_schedule\".\"status\" NOT IN ('disabled', 'completed') AND \"workflow_schedule\".\"source_type\" = 'job'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_schedule_workflow_id_workflow_id_fk": { + "name": "workflow_schedule_workflow_id_workflow_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_schedule_deployment_version_id_workflow_deployment_version_id_fk": { + "name": "workflow_schedule_deployment_version_id_workflow_deployment_version_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["deployment_version_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_schedule_deployment_operation_id_workflow_deployment_operation_id_fk": { + "name": "workflow_schedule_deployment_operation_id_workflow_deployment_operation_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "workflow_deployment_operation", + "columnsFrom": ["deployment_operation_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workflow_schedule_source_user_id_user_id_fk": { + "name": "workflow_schedule_source_user_id_user_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "user", + "columnsFrom": ["source_user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_schedule_source_workspace_id_workspace_id_fk": { + "name": "workflow_schedule_source_workspace_id_workspace_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "workspace", + "columnsFrom": ["source_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_subflows": { + "name": "workflow_subflows", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_subflows_workflow_id_idx": { + "name": "workflow_subflows_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_subflows_workflow_type_idx": { + "name": "workflow_subflows_workflow_type_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_subflows_workflow_id_workflow_id_fk": { + "name": "workflow_subflows_workflow_id_workflow_id_fk", + "tableFrom": "workflow_subflows", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace": { + "name": "workspace", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'#33C482'" + }, + "logo_url": { + "name": "logo_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_mode": { + "name": "workspace_mode", + "type": "workspace_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'grandfathered_shared'" + }, + "billed_account_user_id": { + "name": "billed_account_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_used_bytes": { + "name": "storage_used_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "allow_personal_api_keys": { + "name": "allow_personal_api_keys", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "inbox_enabled": { + "name": "inbox_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "inbox_address": { + "name": "inbox_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "inbox_provider_id": { + "name": "inbox_provider_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "inbox_secret_scope": { + "name": "inbox_secret_scope", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'all'" + }, + "inbox_mounted_secrets": { + "name": "inbox_mounted_secrets", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "organization_assigned_at": { + "name": "organization_assigned_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "forked_from_workspace_id": { + "name": "forked_from_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_owner_id_idx": { + "name": "workspace_owner_id_idx", + "columns": [ + { + "expression": "owner_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_organization_id_idx": { + "name": "workspace_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_mode_idx": { + "name": "workspace_mode_idx", + "columns": [ + { + "expression": "workspace_mode", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_forked_from_workspace_id_idx": { + "name": "workspace_forked_from_workspace_id_idx", + "columns": [ + { + "expression": "forked_from_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_inbox_provider_id_idx": { + "name": "workspace_inbox_provider_id_idx", + "columns": [ + { + "expression": "inbox_provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workspace\".\"inbox_provider_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_owner_id_user_id_fk": { + "name": "workspace_owner_id_user_id_fk", + "tableFrom": "workspace", + "tableTo": "user", + "columnsFrom": ["owner_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_organization_id_organization_id_fk": { + "name": "workspace_organization_id_organization_id_fk", + "tableFrom": "workspace", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workspace_billed_account_user_id_user_id_fk": { + "name": "workspace_billed_account_user_id_user_id_fk", + "tableFrom": "workspace", + "tableTo": "user", + "columnsFrom": ["billed_account_user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "workspace_forked_from_workspace_id_workspace_id_fk": { + "name": "workspace_forked_from_workspace_id_workspace_id_fk", + "tableFrom": "workspace", + "tableTo": "workspace", + "columnsFrom": ["forked_from_workspace_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "workspace_storage_used_bytes_non_negative": { + "name": "workspace_storage_used_bytes_non_negative", + "value": "\"workspace\".\"storage_used_bytes\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.workspace_byok_keys": { + "name": "workspace_byok_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_api_key": { + "name": "encrypted_api_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_byok_workspace_provider_idx": { + "name": "workspace_byok_workspace_provider_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_byok_keys_workspace_id_workspace_id_fk": { + "name": "workspace_byok_keys_workspace_id_workspace_id_fk", + "tableFrom": "workspace_byok_keys", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_byok_keys_created_by_user_id_fk": { + "name": "workspace_byok_keys_created_by_user_id_fk", + "tableFrom": "workspace_byok_keys", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_environment": { + "name": "workspace_environment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "variables": { + "name": "variables", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_environment_workspace_unique": { + "name": "workspace_environment_workspace_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_environment_workspace_id_workspace_id_fk": { + "name": "workspace_environment_workspace_id_workspace_id_fk", + "tableFrom": "workspace_environment", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_file": { + "name": "workspace_file", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "uploaded_by": { + "name": "uploaded_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "uploaded_at": { + "name": "uploaded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_file_workspace_id_idx": { + "name": "workspace_file_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_deleted_at_idx": { + "name": "workspace_file_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_workspace_deleted_partial_idx": { + "name": "workspace_file_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workspace_file\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_file_workspace_id_workspace_id_fk": { + "name": "workspace_file_workspace_id_workspace_id_fk", + "tableFrom": "workspace_file", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_file_uploaded_by_user_id_fk": { + "name": "workspace_file_uploaded_by_user_id_fk", + "tableFrom": "workspace_file", + "tableTo": "user", + "columnsFrom": ["uploaded_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "workspace_file_key_unique": { + "name": "workspace_file_key_unique", + "nullsNotDistinct": false, + "columns": ["key"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_file_collab_state": { + "name": "workspace_file_collab_state", + "schema": "", + "columns": { + "file_id": { + "name": "file_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "doc_state": { + "name": "doc_state", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "source_hash": { + "name": "source_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "workspace_file_collab_state_file_id_workspace_files_id_fk": { + "name": "workspace_file_collab_state_file_id_workspace_files_id_fk", + "tableFrom": "workspace_file_collab_state", + "tableTo": "workspace_files", + "columnsFrom": ["file_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_file_search_backfill": { + "name": "workspace_file_search_backfill", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "after_workspace_id": { + "name": "after_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "after_file_id": { + "name": "after_file_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_file_search_dispatch_queue": { + "name": "workspace_file_search_dispatch_queue", + "schema": "", + "columns": { + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "enqueued_at": { + "name": "enqueued_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_dispatched_at": { + "name": "last_dispatched_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_file_search_dispatch_queue_schedule_idx": { + "name": "workspace_file_search_dispatch_queue_schedule_idx", + "columns": [ + { + "expression": "last_dispatched_at", + "isExpression": false, + "asc": true, + "nulls": "first" + }, + { + "expression": "enqueued_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_file_search_queue_workspace_fk": { + "name": "workspace_file_search_queue_workspace_fk", + "tableFrom": "workspace_file_search_dispatch_queue", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_file_search_index": { + "name": "workspace_file_search_index", + "schema": "", + "columns": { + "file_id": { + "name": "file_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_content_updated_at": { + "name": "source_content_updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "workspace_file_search_index_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "partial": { + "name": "partial", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "failure_reason": { + "name": "failure_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "line_count": { + "name": "line_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "indexed_bytes": { + "name": "indexed_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "dispatched_at": { + "name": "dispatched_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_file_search_index_workspace_status_idx": { + "name": "workspace_file_search_index_workspace_status_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_content_updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_search_index_pending_dispatch_idx": { + "name": "workspace_file_search_index_pending_dispatch_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "file_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_content_updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workspace_file_search_index\".\"status\" = 'pending' AND \"workspace_file_search_index\".\"dispatched_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_search_index_active_dispatch_idx": { + "name": "workspace_file_search_index_active_dispatch_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "dispatched_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workspace_file_search_index\".\"status\" = 'pending' AND \"workspace_file_search_index\".\"dispatched_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_file_search_index_file_fk": { + "name": "workspace_file_search_index_file_fk", + "tableFrom": "workspace_file_search_index", + "tableTo": "workspace_files", + "columnsFrom": ["file_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_file_search_index_workspace_fk": { + "name": "workspace_file_search_index_workspace_fk", + "tableFrom": "workspace_file_search_index", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "workspace_file_search_index_pk": { + "name": "workspace_file_search_index_pk", + "columns": ["file_id", "source_content_updated_at"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_file_search_segment": { + "name": "workspace_file_search_segment", + "schema": "", + "columns": { + "file_id": { + "name": "file_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_content_updated_at": { + "name": "source_content_updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "line_number": { + "name": "line_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "segment_number": { + "name": "segment_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "segment_start": { + "name": "segment_start", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "line_length": { + "name": "line_length", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "workspace_file_search_segment_workspace_revision_idx": { + "name": "workspace_file_search_segment_workspace_revision_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "file_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_content_updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_search_segment_workspace_content_trgm_idx": { + "name": "workspace_file_search_segment_workspace_content_trgm_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "text_ops" + }, + { + "expression": "content", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "gin_trgm_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": { + "workspace_file_search_segment_file_fk": { + "name": "workspace_file_search_segment_file_fk", + "tableFrom": "workspace_file_search_segment", + "tableTo": "workspace_files", + "columnsFrom": ["file_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_file_search_segment_workspace_fk": { + "name": "workspace_file_search_segment_workspace_fk", + "tableFrom": "workspace_file_search_segment", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "workspace_file_search_segment_pk": { + "name": "workspace_file_search_segment_pk", + "columns": ["file_id", "source_content_updated_at", "line_number", "segment_number"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_file_secret_provenance": { + "name": "workspace_file_secret_provenance", + "schema": "", + "columns": { + "file_id": { + "name": "file_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "content_updated_at": { + "name": "content_updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entries": { + "name": "entries", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "workspace_file_secret_provenance_file_id_workspace_files_id_fk": { + "name": "workspace_file_secret_provenance_file_id_workspace_files_id_fk", + "tableFrom": "workspace_file_secret_provenance", + "tableTo": "workspace_files", + "columnsFrom": ["file_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "workspace_file_secret_provenance_status_check": { + "name": "workspace_file_secret_provenance_status_check", + "value": "\"workspace_file_secret_provenance\".\"status\" IN ('exact', 'unknown', 'unrecorded')" + } + }, + "isRLSEnabled": false + }, + "public.workspace_files": { + "name": "workspace_files", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "folder_id": { + "name": "folder_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "context": { + "name": "context", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "original_name": { + "name": "original_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "size_bytes": { + "name": "size_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "width": { + "name": "width", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "height": { + "name": "height", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "uploaded_at": { + "name": "uploaded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "content_updated_at": { + "name": "content_updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "secret_provenance_version": { + "name": "secret_provenance_version", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "workspace_files_key_active_unique": { + "name": "workspace_files_key_active_unique", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workspace_files\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_workspace_folder_name_active_unique": { + "name": "workspace_files_workspace_folder_name_active_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"folder_id\", '')", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "original_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workspace_files\".\"deleted_at\" IS NULL AND \"workspace_files\".\"context\" = 'workspace' AND \"workspace_files\".\"workspace_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_chat_display_name_unique": { + "name": "workspace_files_chat_display_name_unique", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "display_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workspace_files\".\"context\" = 'mothership' AND \"workspace_files\".\"chat_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_key_idx": { + "name": "workspace_files_key_idx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_user_id_idx": { + "name": "workspace_files_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_workspace_id_idx": { + "name": "workspace_files_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_folder_id_idx": { + "name": "workspace_files_folder_id_idx", + "columns": [ + { + "expression": "folder_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_context_idx": { + "name": "workspace_files_context_idx", + "columns": [ + { + "expression": "context", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_chat_id_idx": { + "name": "workspace_files_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_deleted_at_idx": { + "name": "workspace_files_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_workspace_deleted_partial_idx": { + "name": "workspace_files_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workspace_files\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_files_user_id_user_id_fk": { + "name": "workspace_files_user_id_user_id_fk", + "tableFrom": "workspace_files", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_files_workspace_id_workspace_id_fk": { + "name": "workspace_files_workspace_id_workspace_id_fk", + "tableFrom": "workspace_files", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_files_folder_id_folder_id_fk": { + "name": "workspace_files_folder_id_folder_id_fk", + "tableFrom": "workspace_files", + "tableTo": "folder", + "columnsFrom": ["folder_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workspace_files_chat_id_copilot_chats_id_fk": { + "name": "workspace_files_chat_id_copilot_chats_id_fk", + "tableFrom": "workspace_files", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_fork_block_map": { + "name": "workspace_fork_block_map", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "child_workspace_id": { + "name": "child_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_workflow_id": { + "name": "parent_workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_block_id": { + "name": "parent_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_workflow_id": { + "name": "child_workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_block_id": { + "name": "child_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_fork_block_map_child_ws_parent_unique": { + "name": "workspace_fork_block_map_child_ws_parent_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_block_map_child_ws_child_unique": { + "name": "workspace_fork_block_map_child_ws_child_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "child_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_block_map_child_ws_parent_wf_idx": { + "name": "workspace_fork_block_map_child_ws_parent_wf_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_block_map_child_ws_child_wf_idx": { + "name": "workspace_fork_block_map_child_ws_child_wf_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "child_workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_fork_block_map_child_workspace_id_workspace_id_fk": { + "name": "workspace_fork_block_map_child_workspace_id_workspace_id_fk", + "tableFrom": "workspace_fork_block_map", + "tableTo": "workspace", + "columnsFrom": ["child_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_fork_dependent_value": { + "name": "workspace_fork_dependent_value", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "child_workspace_id": { + "name": "child_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_workflow_id": { + "name": "target_workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_block_id": { + "name": "target_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sub_block_key": { + "name": "sub_block_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_fork_dependent_value_child_ws_wf_idx": { + "name": "workspace_fork_dependent_value_child_ws_wf_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_dependent_value_field_unique": { + "name": "workspace_fork_dependent_value_field_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sub_block_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_fork_dependent_value_child_workspace_id_workspace_id_fk": { + "name": "workspace_fork_dependent_value_child_workspace_id_workspace_id_fk", + "tableFrom": "workspace_fork_dependent_value", + "tableTo": "workspace", + "columnsFrom": ["child_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_fork_promote_run": { + "name": "workspace_fork_promote_run", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "child_workspace_id": { + "name": "child_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_workspace_id": { + "name": "source_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_workspace_id": { + "name": "target_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "direction": { + "name": "direction", + "type": "workspace_fork_promote_direction", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "snapshot": { + "name": "snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_fork_promote_run_child_ws_target_unique": { + "name": "workspace_fork_promote_run_child_ws_target_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_promote_run_target_ws_idx": { + "name": "workspace_fork_promote_run_target_ws_idx", + "columns": [ + { + "expression": "target_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_fork_promote_run_child_workspace_id_workspace_id_fk": { + "name": "workspace_fork_promote_run_child_workspace_id_workspace_id_fk", + "tableFrom": "workspace_fork_promote_run", + "tableTo": "workspace", + "columnsFrom": ["child_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_fork_promote_run_created_by_user_id_fk": { + "name": "workspace_fork_promote_run_created_by_user_id_fk", + "tableFrom": "workspace_fork_promote_run", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_fork_resource_map": { + "name": "workspace_fork_resource_map", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "child_workspace_id": { + "name": "child_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "workspace_fork_resource_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "parent_resource_id": { + "name": "parent_resource_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_resource_id": { + "name": "child_resource_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_fork_resource_map_child_ws_idx": { + "name": "workspace_fork_resource_map_child_ws_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_resource_map_child_ws_type_idx": { + "name": "workspace_fork_resource_map_child_ws_type_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_resource_map_child_type_parent_unique": { + "name": "workspace_fork_resource_map_child_type_parent_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_fork_resource_map_child_workspace_id_workspace_id_fk": { + "name": "workspace_fork_resource_map_child_workspace_id_workspace_id_fk", + "tableFrom": "workspace_fork_resource_map", + "tableTo": "workspace", + "columnsFrom": ["child_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_fork_resource_map_created_by_user_id_fk": { + "name": "workspace_fork_resource_map_created_by_user_id_fk", + "tableFrom": "workspace_fork_resource_map", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_sandbox": { + "name": "workspace_sandbox", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "language": { + "name": "language", + "type": "sandbox_language", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "dependencies": { + "name": "dependencies", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "cli_tools": { + "name": "cli_tools", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "system_packages": { + "name": "system_packages", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "spec_hash": { + "name": "spec_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_sandbox_workspace_name_unique": { + "name": "workspace_sandbox_workspace_name_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_sandbox_workspace_idx": { + "name": "workspace_sandbox_workspace_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_sandbox_spec_hash_idx": { + "name": "workspace_sandbox_spec_hash_idx", + "columns": [ + { + "expression": "spec_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_sandbox_workspace_id_workspace_id_fk": { + "name": "workspace_sandbox_workspace_id_workspace_id_fk", + "tableFrom": "workspace_sandbox", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_sandbox_created_by_user_id_fk": { + "name": "workspace_sandbox_created_by_user_id_fk", + "tableFrom": "workspace_sandbox", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.academy_cert_status": { + "name": "academy_cert_status", + "schema": "public", + "values": ["active", "revoked", "expired"] + }, + "public.background_work_kind": { + "name": "background_work_kind", + "schema": "public", + "values": ["deployment_side_effects", "fork_content_copy", "fork_sync", "fork_rollback"] + }, + "public.background_work_status_value": { + "name": "background_work_status_value", + "schema": "public", + "values": ["pending", "processing", "completed", "completed_with_warnings", "failed"] + }, + "public.billing_blocked_reason": { + "name": "billing_blocked_reason", + "schema": "public", + "values": ["payment_failed", "dispute"] + }, + "public.billing_entity_type": { + "name": "billing_entity_type", + "schema": "public", + "values": ["user", "organization"] + }, + "public.chat_type": { + "name": "chat_type", + "schema": "public", + "values": ["mothership", "copilot"] + }, + "public.copilot_async_tool_status": { + "name": "copilot_async_tool_status", + "schema": "public", + "values": ["pending", "running", "completed", "failed", "cancelled", "delivered"] + }, + "public.copilot_run_status": { + "name": "copilot_run_status", + "schema": "public", + "values": ["active", "paused_waiting_for_tool", "resuming", "complete", "error", "cancelled"] + }, + "public.copilot_tool_permission_decision": { + "name": "copilot_tool_permission_decision", + "schema": "public", + "values": ["allow", "allow_chat", "always_allow", "skip"] + }, + "public.credential_group_enrollment_status": { + "name": "credential_group_enrollment_status", + "schema": "public", + "values": ["invited", "delivery_failed", "in_progress", "completed", "revoked"] + }, + "public.credential_group_status": { + "name": "credential_group_status", + "schema": "public", + "values": ["active", "disabled"] + }, + "public.credential_member_role": { + "name": "credential_member_role", + "schema": "public", + "values": ["admin", "member"] + }, + "public.credential_member_status": { + "name": "credential_member_status", + "schema": "public", + "values": ["active", "pending", "revoked"] + }, + "public.credential_type": { + "name": "credential_type", + "schema": "public", + "values": [ + "oauth", + "managed_oauth", + "managed_mcp", + "env_workspace", + "env_personal", + "service_account" + ] + }, + "public.data_drain_cadence": { + "name": "data_drain_cadence", + "schema": "public", + "values": ["hourly", "daily"] + }, + "public.data_drain_destination": { + "name": "data_drain_destination", + "schema": "public", + "values": ["s3", "gcs", "azure_blob", "datadog", "bigquery", "snowflake", "webhook"] + }, + "public.data_drain_run_status": { + "name": "data_drain_run_status", + "schema": "public", + "values": ["running", "success", "failed"] + }, + "public.data_drain_run_trigger": { + "name": "data_drain_run_trigger", + "schema": "public", + "values": ["cron", "manual"] + }, + "public.data_drain_source": { + "name": "data_drain_source", + "schema": "public", + "values": ["workflow_logs", "job_logs", "audit_logs", "copilot_chats", "copilot_runs"] + }, + "public.execution_large_value_reference_source": { + "name": "execution_large_value_reference_source", + "schema": "public", + "values": ["execution_log", "paused_snapshot"] + }, + "public.folder_resource_type": { + "name": "folder_resource_type", + "schema": "public", + "values": ["workflow", "file", "knowledge_base", "table"] + }, + "public.invitation_kind": { + "name": "invitation_kind", + "schema": "public", + "values": ["organization", "workspace"] + }, + "public.invitation_membership_intent": { + "name": "invitation_membership_intent", + "schema": "public", + "values": ["internal", "external"] + }, + "public.invitation_status": { + "name": "invitation_status", + "schema": "public", + "values": ["pending", "accepted", "rejected", "cancelled", "expired"] + }, + "public.managed_oauth_credential_status": { + "name": "managed_oauth_credential_status", + "schema": "public", + "values": ["active", "needs_reauth", "revoked"] + }, + "public.permission_type": { + "name": "permission_type", + "schema": "public", + "values": ["admin", "write", "read"] + }, + "public.sandbox_image_status": { + "name": "sandbox_image_status", + "schema": "public", + "values": ["pending", "building", "ready", "failed"] + }, + "public.sandbox_language": { + "name": "sandbox_language", + "schema": "public", + "values": ["javascript", "python"] + }, + "public.secret_usage_scope": { + "name": "secret_usage_scope", + "schema": "public", + "values": ["workspace", "personal"] + }, + "public.secret_usage_source": { + "name": "secret_usage_source", + "schema": "public", + "values": ["workflow", "copilot", "mcp"] + }, + "public.upload_session_method": { + "name": "upload_session_method", + "schema": "public", + "values": ["put", "multipart"] + }, + "public.upload_session_provider": { + "name": "upload_session_provider", + "schema": "public", + "values": ["local", "s3", "blob", "gcs"] + }, + "public.upload_session_purpose": { + "name": "upload_session_purpose", + "schema": "public", + "values": [ + "workspace_file", + "table_import", + "knowledge_document", + "profile_picture", + "workspace_logo", + "mothership_attachment", + "execution_attachment" + ] + }, + "public.upload_session_status": { + "name": "upload_session_status", + "schema": "public", + "values": [ + "uploading", + "completing", + "finalizing", + "completed", + "aborting", + "aborted", + "failed", + "expired" + ] + }, + "public.usage_log_category": { + "name": "usage_log_category", + "schema": "public", + "values": ["model", "fixed", "tool", "model_unbilled"] + }, + "public.usage_log_source": { + "name": "usage_log_source", + "schema": "public", + "values": [ + "workflow", + "wand", + "copilot", + "workspace-chat", + "mcp_copilot", + "mothership_block", + "knowledge-base", + "voice-input", + "enrichment", + "voice-output" + ] + }, + "public.workspace_file_search_index_status": { + "name": "workspace_file_search_index_status", + "schema": "public", + "values": ["pending", "ready", "skipped", "failed"] + }, + "public.workspace_fork_promote_direction": { + "name": "workspace_fork_promote_direction", + "schema": "public", + "values": ["push", "pull"] + }, + "public.workspace_fork_resource_type": { + "name": "workspace_fork_resource_type", + "schema": "public", + "values": [ + "workflow", + "oauth_credential", + "service_account_credential", + "env_var", + "table", + "knowledge_base", + "knowledge_document", + "file", + "mcp_server", + "workflow_mcp_server", + "custom_block", + "custom_tool", + "skill" + ] + }, + "public.workspace_mode": { + "name": "workspace_mode", + "schema": "public", + "values": ["personal", "organization", "grandfathered_shared"] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/packages/db/migrations/meta/_journal.json b/packages/db/migrations/meta/_journal.json index 286f0463fbf..0be73fed8db 100644 --- a/packages/db/migrations/meta/_journal.json +++ b/packages/db/migrations/meta/_journal.json @@ -2213,6 +2213,13 @@ "when": 1788229583797, "tag": "0316_table_row_execution_capability_subject", "breakpoints": true + }, + { + "idx": 317, + "version": "7", + "when": 1788256060672, + "tag": "0317_managed_mcp_external_identity", + "breakpoints": true } ] } diff --git a/packages/db/schema.ts b/packages/db/schema.ts index c7aa581629e..899b82592f5 100644 --- a/packages/db/schema.ts +++ b/packages/db/schema.ts @@ -3634,6 +3634,10 @@ export const mcpServers = pgTable( workspaceId: text('workspace_id') .notNull() .references(() => workspace.id, { onDelete: 'cascade' }), + credentialGroupId: text('credential_group_id').references( + (): AnyPgColumn => credentialGroup.id, + { onDelete: 'set null' } + ), // Track who created the server, but workspace owns it createdBy: text('created_by').references(() => user.id, { onDelete: 'set null' }), @@ -3679,6 +3683,7 @@ export const mcpServers = pgTable( table.workspaceId, table.enabled ), + credentialGroupIdx: index('mcp_servers_credential_group_idx').on(table.credentialGroupId), // Soft delete pattern - workspace + not deleted (partial: only deleted rows) workspaceDeletedIdx: index('mcp_servers_workspace_deleted_partial_idx') @@ -4135,6 +4140,7 @@ export const usageLog = pgTable( export const credentialTypeEnum = pgEnum('credential_type', [ 'oauth', 'managed_oauth', + 'managed_mcp', 'env_workspace', 'env_personal', 'service_account', @@ -4154,6 +4160,12 @@ export interface ManagedOAuthProviderMetadata { tenantDisplayName?: string } +export interface ManagedMcpToolSnapshot { + name: string + description?: string + inputSchema: Record +} + export const credential = pgTable( 'credential', { @@ -4182,6 +4194,9 @@ export const credential = pgTable( { onDelete: 'cascade' } ), credentialGroupOptionId: text('credential_group_option_id'), + mcpServerId: text('mcp_server_id').references(() => mcpServers.id, { + onDelete: 'cascade', + }), managedOauthScopeVersion: integer('managed_oauth_scope_version'), providerSubjectId: text('provider_subject_id'), providerTenantId: text('provider_tenant_id'), @@ -4189,14 +4204,14 @@ export const credential = pgTable( grantedScopes: text('granted_scopes').array(), providerMetadata: jsonb('provider_metadata').$type(), encryptedOauthTokenSet: text('encrypted_oauth_token_set'), + mcpTools: jsonb('mcp_tools').$type(), + mcpToolsRefreshedAt: timestamp('mcp_tools_refreshed_at'), grantedAt: timestamp('granted_at'), revokedAt: timestamp('revoked_at'), accessTokenExpiresAt: timestamp('access_token_expires_at'), refreshTokenExpiresAt: timestamp('refresh_token_expires_at'), lastRefreshedAt: timestamp('last_refreshed_at'), - createdBy: text('created_by') - .notNull() - .references(() => user.id, { onDelete: 'cascade' }), + createdBy: text('created_by').references(() => user.id, { onDelete: 'cascade' }), createdAt: timestamp('created_at').notNull().defaultNow(), updatedAt: timestamp('updated_at').notNull().defaultNow(), }, @@ -4209,9 +4224,13 @@ export const credential = pgTable( credentialGroupEnrollmentIdx: index('credential_group_enrollment_idx').on( table.credentialGroupEnrollmentId ), + mcpServerIdx: index('credential_mcp_server_idx').on(table.mcpServerId), credentialGroupOptionUnique: uniqueIndex('credential_group_option_unique') .on(table.credentialGroupEnrollmentId, table.credentialGroupOptionId) .where(sql`${table.type} = 'managed_oauth'`), + managedMcpEnrollmentServerUnique: uniqueIndex('credential_managed_mcp_enrollment_server_unique') + .on(table.credentialGroupEnrollmentId, table.mcpServerId) + .where(sql`${table.type} = 'managed_mcp'`), workspaceAccountUnique: uniqueIndex('credential_workspace_account_unique') .on(table.workspaceId, table.accountId) .where(sql`account_id IS NOT NULL`), @@ -4248,6 +4267,38 @@ export const credential = pgTable( AND managed_oauth_scope_version > 0 )` ), + managedMcpSourceConstraint: check( + 'credential_managed_mcp_source_check', + sql`(type::text <> 'managed_mcp') OR ( + id LIKE 'mcp-cg-%' + AND account_id IS NULL + AND provider_id IS NULL + AND authorization_app_id IS NULL + AND credential_group_enrollment_id IS NOT NULL + AND credential_group_option_id IS NULL + AND mcp_server_id IS NOT NULL + AND managed_oauth_status IS NOT NULL + AND (managed_oauth_status <> 'active' OR ( + encrypted_oauth_token_set IS NOT NULL + AND mcp_tools IS NOT NULL + )) + AND granted_at IS NOT NULL + AND managed_oauth_scope_version IS NULL + AND provider_subject_id IS NULL + AND provider_tenant_id IS NULL + AND granted_scopes IS NULL + AND provider_metadata IS NULL + AND created_by IS NULL + AND env_key IS NULL + AND env_owner_user_id IS NULL + AND encrypted_service_account_key IS NULL + AND unredacted = false + )` + ), + creatorSourceConstraint: check( + 'credential_creator_source_check', + sql`(type::text = 'managed_mcp') OR created_by IS NOT NULL` + ), workspaceEnvSourceConstraint: check( 'credential_workspace_env_source_check', sql`(type <> 'env_workspace') OR (env_key IS NOT NULL AND env_owner_user_id IS NULL)` diff --git a/packages/testing/src/mocks/schema.mock.ts b/packages/testing/src/mocks/schema.mock.ts index 3833a8c9392..91270c9470e 100644 --- a/packages/testing/src/mocks/schema.mock.ts +++ b/packages/testing/src/mocks/schema.mock.ts @@ -1070,6 +1070,7 @@ export const schemaMock = { mcpServers: { id: 'mcpServers.id', workspaceId: 'mcpServers.workspaceId', + credentialGroupId: 'mcpServers.credentialGroupId', createdBy: 'mcpServers.createdBy', name: 'mcpServers.name', description: 'mcpServers.description', @@ -1189,6 +1190,7 @@ export const schemaMock = { enumValues: [ 'oauth', 'managed_oauth', + 'managed_mcp', 'env_workspace', 'env_personal', 'service_account', @@ -1209,12 +1211,18 @@ export const schemaMock = { envOwnerUserId: 'credential.envOwnerUserId', encryptedServiceAccountKey: 'credential.encryptedServiceAccountKey', authorizationAppId: 'credential.authorizationAppId', + credentialGroupEnrollmentId: 'credential.credentialGroupEnrollmentId', + credentialGroupOptionId: 'credential.credentialGroupOptionId', + mcpServerId: 'credential.mcpServerId', + managedOauthScopeVersion: 'credential.managedOauthScopeVersion', providerSubjectId: 'credential.providerSubjectId', providerTenantId: 'credential.providerTenantId', managedOauthStatus: 'credential.managedOauthStatus', grantedScopes: 'credential.grantedScopes', providerMetadata: 'credential.providerMetadata', encryptedOauthTokenSet: 'credential.encryptedOauthTokenSet', + mcpTools: 'credential.mcpTools', + mcpToolsRefreshedAt: 'credential.mcpToolsRefreshedAt', grantedAt: 'credential.grantedAt', revokedAt: 'credential.revokedAt', accessTokenExpiresAt: 'credential.accessTokenExpiresAt', From 1798bc5946fd9fff3ff5f44cb507093e8565e010 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Tue, 1 Sep 2026 04:27:19 -0700 Subject: [PATCH 02/10] fix(credential-groups): update managed MCP test fixtures --- .../[workspaceId]/settings/[section]/prefetch.test.ts | 1 + apps/sim/hooks/mcp/use-mcp-oauth-popup.test.tsx | 1 + .../sim/lib/credentials/application/service-account.test.ts | 6 ++++++ 3 files changed, 8 insertions(+) diff --git a/apps/sim/app/workspace/[workspaceId]/settings/[section]/prefetch.test.ts b/apps/sim/app/workspace/[workspaceId]/settings/[section]/prefetch.test.ts index e8cee8a1a54..daf315dc569 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/[section]/prefetch.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/settings/[section]/prefetch.test.ts @@ -78,6 +78,7 @@ describe('credential-groups prefetch', () => { name: 'Engineering', description: null, options: [], + mcpServers: [], status: 'active', createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z', diff --git a/apps/sim/hooks/mcp/use-mcp-oauth-popup.test.tsx b/apps/sim/hooks/mcp/use-mcp-oauth-popup.test.tsx index d7720a3971c..ae76a1fde93 100644 --- a/apps/sim/hooks/mcp/use-mcp-oauth-popup.test.tsx +++ b/apps/sim/hooks/mcp/use-mcp-oauth-popup.test.tsx @@ -17,6 +17,7 @@ vi.mock('@/hooks/queries/mcp', () => ({ useStartMcpOauth: () => ({ mutateAsync: mockStartOauth }), mcpKeys: { serversList: (workspaceId: string) => ['mcp', 'servers', workspaceId], + managedCatalogList: (workspaceId: string) => ['mcp', 'managed-catalog', workspaceId], serverToolsList: (workspaceId: string, serverId: string) => [ 'mcp', 'server-tools', diff --git a/apps/sim/lib/credentials/application/service-account.test.ts b/apps/sim/lib/credentials/application/service-account.test.ts index dbdf310231e..20828646e5a 100644 --- a/apps/sim/lib/credentials/application/service-account.test.ts +++ b/apps/sim/lib/credentials/application/service-account.test.ts @@ -39,6 +39,12 @@ vi.mock('@/lib/credentials/queries', () => ({ })) vi.mock('@/lib/credentials/access', () => ({ getCredentialActorContext: mocks.getActor, + requireOrdinaryCredentialType: (type: string) => { + if (type === 'managed_oauth' || type === 'managed_mcp') { + throw new Error('Managed credential reached an ordinary credential surface') + } + return type + }, })) vi.mock('@/lib/posthog/server', () => ({ captureServerEvent: mocks.capture })) From d1ac994c2f49217c563b05d8be8b9d72888473c0 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Tue, 1 Sep 2026 04:45:13 -0700 Subject: [PATCH 03/10] fix(credential-groups): harden managed MCP lifecycle --- .../enroll/[token]/mcp/[mcpServerId]/route.ts | 18 ++-- .../app/api/mcp/oauth/callback/route.test.ts | 21 +++++ apps/sim/app/api/mcp/oauth/callback/route.ts | 3 + .../enroll/[token]/oauth-toast.test.tsx | 6 +- .../enroll/[token]/oauth-toast.tsx | 2 +- .../enroll/[token]/search-params.ts | 2 + .../components/credential-group-detail.tsx | 6 +- apps/sim/hooks/queries/mcp.test.tsx | 18 ++++ apps/sim/hooks/queries/mcp.ts | 16 +++- .../lib/api/contracts/credential-groups.ts | 4 +- apps/sim/lib/credential-groups/mcp-oauth.ts | 61 ++++++------- apps/sim/lib/credential-groups/service.ts | 18 +++- apps/sim/lib/credential-groups/types.ts | 2 + apps/sim/lib/credentials/managed-mcp.ts | 83 +++++++++++------- .../application/execute-managed-tool.test.ts | 39 +++++++++ .../mcp/application/execute-managed-tool.ts | 16 +++- .../mcp/application/managed-connections.ts | 87 ++++++++++++++----- .../orchestration/server-lifecycle.test.ts | 18 +++- .../lib/mcp/orchestration/server-lifecycle.ts | 48 ++++++++-- apps/sim/lib/mcp/service-pool.test.ts | 2 +- apps/sim/lib/mcp/service.test.ts | 2 +- apps/sim/lib/mcp/service.ts | 26 +++--- apps/sim/lib/mcp/utils.test.ts | 2 +- 23 files changed, 369 insertions(+), 131 deletions(-) diff --git a/apps/sim/app/api/credential-groups/enroll/[token]/mcp/[mcpServerId]/route.ts b/apps/sim/app/api/credential-groups/enroll/[token]/mcp/[mcpServerId]/route.ts index dc63ed8cd90..2fc0ce969b0 100644 --- a/apps/sim/app/api/credential-groups/enroll/[token]/mcp/[mcpServerId]/route.ts +++ b/apps/sim/app/api/credential-groups/enroll/[token]/mcp/[mcpServerId]/route.ts @@ -11,12 +11,15 @@ 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 ( @@ -41,11 +44,16 @@ export const GET = withRouteHandler( } try { - const { authorizationUrl } = await startPublicCredentialGroupMcpOAuth.execute({ - principal, - input: { invitationToken: token, mcpServerId }, - request, - }) + 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') diff --git a/apps/sim/app/api/mcp/oauth/callback/route.test.ts b/apps/sim/app/api/mcp/oauth/callback/route.test.ts index 449d6e0ac68..8dfa1b88a23 100644 --- a/apps/sim/app/api/mcp/oauth/callback/route.test.ts +++ b/apps/sim/app/api/mcp/oauth/callback/route.test.ts @@ -16,11 +16,13 @@ const { 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) @@ -37,6 +39,9 @@ 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' @@ -82,6 +87,7 @@ describe('MCP OAuth callback route', () => { connectionId: 'mcp-cg-connection-1', mcpServerId: 'server-1', }) + mockEnforceCallbackRateLimit.mockResolvedValue(null) }) it('performs the token exchange through the SSRF-guarded mcpAuthGuarded wrapper', async () => { @@ -151,6 +157,7 @@ describe('MCP OAuth callback route', () => { 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( @@ -166,4 +173,18 @@ describe('MCP OAuth callback route', () => { '/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() + }) }) diff --git a/apps/sim/app/api/mcp/oauth/callback/route.ts b/apps/sim/app/api/mcp/oauth/callback/route.ts index c97248fd2a6..f76addcbb80 100644 --- a/apps/sim/app/api/mcp/oauth/callback/route.ts +++ b/apps/sim/app/api/mcp/oauth/callback/route.ts @@ -15,6 +15,7 @@ import { consumeCredentialGroupMcpOAuthAttempt, isCredentialGroupMcpOAuthState, } from '@/lib/credential-groups/mcp-oauth-state' +import { enforcePublicCredentialGroupIpRateLimit } from '@/lib/credential-groups/rate-limit' import { assertSafeOauthServerUrl, clearState, @@ -125,6 +126,8 @@ export const GET = withRouteHandler(async (request: NextRequest) => { const { state, code, error: errorParam } = parsed.data.query if (state && isCredentialGroupMcpOAuthState(state)) { + const limited = await enforcePublicCredentialGroupIpRateLimit(request, 'oauth-callback') + if (limited) return limited return completeManagedMcpCallback({ request, state, code, error: errorParam }) } diff --git a/apps/sim/app/credential-groups/enroll/[token]/oauth-toast.test.tsx b/apps/sim/app/credential-groups/enroll/[token]/oauth-toast.test.tsx index b311fdaf86e..5adbda9bf22 100644 --- a/apps/sim/app/credential-groups/enroll/[token]/oauth-toast.test.tsx +++ b/apps/sim/app/credential-groups/enroll/[token]/oauth-toast.test.tsx @@ -44,7 +44,7 @@ describe('CredentialGroupOAuthToast', () => { expect(mockSuccess).toHaveBeenCalledOnce() expect(mockSuccess).toHaveBeenCalledWith('Gmail connected successfully.') expect(mockSetOAuthStatus).toHaveBeenCalledWith( - { connected: null, oauth: null, submitted: null }, + { connected: null, mcp: null, mcpServerId: null, oauth: null, submitted: null }, { history: 'replace', scroll: false } ) act(() => root.unmount()) @@ -55,7 +55,7 @@ describe('CredentialGroupOAuthToast', () => { expect(mockError).toHaveBeenCalledWith('Authorization was canceled.') expect(mockSetOAuthStatus).toHaveBeenCalledWith( - { connected: null, oauth: null, submitted: null }, + { connected: null, mcp: null, mcpServerId: null, oauth: null, submitted: null }, { history: 'replace', scroll: false } ) act(() => root.unmount()) @@ -66,7 +66,7 @@ describe('CredentialGroupOAuthToast', () => { expect(mockSuccess).toHaveBeenCalledWith('Accounts submitted successfully.') expect(mockSetOAuthStatus).toHaveBeenCalledWith( - { connected: null, oauth: null, submitted: null }, + { connected: null, mcp: null, mcpServerId: null, oauth: null, submitted: null }, { history: 'replace', scroll: false } ) act(() => root.unmount()) diff --git a/apps/sim/app/credential-groups/enroll/[token]/oauth-toast.tsx b/apps/sim/app/credential-groups/enroll/[token]/oauth-toast.tsx index c3029508020..66e946c987a 100644 --- a/apps/sim/app/credential-groups/enroll/[token]/oauth-toast.tsx +++ b/apps/sim/app/credential-groups/enroll/[token]/oauth-toast.tsx @@ -29,7 +29,7 @@ export function CredentialGroupOAuthToast({ message, variant }: CredentialGroupO else toast.error(message) void setOAuthStatus( - { connected: null, oauth: null, submitted: null }, + { connected: null, mcp: null, mcpServerId: null, oauth: null, submitted: null }, { history: 'replace', scroll: false } ) }, [message, setOAuthStatus, toast, variant]) diff --git a/apps/sim/app/credential-groups/enroll/[token]/search-params.ts b/apps/sim/app/credential-groups/enroll/[token]/search-params.ts index 0b6411b0423..c8962949209 100644 --- a/apps/sim/app/credential-groups/enroll/[token]/search-params.ts +++ b/apps/sim/app/credential-groups/enroll/[token]/search-params.ts @@ -3,6 +3,8 @@ import { parseAsString } from 'nuqs/server' /** One-shot OAuth result signals are nullable because absence means no toast. */ export const credentialGroupEnrollmentStatusParsers = { connected: parseAsString, + mcp: parseAsString, + mcpServerId: parseAsString, oauth: parseAsString, submitted: parseAsString, } as const diff --git a/apps/sim/ee/credential-groups/components/credential-group-detail.tsx b/apps/sim/ee/credential-groups/components/credential-group-detail.tsx index 72c96200241..0884035e701 100644 --- a/apps/sim/ee/credential-groups/components/credential-group-detail.tsx +++ b/apps/sim/ee/credential-groups/components/credential-group-detail.tsx @@ -137,7 +137,11 @@ export function CredentialGroupDetail({ : null const configurationReady = Boolean( - credentialGroup && (credentialGroup.options.length || credentialGroup.mcpServers.length) + credentialGroup && + (credentialGroup.options.length || + credentialGroup.mcpServers.some( + (server) => server.enabled && server.authType === 'oauth' + )) ) && credentialGroup?.options.every( (option) => diff --git a/apps/sim/hooks/queries/mcp.test.tsx b/apps/sim/hooks/queries/mcp.test.tsx index c5847e7d062..a3972024483 100644 --- a/apps/sim/hooks/queries/mcp.test.tsx +++ b/apps/sim/hooks/queries/mcp.test.tsx @@ -193,6 +193,24 @@ describe('useMcpToolsQuery', () => { hook.unmount() }) + it('surfaces a shared server-list failure when the managed catalog is empty', async () => { + const serverListError = new Error('server list failed') + mockRequestJson.mockImplementation(async (contract) => { + if (contract === listMcpServersContract) throw serverListError + if (contract === listManagedMcpCatalogContract) return { servers: [], tools: [] } + throw new Error('Unexpected MCP request') + }) + + const hook = renderHookWithClient(() => useMcpToolsQuery(WORKSPACE_ID)) + await flush() + + expect(hook.getResult().data).toEqual([]) + expect(hook.getResult().error).toBe(serverListError) + expect(hook.getResult().isLoading).toBe(false) + + hook.unmount() + }) + it('defers detail and form metadata queries while their surfaces are closed', async () => { mockRequestJson.mockImplementation(async (contract) => { if (contract === listStoredMcpToolsContract || contract === getAllowedMcpDomainsContract) { diff --git a/apps/sim/hooks/queries/mcp.ts b/apps/sim/hooks/queries/mcp.ts index 8d53053b4df..665ea7f4240 100644 --- a/apps/sim/hooks/queries/mcp.ts +++ b/apps/sim/hooks/queries/mcp.ts @@ -190,7 +190,11 @@ function isServerEligibleForDiscovery(server: McpServer, workspaceId: string): b */ export function useMcpToolsQuery(workspaceId: string) { const queryClient = useQueryClient() - const { data: servers, isLoading: serversLoading } = useMcpServers(workspaceId) + const { + data: servers, + isLoading: serversLoading, + error: serversError, + } = useMcpServers(workspaceId) const managedCatalog = useManagedMcpCatalog(workspaceId) // Push is intrinsic to consuming the tools query: every surface that reads tools (settings, // tool picker, dynamic args, tool selector, canvas block) gets real-time `list_changed` @@ -236,10 +240,14 @@ export function useMcpToolsQuery(workspaceId: string) { return useMemo(() => { const tools: McpTool[] = [...(managedCatalog.data?.tools ?? [])] - let hasData = Boolean(managedCatalog.data) + let hasData = Boolean(managedCatalog.data?.tools.length) let anyServerLoading = false let firstError: Error | null = - managedCatalog.error instanceof Error ? managedCatalog.error : null + managedCatalog.error instanceof Error + ? managedCatalog.error + : serversError instanceof Error + ? serversError + : null const statusById = new Map( [...(servers ?? []), ...(managedCatalog.data?.servers ?? [])].map((server) => [ server.id, @@ -282,7 +290,7 @@ export function useMcpToolsQuery(workspaceId: string) { error: hasData ? null : firstError, toolsStateByServer, } - }, [results, serversLoading, serverIds, servers, managedCatalog]) + }, [results, serversLoading, serversError, serverIds, servers, managedCatalog]) } export function useForceRefreshMcpTools() { diff --git a/apps/sim/lib/api/contracts/credential-groups.ts b/apps/sim/lib/api/contracts/credential-groups.ts index dfcc3fea2cd..be92b20b787 100644 --- a/apps/sim/lib/api/contracts/credential-groups.ts +++ b/apps/sim/lib/api/contracts/credential-groups.ts @@ -74,8 +74,10 @@ export const credentialGroupOptionUpdateInputSchema = z.discriminatedUnion('prov export const credentialGroupMcpServerSchema = z.object({ id: z.string().min(1).max(128), - name: z.string().min(1).max(255), + name: z.string().min(1), description: z.string().nullable(), + authType: z.string().min(1), + enabled: z.boolean(), }) export const credentialGroupSchema = z.object({ diff --git a/apps/sim/lib/credential-groups/mcp-oauth.ts b/apps/sim/lib/credential-groups/mcp-oauth.ts index 9b8d6d1972d..f70650217de 100644 --- a/apps/sim/lib/credential-groups/mcp-oauth.ts +++ b/apps/sim/lib/credential-groups/mcp-oauth.ts @@ -8,6 +8,7 @@ import { loadPreregisteredClient, McpOauthRedirectRequired, mcpAuthGuarded, + withMcpOauthRefreshLock, } from '@/lib/mcp/oauth' import { ManagedMcpOauthProvider } from '@/lib/mcp/oauth/managed-provider' import { mcpService } from '@/lib/mcp/service' @@ -17,37 +18,39 @@ export async function startCredentialGroupMcpOAuth( invitationToken: string ): Promise { assertSafeOauthServerUrl(context.server.url) - const clientRow = await getOrCreateOauthRow({ - mcpServerId: context.server.id, - workspaceId: context.workspaceId, - }) - const preregistered = await loadPreregisteredClient(context.server.id) - const provider = new ManagedMcpOauthProvider({ - clientRow, - preregistered, - async onSaveTokens() { - throw new Error('Managed MCP OAuth start cannot persist grant tokens') - }, - }) - - try { - const result = await mcpAuthGuarded(provider, { serverUrl: context.server.url }) - if (result === 'AUTHORIZED') { - throw new Error('Managed MCP OAuth unexpectedly authorized without an enrollment grant') - } - throw new Error('Managed MCP OAuth did not produce an authorization redirect') - } catch (error) { - if (!(error instanceof McpOauthRedirectRequired)) throw error - const attempt = provider.requireAuthorizationAttempt() - await createCredentialGroupMcpOAuthAttempt({ - ...attempt, - enrollmentId: context.enrollmentId, - credentialGroupId: context.credentialGroupId, + return withMcpOauthRefreshLock(context.server.id, async () => { + const clientRow = await getOrCreateOauthRow({ mcpServerId: context.server.id, - invitationToken, + workspaceId: context.workspaceId, }) - return error.authorizationUrl - } + const preregistered = await loadPreregisteredClient(context.server.id) + const provider = new ManagedMcpOauthProvider({ + clientRow, + preregistered, + async onSaveTokens() { + throw new Error('Managed MCP OAuth start cannot persist grant tokens') + }, + }) + + try { + const result = await mcpAuthGuarded(provider, { serverUrl: context.server.url }) + if (result === 'AUTHORIZED') { + throw new Error('Managed MCP OAuth unexpectedly authorized without an enrollment grant') + } + throw new Error('Managed MCP OAuth did not produce an authorization redirect') + } catch (error) { + if (!(error instanceof McpOauthRedirectRequired)) throw error + const attempt = provider.requireAuthorizationAttempt() + await createCredentialGroupMcpOAuthAttempt({ + ...attempt, + enrollmentId: context.enrollmentId, + credentialGroupId: context.credentialGroupId, + mcpServerId: context.server.id, + invitationToken, + }) + return error.authorizationUrl + } + }) } export async function completeCredentialGroupMcpOAuth( diff --git a/apps/sim/lib/credential-groups/service.ts b/apps/sim/lib/credential-groups/service.ts index bbac2c2ea5e..c5980a9e7b7 100644 --- a/apps/sim/lib/credential-groups/service.ts +++ b/apps/sim/lib/credential-groups/service.ts @@ -55,7 +55,13 @@ async function listLinkedMcpServers( executor: DbOrTx = db ): Promise { return executor - .select({ id: mcpServers.id, name: mcpServers.name, description: mcpServers.description }) + .select({ + id: mcpServers.id, + name: mcpServers.name, + description: mcpServers.description, + authType: mcpServers.authType, + enabled: mcpServers.enabled, + }) .from(mcpServers) .where(and(eq(mcpServers.credentialGroupId, credentialGroupId), isNull(mcpServers.deletedAt))) .orderBy(asc(mcpServers.name), asc(mcpServers.id)) @@ -304,6 +310,8 @@ export async function listCredentialGroups(workspaceId: string): Promise() for (const server of serverRows) { if (!server.credentialGroupId) continue - const summary = { id: server.id, name: server.name, description: server.description } + const summary = { + id: server.id, + name: server.name, + description: server.description, + authType: server.authType, + enabled: server.enabled, + } const current = serversByGroupId.get(server.credentialGroupId) if (current) current.push(summary) else serversByGroupId.set(server.credentialGroupId, [summary]) diff --git a/apps/sim/lib/credential-groups/types.ts b/apps/sim/lib/credential-groups/types.ts index a204949891c..0e7e21ce56a 100644 --- a/apps/sim/lib/credential-groups/types.ts +++ b/apps/sim/lib/credential-groups/types.ts @@ -35,6 +35,8 @@ export interface CredentialGroupMcpServer { id: string name: string description: string | null + authType: string + enabled: boolean } interface CredentialGroupOptionBase { diff --git a/apps/sim/lib/credentials/managed-mcp.ts b/apps/sim/lib/credentials/managed-mcp.ts index c47b5eeb097..52c48915725 100644 --- a/apps/sim/lib/credentials/managed-mcp.ts +++ b/apps/sim/lib/credentials/managed-mcp.ts @@ -8,7 +8,7 @@ import { mcpServers, } from '@sim/db/schema' import { getErrorMessage } from '@sim/utils/errors' -import { and, eq, inArray, isNull, ne } from 'drizzle-orm' +import { and, eq, isNull, ne } from 'drizzle-orm' import { getWorkspaceOwnerSubscriptionAccess } from '@/lib/billing/core/workspace-access' import type { WorkspaceAuthorizationContext } from '@/lib/core/application' import { decryptSecret, encryptSecret } from '@/lib/core/security/encryption' @@ -39,6 +39,7 @@ export interface ManagedMcpRuntimeCredential { mcpServerId: string mcpServerName: string workspaceId: string + tokenVersion: string tokens: OAuthTokens tools: ManagedMcpToolSnapshot[] } @@ -186,6 +187,7 @@ export async function loadManagedMcpRuntimeCredential( workspaceId: row.workspaceId, mcpServerId: row.mcpServerId, mcpServerName: row.mcpServerName, + tokenVersion: row.encryptedTokens, tokens: await decryptManagedMcpTokens(row.encryptedTokens), tools: row.tools, } @@ -313,38 +315,53 @@ export async function persistManagedMcpCredential(params: { export async function saveManagedMcpRuntimeTokens( credentialId: string, - tokens: OAuthTokens | null -): Promise { + tokens: OAuthTokens | null, + expectedTokenVersion: string +): Promise { const now = new Date() - const updated = await db - .update(credential) - .set( - tokens - ? { - encryptedOauthTokenSet: await encryptManagedMcpTokens(tokens), - managedOauthStatus: 'active', - accessTokenExpiresAt: - typeof tokens.expires_in === 'number' - ? new Date(now.getTime() + tokens.expires_in * 1000) - : null, - updatedAt: now, - } - : { - encryptedOauthTokenSet: null, - managedOauthStatus: 'needs_reauth', - accessTokenExpiresAt: null, - updatedAt: now, - } - ) - .where( - and( - eq(credential.id, credentialId), - eq(credential.type, 'managed_mcp'), - inArray(credential.managedOauthStatus, ['active', 'needs_reauth']) + const encryptedOauthTokenSet = tokens ? await encryptManagedMcpTokens(tokens) : null + return db.transaction(async (tx) => { + const [source] = await tx + .select({ enrollmentId: credential.credentialGroupEnrollmentId }) + .from(credential) + .where(and(eq(credential.id, credentialId), eq(credential.type, 'managed_mcp'))) + .limit(1) + if (!source?.enrollmentId) { + throw new ManagedMcpCredentialError('Managed MCP credential is no longer active', 401) + } + await lockCredentialGroupEnrollmentLifecycle(tx, source.enrollmentId) + const updated = await tx + .update(credential) + .set( + tokens + ? { + encryptedOauthTokenSet, + managedOauthStatus: 'active', + accessTokenExpiresAt: + typeof tokens.expires_in === 'number' + ? new Date(now.getTime() + tokens.expires_in * 1000) + : null, + updatedAt: now, + } + : { + encryptedOauthTokenSet: null, + managedOauthStatus: 'needs_reauth', + accessTokenExpiresAt: null, + updatedAt: now, + } ) - ) - .returning({ id: credential.id }) - if (updated.length !== 1) { - throw new ManagedMcpCredentialError('Managed MCP credential is no longer active', 401) - } + .where( + and( + eq(credential.id, credentialId), + eq(credential.type, 'managed_mcp'), + eq(credential.managedOauthStatus, 'active'), + eq(credential.encryptedOauthTokenSet, expectedTokenVersion) + ) + ) + .returning({ id: credential.id }) + if (updated.length !== 1) { + throw new ManagedMcpCredentialError('Managed MCP credential grant changed', 401) + } + return encryptedOauthTokenSet + }) } diff --git a/apps/sim/lib/mcp/application/execute-managed-tool.test.ts b/apps/sim/lib/mcp/application/execute-managed-tool.test.ts index cfcf471d23a..02fd81f5cd1 100644 --- a/apps/sim/lib/mcp/application/execute-managed-tool.test.ts +++ b/apps/sim/lib/mcp/application/execute-managed-tool.test.ts @@ -81,6 +81,16 @@ describe('executeManagedMcpToolUseCase', () => { beforeEach(() => { vi.clearAllMocks() mocks.loadContext.mockResolvedValue(context) + mocks.loadRuntime.mockResolvedValue({ + credentialId: context.credentialId, + mcpServerId: context.mcpServerId, + mcpServerName: context.mcpServerName, + workspaceId: context.workspaceId, + tokenVersion: 'encrypted-token-version-1', + tokens: { access_token: 'access-token' }, + tools: [], + }) + mocks.requireCredentialAccess.mockResolvedValue(undefined) mocks.resolvePermission.mockResolvedValue('read') }) @@ -112,4 +122,33 @@ describe('executeManagedMcpToolUseCase', () => { expect(mocks.loadRuntime).not.toHaveBeenCalled() expect(mocks.executeTool).not.toHaveBeenCalled() }) + + it('fails fast when a persisted tool schema is null', async () => { + mocks.loadRuntime.mockResolvedValueOnce({ + credentialId: context.credentialId, + mcpServerId: context.mcpServerId, + mcpServerName: context.mcpServerName, + workspaceId: context.workspaceId, + tokenVersion: 'encrypted-token-version-1', + tokens: { access_token: 'access-token' }, + tools: [{ name: 'search_transcripts', inputSchema: null }], + }) + + await expect( + executeManagedMcpToolUseCase.execute({ + principal, + input: { + workspaceId: 'workspace-1', + credentialId: context.credentialId, + toolName: 'search_transcripts', + arguments: {}, + }, + }) + ).rejects.toMatchObject({ + code: 'validation', + message: 'Managed MCP tool schema is invalid', + }) + + expect(mocks.executeTool).not.toHaveBeenCalled() + }) }) diff --git a/apps/sim/lib/mcp/application/execute-managed-tool.ts b/apps/sim/lib/mcp/application/execute-managed-tool.ts index 3ed2809fd28..29017254496 100644 --- a/apps/sim/lib/mcp/application/execute-managed-tool.ts +++ b/apps/sim/lib/mcp/application/execute-managed-tool.ts @@ -31,8 +31,8 @@ export interface ExecuteManagedMcpToolInput { signal?: AbortSignal } -function requireToolSchema(value: Record): McpToolSchema { - if (value.type !== 'object') { +function requireToolSchema(value: unknown): McpToolSchema { + if (!value || typeof value !== 'object' || !('type' in value) || value.type !== 'object') { throw new OrchestrationError('validation', 'Managed MCP tool schema is invalid') } return value as McpToolSchema @@ -91,11 +91,21 @@ export const executeManagedMcpToolUseCase = defineAuthorizedWorkspaceUseCase({ workspaceId: current.workspaceId, }) const preregistered = await loadPreregisteredClient(current.mcpServerId) + let tokenVersion: string | null = current.tokenVersion return new ManagedMcpOauthProvider({ clientRow, preregistered, tokens: current.tokens, - onSaveTokens: (tokens) => saveManagedMcpRuntimeTokens(current.credentialId, tokens), + async onSaveTokens(tokens) { + if (!tokenVersion) { + throw new Error('Managed MCP credential grant is no longer active') + } + tokenVersion = await saveManagedMcpRuntimeTokens( + current.credentialId, + tokens, + tokenVersion + ) + }, }) }, }) diff --git a/apps/sim/lib/mcp/application/managed-connections.ts b/apps/sim/lib/mcp/application/managed-connections.ts index 157ffcce69c..05b4b08198b 100644 --- a/apps/sim/lib/mcp/application/managed-connections.ts +++ b/apps/sim/lib/mcp/application/managed-connections.ts @@ -9,9 +9,15 @@ import { mcpServerOperations } from '@/lib/mcp/application/operations' import type { McpToolSchema } from '@/lib/mcp/types' const MAX_MANAGED_MCP_CONNECTIONS = 500 +const MAX_MANAGED_MCP_CATALOG_BYTES = 5 * 1024 * 1024 -function requireMcpToolSchema(inputSchema: Record): McpToolSchema { - if (inputSchema.type !== 'object') { +function requireMcpToolSchema(inputSchema: unknown): McpToolSchema { + if ( + !inputSchema || + typeof inputSchema !== 'object' || + !('type' in inputSchema) || + inputSchema.type !== 'object' + ) { throw new Error('Managed MCP tool snapshot must have an object input schema') } return inputSchema as McpToolSchema @@ -27,14 +33,28 @@ export const listManagedMcpConnectionsUseCase = defineAuthorizedWorkspaceUseCase if (!(await isCredentialGroupsAvailable({ workspaceId: context.workspaceId, ownerBilling }))) { return { servers: [], tools: [] } } - const rows = await db + const managedCatalogScope = () => + and( + eq(credential.workspaceId, context.workspaceId), + eq(credential.type, 'managed_mcp'), + eq(credential.managedOauthStatus, 'active'), + eq(credentialGroup.status, 'active'), + inArray(credentialGroupEnrollment.status, ['in_progress', 'completed']), + eq(mcpServers.workspaceId, context.workspaceId), + eq(mcpServers.authType, 'oauth'), + eq(mcpServers.enabled, true), + isNull(mcpServers.deletedAt), + sql`${mcpServers.credentialGroupId} = ${credentialGroup.id}` + ) + const metadataRows = await db .select({ id: credential.id, serverId: mcpServers.id, serverName: mcpServers.name, serverDescription: mcpServers.description, email: credentialGroupEnrollment.email, - tools: credential.mcpTools, + toolSnapshotBytes: + sql`COALESCE(octet_length(${credential.mcpTools}::text), 0)`.mapWith(Number), createdAt: credential.createdAt, updatedAt: credential.updatedAt, }) @@ -48,28 +68,54 @@ export const listManagedMcpConnectionsUseCase = defineAuthorizedWorkspaceUseCase eq(credentialGroup.id, credentialGroupEnrollment.credentialGroupId) ) .innerJoin(mcpServers, eq(mcpServers.id, credential.mcpServerId)) - .where( - and( - eq(credential.workspaceId, context.workspaceId), - eq(credential.type, 'managed_mcp'), - eq(credential.managedOauthStatus, 'active'), - eq(credentialGroup.status, 'active'), - inArray(credentialGroupEnrollment.status, ['in_progress', 'completed']), - eq(mcpServers.workspaceId, context.workspaceId), - eq(mcpServers.authType, 'oauth'), - eq(mcpServers.enabled, true), - isNull(mcpServers.deletedAt), - sql`${mcpServers.credentialGroupId} = ${credentialGroup.id}` - ) - ) + .where(managedCatalogScope()) .orderBy(asc(mcpServers.name), asc(credentialGroupEnrollment.email), asc(credential.id)) .limit(MAX_MANAGED_MCP_CONNECTIONS + 1) - if (rows.length > MAX_MANAGED_MCP_CONNECTIONS) { + if (metadataRows.length > MAX_MANAGED_MCP_CONNECTIONS) { throw new Error( `Managed MCP catalog exceeds the ${MAX_MANAGED_MCP_CONNECTIONS}-connection limit` ) } + const catalogBytes = metadataRows.reduce((total, row) => total + row.toolSnapshotBytes, 0) + if (catalogBytes > MAX_MANAGED_MCP_CATALOG_BYTES) { + throw new Error( + `Managed MCP catalog exceeds the ${MAX_MANAGED_MCP_CATALOG_BYTES}-byte metadata limit` + ) + } + + const toolRows = + metadataRows.length === 0 + ? [] + : await db + .select({ id: credential.id, tools: credential.mcpTools }) + .from(credential) + .innerJoin( + credentialGroupEnrollment, + eq(credentialGroupEnrollment.id, credential.credentialGroupEnrollmentId) + ) + .innerJoin( + credentialGroup, + eq(credentialGroup.id, credentialGroupEnrollment.credentialGroupId) + ) + .innerJoin(mcpServers, eq(mcpServers.id, credential.mcpServerId)) + .where( + and( + managedCatalogScope(), + inArray( + credential.id, + metadataRows.map((row) => row.id) + ) + ) + ) + const toolsByConnectionId = new Map(toolRows.map((row) => [row.id, row.tools])) + const rows = metadataRows.map((row) => { + const tools = toolsByConnectionId.get(row.id) + if (!tools) { + throw new Error(`Managed MCP connection ${row.id} changed while loading its tool snapshot`) + } + return { ...row, tools } + }) return { servers: rows.map((row) => ({ @@ -81,12 +127,11 @@ export const listManagedMcpConnectionsUseCase = defineAuthorizedWorkspaceUseCase authType: 'oauth' as const, enabled: true, connectionStatus: 'connected' as const, - toolCount: row.tools?.length ?? 0, + toolCount: row.tools.length, createdAt: row.createdAt.toISOString(), updatedAt: row.updatedAt.toISOString(), })), tools: rows.flatMap((row) => { - if (!row.tools) throw new Error(`Managed MCP connection ${row.id} has no tool snapshot`) return row.tools.map((tool) => ({ name: tool.name, description: tool.description, diff --git a/apps/sim/lib/mcp/orchestration/server-lifecycle.test.ts b/apps/sim/lib/mcp/orchestration/server-lifecycle.test.ts index 489d9cf4d05..0e07aa8f652 100644 --- a/apps/sim/lib/mcp/orchestration/server-lifecycle.test.ts +++ b/apps/sim/lib/mcp/orchestration/server-lifecycle.test.ts @@ -8,6 +8,7 @@ import { dbChainMockFns, encryptionMock, posthogServerMock, + queueTableRows, resetDbChainMock, schemaMock, } from '@sim/testing' @@ -33,6 +34,7 @@ vi.mock('@sim/db', () => ({ mcpServers: schemaMock.mcpServers, })) vi.mock('@sim/db/schema', () => ({ + credential: schemaMock.credential, mcpServerOauth: schemaMock.mcpServerOauth, })) vi.mock('@sim/utils/id', () => ({ generateId: vi.fn() })) @@ -635,9 +637,19 @@ describe('MCP server lifecycle orchestration', () => { }) it('evicts the deleted server from the connection pool (row is already gone from clearCache)', async () => { - dbChainMockFns.returning.mockResolvedValueOnce([ + queueTableRows(schemaMock.mcpServers, [ { id: 'server-1', workspaceId: 'workspace-1', name: 'Example', transport: 'streamable-http' }, ]) + dbChainMockFns.returning + .mockResolvedValueOnce([{ id: 'mcp-cg-connection-1' }]) + .mockResolvedValueOnce([ + { + id: 'server-1', + workspaceId: 'workspace-1', + name: 'Example', + transport: 'streamable-http', + }, + ]) const result = await performDeleteMcpServer({ workspaceId: 'workspace-1', @@ -648,5 +660,9 @@ describe('MCP server lifecycle orchestration', () => { expect(result.success).toBe(true) expect(mockRevokeOauthTokens).toHaveBeenCalledWith('server-1', 'workspace-1') expect(mockEvictServerConnections).toHaveBeenCalledWith('server-1', expect.any(String)) + expect(mockEvictServerConnections).toHaveBeenCalledWith( + 'mcp-cg-connection-1', + 'managed connection retired' + ) }) }) diff --git a/apps/sim/lib/mcp/orchestration/server-lifecycle.ts b/apps/sim/lib/mcp/orchestration/server-lifecycle.ts index f0a7cc64f34..b5d1b010d66 100644 --- a/apps/sim/lib/mcp/orchestration/server-lifecycle.ts +++ b/apps/sim/lib/mcp/orchestration/server-lifecycle.ts @@ -1,6 +1,6 @@ import { AuditAction, AuditResourceType, auditUpdatedFields, recordAudit } from '@sim/audit' import { db, mcpServers } from '@sim/db' -import { mcpServerOauth } from '@sim/db/schema' +import { credential, mcpServerOauth } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' import { and, eq, isNull } from 'drizzle-orm' @@ -99,6 +99,7 @@ export interface PerformMcpServerResult { revived?: boolean authType?: McpAuthType configurationChanged?: boolean + retiredManagedConnectionIds?: string[] /** * Fields the update's SET clause wrote, minus `updatedAt`, for audit. Only * the writer knows these: a param is not a write, and callers cannot see the @@ -506,16 +507,40 @@ export async function deleteMcpServer( ): Promise { try { await revokeMcpOauthTokens(params.serverId, params.workspaceId) - const [server] = await db - .delete(mcpServers) - .where( - and(eq(mcpServers.id, params.serverId), eq(mcpServers.workspaceId, params.workspaceId)) - ) - .returning() + const deleted = await db.transaction(async (tx) => { + const [target] = await tx + .select() + .from(mcpServers) + .where( + and(eq(mcpServers.id, params.serverId), eq(mcpServers.workspaceId, params.workspaceId)) + ) + .limit(1) + .for('update') + if (!target) return null - if (!server) return { success: false, error: 'Server not found', errorCode: 'not_found' } + const retired = await tx + .delete(credential) + .where( + and( + eq(credential.workspaceId, params.workspaceId), + eq(credential.type, 'managed_mcp'), + eq(credential.mcpServerId, params.serverId) + ) + ) + .returning({ id: credential.id }) + const [server] = await tx + .delete(mcpServers) + .where( + and(eq(mcpServers.id, params.serverId), eq(mcpServers.workspaceId, params.workspaceId)) + ) + .returning() + if (!server) throw new Error('MCP server disappeared during deletion') + return { server, retiredManagedConnectionIds: retired.map((row) => row.id) } + }) - return { success: true, server } + if (!deleted) return { success: false, error: 'Server not found', errorCode: 'not_found' } + + return { success: true, ...deleted } } catch (error) { logger.error('Failed to delete MCP server', { error }) throw error @@ -700,6 +725,11 @@ export async function applyMcpServerMutationEffects(params: { action === 'delete' ? 'server deleted' : 'config changed' ) } + await Promise.all( + (result.retiredManagedConnectionIds ?? []).map((connectionId) => + mcpService.evictServerConnections(connectionId, 'managed connection retired') + ) + ) if (action === 'create' && result.updated === false && result.server) { const { PlatformEvents } = await import('@/lib/core/telemetry') diff --git a/apps/sim/lib/mcp/service-pool.test.ts b/apps/sim/lib/mcp/service-pool.test.ts index 3f1566b7ab9..144890f0103 100644 --- a/apps/sim/lib/mcp/service-pool.test.ts +++ b/apps/sim/lib/mcp/service-pool.test.ts @@ -112,7 +112,7 @@ vi.mock('@/lib/mcp/oauth', () => ({ getOrCreateOauthRow: vi.fn(), loadPreregisteredClient: vi.fn(), SimMcpOauthProvider: vi.fn(), - withMcpOauthRefreshLock: vi.fn(), + withMcpOauthRefreshLock: vi.fn((_id: string, fn: () => Promise) => fn()), })) vi.mock('@/lib/mcp/resolve-config', () => ({ resolveMcpConfigEnvVars: (...args: unknown[]) => mockResolveEnvVars(...args), diff --git a/apps/sim/lib/mcp/service.test.ts b/apps/sim/lib/mcp/service.test.ts index 8b297d5272d..f85f29feec4 100644 --- a/apps/sim/lib/mcp/service.test.ts +++ b/apps/sim/lib/mcp/service.test.ts @@ -111,7 +111,7 @@ vi.mock('@/lib/mcp/oauth', () => ({ getOrCreateOauthRow: vi.fn(), loadPreregisteredClient: vi.fn(), SimMcpOauthProvider: vi.fn(), - withMcpOauthRefreshLock: vi.fn(), + withMcpOauthRefreshLock: vi.fn((_id: string, fn: () => Promise) => fn()), })) vi.mock('@/lib/mcp/resolve-config', () => ({ diff --git a/apps/sim/lib/mcp/service.ts b/apps/sim/lib/mcp/service.ts index 51f30a2bbcf..fb042a48239 100644 --- a/apps/sim/lib/mcp/service.ts +++ b/apps/sim/lib/mcp/service.ts @@ -520,25 +520,21 @@ class McpService { const effectiveConfig = params.extraHeaders ? { ...config, headers: { ...config.headers, ...params.extraHeaders } } : config - return this.withServerClient( - { - key: this.poolKey(params.connectionId, params.workspaceId, params.connectionId), - serverId: params.connectionId, - allowPool: !params.extraHeaders, - }, - () => - withMcpOauthRefreshLock(params.connectionId, async () => + return withMcpOauthRefreshLock(params.connectionId, () => + this.withServerClient( + { key: '', serverId: params.serverId, allowPool: false }, + async () => this.createManagedOauthClient( effectiveConfig, await params.loadAuthProvider(), params.signal - ) - ), - (client) => - client.callTool(params.toolCall, { - signal: params.signal, - timeoutMs: params.timeoutMs, - }) + ), + (client) => + client.callTool(params.toolCall, { + signal: params.signal, + timeoutMs: params.timeoutMs, + }) + ) ) } diff --git a/apps/sim/lib/mcp/utils.test.ts b/apps/sim/lib/mcp/utils.test.ts index 5eb5ec2913e..9eef822bf15 100644 --- a/apps/sim/lib/mcp/utils.test.ts +++ b/apps/sim/lib/mcp/utils.test.ts @@ -436,7 +436,7 @@ describe('parseMcpToolId', () => { describe('parseMcpToolTarget', () => { it('preserves a managed connection ID even when its random segment contains hyphens', () => { - const credentialId = generateManagedMcpConnectionId() + const credentialId = 'mcp-cg-abcd-efghijklmnopqrst' const result = parseMcpToolTarget(`${credentialId}-fireflies-search-transcripts`) expect(result).toEqual({ From 6ae71ae763111e9c2bb560e34f6621eb77cbf4a4 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Tue, 1 Sep 2026 10:33:04 -0700 Subject: [PATCH 04/10] chore(db): squash managed MCP migration --- .../0315_credential_group_managed_mcp.sql | 3 - ...edential_group_managed_mcp_constraints.sql | 47 ---------- .../0317_credential_group_managed_mcp.sql | 90 +++++++++++++++++++ .../0317_managed_mcp_external_identity.sql | 3 - .../db/migrations/meta/0317_snapshot.json | 56 +++++++++++- packages/db/migrations/meta/_journal.json | 4 +- 6 files changed, 146 insertions(+), 57 deletions(-) delete mode 100644 packages/db/migrations/0315_credential_group_managed_mcp.sql delete mode 100644 packages/db/migrations/0316_credential_group_managed_mcp_constraints.sql create mode 100644 packages/db/migrations/0317_credential_group_managed_mcp.sql delete mode 100644 packages/db/migrations/0317_managed_mcp_external_identity.sql diff --git a/packages/db/migrations/0315_credential_group_managed_mcp.sql b/packages/db/migrations/0315_credential_group_managed_mcp.sql deleted file mode 100644 index a82114bc95b..00000000000 --- a/packages/db/migrations/0315_credential_group_managed_mcp.sql +++ /dev/null @@ -1,3 +0,0 @@ --- Adds the managed MCP credential type separately from the storage migration. PostgreSQL cannot --- use a new enum label in an index predicate until the transaction that introduced it commits. -ALTER TYPE "public"."credential_type" ADD VALUE IF NOT EXISTS 'managed_mcp' BEFORE 'env_workspace'; diff --git a/packages/db/migrations/0316_credential_group_managed_mcp_constraints.sql b/packages/db/migrations/0316_credential_group_managed_mcp_constraints.sql deleted file mode 100644 index fc6b36c1c77..00000000000 --- a/packages/db/migrations/0316_credential_group_managed_mcp_constraints.sql +++ /dev/null @@ -1,47 +0,0 @@ --- Pure expand: every new column is nullable, and no managed_mcp row can predate 0315. -ALTER TABLE "credential" ADD COLUMN IF NOT EXISTS "mcp_server_id" text;--> statement-breakpoint -ALTER TABLE "credential" ADD COLUMN IF NOT EXISTS "mcp_tools" jsonb;--> statement-breakpoint -ALTER TABLE "credential" ADD COLUMN IF NOT EXISTS "mcp_tools_refreshed_at" timestamp;--> statement-breakpoint -ALTER TABLE "mcp_servers" ADD COLUMN IF NOT EXISTS "credential_group_id" text;--> statement-breakpoint - --- NOT VALID avoids scanning existing tables while the foreign keys are installed. The new --- nullable columns contain no values, so validation is immediate and safe. -ALTER TABLE "credential" ADD CONSTRAINT "credential_mcp_server_id_mcp_servers_id_fk" FOREIGN KEY ("mcp_server_id") REFERENCES "public"."mcp_servers"("id") ON DELETE cascade ON UPDATE no action NOT VALID;--> statement-breakpoint -ALTER TABLE "credential" VALIDATE CONSTRAINT "credential_mcp_server_id_mcp_servers_id_fk";--> statement-breakpoint -ALTER TABLE "mcp_servers" ADD CONSTRAINT "mcp_servers_credential_group_id_credential_group_id_fk" FOREIGN KEY ("credential_group_id") REFERENCES "public"."credential_group"("id") ON DELETE set null ON UPDATE no action NOT VALID;--> statement-breakpoint -ALTER TABLE "mcp_servers" VALIDATE CONSTRAINT "mcp_servers_credential_group_id_credential_group_id_fk";--> statement-breakpoint - -ALTER TABLE "credential" ADD CONSTRAINT "credential_managed_mcp_source_check" CHECK ((type::text <> 'managed_mcp') OR ( - id LIKE 'mcp-cg-%' - AND account_id IS NULL - AND provider_id IS NULL - AND authorization_app_id IS NULL - AND credential_group_enrollment_id IS NOT NULL - AND credential_group_option_id IS NULL - AND mcp_server_id IS NOT NULL - AND managed_oauth_status IS NOT NULL - AND (managed_oauth_status <> 'active' OR ( - encrypted_oauth_token_set IS NOT NULL - AND mcp_tools IS NOT NULL - )) - AND granted_at IS NOT NULL - AND managed_oauth_scope_version IS NULL - AND provider_subject_id IS NULL - AND provider_tenant_id IS NULL - AND granted_scopes IS NULL - AND provider_metadata IS NULL - AND created_by IS NULL - AND env_key IS NULL - AND env_owner_user_id IS NULL - AND encrypted_service_account_key IS NULL - AND unredacted = false - )) NOT VALID;--> statement-breakpoint -ALTER TABLE "credential" VALIDATE CONSTRAINT "credential_managed_mcp_source_check";--> statement-breakpoint - --- Concurrent index operations cannot run inside the migration runner's transaction. -COMMIT;--> statement-breakpoint -SET lock_timeout = 0;--> statement-breakpoint -CREATE INDEX CONCURRENTLY IF NOT EXISTS "credential_mcp_server_idx" ON "credential" USING btree ("mcp_server_id");--> statement-breakpoint -CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS "credential_managed_mcp_enrollment_server_unique" ON "credential" USING btree ("credential_group_enrollment_id","mcp_server_id") WHERE "credential"."type" = 'managed_mcp';--> statement-breakpoint -CREATE INDEX CONCURRENTLY IF NOT EXISTS "mcp_servers_credential_group_idx" ON "mcp_servers" USING btree ("credential_group_id");--> statement-breakpoint -SET lock_timeout = '5s'; diff --git a/packages/db/migrations/0317_credential_group_managed_mcp.sql b/packages/db/migrations/0317_credential_group_managed_mcp.sql new file mode 100644 index 00000000000..110986fe116 --- /dev/null +++ b/packages/db/migrations/0317_credential_group_managed_mcp.sql @@ -0,0 +1,90 @@ +-- Pure expand: every new column is nullable, and no managed_mcp row can predate this migration. +-- Every pre-COMMIT statement is replay-safe because a concurrent index failure leaves this file +-- unjournaled while preserving the committed schema changes. +ALTER TYPE "public"."credential_type" ADD VALUE IF NOT EXISTS 'managed_mcp' BEFORE 'env_workspace';--> statement-breakpoint +ALTER TABLE "credential" ADD COLUMN IF NOT EXISTS "mcp_server_id" text;--> statement-breakpoint +ALTER TABLE "credential" ADD COLUMN IF NOT EXISTS "mcp_tools" jsonb;--> statement-breakpoint +ALTER TABLE "credential" ADD COLUMN IF NOT EXISTS "mcp_tools_refreshed_at" timestamp;--> statement-breakpoint +ALTER TABLE "mcp_servers" ADD COLUMN IF NOT EXISTS "credential_group_id" text;--> statement-breakpoint + +-- PostgreSQL has no ADD CONSTRAINT IF NOT EXISTS, so replay guards are scoped to each table. +-- NOT VALID keeps foreign-key installation to a metadata change before validation. +DO $$ BEGIN + IF NOT EXISTS ( + SELECT 1 FROM "pg_constraint" + WHERE "conname" = 'credential_mcp_server_id_mcp_servers_id_fk' + AND "conrelid" = '"credential"'::regclass + ) THEN + ALTER TABLE "credential" ADD CONSTRAINT "credential_mcp_server_id_mcp_servers_id_fk" FOREIGN KEY ("mcp_server_id") REFERENCES "public"."mcp_servers"("id") ON DELETE cascade ON UPDATE no action NOT VALID; + END IF; +END $$;--> statement-breakpoint +ALTER TABLE "credential" VALIDATE CONSTRAINT "credential_mcp_server_id_mcp_servers_id_fk";--> statement-breakpoint +DO $$ BEGIN + IF NOT EXISTS ( + SELECT 1 FROM "pg_constraint" + WHERE "conname" = 'mcp_servers_credential_group_id_credential_group_id_fk' + AND "conrelid" = '"mcp_servers"'::regclass + ) THEN + ALTER TABLE "mcp_servers" ADD CONSTRAINT "mcp_servers_credential_group_id_credential_group_id_fk" FOREIGN KEY ("credential_group_id") REFERENCES "public"."credential_group"("id") ON DELETE set null ON UPDATE no action NOT VALID; + END IF; +END $$;--> statement-breakpoint +ALTER TABLE "mcp_servers" VALIDATE CONSTRAINT "mcp_servers_credential_group_id_credential_group_id_fk";--> statement-breakpoint + +DO $$ BEGIN + IF NOT EXISTS ( + SELECT 1 FROM "pg_constraint" + WHERE "conname" = 'credential_managed_mcp_source_check' + AND "conrelid" = '"credential"'::regclass + ) THEN + ALTER TABLE "credential" ADD CONSTRAINT "credential_managed_mcp_source_check" CHECK ((type::text <> 'managed_mcp') OR ( + id LIKE 'mcp-cg-%' + AND account_id IS NULL + AND provider_id IS NULL + AND authorization_app_id IS NULL + AND credential_group_enrollment_id IS NOT NULL + AND credential_group_option_id IS NULL + AND mcp_server_id IS NOT NULL + AND managed_oauth_status IS NOT NULL + AND (managed_oauth_status <> 'active' OR ( + encrypted_oauth_token_set IS NOT NULL + AND mcp_tools IS NOT NULL + )) + AND granted_at IS NOT NULL + AND managed_oauth_scope_version IS NULL + AND provider_subject_id IS NULL + AND provider_tenant_id IS NULL + AND granted_scopes IS NULL + AND provider_metadata IS NULL + AND created_by IS NULL + AND env_key IS NULL + AND env_owner_user_id IS NULL + AND encrypted_service_account_key IS NULL + AND unredacted = false + )) NOT VALID; + END IF; +END $$;--> statement-breakpoint +ALTER TABLE "credential" VALIDATE CONSTRAINT "credential_managed_mcp_source_check";--> statement-breakpoint +DO $$ BEGIN + IF NOT EXISTS ( + SELECT 1 FROM "pg_constraint" + WHERE "conname" = 'credential_creator_source_check' + AND "conrelid" = '"credential"'::regclass + ) THEN + ALTER TABLE "credential" ADD CONSTRAINT "credential_creator_source_check" CHECK ((type::text = 'managed_mcp') OR created_by IS NOT NULL) NOT VALID; + END IF; +END $$;--> statement-breakpoint +ALTER TABLE "credential" VALIDATE CONSTRAINT "credential_creator_source_check";--> statement-breakpoint +ALTER TABLE "credential" ALTER COLUMN "created_by" DROP NOT NULL;--> statement-breakpoint + +-- The commit makes the new enum label visible and moves index builds outside the migration +-- runner's transaction, as required by PostgreSQL for the partial and concurrent indexes. +COMMIT;--> statement-breakpoint +SET lock_timeout = 0;--> statement-breakpoint +-- A failed concurrent build leaves an invalid index behind, so each replay removes it first. +DROP INDEX CONCURRENTLY IF EXISTS "credential_mcp_server_idx";--> statement-breakpoint +CREATE INDEX CONCURRENTLY IF NOT EXISTS "credential_mcp_server_idx" ON "credential" USING btree ("mcp_server_id");--> statement-breakpoint +DROP INDEX CONCURRENTLY IF EXISTS "credential_managed_mcp_enrollment_server_unique";--> statement-breakpoint +CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS "credential_managed_mcp_enrollment_server_unique" ON "credential" USING btree ("credential_group_enrollment_id","mcp_server_id") WHERE "credential"."type" = 'managed_mcp';--> statement-breakpoint +DROP INDEX CONCURRENTLY IF EXISTS "mcp_servers_credential_group_idx";--> statement-breakpoint +CREATE INDEX CONCURRENTLY IF NOT EXISTS "mcp_servers_credential_group_idx" ON "mcp_servers" USING btree ("credential_group_id");--> statement-breakpoint +SET lock_timeout = '5s'; diff --git a/packages/db/migrations/0317_managed_mcp_external_identity.sql b/packages/db/migrations/0317_managed_mcp_external_identity.sql deleted file mode 100644 index d23e1570122..00000000000 --- a/packages/db/migrations/0317_managed_mcp_external_identity.sql +++ /dev/null @@ -1,3 +0,0 @@ -ALTER TABLE "credential" ADD CONSTRAINT "credential_creator_source_check" CHECK ((type::text = 'managed_mcp') OR created_by IS NOT NULL) NOT VALID;--> statement-breakpoint -ALTER TABLE "credential" VALIDATE CONSTRAINT "credential_creator_source_check";--> statement-breakpoint -ALTER TABLE "credential" ALTER COLUMN "created_by" DROP NOT NULL; diff --git a/packages/db/migrations/meta/0317_snapshot.json b/packages/db/migrations/meta/0317_snapshot.json index 408a786621e..671e962ad5c 100644 --- a/packages/db/migrations/meta/0317_snapshot.json +++ b/packages/db/migrations/meta/0317_snapshot.json @@ -1,6 +1,6 @@ { - "id": "3e774f5a-401e-40b7-a59e-c97238a721b9", - "prevId": "cfa8e676-67d0-48f3-97ea-c5a3c3dfa594", + "id": "7d3eae54-bbe1-4cdb-b9d6-bf430c8396c6", + "prevId": "1fafbd78-3e0f-4cc8-9a51-053a3b11af9e", "version": "7", "dialect": "postgresql", "tables": { @@ -13037,6 +13037,12 @@ "primaryKey": false, "notNull": false }, + "capability_governed_user_id": { + "name": "capability_governed_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, "enrichment_details": { "name": "enrichment_details", "type": "jsonb", @@ -13130,6 +13136,15 @@ "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" + }, + "table_row_executions_capability_governed_user_id_user_id_fk": { + "name": "table_row_executions_capability_governed_user_id_user_id_fk", + "tableFrom": "table_row_executions", + "tableTo": "user", + "columnsFrom": ["capability_governed_user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" } }, "compositePrimaryKeys": { @@ -13223,6 +13238,12 @@ "primaryKey": false, "notNull": false }, + "capability_governed_user_id": { + "name": "capability_governed_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, "requested_at": { "name": "requested_at", "type": "timestamp", @@ -13291,6 +13312,28 @@ "concurrently": false, "method": "btree", "with": {} + }, + "table_run_dispatches_governed_active_idx": { + "name": "table_run_dispatches_governed_active_idx", + "columns": [ + { + "expression": "capability_governed_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"table_run_dispatches\".\"status\" IN ('pending', 'dispatching')", + "concurrently": false, + "method": "btree", + "with": {} } }, "foreignKeys": { @@ -13320,6 +13363,15 @@ "columnsTo": ["id"], "onDelete": "set null", "onUpdate": "no action" + }, + "table_run_dispatches_capability_governed_user_id_user_id_fk": { + "name": "table_run_dispatches_capability_governed_user_id_user_id_fk", + "tableFrom": "table_run_dispatches", + "tableTo": "user", + "columnsFrom": ["capability_governed_user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, diff --git a/packages/db/migrations/meta/_journal.json b/packages/db/migrations/meta/_journal.json index 0be73fed8db..2b52d66caaf 100644 --- a/packages/db/migrations/meta/_journal.json +++ b/packages/db/migrations/meta/_journal.json @@ -2217,8 +2217,8 @@ { "idx": 317, "version": "7", - "when": 1788256060672, - "tag": "0317_managed_mcp_external_identity", + "when": 1788284109673, + "tag": "0317_credential_group_managed_mcp", "breakpoints": true } ] From 66c191fe010eb29d75975b063d69cf0257a435e6 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Tue, 1 Sep 2026 16:44:19 -0700 Subject: [PATCH 05/10] feat(mcp): add credential-scoped agent tools --- apps/docs/openapi-v2-workflows.json | 49 +++- .../components/tool-input/tool-input.tsx | 230 ++++++++++-------- apps/sim/blocks/blocks/credential-group.ts | 59 ++++- .../lib/remap/remap-references.ts | 7 +- .../handlers/agent/agent-handler.test.ts | 68 ++++++ .../executor/handlers/agent/agent-handler.ts | 104 ++++++-- apps/sim/executor/handlers/agent/types.ts | 1 + .../credential-group-handler.test.ts | 35 +++ .../credential-group-handler.ts | 20 ++ .../mothership/mothership-handler.test.ts | 48 ++++ .../handlers/mothership/mothership-handler.ts | 68 +++++- apps/sim/hooks/queries/mcp.ts | 6 +- apps/sim/lib/api/contracts/mcp.ts | 1 + .../v2/__tests__/workflow-agent-tools.test.ts | 16 +- apps/sim/lib/api/contracts/v2/workflows.ts | 46 +++- .../application/list-mcp-connections.test.ts | 203 ++++++++++++++++ .../application/list-mcp-connections.ts | 87 +++++++ .../application/operations.ts | 9 + .../credential-groups/mcp-connections.test.ts | 97 ++++++++ .../lib/credential-groups/mcp-connections.ts | 152 ++++++++++++ apps/sim/lib/credential-groups/mcp-oauth.ts | 4 +- .../discover-managed-mcp-tools.test.ts | 143 +++++++++++ .../application/discover-managed-mcp-tools.ts | 73 ++++++ .../lib/credentials/application/operations.ts | 1 + apps/sim/lib/credentials/managed-mcp.ts | 21 ++ apps/sim/lib/internal/mcp/discover-tools.ts | 24 +- apps/sim/lib/internal/mcp/execute-tool.ts | 2 +- apps/sim/lib/mcp/application/authorization.ts | 9 + .../application/execute-managed-tool.test.ts | 96 ++++++-- .../mcp/application/execute-managed-tool.ts | 61 ++--- .../lib/mcp/application/execute-tool.test.ts | 24 ++ apps/sim/lib/mcp/application/execute-tool.ts | 10 +- .../mcp/application/managed-auth-provider.ts | 30 +++ apps/sim/lib/mcp/application/operations.ts | 1 + .../sim/lib/mcp/application/use-cases.test.ts | 16 ++ apps/sim/lib/mcp/application/use-cases.ts | 38 ++- apps/sim/lib/mcp/client.test.ts | 15 ++ apps/sim/lib/mcp/client.ts | 13 +- apps/sim/lib/mcp/service.ts | 34 ++- apps/sim/lib/mcp/shared.test.ts | 54 ++++ apps/sim/lib/mcp/shared.ts | 61 +++++ apps/sim/lib/mcp/utils.test.ts | 12 + apps/sim/lib/mcp/utils.ts | 7 + apps/sim/lib/workflows/editing/builders.ts | 8 +- .../lib/workflows/editing/validation.test.ts | 27 ++ apps/sim/lib/workflows/editing/validation.ts | 16 +- apps/sim/lib/workflows/subblocks/display.ts | 5 + packages/auth/src/principal.ts | 2 + packages/sim-cli/src/generated/v2-api.ts | 9 + 49 files changed, 1912 insertions(+), 210 deletions(-) create mode 100644 apps/sim/lib/credential-groups/application/list-mcp-connections.test.ts create mode 100644 apps/sim/lib/credential-groups/application/list-mcp-connections.ts create mode 100644 apps/sim/lib/credential-groups/mcp-connections.test.ts create mode 100644 apps/sim/lib/credential-groups/mcp-connections.ts create mode 100644 apps/sim/lib/credentials/application/discover-managed-mcp-tools.test.ts create mode 100644 apps/sim/lib/credentials/application/discover-managed-mcp-tools.ts create mode 100644 apps/sim/lib/mcp/application/managed-auth-provider.ts create mode 100644 apps/sim/lib/mcp/shared.test.ts diff --git a/apps/docs/openapi-v2-workflows.json b/apps/docs/openapi-v2-workflows.json index 3c3a4828557..9344adf9b9d 100644 --- a/apps/docs/openapi-v2-workflows.json +++ b/apps/docs/openapi-v2-workflows.json @@ -6215,6 +6215,9 @@ }, { "$ref": "#/components/schemas/AgentMcpTool" + }, + { + "$ref": "#/components/schemas/AgentMcpServerAdvanced" } ], "title": "Agent tool", @@ -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": { @@ -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": { diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/tool-input.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/tool-input.tsx index e7a14bf6f76..1ea743650f9 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/tool-input.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/tool-input.tsx @@ -16,6 +16,7 @@ import { ArrowLeft, ChevronRight, Server, Wrench, X } from '@sim/emcn/icons' import { createLogger } from '@sim/logger' import { useParams } from 'next/navigation' import { McpIcon, WorkflowIcon } from '@/components/icons' +import { MCP_SERVER_ADVANCED_TOOL_TYPE } from '@/lib/mcp/shared' import { getIssueBadgeLabel, getIssueBadgeVariant, @@ -104,6 +105,17 @@ import { const logger = createLogger('ToolInput') +const ADVANCED_MCP_SERVER_TOOL_SCHEMA: McpToolSchema = { + type: 'object', + properties: { + serverId: { + type: 'string', + description: 'Canonical workspace MCP server ID', + }, + }, + required: ['serverId'], +} + function WorkflowToolDeployBadge({ workflowId, onDeploySuccess, @@ -472,7 +484,10 @@ export const ToolInput = memo(function ToolInput({ ) const hasRefreshedRef = useRef(false) - const hasMcpTools = selectedTools.some((tool) => tool.type === 'mcp') + const hasMcpTools = selectedTools.some( + (tool) => tool.type === 'mcp' || tool.type === MCP_SERVER_ADVANCED_TOOL_TYPE + ) + const supportsAdvancedMcpServer = blockType === 'agent' || blockType === 'mothership' useEffect(() => { if (isPreview) return @@ -1108,46 +1123,72 @@ export const ToolInput = memo(function ToolInput({ mcpServerDrilldown && !permissionConfig.disableMcpTools && !mcpUnsupported && - mcpToolsByServer.size > 0 + mcpServers.some((server) => server.id === mcpServerDrilldown) ) { - const tools = mcpToolsByServer.get(mcpServerDrilldown) - if (tools && tools.length > 0) { - const server = mcpServers.find((s) => s.id === mcpServerDrilldown) - const serverName = tools[0]?.serverName || server?.name || 'Unknown Server' - const toolCount = tools.length - const selectedToolIdsForServer = new Set( - selectedTools - .filter((t) => t.type === 'mcp' && t.params?.serverId === mcpServerDrilldown) - .map((t) => t.toolId) - ) - const allAlreadySelected = tools.every((t) => selectedToolIdsForServer.has(t.id)) - const serverToolItems: ComboboxOption[] = [] + const tools = mcpToolsByServer.get(mcpServerDrilldown) ?? [] + const server = mcpServers.find((candidate) => candidate.id === mcpServerDrilldown) + const serverName = tools[0]?.serverName || server?.name || 'Unknown Server' + const allAlreadySelected = selectedTools.some( + (tool) => + tool.type === MCP_SERVER_ADVANCED_TOOL_TYPE && + tool.params?.serverId === mcpServerDrilldown + ) + const serverToolItems: ComboboxOption[] = [] - // Back navigation + serverToolItems.push({ + label: 'Back', + value: `mcp-server-back`, + iconElement: , + onSelect: () => { + setMcpServerDrilldown(null) + }, + keepOpen: true, + }) + + if (supportsAdvancedMcpServer) { serverToolItems.push({ - label: 'Back', - value: `mcp-server-back`, - iconElement: , + label: 'Use all available tools', + value: `mcp-server-all-${mcpServerDrilldown}`, + iconElement: createToolIcon('var(--brand-agent)', Server), onSelect: () => { + if (allAlreadySelected) return + const filteredTools = selectedTools.filter( + (tool) => + !( + (tool.type === 'mcp' || tool.type === MCP_SERVER_ADVANCED_TOOL_TYPE) && + tool.params?.serverId === mcpServerDrilldown + ) + ) + const serverBinding: StoredTool = { + type: MCP_SERVER_ADVANCED_TOOL_TYPE, + params: { serverId: mcpServerDrilldown }, + isExpanded: false, + usageControl: 'auto', + } + const nextTools = [ + ...filteredTools.map((tool) => ({ ...tool, isExpanded: false })), + serverBinding, + ] + reindexCanonicalModesOnMutate(selectedTools, filteredTools) + setStoreValue(nextTools) setMcpServerDrilldown(null) + setOpen(false) }, - keepOpen: true, + disabled: isPreview || disabled || allAlreadySelected, }) + } - // "Use all tools" option — adds each tool individually + for (const mcpTool of tools) { + const alreadySelected = + allAlreadySelected || isMcpToolAlreadySelected(selectedTools, mcpTool.id) serverToolItems.push({ - label: `Use all ${toolCount} tools`, - value: `mcp-server-all-${mcpServerDrilldown}`, - iconElement: createToolIcon('#6366F1', Server), + label: mcpTool.name, + value: `mcp-${mcpTool.id}`, + iconElement: createToolIcon(mcpTool.bgColor || '#6366F1', mcpTool.icon || McpIcon), onSelect: () => { - if (allAlreadySelected) return - // Remove existing individual tools from this server to avoid duplicates - const filteredTools = selectedTools.filter( - (t) => !(t.type === 'mcp' && t.params?.serverId === mcpServerDrilldown) - ) - // Add all tools individually - const newTools: StoredTool[] = tools.map((mcpTool) => ({ - type: 'mcp' as const, + if (alreadySelected) return + const newTool: StoredTool = { + type: 'mcp', title: mcpTool.name, toolId: mcpTool.id, params: { @@ -1156,61 +1197,23 @@ export const ToolInput = memo(function ToolInput({ toolName: mcpTool.name, serverName: mcpTool.serverName, }, - isExpanded: false, - usageControl: 'auto' as const, + isExpanded: true, + usageControl: 'auto', schema: { ...mcpTool.inputSchema, description: mcpTool.description, }, - })) - // Diff against `filteredTools` (pre-spread, same refs as `selectedTools`) - the - // spread copy below preserves the same relative order, so this correctly reflects - // each surviving tool's new position. - reindexCanonicalModesOnMutate(selectedTools, filteredTools) - setStoreValue([...filteredTools.map((t) => ({ ...t, isExpanded: false })), ...newTools]) - setMcpServerDrilldown(null) - setOpen(false) + } + handleMcpToolSelect(newTool, true) }, - disabled: isPreview || disabled || allAlreadySelected, - }) - - // Individual tools - for (const mcpTool of tools) { - const alreadySelected = isMcpToolAlreadySelected(selectedTools, mcpTool.id) - serverToolItems.push({ - label: mcpTool.name, - value: `mcp-${mcpTool.id}`, - iconElement: createToolIcon(mcpTool.bgColor || '#6366F1', mcpTool.icon || McpIcon), - onSelect: () => { - if (alreadySelected) return - const newTool: StoredTool = { - type: 'mcp', - title: mcpTool.name, - toolId: mcpTool.id, - params: { - serverId: mcpTool.serverId, - ...(server?.url && { serverUrl: server.url }), - toolName: mcpTool.name, - serverName: mcpTool.serverName, - }, - isExpanded: true, - usageControl: 'auto', - schema: { - ...mcpTool.inputSchema, - description: mcpTool.description, - }, - } - handleMcpToolSelect(newTool, true) - }, - disabled: isPreview || disabled || alreadySelected, - }) - } - - groups.push({ - section: serverName, - items: serverToolItems, + disabled: isPreview || disabled || alreadySelected, }) } + + groups.push({ + section: serverName, + items: serverToolItems, + }) return groups } @@ -1245,6 +1248,29 @@ export const ToolInput = memo(function ToolInput({ ) : undefined, }) + if (supportsAdvancedMcpServer) { + actionItems.push({ + label: 'MCP Server (Advanced)', + value: 'action-mcp-server-advanced', + icon: Server, + onSelect: () => { + setStoreValue([ + ...selectedTools.map((tool) => ({ ...tool, isExpanded: false })), + { + type: MCP_SERVER_ADVANCED_TOOL_TYPE, + params: { serverId: '' }, + isExpanded: true, + usageControl: 'auto', + }, + ]) + setOpen(false) + }, + disabled: isPreview || disabled || mcpUnsupported, + suffixElement: mcpUnsupported ? ( + + ) : undefined, + }) + } } if (actionItems.length > 0) { groups.push({ items: actionItems }) @@ -1280,11 +1306,13 @@ export const ToolInput = memo(function ToolInput({ } // MCP Servers — root folder view - if (!permissionConfig.disableMcpTools && !mcpUnsupported && mcpToolsByServer.size > 0) { + if (!permissionConfig.disableMcpTools && !mcpUnsupported && mcpServers.length > 0) { const serverItems: ComboboxOption[] = [] - for (const [serverId, tools] of mcpToolsByServer) { - const server = mcpServers.find((s) => s.id === serverId) + for (const server of mcpServers) { + if (!server.enabled) continue + const serverId = server.id + const tools = mcpToolsByServer.get(serverId) ?? [] const serverName = tools[0]?.serverName || server?.name || 'Unknown Server' const toolCount = tools.length @@ -1396,6 +1424,7 @@ export const ToolInput = memo(function ToolInput({ permissionConfig.disableMcpTools, mcpUnsupported, customUnsupported, + supportsAdvancedMcpServer, availableWorkflows, isToolAlreadySelected, reindexCanonicalModesOnMutate, @@ -1424,16 +1453,18 @@ export const ToolInput = memo(function ToolInput({ selectedTools.map((tool, toolIndex) => { const isCustomTool = tool.type === 'custom-tool' const isMcpTool = tool.type === 'mcp' + const isAdvancedMcpServer = tool.type === MCP_SERVER_ADVANCED_TOOL_TYPE + const isMcpFamily = isMcpTool || isAdvancedMcpServer const isWorkflowTool = tool.type === 'workflow' // Fall back to the unfiltered registry so chips for types hidden // from the picker (permissions, hideFromToolbar) keep their chrome. const toolBlock = - !isCustomTool && !isMcpTool + !isCustomTool && !isMcpFamily ? (toolBlocks.find((block) => block.type === tool.type) ?? getBlock(tool.type)) : null const currentToolId = - !isCustomTool && !isMcpTool + !isCustomTool && !isMcpFamily ? getToolIdForOperation(tool.type, tool.operation, toolBlock ?? undefined) || tool.toolId || '' @@ -1446,7 +1477,7 @@ export const ToolInput = memo(function ToolInput({ ) const subBlocksResult: SubBlocksForToolInput | null = - !isCustomTool && !isMcpTool && currentToolId + !isCustomTool && !isMcpFamily && currentToolId ? getSubBlocksForToolInput( currentToolId, tool.type, @@ -1465,12 +1496,15 @@ export const ToolInput = memo(function ToolInput({ : null const mcpTool = isMcpTool ? mcpTools.find((t) => t.id === tool.toolId) : null + const mcpTileColor = mcpTool?.bgColor || 'var(--brand-agent)' const mcpToolSchema = isMcpTool ? tool.schema || mcpTool?.inputSchema : null // Canonical name wins; stored title only when nothing resolves // (same policy as the canvas summary — see resolveStoredToolName). - const toolDisplayName = - resolveStoredToolName(tool, { customTools, mcpToolNamesById }) ?? 'Unknown Tool' + const toolDisplayName = isAdvancedMcpServer + ? (mcpServers.find((server) => server.id === tool.params?.serverId)?.name ?? + 'MCP Server (Advanced)') + : (resolveStoredToolName(tool, { customTools, mcpToolNamesById }) ?? 'Unknown Tool') /** * Every field this tool row renders, as `SubBlockConfig`s. A registry tool's @@ -1478,8 +1512,13 @@ export const ToolInput = memo(function ToolInput({ * declared type); an MCP tool's are derived from its JSON Schema. Both then * render through the one canonical sub-block renderer. */ - const displaySubBlocks: BlockSubBlockConfig[] = isMcpTool - ? buildSubBlocksFromJsonSchema(mcpToolSchema ?? undefined, formatParameterLabel) + const displaySubBlocks: BlockSubBlockConfig[] = isMcpFamily + ? buildSubBlocksFromJsonSchema( + isAdvancedMcpServer + ? ADVANCED_MCP_SERVER_TOOL_SCHEMA + : (mcpToolSchema ?? undefined), + formatParameterLabel + ) : (subBlocksResult?.subBlocks ?? []).filter( (sb) => !sb.reactiveCondition || @@ -1487,7 +1526,7 @@ export const ToolInput = memo(function ToolInput({ ) const hasOperations = - !isCustomTool && !isMcpTool && hasMultipleOperations(toolBlock ?? undefined) + !isCustomTool && !isMcpFamily && hasMultipleOperations(toolBlock ?? undefined) const hasToolBody = hasOperations || displaySubBlocks.length > 0 const isSearchExpanded = @@ -1547,8 +1586,8 @@ export const ToolInput = memo(function ToolInput({ style={{ backgroundColor: isCustomTool ? '#3B82F6' - : isMcpTool - ? mcpTool?.bgColor || '#6366F1' + : isMcpFamily + ? mcpTileColor : isWorkflowTool ? '#6366F1' : toolBlock?.bgColor, @@ -1556,13 +1595,10 @@ export const ToolInput = memo(function ToolInput({ > {isCustomTool ? ( - ) : isMcpTool ? ( + ) : isMcpFamily ? ( ) : isWorkflowTool ? ( + mcpConnections: Array<{ + credentialId: string + email: string + displayName: string + mcpServerId: string + mcpServerName: string + toolNames: string[] + }> credentialGroups: Array<{ id: string name: string @@ -92,21 +100,32 @@ interface CredentialGroupBlockOutput { } const INVITE_OPERATIONS = ['send_invite', 'get_invite_link'] as const -const GROUP_OPERATIONS = ['list_credentials', ...INVITE_OPERATIONS, 'list_people'] as const -const LIST_OPERATIONS = ['list_credentials', 'list_people', 'list_groups'] as const +const GROUP_OPERATIONS = [ + 'list_credentials', + 'list_mcp_connections', + ...INVITE_OPERATIONS, + 'list_people', +] as const +const LIST_OPERATIONS = [ + 'list_credentials', + 'list_mcp_connections', + 'list_people', + 'list_groups', +] as const export const CredentialGroupBlock: BlockConfig = { type: 'credential_group', name: 'Credential Groups', - description: 'Invite people and use credentials collected by Credential Groups', + description: 'Invite people and use credentials or MCP connections from Credential Groups', longDescription: - 'List usable managed credentials, inspect invited people, send or generate an account-connection invitation, or discover Credential Groups in the current workspace. The block returns credential IDs and account metadata without exposing OAuth tokens.', + 'List usable managed credentials or MCP connections, inspect invited people, send or generate an account-connection invitation, or discover Credential Groups in the current workspace. The block returns credential IDs and account metadata without exposing OAuth tokens.', bestPractices: ` - "List Credentials" returns every active credential. Filter by email to select one enrolled person, by provider to select one account type, or by both for an exact match. - Provider blocks can use the current actor's enrolled credential by default. Using another enrollment requires an explicit workflow access grant. - With a workflow access grant, use "List Credentials" with a ForEach loop to run a provider block once for every connected account. - Continue with nextCursor until hasMore is false when a list operation returns multiple pages. - "List Credentials" returns active, usable credentials only. Reconnect-needed and revoked credentials are excluded. + - "List MCP Connections" returns explicit managed MCP credential IDs and tool names. Pass one credentialId to an advanced MCP server tool. - Use "List People" to inspect invitation and connection progress without exposing credential secrets. - "Send Invite" sends one email. Use a loop when invitations should come from a dynamic list. - "Get Invite Link" issues a fresh seven-day bearer link without sending email. It invalidates the previous link for that email, so treat the output as a secret. @@ -128,6 +147,16 @@ export const CredentialGroupBlock: BlockConfig = { { text: ', from', field: ['providerFilter', 'manualProviderIds'] }, { text: ', up to', field: 'limit', after: 'credentials' }, ], + list_mcp_connections: [ + { + text: 'List MCP connections from', + field: ['credentialGroup', 'manualCredentialGroup'], + core: true, + }, + { text: ', for', field: 'email' }, + { text: ', on server', field: 'mcpServerId' }, + { text: ', up to', field: 'limit', after: 'connections' }, + ], send_invite: [ { text: 'Invite', field: 'email', core: true }, { @@ -165,6 +194,7 @@ export const CredentialGroupBlock: BlockConfig = { type: 'dropdown', options: [ { label: 'List Credentials', id: 'list_credentials' }, + { label: 'List MCP Connections', id: 'list_mcp_connections' }, { label: 'Send Invite', id: 'send_invite' }, { label: 'Get Invite Link', id: 'get_invite_link' }, { label: 'List People', id: 'list_people' }, @@ -224,6 +254,15 @@ export const CredentialGroupBlock: BlockConfig = { placeholder: '["google-email", "slack"] — leave empty for all providers', condition: { field: 'operation', value: 'list_credentials' }, }, + { + id: 'mcpServerId', + title: 'MCP Server ID', + type: 'short-input', + required: false, + mode: 'advanced', + placeholder: 'mcp-... — leave empty for all MCP servers', + condition: { field: 'operation', value: 'list_mcp_connections' }, + }, { id: 'peopleStatuses', title: 'Status', @@ -262,7 +301,7 @@ export const CredentialGroupBlock: BlockConfig = { operation: { type: 'string', description: - "'list_credentials', 'send_invite', 'get_invite_link', 'list_people', or 'list_groups'", + "'list_credentials', 'list_mcp_connections', 'send_invite', 'get_invite_link', 'list_people', or 'list_groups'", }, credentialGroupId: { type: 'string', description: 'Credential Group ID' }, email: { @@ -273,6 +312,10 @@ export const CredentialGroupBlock: BlockConfig = { type: 'json', description: 'Optional OAuth provider IDs to include when listing credentials', }, + mcpServerId: { + type: 'string', + description: 'Optional root MCP server ID to include when listing MCP connections', + }, peopleStatuses: { type: 'json', description: 'Optional invitation statuses to include when listing people', @@ -287,6 +330,12 @@ export const CredentialGroupBlock: BlockConfig = { 'Usable credential references (credentialId, email, displayName, providerId, providerSubjectId, providerTenantId)', condition: { field: 'operation', value: 'list_credentials' }, }, + mcpConnections: { + type: 'json', + description: + 'Usable MCP connection references (credentialId, email, displayName, mcpServerId, mcpServerName, toolNames)', + condition: { field: 'operation', value: 'list_mcp_connections' }, + }, credentialGroups: { type: 'json', description: diff --git a/apps/sim/ee/workspace-forking/lib/remap/remap-references.ts b/apps/sim/ee/workspace-forking/lib/remap/remap-references.ts index 916acd6ecdf..6886502536d 100644 --- a/apps/sim/ee/workspace-forking/lib/remap/remap-references.ts +++ b/apps/sim/ee/workspace-forking/lib/remap/remap-references.ts @@ -4,7 +4,7 @@ import { isRecordLike, omit } from '@sim/utils/object' import type { SubBlockType } from '@sim/workflow-types/blocks' import type { z } from 'zod' import type { forkRemapKindSchema } from '@/lib/api/contracts/workspace-fork' -import { createMcpToolId } from '@/lib/mcp/shared' +import { createMcpToolId, MCP_SERVER_ADVANCED_TOOL_TYPE } from '@/lib/mcp/shared' import { coerceObjectArray, type SubBlockRecord, @@ -954,7 +954,7 @@ function remapForkToolInputValue( return } if ( - tool.type === 'mcp' && + (tool.type === 'mcp' || tool.type === MCP_SERVER_ADVANCED_TOOL_TYPE) && isRecordLike(tool.params) && typeof tool.params.serverId === 'string' ) { @@ -981,7 +981,8 @@ function remapForkToolInputValue( keep({ ...tool, params: nextParams, - toolId: toolName ? createMcpToolId(target, toolName) : tool.toolId, + toolId: + tool.type === 'mcp' && toolName ? createMcpToolId(target, toolName) : tool.toolId, }) return } diff --git a/apps/sim/executor/handlers/agent/agent-handler.test.ts b/apps/sim/executor/handlers/agent/agent-handler.test.ts index c3026f5db06..df48acbf8c4 100644 --- a/apps/sim/executor/handlers/agent/agent-handler.test.ts +++ b/apps/sim/executor/handlers/agent/agent-handler.test.ts @@ -3701,6 +3701,74 @@ describe('AgentBlockHandler', () => { ) }) + it('expands every live tool from an explicitly selected managed MCP connection', async () => { + const credentialId = 'mcp-cg-123456789012345678901' + mockDiscoverMcpServerToolsAsExecutor.mockResolvedValue([ + { + name: 'search_transcripts', + description: 'Search transcripts', + inputSchema: { + type: 'object', + properties: { query: { type: 'string' } }, + required: ['query'], + }, + serverId: credentialId, + serverName: 'Fireflies', + }, + { + name: 'get_transcript', + description: 'Get one transcript', + inputSchema: { + type: 'object', + properties: { transcriptId: { type: 'string' } }, + required: ['transcriptId'], + }, + serverId: credentialId, + serverName: 'Fireflies', + }, + ]) + + await handler.execute( + { + ...mockContext, + userId: 'permission-check-user', + workspaceId: 'test-workspace-123', + workflowId: 'test-workflow-456', + }, + mockBlock, + { + model: 'gpt-4o', + userPrompt: 'Use Fireflies', + apiKey: 'test-api-key', + tools: [ + { + type: 'mcp-server-advanced', + params: { serverId: credentialId }, + usageControl: 'auto' as const, + }, + ], + } + ) + + expect(mockDiscoverMcpServerToolsAsExecutor).toHaveBeenCalledWith( + expect.objectContaining({ + serverId: credentialId, + workspaceId: 'test-workspace-123', + }) + ) + const providerTools = mockExecuteProviderRequest.mock.calls[0][1].tools + expect(providerTools).toEqual([ + expect.objectContaining({ + id: `${credentialId}-search_transcripts`, + params: {}, + }), + expect.objectContaining({ + id: `${credentialId}-get_transcript`, + params: {}, + }), + ]) + }) + describe('customToolId resolution - DB as source of truth', () => { const staleInlineSchema = { function: { diff --git a/apps/sim/executor/handlers/agent/agent-handler.ts b/apps/sim/executor/handlers/agent/agent-handler.ts index ef359254ea6..00a7dfc196f 100644 --- a/apps/sim/executor/handlers/agent/agent-handler.ts +++ b/apps/sim/executor/handlers/agent/agent-handler.ts @@ -17,8 +17,13 @@ import { readWorkflowInputFieldsForTool, readWorkflowMetadataForTool, } from '@/lib/internal/workflows/read-tool-enrichment' +import { assertValidMcpServerToolBindings, MCP_SERVER_ADVANCED_TOOL_TYPE } from '@/lib/mcp/shared' import type { McpToolSchema } from '@/lib/mcp/types' -import { createMcpToolId } from '@/lib/mcp/utils' +import { + createMcpToolId, + isManagedMcpConnectionId, + MANAGED_MCP_CONNECTION_PREFIX, +} from '@/lib/mcp/utils' import { type AutoMediaKind, type AutoRoutingResult, @@ -595,7 +600,9 @@ export class AgentBlockHandler implements BlockHandler { private async validateToolPermissions(ctx: ExecutionContext, tools: ToolInput[]): Promise { if (!Array.isArray(tools) || tools.length === 0) return - const hasMcpTools = tools.some((t) => t.type === 'mcp') + const hasMcpTools = tools.some( + (t) => t.type === 'mcp' || t.type === MCP_SERVER_ADVANCED_TOOL_TYPE + ) const hasCustomTools = tools.some((t) => t.type === 'custom-tool') if (hasMcpTools) { @@ -635,21 +642,41 @@ export class AgentBlockHandler implements BlockHandler { } const availableServerIds = new Set() - if (serverIds.length > 0) { + const sharedServerIds: string[] = [] + for (const serverId of serverIds) { + if (serverId.startsWith(MANAGED_MCP_CONNECTION_PREFIX)) { + if (!isManagedMcpConnectionId(serverId)) { + throw new Error('Invalid managed MCP connection ID') + } + availableServerIds.add(serverId) + } else { + sharedServerIds.push(serverId) + } + } + if (sharedServerIds.length > 0) { try { const servers = await db - .select({ id: mcpServers.id, connectionStatus: mcpServers.connectionStatus }) + .select({ + id: mcpServers.id, + connectionStatus: mcpServers.connectionStatus, + credentialGroupId: mcpServers.credentialGroupId, + enabled: mcpServers.enabled, + }) .from(mcpServers) .where( and( eq(mcpServers.workspaceId, ctx.workspaceId), - inArray(mcpServers.id, serverIds), + inArray(mcpServers.id, sharedServerIds), isNull(mcpServers.deletedAt) ) ) for (const server of servers) { - if (server.connectionStatus === 'connected') { + if ( + server.enabled && + !server.credentialGroupId && + server.connectionStatus === 'connected' + ) { availableServerIds.add(server.id) } } @@ -662,7 +689,7 @@ export class AgentBlockHandler implements BlockHandler { getErrorDiagnosticFallback(error) ) ) - for (const serverId of serverIds) { + for (const serverId of sharedServerIds) { availableServerIds.add(serverId) } } @@ -723,8 +750,11 @@ export class AgentBlockHandler implements BlockHandler { const root = ['tools', String(toolIndex)] as const const paths: ResolvedSecretInputPath[] = [[...root, 'type']] if (tool.operation !== undefined) paths.push([...root, 'operation']) + if (tool.type === 'mcp' || tool.type === MCP_SERVER_ADVANCED_TOOL_TYPE) { + paths.push([...root, 'params', 'serverId']) + } if (tool.type === 'mcp') { - paths.push([...root, 'params', 'serverId'], [...root, 'params', 'toolName']) + paths.push([...root, 'params', 'toolName']) } if (tool.type === 'custom-tool' && !tool.customToolId) { paths.push([...root, 'title'], [...root, 'schema', 'function', 'name']) @@ -735,6 +765,7 @@ export class AgentBlockHandler implements BlockHandler { ) const mcpTools: IndexedToolInput[] = [] + const advancedMcpServers: IndexedToolInput[] = [] const otherTools: IndexedToolInput[] = [] const inputProvenance = new Map< ProviderToolConfig, @@ -767,9 +798,12 @@ export class AgentBlockHandler implements BlockHandler { return formattedTool } + assertValidMcpServerToolBindings(filtered.map(({ tool }) => tool)) for (const entry of filtered) { if (entry.tool.type === 'mcp') { mcpTools.push(entry) + } else if (entry.tool.type === MCP_SERVER_ADVANCED_TOOL_TYPE) { + advancedMcpServers.push(entry) } else { otherTools.push(entry) } @@ -820,8 +854,13 @@ export class AgentBlockHandler implements BlockHandler { trackInputProvenance, projectedToolInputs ) + const advancedMcpResults = await this.processAdvancedMcpServers( + ctx, + advancedMcpServers, + trackInputProvenance + ) - const allTools = [...otherResults, ...mcpResults] + const allTools = [...otherResults, ...mcpResults, ...advancedMcpResults] const tools = allTools.filter( (tool): tool is ProviderToolConfig => tool !== null && tool !== undefined ) @@ -880,7 +919,10 @@ export class AgentBlockHandler implements BlockHandler { // An MCP tool has no block, so its only structured keys are the ones its own // `paramsTransform` decodes. A custom tool has neither. const blockInputs = - tool.type && tool.type !== 'mcp' && tool.type !== 'custom-tool' + tool.type && + tool.type !== 'mcp' && + tool.type !== MCP_SERVER_ADVANCED_TOOL_TYPE && + tool.type !== 'custom-tool' ? getBlock(tool.type)?.inputs : undefined return prepareResolvedSecretProjectedInputs(alignedParams, blockInputs, formattedParams, { @@ -1145,6 +1187,37 @@ export class AgentBlockHandler implements BlockHandler { return results } + private async processAdvancedMcpServers( + ctx: ExecutionContext, + entries: IndexedToolInput[], + trackInputProvenance: ( + formattedTool: ProviderToolConfig | null, + entry: IndexedToolInput + ) => ProviderToolConfig | null + ): Promise> { + const results = await Promise.all( + entries.map(async (entry) => { + const serverId = entry.tool.params?.serverId + if (!serverId) throw new Error('MCP Server (Advanced) requires params.serverId') + const tools = await this.discoverMcpToolsForServer(ctx, serverId) + return Promise.all( + tools.map(async (tool) => { + const created = await this.buildMcpTool({ + serverId, + toolName: tool.name, + description: tool.description || `MCP tool ${tool.name} from ${tool.serverName}`, + schema: tool.inputSchema || { type: 'object', properties: {} }, + userProvidedParams: {}, + usageControl: entry.tool.usageControl, + }) + return trackInputProvenance(created, entry) + }) + ) + }) + ) + return results.flat() + } + /** * Create MCP tool from cached schema. No MCP server connection required. */ @@ -1296,9 +1369,6 @@ export class AgentBlockHandler implements BlockHandler { /** Discovers one server's tools through the authorized MCP operation. */ private async discoverMcpToolsForServer(ctx: ExecutionContext, serverId: string): Promise { - if (!ctx.userId) { - throw new Error('userId is required for MCP tool discovery') - } if (!ctx.workspaceId) { throw new Error('workspaceId is required for MCP tool discovery') } @@ -1344,7 +1414,7 @@ export class AgentBlockHandler implements BlockHandler { schema: McpToolSchema userProvidedParams: Record usageControl?: 'auto' | 'force' | 'none' - }) { + }): Promise { const filteredSchema = filterSchemaForLLM(config.schema, config.userProvidedParams) const toolId = createMcpToolId(config.serverId, config.toolName) @@ -1360,7 +1430,11 @@ export class AgentBlockHandler implements BlockHandler { return { id: toolId, description: config.description, - parameters: filteredSchema, + parameters: { + type: filteredSchema.type, + properties: filteredSchema.properties ?? {}, + required: filteredSchema.required ?? [], + }, params: config.userProvidedParams, usageControl: config.usageControl || 'auto', paramsTransform: (params: Record) => decodeToolParams(params, paramShapes), diff --git a/apps/sim/executor/handlers/agent/types.ts b/apps/sim/executor/handlers/agent/types.ts index 4c311b190dd..35f97a6cd29 100644 --- a/apps/sim/executor/handlers/agent/types.ts +++ b/apps/sim/executor/handlers/agent/types.ts @@ -51,6 +51,7 @@ export interface AgentInputs { * - Standard block types (e.g., 'api', 'search', 'function') * - 'custom-tool': User-defined tools with custom code * - 'mcp': Individual MCP tool from a connected server + * - 'mcp-server-advanced': All tools available to the executing subject from one MCP server */ export interface ToolInput { /** Tool type identifier */ diff --git a/apps/sim/executor/handlers/credential-group/credential-group-handler.test.ts b/apps/sim/executor/handlers/credential-group/credential-group-handler.test.ts index f1bdeaa44d5..02b2080019a 100644 --- a/apps/sim/executor/handlers/credential-group/credential-group-handler.test.ts +++ b/apps/sim/executor/handlers/credential-group/credential-group-handler.test.ts @@ -13,6 +13,7 @@ const mocks = vi.hoisted(() => ({ enforceInviteRateLimit: vi.fn(), listCredentials: vi.fn(), listGroups: vi.fn(), + listMcpConnections: vi.fn(), listPeople: vi.fn(), sendInvite: vi.fn(), })) @@ -29,6 +30,10 @@ vi.mock('@/lib/credential-groups/application/list-groups', () => ({ listCredentialGroupsForWorkflow: { execute: mocks.listGroups }, })) +vi.mock('@/lib/credential-groups/application/list-mcp-connections', () => ({ + listCredentialGroupMcpConnections: { execute: mocks.listMcpConnections }, +})) + vi.mock('@/lib/credential-groups/application/list-people', () => ({ CREDENTIAL_GROUP_PEOPLE_STATUSES: [ 'invited', @@ -201,6 +206,36 @@ describe('CredentialGroupBlockHandler', () => { }) }) + it('lists explicit MCP connection references for an advanced MCP tool', async () => { + mocks.listMcpConnections.mockResolvedValue({ + mcpConnections: [], + count: 0, + hasMore: false, + nextCursor: null, + }) + + const result = await new CredentialGroupBlockHandler().execute(context, block, { + operation: 'list_mcp_connections', + credentialGroupId: ' group-1 ', + email: ' person@example.com ', + mcpServerId: ' mcp-server-1 ', + limit: '25', + cursor: ' mcp-cg-connection-1 ', + }) + + expect(mocks.listMcpConnections).toHaveBeenCalledWith({ + principal, + input: { + credentialGroupId: 'group-1', + email: 'person@example.com', + mcpServerId: 'mcp-server-1', + limit: 25, + cursor: 'mcp-cg-connection-1', + }, + }) + expect(result).toEqual({ mcpConnections: [], count: 0, hasMore: false, nextCursor: null }) + }) + it('lists groups under workspace-scoped delegation', async () => { mocks.listGroups.mockResolvedValue({ credentialGroups: [], diff --git a/apps/sim/executor/handlers/credential-group/credential-group-handler.ts b/apps/sim/executor/handlers/credential-group/credential-group-handler.ts index 0bc0c24169a..44d90ea3bd5 100644 --- a/apps/sim/executor/handlers/credential-group/credential-group-handler.ts +++ b/apps/sim/executor/handlers/credential-group/credential-group-handler.ts @@ -3,6 +3,7 @@ import { CREDENTIAL_GROUP_DELEGATION_AUDIENCE } from '@/lib/credential-groups/ap import { createCredentialGroupInviteLink } from '@/lib/credential-groups/application/create-invite-link' import { listCredentialGroupCredentials } from '@/lib/credential-groups/application/list-credentials' import { listCredentialGroupsForWorkflow } from '@/lib/credential-groups/application/list-groups' +import { listCredentialGroupMcpConnections } from '@/lib/credential-groups/application/list-mcp-connections' import { CREDENTIAL_GROUP_PEOPLE_STATUSES, listCredentialGroupPeople, @@ -21,6 +22,7 @@ const logger = createLogger('CredentialGroupBlockHandler') const CREDENTIAL_GROUP_OPERATION_IDS = [ 'list_credentials', + 'list_mcp_connections', 'send_invite', 'get_invite_link', 'list_people', @@ -132,6 +134,24 @@ export class CredentialGroupBlockHandler implements BlockHandler { }) return result } + case 'list_mcp_connections': { + const result = await listCredentialGroupMcpConnections.execute({ + principal, + input: { + credentialGroupId: credentialGroupId!, + limit: parseLimit(inputs.limit), + cursor: parseOptionalString(inputs.cursor, 'Cursor'), + email: parseOptionalString(inputs.email, 'Email'), + mcpServerId: parseOptionalString(inputs.mcpServerId, 'MCP Server ID'), + }, + }) + logger.info('Listed Credential Group MCP connections', { + credentialGroupId, + count: result.count, + hasMore: result.hasMore, + }) + return result + } case 'send_invite': { await enforceCredentialGroupInvitationExecutionRateLimit(principal.workspaceId) const result = await sendCredentialGroupInvite.execute({ diff --git a/apps/sim/executor/handlers/mothership/mothership-handler.test.ts b/apps/sim/executor/handlers/mothership/mothership-handler.test.ts index d66264203fc..98a90bd6823 100644 --- a/apps/sim/executor/handlers/mothership/mothership-handler.test.ts +++ b/apps/sim/executor/handlers/mothership/mothership-handler.test.ts @@ -33,6 +33,7 @@ const { mockAreModelSafeWorkspaceFileKeys, mockBuildAuthHeaders, mockBuildAPIUrl, + mockDiscoverMcpServerToolsAsExecutor, mockExtractAPIErrorMessage, mockGenerateId, mockReadUserFileContent, @@ -40,6 +41,7 @@ const { mockAreModelSafeWorkspaceFileKeys: vi.fn(), mockBuildAuthHeaders: vi.fn(), mockBuildAPIUrl: vi.fn(), + mockDiscoverMcpServerToolsAsExecutor: vi.fn(), mockExtractAPIErrorMessage: vi.fn(), mockGenerateId: vi.fn(), mockReadUserFileContent: vi.fn(), @@ -51,6 +53,10 @@ vi.mock('@/lib/uploads/contexts/workspace/workspace-file-secret-provenance', () 'File cannot be sent to a model because its secret provenance is unavailable', })) +vi.mock('@/lib/internal/mcp/discover-tools', () => ({ + discoverMcpServerToolsAsExecutor: mockDiscoverMcpServerToolsAsExecutor, +})) + vi.mock('@/executor/utils/http', () => ({ buildAuthHeaders: mockBuildAuthHeaders, buildAPIUrl: mockBuildAPIUrl, @@ -982,6 +988,48 @@ describe('MothershipBlockHandler', () => { expect(body.contexts).toEqual([{ kind: 'skill', skillId: 'skill-1', label: 'sales-playbook' }]) }) + it('expands an explicitly selected managed MCP connection for the request', async () => { + const credentialId = 'mcp-cg-123456789012345678901' + mockDiscoverMcpServerToolsAsExecutor.mockResolvedValueOnce([ + { + name: 'search_transcripts', + description: 'Search transcripts', + inputSchema: { type: 'object', properties: { query: { type: 'string' } } }, + serverId: credentialId, + serverName: 'Fireflies', + }, + ]) + fetchMock.mockResolvedValue(createJsonResponse({ content: 'done', toolCalls: [] })) + + await handler.execute(context, block, { + prompt: 'Search Fireflies', + tools: [ + { + type: 'mcp-server-advanced', + params: { serverId: credentialId }, + usageControl: 'force', + }, + ], + }) + + expect(mockDiscoverMcpServerToolsAsExecutor).toHaveBeenCalledWith( + expect.objectContaining({ serverId: credentialId, workspaceId: context.workspaceId }) + ) + const [, options] = fetchMock.mock.calls[0] as [string, RequestInit] + expect(JSON.parse(String(options.body)).mcpTools).toEqual([ + { + type: 'mcp', + usageControl: 'force', + schema: { type: 'object', properties: { query: { type: 'string' } } }, + params: { + serverId: credentialId, + toolName: 'search_transcripts', + serverName: 'Fireflies', + }, + }, + ]) + }) + it('does not scan arbitrary Mothership metadata, attachment names, or payloads', async () => { const secret = 'boundary-secret' const registry = new ResolvedSecretTraceRegistry([ diff --git a/apps/sim/executor/handlers/mothership/mothership-handler.ts b/apps/sim/executor/handlers/mothership/mothership-handler.ts index e75a1814c41..bf2b275ed59 100644 --- a/apps/sim/executor/handlers/mothership/mothership-handler.ts +++ b/apps/sim/executor/handlers/mothership/mothership-handler.ts @@ -20,6 +20,8 @@ import { RESOLVED_SECRET_PROVENANCE_FIELD, RESOLVED_SECRET_PROVENANCE_METADATA_V1, } from '@/lib/execution/private-tool-metadata' +import { discoverMcpServerToolsAsExecutor } from '@/lib/internal/mcp/discover-tools' +import { assertValidMcpServerToolBindings, MCP_SERVER_ADVANCED_TOOL_TYPE } from '@/lib/mcp/shared' import { areModelSafeWorkspaceFileKeys, MODEL_UNSAFE_WORKSPACE_FILE_ERROR_MESSAGE, @@ -144,6 +146,64 @@ function selectMothershipMcpTools(tools: unknown): MothershipMcpToolSelection[] return selectIndexedMothershipMcpTools(tools).map(({ selection }) => selection) } +async function expandMothershipMcpTools( + ctx: ExecutionContext, + tools: unknown +): Promise { + if (!Array.isArray(tools)) return [] + assertValidMcpServerToolBindings(tools) + const individual = selectMothershipMcpTools(tools) + const advanced: Array<{ serverId: string; usageControl: 'auto' | 'force' }> = tools.flatMap( + (candidate) => { + if (!isPlainRecord(candidate) || candidate.type !== MCP_SERVER_ADVANCED_TOOL_TYPE) return [] + if (candidate.usageControl === 'none') return [] + if (!isPlainRecord(candidate.params)) { + throw new Error('MCP Server (Advanced) requires params.serverId') + } + const serverId = candidate.params.serverId + if (typeof serverId !== 'string' || !serverId.trim()) { + throw new Error('MCP Server (Advanced) requires params.serverId') + } + const usageControl: 'auto' | 'force' = candidate.usageControl === 'force' ? 'force' : 'auto' + return [{ serverId, usageControl }] + } + ) + if (advanced.length === 0) return individual + if (!ctx.workspaceId || !ctx.workflowId) { + throw new Error('Workspace and workflow context are required for MCP Server (Advanced)') + } + const workspaceId = ctx.workspaceId + const workflowId = ctx.workflowId + + const expanded = await Promise.all( + advanced.map(async ({ serverId, usageControl }) => { + const discovered = await discoverMcpServerToolsAsExecutor({ + workspaceId, + context: { + workflowId, + workspaceId, + executionId: ctx.executionId, + userId: ctx.userId, + executorDelegationOrigin: ctx.executorDelegationOrigin, + }, + serverId, + signal: ctx.abortSignal, + }) + return discovered.map((tool) => ({ + type: 'mcp' as const, + usageControl, + schema: tool.inputSchema, + params: { + serverId, + toolName: tool.name, + serverName: tool.serverName, + }, + })) + }) + ) + return [...individual, ...expanded.flat()] +} + function selectIndexedMothershipSkillContexts( skills: unknown, privateSelectorIndexes: ReadonlySet = new Set() @@ -288,6 +348,12 @@ function selectMothershipMetadataModelInputPaths( modelInputPaths.push([...root, 'params', 'serverName']) } } + if (Array.isArray(tools)) { + tools.forEach((candidate, inputIndex) => { + if (!isPlainRecord(candidate) || candidate.type !== MCP_SERVER_ADVANCED_TOOL_TYPE) return + structuralInputPaths.push(['tools', String(inputIndex), 'params', 'serverId']) + }) + } for (const { inputIndex, hasExplicitLabel } of selectIndexedMothershipSkillContexts(skills)) { const root = ['skills', String(inputIndex)] as const @@ -827,7 +893,7 @@ export class MothershipBlockHandler implements BlockHandler { secretScope: inputs.secretScope, mountedSecrets: inputs.mountedSecrets, }) - const mcpTools = selectMothershipMcpTools(modelInputProjection.value.tools) + const mcpTools = await expandMothershipMcpTools(ctx, modelInputProjection.value.tools) const skillContexts = selectMothershipSkillContexts( modelInputProjection.value.skills, privateSkillSelectors.inputIndexes diff --git a/apps/sim/hooks/queries/mcp.ts b/apps/sim/hooks/queries/mcp.ts index 665ea7f4240..78b22793780 100644 --- a/apps/sim/hooks/queries/mcp.ts +++ b/apps/sim/hooks/queries/mcp.ts @@ -144,7 +144,10 @@ export function useMcpToolServers(workspaceId: string) { const managed = useManagedMcpCatalog(workspaceId) return useMemo( () => ({ - data: [...(shared.data ?? []), ...(managed.data?.servers ?? [])], + data: [ + ...(shared.data ?? []).filter((server) => !server.credentialGroupId), + ...(managed.data?.servers ?? []), + ], isLoading: shared.isLoading || managed.isLoading, error: shared.error ?? managed.error, }), @@ -180,6 +183,7 @@ function isServerEligibleForDiscovery(server: McpServer, workspaceId: string): b return ( server.enabled && server.workspaceId === workspaceId && + !server.credentialGroupId && (server.authType !== 'oauth' || server.connectionStatus === 'connected') ) } diff --git a/apps/sim/lib/api/contracts/mcp.ts b/apps/sim/lib/api/contracts/mcp.ts index 3e1225a2336..3aa28c00a40 100644 --- a/apps/sim/lib/api/contracts/mcp.ts +++ b/apps/sim/lib/api/contracts/mcp.ts @@ -143,6 +143,7 @@ export const mcpServerSchema = z deletedAt: optionalDateStringFromNullableSchema, oauthClientId: optionalStringFromNullableSchema, hasOauthClientSecret: z.boolean().optional(), + credentialGroupId: optionalStringFromNullableSchema, }) .passthrough() export type McpServer = z.output diff --git a/apps/sim/lib/api/contracts/v2/__tests__/workflow-agent-tools.test.ts b/apps/sim/lib/api/contracts/v2/__tests__/workflow-agent-tools.test.ts index dd5c2a1c115..7a00015bb7f 100644 --- a/apps/sim/lib/api/contracts/v2/__tests__/workflow-agent-tools.test.ts +++ b/apps/sim/lib/api/contracts/v2/__tests__/workflow-agent-tools.test.ts @@ -11,7 +11,7 @@ import { import { MAX_MCP_TOOL_NAME_BYTES } from '@/lib/mcp/constants' describe('v2AgentToolInputSchema', () => { - it('accepts catalog integration, custom-tool reference, and MCP tool shapes', () => { + it('accepts catalog integration, custom-tool reference, and both MCP tool shapes', () => { const tools = [ { type: 'cloudwatch', @@ -29,6 +29,11 @@ describe('v2AgentToolInputSchema', () => { params: { serverId: 'mcp_123', toolName: 'search_docs', collection: 'incidents' }, usageControl: 'none', }, + { + type: 'mcp-server-advanced', + params: { serverId: 'mcp_456' }, + usageControl: 'auto', + }, ] expect(v2AgentToolInputSchema.parse(tools)).toEqual(tools) @@ -56,6 +61,8 @@ describe('v2AgentToolInputSchema', () => { it.each([ [{ type: 'custom-tool', usageControl: 'auto' }], [{ type: 'mcp', params: { serverId: 'mcp_123' }, usageControl: 'auto' }], + [{ type: 'mcp-server-advanced', params: {}, usageControl: 'auto' }], + [{ type: 'mcp-server-advanced', params: { serverId: 'mcp_123', toolName: 'lookup' } }], [{ type: 'slack', operation: 'send', usageControl: 'sometimes' }], ])('rejects a malformed reserved tool shape', (tools) => { expect(v2AgentToolInputSchema.safeParse(tools).success).toBe(false) @@ -98,6 +105,13 @@ describe('v2AgentToolInputSchema', () => { }, }, ], + [ + 'advanced MCP server id', + { + type: 'mcp-server-advanced', + params: { serverId: 'a'.repeat(MAX_ID_LENGTH + 1) }, + }, + ], [ 'MCP multibyte tool name', { diff --git a/apps/sim/lib/api/contracts/v2/workflows.ts b/apps/sim/lib/api/contracts/v2/workflows.ts index 6a78ce0f8bf..2a2e828361b 100644 --- a/apps/sim/lib/api/contracts/v2/workflows.ts +++ b/apps/sim/lib/api/contracts/v2/workflows.ts @@ -2659,8 +2659,8 @@ export const v2AgentIntegrationToolSchema = z .min(1, 'Agent integration tool type cannot be empty') .max(255, 'Agent integration tool type must be at most 255 characters') .regex( - /^(?!(?:custom-tool|mcp)$).+$/, - 'Agent integration tool type must be a catalog block id, not `custom-tool` or `mcp`' + /^(?!(?:custom-tool|mcp|mcp-server-advanced)$).+$/, + 'Agent integration tool type must be a catalog block id, not a reserved custom or MCP type' ) .describe( 'Catalog block id, such as `cloudwatch` or `slack`. Use the block id, never an underlying tool id.' @@ -2820,9 +2820,49 @@ export const v2AgentMcpToolSchema = z ], }) +/** Every tool currently available through one workspace MCP server. */ +export const v2AgentMcpServerAdvancedSchema = z + .object({ + type: z.literal('mcp-server-advanced').describe('Server-wide MCP binding discriminator.'), + params: z + .object({ + serverId: z + .string() + .trim() + .min(1, 'Agent MCP serverId cannot be empty') + .max(MAX_ID_LENGTH, `Agent MCP serverId must be at most ${MAX_ID_LENGTH} characters`) + .describe( + 'Workspace MCP server ID or explicit credential-group managed MCP connection ID.' + ), + }) + .strict() + .describe('Server identity for discovering and invoking every available MCP tool.'), + usageControl: v2AgentToolUsageControlSchema.optional(), + }) + .catchall( + z.unknown().describe('Forward-compatible MCP server metadata preserved by the workflow editor.') + ) + .meta({ + id: 'AgentMcpServerAdvanced', + 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', + }, + ], + }) + /** One callable tool attached directly to an Agent block. */ export const v2AgentToolSchema = z - .xor([v2AgentIntegrationToolSchema, v2AgentCustomToolSchema, v2AgentMcpToolSchema]) + .xor([ + v2AgentIntegrationToolSchema, + v2AgentCustomToolSchema, + v2AgentMcpToolSchema, + v2AgentMcpServerAdvancedSchema, + ]) .meta({ id: 'AgentTool', title: 'Agent tool', diff --git a/apps/sim/lib/credential-groups/application/list-mcp-connections.test.ts b/apps/sim/lib/credential-groups/application/list-mcp-connections.test.ts new file mode 100644 index 00000000000..b543cafb4d8 --- /dev/null +++ b/apps/sim/lib/credential-groups/application/list-mcp-connections.test.ts @@ -0,0 +1,203 @@ +/** + * @vitest-environment node + */ +import type { SessionPrincipal, WorkflowExecutionDelegatedPrincipal } from '@sim/auth/principal' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + getWorkspaceOwnerSubscriptionAccess: vi.fn(), + listMcpConnections: vi.fn(), + loadGroup: vi.fn(), + loadWorkspace: vi.fn(), + resolveCredentialGroupsAvailability: vi.fn(), + resolvePermission: vi.fn(), +})) + +vi.mock('@/lib/billing/core/workspace-access', () => ({ + getWorkspaceOwnerSubscriptionAccess: mocks.getWorkspaceOwnerSubscriptionAccess, +})) + +vi.mock('@/lib/credential-groups/availability', () => ({ + resolveCredentialGroupsAvailability: mocks.resolveCredentialGroupsAvailability, +})) + +vi.mock('@/lib/credential-groups/credentials', () => ({ + loadCredentialGroupCredentialListContext: mocks.loadGroup, +})) + +vi.mock('@/lib/credential-groups/mcp-connections', () => ({ + CredentialGroupMcpConnectionCursorNotFoundError: class extends Error { + constructor() { + super('Credential group MCP connection cursor not found') + this.name = 'CredentialGroupMcpConnectionCursorNotFoundError' + } + }, + listCredentialGroupMcpConnectionReferences: mocks.listMcpConnections, + MAX_CREDENTIAL_GROUP_MCP_CONNECTION_PAGE_SIZE: 100, +})) + +vi.mock('@/lib/workspaces/application/workspace-context', () => ({ + loadActiveWorkspaceApplicationContext: mocks.loadWorkspace, +})) + +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: (permission: string | null, required: string) => + permission === 'admin' || permission === 'write' || permission === required, + resolveEffectiveWorkspacePermission: mocks.resolvePermission, +})) + +import { listCredentialGroupMcpConnections } from '@/lib/credential-groups/application/list-mcp-connections' +import { CredentialGroupMcpConnectionCursorNotFoundError } from '@/lib/credential-groups/mcp-connections' + +const groupContext = { + credentialGroupId: 'group-1', + workspaceId: 'workspace-1', + name: 'Credential Group', + status: 'active' as const, + options: [], +} +const workspaceContext = { + workspaceId: 'workspace-1', + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner-1', +} +const input = { credentialGroupId: 'group-1', limit: 50 } + +function executorPrincipal(credentialGroupId = 'group-1'): WorkflowExecutionDelegatedPrincipal { + return { + kind: 'delegated', + serviceId: 'executor', + subjectUserId: 'user-1', + workspaceId: 'workspace-1', + delegationId: 'delegation-1', + audience: 'sim:credential-groups', + issuedAt: new Date(Date.now() - 1_000), + expiresAt: new Date(Date.now() + 60_000), + resourceScope: { credentialGroupId }, + delegationContext: { + kind: 'workflow_execution', + workflowId: 'workflow-1', + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + currentWorkflow: { + workflowId: 'workflow-1', + mode: 'deployment', + deploymentVersionId: 'deployment-version-1', + }, + }, + } +} + +describe('listCredentialGroupMcpConnections', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.loadGroup.mockResolvedValue(groupContext) + mocks.loadWorkspace.mockResolvedValue(workspaceContext) + mocks.resolvePermission.mockResolvedValue('read') + mocks.getWorkspaceOwnerSubscriptionAccess.mockResolvedValue({ isEnterprise: true }) + mocks.resolveCredentialGroupsAvailability.mockResolvedValue({ available: true }) + mocks.listMcpConnections.mockResolvedValue({ + mcpConnections: [ + { + credentialId: 'mcp-cg-connection-1', + email: 'person@example.com', + displayName: 'Fireflies', + mcpServerId: 'mcp-server-1', + mcpServerName: 'Fireflies', + toolNames: ['list_transcripts'], + }, + ], + nextCursor: null, + }) + }) + + it('rejects unsupported principals before loading the group', async () => { + const principal: SessionPrincipal = { + kind: 'session', + userId: 'user-1', + sessionId: 'session-1', + } + + await expect( + listCredentialGroupMcpConnections.execute({ principal, input }) + ).rejects.toMatchObject({ code: 'forbidden' }) + expect(mocks.loadGroup).not.toHaveBeenCalled() + }) + + it('rejects executor delegation scoped to another group', async () => { + await expect( + listCredentialGroupMcpConnections.execute({ + principal: executorPrincipal('group-2'), + input, + }) + ).rejects.toMatchObject({ code: 'forbidden' }) + expect(mocks.listMcpConnections).not.toHaveBeenCalled() + }) + + it('lists bounded MCP connection references after authorization and entitlement checks', async () => { + const result = await listCredentialGroupMcpConnections.execute({ + principal: executorPrincipal(), + input: { + ...input, + email: ' Person@Example.COM ', + mcpServerId: ' mcp-server-1 ', + }, + }) + + expect(mocks.listMcpConnections).toHaveBeenCalledWith({ + workspaceId: 'workspace-1', + credentialGroupId: 'group-1', + limit: 50, + cursor: undefined, + email: 'person@example.com', + mcpServerId: 'mcp-server-1', + }) + expect(result).toEqual({ + mcpConnections: [ + { + credentialId: 'mcp-cg-connection-1', + email: 'person@example.com', + displayName: 'Fireflies', + mcpServerId: 'mcp-server-1', + mcpServerName: 'Fireflies', + toolNames: ['list_transcripts'], + }, + ], + count: 1, + hasMore: false, + nextCursor: null, + }) + }) + + it('rejects invalid filters before querying MCP connections', async () => { + await expect( + listCredentialGroupMcpConnections.execute({ + principal: executorPrincipal(), + input: { ...input, email: 'not-an-email' }, + }) + ).rejects.toMatchObject({ code: 'validation', message: 'Email must be a valid address' }) + expect(mocks.listMcpConnections).not.toHaveBeenCalled() + }) + + it('fails before listing when the group is disabled', async () => { + mocks.loadGroup.mockResolvedValue({ ...groupContext, status: 'disabled' }) + + await expect( + listCredentialGroupMcpConnections.execute({ principal: executorPrincipal(), input }) + ).rejects.toMatchObject({ code: 'conflict' }) + expect(mocks.listMcpConnections).not.toHaveBeenCalled() + }) + + it('classifies a stale or cross-group cursor as invalid input', async () => { + mocks.listMcpConnections.mockRejectedValueOnce( + new CredentialGroupMcpConnectionCursorNotFoundError() + ) + + await expect( + listCredentialGroupMcpConnections.execute({ + principal: executorPrincipal(), + input: { ...input, cursor: 'mcp-cg-other' }, + }) + ).rejects.toMatchObject({ code: 'validation' }) + }) +}) diff --git a/apps/sim/lib/credential-groups/application/list-mcp-connections.ts b/apps/sim/lib/credential-groups/application/list-mcp-connections.ts new file mode 100644 index 00000000000..4eda63879fe --- /dev/null +++ b/apps/sim/lib/credential-groups/application/list-mcp-connections.ts @@ -0,0 +1,87 @@ +import { isValidEmailSyntax, normalizeEmail } from '@sim/utils/string' +import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { credentialGroupDelegationPolicy } from '@/lib/credential-groups/application/authorization' +import { + requireCredentialGroupsAvailable, + resolveCredentialGroupContext, +} from '@/lib/credential-groups/application/context' +import { credentialGroupOperations } from '@/lib/credential-groups/application/operations' +import { + CredentialGroupMcpConnectionCursorNotFoundError, + type CredentialGroupMcpConnectionReference, + listCredentialGroupMcpConnectionReferences, + MAX_CREDENTIAL_GROUP_MCP_CONNECTION_PAGE_SIZE, +} from '@/lib/credential-groups/mcp-connections' + +export interface ListCredentialGroupMcpConnectionsInput { + credentialGroupId: string + limit: number + cursor?: string + email?: string + mcpServerId?: string +} + +export interface ListCredentialGroupMcpConnectionsResult { + mcpConnections: CredentialGroupMcpConnectionReference[] + count: number + hasMore: boolean + nextCursor: string | null +} + +export const listCredentialGroupMcpConnections = defineAuthorizedWorkspaceUseCase({ + operation: credentialGroupOperations.listMcpConnections, + resolveContext: ({ input }: { input: ListCredentialGroupMcpConnectionsInput }) => + resolveCredentialGroupContext(input.credentialGroupId), + authorizationOptions: { delegation: credentialGroupDelegationPolicy }, + execute: async ({ input, context }): Promise => { + if ( + !Number.isInteger(input.limit) || + input.limit < 1 || + input.limit > MAX_CREDENTIAL_GROUP_MCP_CONNECTION_PAGE_SIZE + ) { + throw new OrchestrationError( + 'validation', + `Limit must be an integer between 1 and ${MAX_CREDENTIAL_GROUP_MCP_CONNECTION_PAGE_SIZE}` + ) + } + if (context.status !== 'active') { + throw new OrchestrationError('conflict', 'Credential group is disabled') + } + + const email = input.email ? normalizeEmail(input.email) : undefined + if (email && !isValidEmailSyntax(email)) { + throw new OrchestrationError('validation', 'Email must be a valid address') + } + const mcpServerId = input.mcpServerId?.trim() + if (input.mcpServerId !== undefined && !mcpServerId) { + throw new OrchestrationError('validation', 'MCP server ID must not be empty') + } + + await requireCredentialGroupsAvailable(context.workspaceId) + + let page + try { + page = await listCredentialGroupMcpConnectionReferences({ + workspaceId: context.workspaceId, + credentialGroupId: context.credentialGroupId, + limit: input.limit, + cursor: input.cursor, + email, + mcpServerId, + }) + } catch (error) { + if (error instanceof CredentialGroupMcpConnectionCursorNotFoundError) { + throw new OrchestrationError('validation', error.message) + } + throw error + } + + return { + mcpConnections: page.mcpConnections, + count: page.mcpConnections.length, + hasMore: page.nextCursor !== null, + nextCursor: page.nextCursor, + } + }, +}) diff --git a/apps/sim/lib/credential-groups/application/operations.ts b/apps/sim/lib/credential-groups/application/operations.ts index 409920b039e..c7c1e3132c0 100644 --- a/apps/sim/lib/credential-groups/application/operations.ts +++ b/apps/sim/lib/credential-groups/application/operations.ts @@ -104,6 +104,15 @@ export const credentialGroupOperations = { principalKinds: ['delegated'], delegatedServices: ['executor'], }), + // permission-group-exempt: read by the executor to resolve an enrolled person's MCP connection; use is enforced by the Credential Group policy + listMcpConnections: defineWorkspaceOperation({ + id: 'credential_groups.mcp_connections.list', + minimumRole: 'read', + workspaceApiKey: 'deny', + capability: 'none', + principalKinds: ['delegated'], + delegatedServices: ['executor'], + }), // permission-group-exempt: read by the executor to resolve an enrolled person's credential; the group's enrollment rows are the gate, and no group key names them listGroups: defineWorkspaceOperation({ id: 'credential_groups.list', diff --git a/apps/sim/lib/credential-groups/mcp-connections.test.ts b/apps/sim/lib/credential-groups/mcp-connections.test.ts new file mode 100644 index 00000000000..a56a41c0e35 --- /dev/null +++ b/apps/sim/lib/credential-groups/mcp-connections.test.ts @@ -0,0 +1,97 @@ +/** + * @vitest-environment node + */ +import { dbChainMockFns, hasMockCondition, resetDbChainMock } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { listCredentialGroupMcpConnectionReferences } from '@/lib/credential-groups/mcp-connections' + +describe('listCredentialGroupMcpConnectionReferences', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + }) + + it('returns MCP credential IDs and tool names without secret material', async () => { + dbChainMockFns.limit.mockResolvedValueOnce([ + { + id: 'mcp-cg-connection-1', + email: 'person@example.com', + displayName: 'Fireflies', + mcpServerId: 'mcp-server-1', + mcpServerName: 'Fireflies', + hasToolSnapshot: true, + toolNames: ['list_transcripts', 'get_transcript'], + createdAt: new Date('2026-09-01T12:00:00.000Z'), + }, + ]) + + const result = await listCredentialGroupMcpConnectionReferences({ + workspaceId: 'workspace-1', + credentialGroupId: 'group-1', + limit: 50, + }) + + expect(result).toEqual({ + mcpConnections: [ + { + credentialId: 'mcp-cg-connection-1', + email: 'person@example.com', + displayName: 'Fireflies', + mcpServerId: 'mcp-server-1', + mcpServerName: 'Fireflies', + toolNames: ['list_transcripts', 'get_transcript'], + }, + ], + nextCursor: null, + }) + }) + + it('applies email and root MCP server filters inside the credential group scope', async () => { + dbChainMockFns.limit.mockResolvedValueOnce([]) + + await listCredentialGroupMcpConnectionReferences({ + workspaceId: 'workspace-1', + credentialGroupId: 'group-1', + email: 'person@example.com', + mcpServerId: 'mcp-server-1', + limit: 50, + }) + + const where = dbChainMockFns.where.mock.calls.at(-1)?.[0] + expect( + hasMockCondition( + where, + (condition) => condition.type === 'eq' && condition.right === 'person@example.com' + ) + ).toBe(true) + expect( + hasMockCondition( + where, + (condition) => condition.type === 'eq' && condition.right === 'mcp-server-1' + ) + ).toBe(true) + }) + + it('fails fast when an active connection has no tool snapshot', async () => { + dbChainMockFns.limit.mockResolvedValueOnce([ + { + id: 'mcp-cg-connection-1', + email: 'person@example.com', + displayName: 'Fireflies', + mcpServerId: 'mcp-server-1', + mcpServerName: 'Fireflies', + hasToolSnapshot: false, + toolNames: [], + createdAt: new Date('2026-09-01T12:00:00.000Z'), + }, + ]) + + await expect( + listCredentialGroupMcpConnectionReferences({ + workspaceId: 'workspace-1', + credentialGroupId: 'group-1', + limit: 50, + }) + ).rejects.toThrow('Managed MCP connection mcp-cg-connection-1 has no tool snapshot') + }) +}) diff --git a/apps/sim/lib/credential-groups/mcp-connections.ts b/apps/sim/lib/credential-groups/mcp-connections.ts new file mode 100644 index 00000000000..f7241050893 --- /dev/null +++ b/apps/sim/lib/credential-groups/mcp-connections.ts @@ -0,0 +1,152 @@ +import { db } from '@sim/db' +import { credential, credentialGroup, credentialGroupEnrollment, mcpServers } from '@sim/db/schema' +import { and, asc, eq, gt, inArray, isNull, or, sql } from 'drizzle-orm' + +export const MAX_CREDENTIAL_GROUP_MCP_CONNECTION_PAGE_SIZE = 100 + +export interface CredentialGroupMcpConnectionReference { + credentialId: string + email: string + displayName: string + mcpServerId: string + mcpServerName: string + toolNames: string[] +} + +export class CredentialGroupMcpConnectionCursorNotFoundError extends Error { + constructor() { + super('Credential group MCP connection cursor not found') + this.name = 'CredentialGroupMcpConnectionCursorNotFoundError' + } +} + +interface ListCredentialGroupMcpConnectionReferencesInput { + workspaceId: string + credentialGroupId: string + limit: number + cursor?: string + email?: string + mcpServerId?: string +} + +function decodeToolNames(value: unknown): string[] { + const decoded = credential.mcpTools.mapFromDriverValue(value) + if (!Array.isArray(decoded) || !decoded.every((name) => typeof name === 'string')) { + throw new Error('Managed MCP tool name projection is invalid') + } + return decoded +} + +/** Lists one bounded page of active managed MCP connections without selecting token material. */ +export async function listCredentialGroupMcpConnectionReferences({ + workspaceId, + credentialGroupId, + limit, + cursor, + email, + mcpServerId, +}: ListCredentialGroupMcpConnectionReferencesInput): Promise<{ + mcpConnections: CredentialGroupMcpConnectionReference[] + nextCursor: string | null +}> { + const scope = () => + and( + eq(credential.workspaceId, workspaceId), + eq(credential.type, 'managed_mcp'), + eq(credential.managedOauthStatus, 'active'), + eq(credentialGroup.id, credentialGroupId), + eq(credentialGroup.workspaceId, workspaceId), + eq(credentialGroup.status, 'active'), + inArray(credentialGroupEnrollment.status, ['in_progress', 'completed']), + eq(mcpServers.workspaceId, workspaceId), + eq(mcpServers.authType, 'oauth'), + eq(mcpServers.enabled, true), + isNull(mcpServers.deletedAt), + sql`${mcpServers.credentialGroupId} = ${credentialGroup.id}`, + email ? eq(credentialGroupEnrollment.email, email) : undefined, + mcpServerId ? eq(mcpServers.id, mcpServerId) : undefined + ) + + let cursorPosition: { id: string; createdAt: Date } | undefined + if (cursor) { + const [cursorRow] = await db + .select({ id: credential.id, createdAt: credential.createdAt }) + .from(credential) + .innerJoin( + credentialGroupEnrollment, + eq(credentialGroupEnrollment.id, credential.credentialGroupEnrollmentId) + ) + .innerJoin( + credentialGroup, + eq(credentialGroup.id, credentialGroupEnrollment.credentialGroupId) + ) + .innerJoin(mcpServers, eq(mcpServers.id, credential.mcpServerId)) + .where(and(eq(credential.id, cursor), scope())) + .limit(1) + if (!cursorRow) throw new CredentialGroupMcpConnectionCursorNotFoundError() + cursorPosition = cursorRow + } + + const rows = await db + .select({ + id: credential.id, + email: credentialGroupEnrollment.email, + displayName: credential.displayName, + mcpServerId: mcpServers.id, + mcpServerName: mcpServers.name, + hasToolSnapshot: sql`${credential.mcpTools} IS NOT NULL`, + toolNames: + sql`COALESCE(jsonb_path_query_array(${credential.mcpTools}, '$[*].name'), '[]'::jsonb)`.mapWith( + decodeToolNames + ), + createdAt: credential.createdAt, + }) + .from(credential) + .innerJoin( + credentialGroupEnrollment, + eq(credentialGroupEnrollment.id, credential.credentialGroupEnrollmentId) + ) + .innerJoin(credentialGroup, eq(credentialGroup.id, credentialGroupEnrollment.credentialGroupId)) + .innerJoin(mcpServers, eq(mcpServers.id, credential.mcpServerId)) + .where( + and( + scope(), + cursorPosition + ? or( + gt(credential.createdAt, cursorPosition.createdAt), + and( + eq(credential.createdAt, cursorPosition.createdAt), + gt(credential.id, cursorPosition.id) + ) + ) + : undefined + ) + ) + .orderBy(asc(credential.createdAt), asc(credential.id)) + .limit(limit + 1) + + const hasMore = rows.length > limit + const pageRows = hasMore ? rows.slice(0, limit) : rows + const nextCursor = hasMore ? pageRows.at(-1)?.id : null + if (hasMore && !nextCursor) throw new Error('MCP connection page cursor could not be derived') + + return { + mcpConnections: pageRows.map((row) => { + if (!row.hasToolSnapshot) { + throw new Error(`Managed MCP connection ${row.id} has no tool snapshot`) + } + if (row.toolNames.some((name) => typeof name !== 'string' || !name.trim())) { + throw new Error(`Managed MCP connection ${row.id} has invalid tool metadata`) + } + return { + credentialId: row.id, + email: row.email, + displayName: row.displayName, + mcpServerId: row.mcpServerId, + mcpServerName: row.mcpServerName, + toolNames: row.toolNames, + } + }), + nextCursor: nextCursor ?? null, + } +} diff --git a/apps/sim/lib/credential-groups/mcp-oauth.ts b/apps/sim/lib/credential-groups/mcp-oauth.ts index f70650217de..0e9df666c25 100644 --- a/apps/sim/lib/credential-groups/mcp-oauth.ts +++ b/apps/sim/lib/credential-groups/mcp-oauth.ts @@ -88,7 +88,9 @@ export async function completeCredentialGroupMcpOAuth( const tools = await mcpService.discoverManagedMcpTools( context.server.id, context.workspaceId, - provider + provider, + undefined, + { requireComplete: true } ) const connectionId = await persistManagedMcpCredential({ enrollmentId: context.enrollmentId, diff --git a/apps/sim/lib/credentials/application/discover-managed-mcp-tools.test.ts b/apps/sim/lib/credentials/application/discover-managed-mcp-tools.test.ts new file mode 100644 index 00000000000..0e6f6f77a7a --- /dev/null +++ b/apps/sim/lib/credentials/application/discover-managed-mcp-tools.test.ts @@ -0,0 +1,143 @@ +/** + * @vitest-environment node + */ +import type { WorkflowExecutionDelegatedPrincipal } from '@sim/auth/principal' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + discoverTools: vi.fn(), + loadAuthProvider: vi.fn(), + loadContext: vi.fn(), + loadRuntime: vi.fn(), + requireCredentialAccess: vi.fn(), + resolvePermission: vi.fn(), + saveToolSnapshot: vi.fn(), +})) + +vi.mock('@/lib/credentials/managed-mcp', () => ({ + loadManagedMcpCredentialApplicationContext: mocks.loadContext, + loadManagedMcpRuntimeCredential: mocks.loadRuntime, + saveManagedMcpToolSnapshot: mocks.saveToolSnapshot, +})) + +vi.mock('@/lib/credential-groups/application/authorization', () => ({ + requireCredentialGroupCredentialAccess: mocks.requireCredentialAccess, +})) + +vi.mock('@/lib/mcp/application/managed-auth-provider', () => ({ + loadManagedMcpAuthProvider: mocks.loadAuthProvider, +})) + +vi.mock('@/lib/mcp/oauth', () => ({ + withMcpOauthRefreshLock: vi.fn((_credentialId: string, operation: () => Promise) => + operation() + ), +})) + +vi.mock('@/lib/mcp/service', () => ({ + mcpService: { discoverManagedMcpTools: mocks.discoverTools }, +})) + +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: (permission: string | null, required: string) => + permission === 'admin' || permission === 'write' || permission === required, + resolveEffectiveWorkspacePermission: mocks.resolvePermission, +})) + +import { discoverManagedMcpToolsUseCase } from '@/lib/credentials/application/discover-managed-mcp-tools' + +const context = { + credentialId: 'mcp-cg-123456789012345678901', + credentialGroupId: 'group-1', + credentialGroupEnrollmentId: 'selected-enrollment', + mcpServerId: 'mcp-fireflies', + mcpServerName: 'Fireflies', + workspaceId: 'workspace-1', + workspaceOrganizationId: null, + allowPersonalApiKeys: true, +} + +const principal: WorkflowExecutionDelegatedPrincipal = { + kind: 'delegated', + serviceId: 'executor', + subjectUserId: 'execution-user', + workspaceId: context.workspaceId, + delegationId: 'delegation-1', + audience: 'sim:managed-mcp-credentials', + issuedAt: new Date(Date.now() - 1_000), + expiresAt: new Date(Date.now() + 60_000), + resourceScope: { credentialId: context.credentialId }, + delegationContext: { + kind: 'workflow_execution', + workflowId: 'workflow-1', + principal: { kind: 'session', userId: 'execution-user', sessionId: 'session-1' }, + currentWorkflow: { workflowId: 'workflow-1', mode: 'draft' }, + }, +} + +describe('discoverManagedMcpToolsUseCase', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.loadContext.mockResolvedValue(context) + mocks.loadRuntime.mockResolvedValue({ + credentialId: context.credentialId, + mcpServerId: context.mcpServerId, + mcpServerName: context.mcpServerName, + workspaceId: context.workspaceId, + tokenVersion: 'encrypted-token-version-1', + tokens: { access_token: 'access-token' }, + tools: [], + }) + mocks.loadAuthProvider.mockResolvedValue({}) + mocks.requireCredentialAccess.mockResolvedValue(undefined) + mocks.resolvePermission.mockResolvedValue('read') + mocks.discoverTools.mockResolvedValue([ + { + name: 'search_transcripts', + description: 'Search transcripts', + inputSchema: { type: 'object', properties: {} }, + serverId: context.mcpServerId, + serverName: context.mcpServerName, + }, + ]) + }) + + it('discovers through the explicit credential and projects that ID as the tool server', async () => { + const signal = new AbortController().signal + const result = await discoverManagedMcpToolsUseCase.execute({ + principal, + input: { + workspaceId: context.workspaceId, + credentialId: context.credentialId, + signal, + }, + }) + + expect(mocks.requireCredentialAccess).toHaveBeenCalledWith(principal, context, { + resourceType: 'credential_group', + action: 'credential_groups.credentials.use', + }) + expect(mocks.loadRuntime).toHaveBeenCalledWith(context.credentialId, context.workspaceId) + expect(mocks.discoverTools).toHaveBeenCalledWith( + context.mcpServerId, + context.workspaceId, + {}, + signal, + { requireComplete: true } + ) + expect(result.tools).toEqual([ + expect.objectContaining({ + name: 'search_transcripts', + serverId: context.credentialId, + serverName: context.mcpServerName, + }), + ]) + expect(mocks.saveToolSnapshot).toHaveBeenCalledWith(context.credentialId, [ + { + name: 'search_transcripts', + description: 'Search transcripts', + inputSchema: { type: 'object', properties: {} }, + }, + ]) + }) +}) diff --git a/apps/sim/lib/credentials/application/discover-managed-mcp-tools.ts b/apps/sim/lib/credentials/application/discover-managed-mcp-tools.ts new file mode 100644 index 00000000000..0e8ba546f31 --- /dev/null +++ b/apps/sim/lib/credentials/application/discover-managed-mcp-tools.ts @@ -0,0 +1,73 @@ +import { AuditAction, AuditResourceType } from '@sim/audit' +import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { requireCredentialGroupCredentialAccess } from '@/lib/credential-groups/application/authorization' +import { managedMcpCredentialDelegationPolicy } from '@/lib/credentials/application/authorization' +import { credentialOperations } from '@/lib/credentials/application/operations' +import { + loadManagedMcpCredentialApplicationContext, + loadManagedMcpRuntimeCredential, + saveManagedMcpToolSnapshot, +} from '@/lib/credentials/managed-mcp' +import { loadManagedMcpAuthProvider } from '@/lib/mcp/application/managed-auth-provider' +import { withMcpOauthRefreshLock } from '@/lib/mcp/oauth' +import { mcpService } from '@/lib/mcp/service' + +export interface DiscoverManagedMcpToolsInput { + workspaceId: string + credentialId: string + signal?: AbortSignal +} + +export const discoverManagedMcpToolsUseCase = defineAuthorizedWorkspaceUseCase({ + operation: credentialOperations.useManagedMcp, + resolveContext: async ({ input }: { input: DiscoverManagedMcpToolsInput }) => { + const context = await loadManagedMcpCredentialApplicationContext(input.credentialId) + if (!context || context.workspaceId !== input.workspaceId) { + throw new OrchestrationError('not_found', 'Managed MCP connection not found') + } + return context + }, + authorizationOptions: { delegation: managedMcpCredentialDelegationPolicy }, + async authorizeResource({ principal, context, resourcePolicy }) { + await requireCredentialGroupCredentialAccess(principal, context, resourcePolicy) + }, + async execute({ input, context }) { + input.signal?.throwIfAborted() + const runtime = await loadManagedMcpRuntimeCredential(context.credentialId, context.workspaceId) + const tools = await withMcpOauthRefreshLock(runtime.credentialId, async () => + mcpService.discoverManagedMcpTools( + runtime.mcpServerId, + runtime.workspaceId, + await loadManagedMcpAuthProvider(runtime.credentialId, runtime.workspaceId), + input.signal, + { requireComplete: true } + ) + ) + await saveManagedMcpToolSnapshot( + runtime.credentialId, + tools.map((tool) => ({ + name: tool.name, + ...(tool.description ? { description: tool.description } : {}), + inputSchema: tool.inputSchema, + })) + ) + return { + tools: tools.map((tool) => ({ + ...tool, + serverId: runtime.credentialId, + serverName: runtime.mcpServerName, + })), + } + }, + projectAudit: ({ context }) => ({ + action: AuditAction.CREDENTIAL_ACCESSED, + resourceType: AuditResourceType.CREDENTIAL, + resourceId: context.credentialId, + description: `Discovered tools from managed MCP credential ${context.credentialId}`, + metadata: { + credentialType: 'managed_mcp', + mcpServerId: context.mcpServerId, + }, + }), +}) diff --git a/apps/sim/lib/credentials/application/operations.ts b/apps/sim/lib/credentials/application/operations.ts index be9b7766dd8..c851eaac563 100644 --- a/apps/sim/lib/credentials/application/operations.ts +++ b/apps/sim/lib/credentials/application/operations.ts @@ -187,6 +187,7 @@ export const credentialOperations = { id: 'credentials.managed_mcp.use', minimumRole: 'read', workspaceApiKey: 'deny', + capability: 'integrations.manage', principalKinds: ['delegated'], delegatedServices: ['executor'], resourcePolicy: { diff --git a/apps/sim/lib/credentials/managed-mcp.ts b/apps/sim/lib/credentials/managed-mcp.ts index 52c48915725..bc7b60acae1 100644 --- a/apps/sim/lib/credentials/managed-mcp.ts +++ b/apps/sim/lib/credentials/managed-mcp.ts @@ -365,3 +365,24 @@ export async function saveManagedMcpRuntimeTokens( return encryptedOauthTokenSet }) } + +/** Replaces the editor snapshot only after a complete live tools/list succeeds. */ +export async function saveManagedMcpToolSnapshot( + credentialId: string, + tools: ManagedMcpToolSnapshot[] +): Promise { + const updated = await db + .update(credential) + .set({ mcpTools: tools, mcpToolsRefreshedAt: new Date(), updatedAt: new Date() }) + .where( + and( + eq(credential.id, credentialId), + eq(credential.type, 'managed_mcp'), + eq(credential.managedOauthStatus, 'active') + ) + ) + .returning({ id: credential.id }) + if (updated.length !== 1) { + throw new ManagedMcpCredentialError('Managed MCP credential grant changed', 401) + } +} diff --git a/apps/sim/lib/internal/mcp/discover-tools.ts b/apps/sim/lib/internal/mcp/discover-tools.ts index 78050f11e9c..8d8be851166 100644 --- a/apps/sim/lib/internal/mcp/discover-tools.ts +++ b/apps/sim/lib/internal/mcp/discover-tools.ts @@ -1,7 +1,10 @@ +import { MANAGED_MCP_DELEGATION_AUDIENCE } from '@/lib/credentials/application/authorization' +import { discoverManagedMcpToolsUseCase } from '@/lib/credentials/application/discover-managed-mcp-tools' import { createExecutorPrincipalFromExecutionContext } from '@/lib/internal/principals/executor' import type { InternalToolOperationContext } from '@/lib/internal/tool-operations/types' import { MCP_SERVER_DELEGATION_AUDIENCE } from '@/lib/mcp/application/authorization' import { discoverMcpServerToolsUseCase } from '@/lib/mcp/application/use-cases' +import { isManagedMcpConnectionId, MANAGED_MCP_CONNECTION_PREFIX } from '@/lib/mcp/utils' export interface DiscoverMcpServerToolsAsExecutorInput { workspaceId: string @@ -17,15 +20,34 @@ export async function discoverMcpServerToolsAsExecutor({ signal, }: DiscoverMcpServerToolsAsExecutorInput) { signal?.throwIfAborted() + if (serverId.startsWith(MANAGED_MCP_CONNECTION_PREFIX)) { + if (!isManagedMcpConnectionId(serverId)) { + throw new Error('Invalid managed MCP connection ID') + } + const principal = await createExecutorPrincipalFromExecutionContext({ + context, + audience: MANAGED_MCP_DELEGATION_AUDIENCE, + resourceScope: { credentialId: serverId }, + }) + signal?.throwIfAborted() + const result = await discoverManagedMcpToolsUseCase.execute({ + principal, + input: { workspaceId, credentialId: serverId, signal }, + }) + signal?.throwIfAborted() + return result.tools + } + const principal = await createExecutorPrincipalFromExecutionContext({ context, audience: MCP_SERVER_DELEGATION_AUDIENCE, + resourceScope: { mcpServerId: serverId }, }) signal?.throwIfAborted() const result = await discoverMcpServerToolsUseCase.execute({ principal, - input: { workspaceId, serverId }, + input: { workspaceId, serverId, signal, requireComplete: true }, }) signal?.throwIfAborted() return result.tools diff --git a/apps/sim/lib/internal/mcp/execute-tool.ts b/apps/sim/lib/internal/mcp/execute-tool.ts index bcf894cf8de..0f1b459f98f 100644 --- a/apps/sim/lib/internal/mcp/execute-tool.ts +++ b/apps/sim/lib/internal/mcp/execute-tool.ts @@ -136,7 +136,7 @@ export const executeMcpTool: InternalToolOperationHandler = async (request) => { : MANAGED_MCP_DELEGATION_AUDIENCE, ...(target.kind === 'managed_connection' ? { resourceScope: { credentialId: target.credentialId } } - : {}), + : { resourceScope: { mcpServerId: target.serverId } }), }) request.signal?.throwIfAborted() const subject = resolvePrincipalSubject(principal) diff --git a/apps/sim/lib/mcp/application/authorization.ts b/apps/sim/lib/mcp/application/authorization.ts index 38a67e73f09..fba4915e18a 100644 --- a/apps/sim/lib/mcp/application/authorization.ts +++ b/apps/sim/lib/mcp/application/authorization.ts @@ -1,6 +1,7 @@ import { type Principal, resolvePrincipalExecutionActorUserId } from '@sim/auth/principal' import type { WorkspaceDelegationPolicy } from '@/lib/core/application' import { OrchestrationError } from '@/lib/core/orchestration/types' +import type { McpServerContext } from '@/lib/mcp/application/context' export const MCP_SERVER_DELEGATION_AUDIENCE = 'sim:mcp-servers' @@ -13,6 +14,14 @@ export const mcpServerDelegationPolicy = { allowPersonalApiKeys: boolean }> +export const mcpServerExecutionDelegationPolicy = { + audience: MCP_SERVER_DELEGATION_AUDIENCE, + isWithinScope: ( + principal: Extract, + context: McpServerContext + ) => principal.resourceScope?.mcpServerId === context.server.id, +} satisfies WorkspaceDelegationPolicy + /** * The user whose MCP server credentials an operation presents. * diff --git a/apps/sim/lib/mcp/application/execute-managed-tool.test.ts b/apps/sim/lib/mcp/application/execute-managed-tool.test.ts index 02fd81f5cd1..7d1f44a1ae6 100644 --- a/apps/sim/lib/mcp/application/execute-managed-tool.test.ts +++ b/apps/sim/lib/mcp/application/execute-managed-tool.test.ts @@ -5,18 +5,20 @@ import type { WorkflowExecutionDelegatedPrincipal } from '@sim/auth/principal' import { beforeEach, describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => ({ + discoverTools: vi.fn(), executeTool: vi.fn(), + loadAuthProvider: vi.fn(), loadContext: vi.fn(), loadRuntime: vi.fn(), requireCredentialAccess: vi.fn(), resolvePermission: vi.fn(), - saveTokens: vi.fn(), + saveToolSnapshot: vi.fn(), })) vi.mock('@/lib/credentials/managed-mcp', () => ({ loadManagedMcpCredentialApplicationContext: mocks.loadContext, loadManagedMcpRuntimeCredential: mocks.loadRuntime, - saveManagedMcpRuntimeTokens: mocks.saveTokens, + saveManagedMcpToolSnapshot: mocks.saveToolSnapshot, })) vi.mock('@/lib/credential-groups/application/authorization', () => ({ @@ -24,16 +26,20 @@ vi.mock('@/lib/credential-groups/application/authorization', () => ({ })) vi.mock('@/lib/mcp/service', () => ({ - mcpService: { executeManagedMcpTool: mocks.executeTool }, + mcpService: { + discoverManagedMcpTools: mocks.discoverTools, + executeManagedMcpTool: mocks.executeTool, + }, })) vi.mock('@/lib/mcp/oauth', () => ({ - getOrCreateOauthRow: vi.fn(), - loadPreregisteredClient: vi.fn(), + withMcpOauthRefreshLock: vi.fn((_credentialId: string, operation: () => Promise) => + operation() + ), })) -vi.mock('@/lib/mcp/oauth/managed-provider', () => ({ - ManagedMcpOauthProvider: class ManagedMcpOauthProvider {}, +vi.mock('@/lib/mcp/application/managed-auth-provider', () => ({ + loadManagedMcpAuthProvider: mocks.loadAuthProvider, })) vi.mock('@sim/platform-authz/workspace', () => ({ @@ -92,6 +98,9 @@ describe('executeManagedMcpToolUseCase', () => { }) mocks.requireCredentialAccess.mockResolvedValue(undefined) mocks.resolvePermission.mockResolvedValue('read') + mocks.loadAuthProvider.mockResolvedValue({}) + mocks.discoverTools.mockResolvedValue([]) + mocks.executeTool.mockResolvedValue({ content: [{ type: 'text', text: 'done' }] }) }) it('does not load token material when Credential Group policy denies execution', async () => { @@ -123,16 +132,8 @@ describe('executeManagedMcpToolUseCase', () => { expect(mocks.executeTool).not.toHaveBeenCalled() }) - it('fails fast when a persisted tool schema is null', async () => { - mocks.loadRuntime.mockResolvedValueOnce({ - credentialId: context.credentialId, - mcpServerId: context.mcpServerId, - mcpServerName: context.mcpServerName, - workspaceId: context.workspaceId, - tokenVersion: 'encrypted-token-version-1', - tokens: { access_token: 'access-token' }, - tools: [{ name: 'search_transcripts', inputSchema: null }], - }) + it('fails fast when the live tool schema is invalid', async () => { + mocks.discoverTools.mockResolvedValueOnce([{ name: 'search_transcripts', inputSchema: null }]) await expect( executeManagedMcpToolUseCase.execute({ @@ -151,4 +152,65 @@ describe('executeManagedMcpToolUseCase', () => { expect(mocks.executeTool).not.toHaveBeenCalled() }) + + it('discovers and executes with the explicitly selected managed connection', async () => { + const signal = new AbortController().signal + mocks.discoverTools.mockResolvedValueOnce([ + { + name: 'search_transcripts', + description: 'Search Fireflies transcripts', + inputSchema: { + type: 'object', + required: ['query'], + properties: { query: { type: 'string' } }, + }, + }, + ]) + + const result = await executeManagedMcpToolUseCase.execute({ + principal, + input: { + workspaceId: context.workspaceId, + credentialId: context.credentialId, + toolName: 'search_transcripts', + arguments: { query: 'onboarding' }, + signal, + }, + }) + + expect(result).toEqual({ + success: true, + output: { content: [{ type: 'text', text: 'done' }] }, + }) + expect(mocks.loadRuntime).toHaveBeenCalledWith(context.credentialId, context.workspaceId) + expect(mocks.discoverTools).toHaveBeenCalledWith( + context.mcpServerId, + context.workspaceId, + {}, + signal, + { requireComplete: true } + ) + expect(mocks.saveToolSnapshot).toHaveBeenCalledWith(context.credentialId, [ + { + name: 'search_transcripts', + description: 'Search Fireflies transcripts', + inputSchema: { + type: 'object', + required: ['query'], + properties: { query: { type: 'string' } }, + }, + }, + ]) + expect(mocks.executeTool).toHaveBeenCalledWith( + expect.objectContaining({ + connectionId: context.credentialId, + serverId: context.mcpServerId, + workspaceId: context.workspaceId, + toolCall: { + name: 'search_transcripts', + arguments: { query: 'onboarding' }, + }, + }) + ) + }) }) diff --git a/apps/sim/lib/mcp/application/execute-managed-tool.ts b/apps/sim/lib/mcp/application/execute-managed-tool.ts index 29017254496..8e2c43a61de 100644 --- a/apps/sim/lib/mcp/application/execute-managed-tool.ts +++ b/apps/sim/lib/mcp/application/execute-managed-tool.ts @@ -7,7 +7,7 @@ import { credentialOperations } from '@/lib/credentials/application/operations' import { loadManagedMcpCredentialApplicationContext, loadManagedMcpRuntimeCredential, - saveManagedMcpRuntimeTokens, + saveManagedMcpToolSnapshot, } from '@/lib/credentials/managed-mcp' import { SIM_VIA_HEADER, serializeCallChain } from '@/lib/execution/call-chain' import { @@ -16,8 +16,8 @@ import { transformToolResult, validateToolArguments, } from '@/lib/mcp/application/execute-tool' -import { getOrCreateOauthRow, loadPreregisteredClient } from '@/lib/mcp/oauth' -import { ManagedMcpOauthProvider } from '@/lib/mcp/oauth/managed-provider' +import { loadManagedMcpAuthProvider } from '@/lib/mcp/application/managed-auth-provider' +import { withMcpOauthRefreshLock } from '@/lib/mcp/oauth' import { mcpService } from '@/lib/mcp/service' import type { McpTool, McpToolCall, McpToolSchema } from '@/lib/mcp/types' @@ -55,14 +55,31 @@ export const executeManagedMcpToolUseCase = defineAuthorizedWorkspaceUseCase({ async execute({ input, context }): Promise { input.signal?.throwIfAborted() const runtime = await loadManagedMcpRuntimeCredential(context.credentialId, context.workspaceId) - const snapshot = runtime.tools.find((tool) => tool.name === input.toolName) - if (!snapshot) { + const tools = await withMcpOauthRefreshLock(runtime.credentialId, async () => + mcpService.discoverManagedMcpTools( + runtime.mcpServerId, + runtime.workspaceId, + await loadManagedMcpAuthProvider(runtime.credentialId, runtime.workspaceId), + input.signal, + { requireComplete: true } + ) + ) + await saveManagedMcpToolSnapshot( + runtime.credentialId, + tools.map((tool) => ({ + name: tool.name, + ...(tool.description ? { description: tool.description } : {}), + inputSchema: tool.inputSchema, + })) + ) + const discovered = tools.find((tool) => tool.name === input.toolName) + if (!discovered) { throw new OrchestrationError('not_found', 'Tool not found on the managed MCP connection') } const tool: McpTool = { - name: snapshot.name, - ...(snapshot.description ? { description: snapshot.description } : {}), - inputSchema: requireToolSchema(snapshot.inputSchema), + name: discovered.name, + ...(discovered.description ? { description: discovered.description } : {}), + inputSchema: requireToolSchema(discovered.inputSchema), serverId: runtime.credentialId, serverName: runtime.mcpServerName, } @@ -81,33 +98,7 @@ export const executeManagedMcpToolUseCase = defineAuthorizedWorkspaceUseCase({ extraHeaders, signal: input.signal, timeoutMs: input.timeoutMs, - async loadAuthProvider() { - const current = await loadManagedMcpRuntimeCredential( - context.credentialId, - context.workspaceId - ) - const clientRow = await getOrCreateOauthRow({ - mcpServerId: current.mcpServerId, - workspaceId: current.workspaceId, - }) - const preregistered = await loadPreregisteredClient(current.mcpServerId) - let tokenVersion: string | null = current.tokenVersion - return new ManagedMcpOauthProvider({ - clientRow, - preregistered, - tokens: current.tokens, - async onSaveTokens(tokens) { - if (!tokenVersion) { - throw new Error('Managed MCP credential grant is no longer active') - } - tokenVersion = await saveManagedMcpRuntimeTokens( - current.credentialId, - tokens, - tokenVersion - ) - }, - }) - }, + loadAuthProvider: () => loadManagedMcpAuthProvider(context.credentialId, context.workspaceId), }) input.signal?.throwIfAborted() return transformToolResult(providerResult) diff --git a/apps/sim/lib/mcp/application/execute-tool.test.ts b/apps/sim/lib/mcp/application/execute-tool.test.ts index 8406c18a812..fefb84eb118 100644 --- a/apps/sim/lib/mcp/application/execute-tool.test.ts +++ b/apps/sim/lib/mcp/application/execute-tool.test.ts @@ -60,6 +60,7 @@ const PRINCIPAL: WorkflowExecutionDelegatedPrincipal = { issuedAt: new Date('2026-08-27T00:00:00.000Z'), expiresAt: new Date('2099-08-27T00:05:00.000Z'), delegationContext: { kind: 'workflow_execution', workflowId: 'workflow-1' }, + resourceScope: { mcpServerId: SERVER.id }, } const ACTORLESS_PRINCIPAL: WorkflowExecutionDelegatedPrincipal = { kind: 'delegated', @@ -84,6 +85,7 @@ const ACTORLESS_PRINCIPAL: WorkflowExecutionDelegatedPrincipal = { workflowId: 'workflow-1', }, }, + resourceScope: { mcpServerId: SERVER.id }, } const COMPATIBILITY_ACTOR_PRINCIPAL: WorkflowExecutionDelegatedPrincipal = { ...ACTORLESS_PRINCIPAL, @@ -178,6 +180,28 @@ describe('executeMcpToolUseCase', () => { expect(mocks.executeTool).not.toHaveBeenCalled() }) + it('does not infer a managed credential from the execution actor', async () => { + mocks.getServer.mockResolvedValueOnce({ ...SERVER, credentialGroupId: 'group-1' }) + + await expect( + executeMcpToolUseCase.execute({ + principal: PRINCIPAL, + input: { + workspaceId: WORKSPACE.workspaceId, + serverId: SERVER.id, + toolName: 'lookup', + }, + }) + ).rejects.toMatchObject({ + code: 'conflict', + message: 'Credential Group MCP servers require an explicit managed connection ID', + }) + + expect(mocks.assertPermissionsAllowed).not.toHaveBeenCalled() + expect(mocks.discoverServerTools).not.toHaveBeenCalled() + expect(mocks.executeTool).not.toHaveBeenCalled() + }) + it('keeps an unattended run connecting as its execution actor', async () => { // Pre-in-process behavior: the executor minted an internal token from // ExecutionContext.userId and MCP ran as that user. Preserved deliberately — diff --git a/apps/sim/lib/mcp/application/execute-tool.ts b/apps/sim/lib/mcp/application/execute-tool.ts index 186a6a8d67f..8f7388619be 100644 --- a/apps/sim/lib/mcp/application/execute-tool.ts +++ b/apps/sim/lib/mcp/application/execute-tool.ts @@ -4,7 +4,7 @@ import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application' import { OrchestrationError } from '@/lib/core/orchestration/types' import { SIM_VIA_HEADER, serializeCallChain } from '@/lib/execution/call-chain' import { - mcpServerDelegationPolicy, + mcpServerExecutionDelegationPolicy, requireMcpCredentialUserId, } from '@/lib/mcp/application/authorization' import { resolveMcpServerContext } from '@/lib/mcp/application/context' @@ -131,9 +131,15 @@ export const executeMcpToolUseCase = defineAuthorizedWorkspaceUseCase({ operation: mcpServerOperations.executeTool, resolveContext: ({ input }: { input: ExecuteMcpToolInput }) => resolveMcpServerContext(input.workspaceId, input.serverId), - authorizationOptions: { delegation: mcpServerDelegationPolicy }, + authorizationOptions: { delegation: mcpServerExecutionDelegationPolicy }, async execute({ principal, input, context }): Promise { input.signal?.throwIfAborted() + if (context.server.credentialGroupId) { + throw new OrchestrationError( + 'conflict', + 'Credential Group MCP servers require an explicit managed connection ID' + ) + } const userId = requireMcpCredentialUserId(principal) await assertPermissionsAllowed({ userId, diff --git a/apps/sim/lib/mcp/application/managed-auth-provider.ts b/apps/sim/lib/mcp/application/managed-auth-provider.ts new file mode 100644 index 00000000000..039062babfe --- /dev/null +++ b/apps/sim/lib/mcp/application/managed-auth-provider.ts @@ -0,0 +1,30 @@ +import type { OAuthClientProvider } from '@modelcontextprotocol/sdk/client/auth.js' +import { + loadManagedMcpRuntimeCredential, + saveManagedMcpRuntimeTokens, +} from '@/lib/credentials/managed-mcp' +import { getOrCreateOauthRow, loadPreregisteredClient } from '@/lib/mcp/oauth' +import { ManagedMcpOauthProvider } from '@/lib/mcp/oauth/managed-provider' + +/** Creates an OAuth provider whose refresh writes stay bound to the same personal grant. */ +export async function loadManagedMcpAuthProvider( + credentialId: string, + workspaceId: string +): Promise { + const current = await loadManagedMcpRuntimeCredential(credentialId, workspaceId) + const clientRow = await getOrCreateOauthRow({ + mcpServerId: current.mcpServerId, + workspaceId: current.workspaceId, + }) + const preregistered = await loadPreregisteredClient(current.mcpServerId) + let tokenVersion: string | null = current.tokenVersion + return new ManagedMcpOauthProvider({ + clientRow, + preregistered, + tokens: current.tokens, + async onSaveTokens(tokens) { + if (!tokenVersion) throw new Error('Managed MCP credential grant is no longer active') + tokenVersion = await saveManagedMcpRuntimeTokens(current.credentialId, tokens, tokenVersion) + }, + }) +} diff --git a/apps/sim/lib/mcp/application/operations.ts b/apps/sim/lib/mcp/application/operations.ts index ab392808cbe..e9346e82078 100644 --- a/apps/sim/lib/mcp/application/operations.ts +++ b/apps/sim/lib/mcp/application/operations.ts @@ -43,6 +43,7 @@ export const mcpServerOperations = { id: 'mcp_servers.managed_connections.list', minimumRole: 'read', workspaceApiKey: 'deny', + capability: 'mcp_tools.use', principalKinds: ['session'], }), discoverTools: defineWorkspaceOperation({ diff --git a/apps/sim/lib/mcp/application/use-cases.test.ts b/apps/sim/lib/mcp/application/use-cases.test.ts index d75f79e7ada..b96b83c51ae 100644 --- a/apps/sim/lib/mcp/application/use-cases.test.ts +++ b/apps/sim/lib/mcp/application/use-cases.test.ts @@ -291,6 +291,22 @@ describe('MCP server application use cases', () => { expect(mocks.discoverServerTools).not.toHaveBeenCalled() }) + it('requires an explicit managed connection ID for a Credential Group server', async () => { + mocks.getServer.mockResolvedValueOnce({ ...server, credentialGroupId: 'group-1' }) + + await expect( + discoverMcpServerToolsUseCase.execute({ + principal: { kind: 'personal_api_key', userId: 'user-1', keyId: 'key-1' }, + input: { workspaceId: workspace.workspaceId, serverId: server.id }, + }) + ).rejects.toMatchObject({ + code: 'conflict', + message: 'Credential Group MCP servers require an explicit managed connection ID', + }) + + expect(mocks.discoverServerTools).not.toHaveBeenCalled() + }) + it('discovers one server tools for the acting subject, honouring refresh', async () => { const tools = [ { diff --git a/apps/sim/lib/mcp/application/use-cases.ts b/apps/sim/lib/mcp/application/use-cases.ts index 60bdb4840a7..f20fe618fb4 100644 --- a/apps/sim/lib/mcp/application/use-cases.ts +++ b/apps/sim/lib/mcp/application/use-cases.ts @@ -118,6 +118,8 @@ export interface DiscoverMcpServerToolsInput { workspaceId: string serverId: string refresh?: boolean + signal?: AbortSignal + requireComplete?: boolean } /** @@ -139,6 +141,7 @@ export const discoverMcpServerToolsUseCase = defineAuthorizedWorkspaceUseCase({ resolveMcpServerContext(input.workspaceId, input.serverId), authorizationOptions, async execute({ principal, input, context }) { + input.signal?.throwIfAborted() /** * `enabled: false` is a documented registration value, but discovery loads * its configuration through a query that filters on `enabled`, so a @@ -152,18 +155,31 @@ export const discoverMcpServerToolsUseCase = defineAuthorizedWorkspaceUseCase({ 'The MCP server is disabled; enable it before listing its tools' ) } + if (context.server.credentialGroupId) { + throw new OrchestrationError( + 'conflict', + 'Credential Group MCP servers require an explicit managed connection ID' + ) + } - const tools = await mcpService.discoverServerTools( - requireMcpCredentialUserId(principal), - context.server.id, - context.workspaceId, - /** - * A public `refresh` skips the positive cache but keeps the failure - * cooldown; only an explicit user action on their own server may bypass - * both. See {@link McpDiscoveryRefresh}. - */ - input.refresh ? 'skip-cache' : 'cache-aside' - ) + const userId = requireMcpCredentialUserId(principal) + const refresh = input.refresh ? 'skip-cache' : 'cache-aside' + const tools = + input.signal || input.requireComplete + ? await mcpService.discoverServerTools( + userId, + context.server.id, + context.workspaceId, + refresh, + undefined, + { signal: input.signal, requireComplete: input.requireComplete } + ) + : await mcpService.discoverServerTools( + userId, + context.server.id, + context.workspaceId, + refresh + ) return { tools } }, }) diff --git a/apps/sim/lib/mcp/client.test.ts b/apps/sim/lib/mcp/client.test.ts index 8a87eac5a5f..27d3dbdca4b 100644 --- a/apps/sim/lib/mcp/client.test.ts +++ b/apps/sim/lib/mcp/client.test.ts @@ -248,6 +248,21 @@ describe('McpClient notification handler', () => { expect(tools.map((t) => t.name)).toEqual(['a']) }) + it('fails instead of returning partial tools when complete discovery is required', async () => { + mockSdkListTools + .mockResolvedValueOnce({ tools: [{ name: 'a' }], nextCursor: 'c1' }) + .mockRejectedValueOnce(new Error('page 2 blew up')) + const client = new McpClient({ + config: createConfig(), + securityPolicy: { requireConsent: false, auditLevel: 'basic' }, + }) + + await client.connect() + await expect(client.listTools(undefined, { requireComplete: true })).rejects.toThrow( + 'page 2 blew up' + ) + }) + it('keeps an empty partial (does not throw) when page one succeeds but a later page fails', async () => { // Page one is valid but empty with a cursor; page two fails. Page one succeeded, so // discovery must not fail the server — it returns [] rather than throwing. diff --git a/apps/sim/lib/mcp/client.ts b/apps/sim/lib/mcp/client.ts index c9b2607feac..6f0b2cd4828 100644 --- a/apps/sim/lib/mcp/client.ts +++ b/apps/sim/lib/mcp/client.ts @@ -275,7 +275,10 @@ export class McpClient { return { ...this.connectionStatus } } - async listTools(signal?: AbortSignal): Promise { + async listTools( + signal?: AbortSignal, + options: { requireComplete?: boolean } = {} + ): Promise { if (!this.isConnected) { throw new McpConnectionError('Not connected to server', this.config.name) } @@ -381,6 +384,12 @@ export class McpClient { toolsCollected: tools.length, pagesFetched, }) + if (options.requireComplete) { + throw new McpConnectionError( + `Tool discovery was truncated by the ${truncated} limit`, + this.config.name + ) + } } return tools @@ -397,6 +406,8 @@ export class McpClient { sessionIdPresent: Boolean(this.transport.sessionId), error: getMcpSafeErrorDiagnostics(error), }) + if (options.requireComplete) throw error + // At least one page succeeded → keep its (possibly empty) partial result rather than // failing discovery and marking the server unhealthy; only a page-one failure throws. if (pagesFetched > 0) return tools diff --git a/apps/sim/lib/mcp/service.ts b/apps/sim/lib/mcp/service.ts index fb042a48239..9aeb3620556 100644 --- a/apps/sim/lib/mcp/service.ts +++ b/apps/sim/lib/mcp/service.ts @@ -68,6 +68,7 @@ type ResolvedSecretTraceProvenanceCallback = (provenance: ResolvedSecretTracePro interface McpRequestOptions { signal?: AbortSignal + requireComplete?: boolean } interface McpToolExecutionOptions extends McpRequestOptions { @@ -494,14 +495,18 @@ class McpService { serverId: string, workspaceId: string, authProvider: OAuthClientProvider, - signal?: AbortSignal + signal?: AbortSignal, + options: { requireComplete?: boolean } = {} ): Promise { const config = await this.getServerConfig(serverId, workspaceId) if (!config) throw new Error('Managed MCP server is unavailable') return this.withServerClient( { key: '', serverId, allowPool: false }, () => this.createManagedOauthClient(config, authProvider, signal), - (client) => client.listTools(signal) + (client) => + options.requireComplete + ? client.listTools(signal, { requireComplete: true }) + : client.listTools(signal) ) } @@ -596,7 +601,8 @@ class McpService { userId: string, workspaceId: string, onResolvedSecretTraceProvenance?: ResolvedSecretTraceProvenanceCallback, - signal?: AbortSignal + signal?: AbortSignal, + requireComplete = false ): Promise { for (let attempt = 0; ; attempt++) { signal?.throwIfAborted() @@ -622,7 +628,9 @@ class McpService { workspaceId, onResolvedSecretTraceProvenance ) - return client.listTools(signal) + return requireComplete + ? client.listTools(signal, { requireComplete: true }) + : client.listTools(signal) } ) } catch (error) { @@ -1125,18 +1133,19 @@ class McpService { onResolvedSecretTraceProvenance?: ResolvedSecretTraceProvenanceCallback, options: McpRequestOptions = {} ): Promise { - if (onResolvedSecretTraceProvenance || options.signal) { + if (onResolvedSecretTraceProvenance || options.signal || options.requireComplete) { return this.discoverServerToolsImpl( userId, serverId, workspaceId, refresh, createInvocationProvenanceReporter(onResolvedSecretTraceProvenance), - options.signal + options.signal, + options.requireComplete ) } - const inflightKey = `${workspaceId}:${serverId}:${userId}:${refresh}` + const inflightKey = `${workspaceId}:${serverId}:${userId}:${refresh}:partial-ok` const existing = this.inflightServerDiscovery.get(inflightKey) if (existing) return existing @@ -1146,7 +1155,8 @@ class McpService { workspaceId, refresh, undefined, - undefined + undefined, + false ).finally(() => { this.inflightServerDiscovery.delete(inflightKey) }) @@ -1160,14 +1170,15 @@ class McpService { workspaceId: string, refresh: McpDiscoveryRefresh, onResolvedSecretTraceProvenance?: ResolvedSecretTraceProvenanceCallback, - signal?: AbortSignal + signal?: AbortSignal, + requireComplete = false ): Promise { signal?.throwIfAborted() const requestId = generateRequestId() const discoveryStartedAt = new Date() const maxRetries = 2 - if (refresh === 'cache-aside') { + if (refresh === 'cache-aside' && !requireComplete) { try { const cached = await this.cacheAdapter.get(serverCacheKey(workspaceId, serverId)) if (cached) { @@ -1203,7 +1214,8 @@ class McpService { userId, workspaceId, onResolvedSecretTraceProvenance, - signal + signal, + requireComplete ) logger.info(`[${requestId}] Discovered ${tools.length} tools from server ${config.name}`) await Promise.allSettled([ diff --git a/apps/sim/lib/mcp/shared.test.ts b/apps/sim/lib/mcp/shared.test.ts new file mode 100644 index 00000000000..91ccaecb54f --- /dev/null +++ b/apps/sim/lib/mcp/shared.test.ts @@ -0,0 +1,54 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { assertValidMcpServerToolBindings } from '@/lib/mcp/shared' + +describe('assertValidMcpServerToolBindings', () => { + it('accepts distinct server-wide bindings and unrelated individual tools', () => { + expect(() => + assertValidMcpServerToolBindings([ + { type: 'mcp-server-advanced', params: { serverId: 'mcp-a' } }, + { type: 'mcp-server-advanced', params: { serverId: 'mcp-b' } }, + { type: 'mcp', params: { serverId: 'mcp-c', toolName: 'lookup' } }, + ]) + ).not.toThrow() + }) + + it('rejects duplicate server-wide bindings', () => { + expect(() => + assertValidMcpServerToolBindings([ + { type: 'mcp-server-advanced', params: { serverId: 'mcp-a' } }, + { type: 'mcp-server-advanced', params: { serverId: 'mcp-a' } }, + ]) + ).toThrow('Duplicate MCP Server (Advanced) binding for mcp-a') + }) + + it('rejects mixing a server-wide binding with individual tools from that server', () => { + expect(() => + assertValidMcpServerToolBindings([ + { type: 'mcp', params: { serverId: 'mcp-a', toolName: 'lookup' } }, + { type: 'mcp-server-advanced', params: { serverId: 'mcp-a' } }, + ]) + ).toThrow('cannot be attached as both an advanced server and individual tools') + }) + + it('ignores disabled bindings when checking conflicts', () => { + expect(() => + assertValidMcpServerToolBindings([ + { type: 'mcp', params: { serverId: 'mcp-a', toolName: 'lookup' } }, + { + type: 'mcp-server-advanced', + params: { serverId: 'mcp-a' }, + usageControl: 'none', + }, + ]) + ).not.toThrow() + }) + + it('fails fast on a malformed active server-wide binding', () => { + expect(() => + assertValidMcpServerToolBindings([{ type: 'mcp-server-advanced', params: { serverId: '' } }]) + ).toThrow('requires params.serverId') + }) +}) diff --git a/apps/sim/lib/mcp/shared.ts b/apps/sim/lib/mcp/shared.ts index eaecff1f0e9..7e0430cc793 100644 --- a/apps/sim/lib/mcp/shared.ts +++ b/apps/sim/lib/mcp/shared.ts @@ -5,6 +5,67 @@ import { isMcpTool, MCP } from '@/executor/constants' +export const MCP_SERVER_ADVANCED_TOOL_TYPE = 'mcp-server-advanced' as const + +export interface McpServerAdvancedToolBinding { + type: typeof MCP_SERVER_ADVANCED_TOOL_TYPE + params: { + serverId: string + } + usageControl?: 'auto' | 'force' | 'none' +} + +export function isMcpServerAdvancedToolBinding( + value: unknown +): value is McpServerAdvancedToolBinding { + if (!value || typeof value !== 'object' || Array.isArray(value)) return false + const binding = value as { type?: unknown; params?: unknown } + if (binding.type !== MCP_SERVER_ADVANCED_TOOL_TYPE) return false + if (!binding.params || typeof binding.params !== 'object' || Array.isArray(binding.params)) { + return false + } + const serverId = (binding.params as { serverId?: unknown }).serverId + return typeof serverId === 'string' && serverId.trim().length > 0 +} + +/** Rejects ambiguous server-wide bindings while leaving legacy MCP entries untouched. */ +export function assertValidMcpServerToolBindings(value: unknown): void { + if (!Array.isArray(value)) return + const advancedServerIds = new Set() + const individualServerIds = new Set() + for (const candidate of value) { + if (!candidate || typeof candidate !== 'object' || Array.isArray(candidate)) continue + const tool = candidate as { + type?: unknown + usageControl?: unknown + params?: { serverId?: unknown } + } + if (tool.usageControl === 'none') continue + if (tool.type === 'mcp') { + if (typeof tool.params?.serverId === 'string' && tool.params.serverId) { + individualServerIds.add(tool.params.serverId) + } + continue + } + if (tool.type !== MCP_SERVER_ADVANCED_TOOL_TYPE) continue + const serverId = tool.params?.serverId + if (typeof serverId !== 'string' || !serverId.trim()) { + throw new Error('MCP Server (Advanced) requires params.serverId') + } + if (advancedServerIds.has(serverId)) { + throw new Error(`Duplicate MCP Server (Advanced) binding for ${serverId}`) + } + advancedServerIds.add(serverId) + } + for (const serverId of advancedServerIds) { + if (individualServerIds.has(serverId)) { + throw new Error( + `MCP server ${serverId} cannot be attached as both an advanced server and individual tools` + ) + } + } +} + /** * Sanitizes a string by removing invisible Unicode characters that cause HTTP header errors. * Handles characters like U+2028 (Line Separator) that can be introduced via copy-paste. diff --git a/apps/sim/lib/mcp/utils.test.ts b/apps/sim/lib/mcp/utils.test.ts index 9eef822bf15..8a459a48c3e 100644 --- a/apps/sim/lib/mcp/utils.test.ts +++ b/apps/sim/lib/mcp/utils.test.ts @@ -11,6 +11,7 @@ import { createMcpToolId, generateManagedMcpConnectionId, generateMcpServerId, + isManagedMcpConnectionId, MCP_CLIENT_CONSTANTS, MCP_CONSTANTS, parseMcpToolId, @@ -459,3 +460,14 @@ describe('parseMcpToolTarget', () => { expect(() => parseMcpToolTarget(credentialId)).toThrow('Invalid managed MCP tool ID format') }) }) + +describe('isManagedMcpConnectionId', () => { + it('accepts only a complete managed connection ID', () => { + const credentialId = generateManagedMcpConnectionId() + + expect(isManagedMcpConnectionId(credentialId)).toBe(true) + expect(isManagedMcpConnectionId(`${credentialId}-tool`)).toBe(false) + expect(isManagedMcpConnectionId('mcp-cg-short')).toBe(false) + expect(isManagedMcpConnectionId('mcp-shared')).toBe(false) + }) +}) diff --git a/apps/sim/lib/mcp/utils.ts b/apps/sim/lib/mcp/utils.ts index f5d499ac91d..b5b417dc421 100644 --- a/apps/sim/lib/mcp/utils.ts +++ b/apps/sim/lib/mcp/utils.ts @@ -34,6 +34,13 @@ export function generateManagedMcpConnectionId(): string { return `${MANAGED_MCP_CONNECTION_PREFIX}${generateShortId(MANAGED_MCP_RANDOM_ID_LENGTH)}` } +export function isManagedMcpConnectionId(value: string): boolean { + return ( + value.startsWith(MANAGED_MCP_CONNECTION_PREFIX) && + value.length === MANAGED_MCP_CONNECTION_ID_LENGTH + ) +} + /** * Sanitizes a string by removing invisible Unicode characters that cause HTTP header errors. * Handles characters like U+2028 (Line Separator) that can be introduced via copy-paste. diff --git a/apps/sim/lib/workflows/editing/builders.ts b/apps/sim/lib/workflows/editing/builders.ts index 2218ea5915d..3b45b30325c 100644 --- a/apps/sim/lib/workflows/editing/builders.ts +++ b/apps/sim/lib/workflows/editing/builders.ts @@ -7,6 +7,7 @@ import { normalizeBlockRetryWaitMs, } from '@sim/workflow-types/workflow' import { isIntegrationDeploymentAvailableForVisibility } from '@/lib/integrations/availability.server' +import { MCP_SERVER_ADVANCED_TOOL_TYPE } from '@/lib/mcp/shared' import { capabilityDeniedBy } from '@/lib/permission-groups/capability-assertions' import type { PermissionGroupConfig } from '@/lib/permission-groups/fields' import { createModelAccessGate } from '@/lib/permission-groups/model-access' @@ -801,13 +802,16 @@ export function filterDisallowedTools( }) continue } - if (tool.type === 'mcp' && capabilityDeniedBy('mcp_tools.use', permissionConfig)) { + if ( + (tool.type === 'mcp' || tool.type === MCP_SERVER_ADVANCED_TOOL_TYPE) && + capabilityDeniedBy('mcp_tools.use', permissionConfig) + ) { logSkippedItem(skippedItems, { type: 'tool_not_allowed', operationType: 'add', blockId, reason: `MCP tool "${tool.title || 'unknown'}" is not allowed by permission group - tool not added`, - details: { toolType: 'mcp', serverId: tool.params?.serverId }, + details: { toolType: tool.type, serverId: tool.params?.serverId }, }) continue } diff --git a/apps/sim/lib/workflows/editing/validation.test.ts b/apps/sim/lib/workflows/editing/validation.test.ts index f3255a20200..00d596acc31 100644 --- a/apps/sim/lib/workflows/editing/validation.test.ts +++ b/apps/sim/lib/workflows/editing/validation.test.ts @@ -1493,6 +1493,33 @@ describe('collectUnresolvedAgentToolReferences', () => { expect(mockValidateSelectorIds).toHaveBeenCalledWith('mcp-server-selector', 'srv_missing', CTX) }) + it('defers an advanced MCP server reference until workflow execution', async () => { + const state = { + blocks: { + a1: { + type: 'agent', + subBlocks: { + tools: { + value: [ + { + type: 'mcp-server-advanced', + params: { + serverId: '', + }, + }, + ], + }, + }, + }, + }, + } + + const refs = await collectUnresolvedAgentToolReferences(state, CTX) + + expect(refs).toHaveLength(0) + expect(mockValidateSelectorIds).not.toHaveBeenCalled() + }) + it('flags a skill whose skillId does not resolve', async () => { mockGetSkillById.mockResolvedValue(null) const state = { diff --git a/apps/sim/lib/workflows/editing/validation.ts b/apps/sim/lib/workflows/editing/validation.ts index 5a676c57e0c..aada8e73d19 100644 --- a/apps/sim/lib/workflows/editing/validation.ts +++ b/apps/sim/lib/workflows/editing/validation.ts @@ -3,11 +3,13 @@ import { toError } from '@sim/utils/errors' import { omit } from '@sim/utils/object' import { isHosted as isHostedDeployment } from '@/lib/core/config/env-flags' import { isIntegrationDeploymentAvailableForVisibility } from '@/lib/integrations/availability.server' +import { MCP_SERVER_ADVANCED_TOOL_TYPE } from '@/lib/mcp/shared' import { isBlockTypeAccessControlExempt } from '@/lib/permission-groups/block-access' import type { PermissionGroupConfig } from '@/lib/permission-groups/fields' import { resolveAccessControlBlockType } from '@/lib/permission-groups/integration-allowlist' import { getCustomToolById } from '@/lib/workflows/custom-tools/operations' import { validateSelectorIds } from '@/lib/workflows/editing/selector-validator' +import { containsReference } from '@/lib/workflows/sanitization/references' import { getSkillById } from '@/lib/workflows/skills/operations' import { buildCanonicalIndex, @@ -282,6 +284,14 @@ function validateAgentToolEntry(item: any, index: number): string | null { return null } + if (type === MCP_SERVER_ADVANCED_TOOL_TYPE) { + const serverId = item.params?.serverId + if (typeof serverId !== 'string' || !serverId.trim()) { + return `${where} (${MCP_SERVER_ADVANCED_TOOL_TYPE}) must include params.serverId` + } + return null + } + // Integration/block-based tool: the type must be a real registry block that // actually exposes callable tools. A known block with an empty tools.access // (control-flow blocks like condition/loop/parallel/router, or the agent block @@ -1303,9 +1313,13 @@ export async function collectUnresolvedAgentToolReferences( error: toError(error).message, }) } - } else if (tool.type === 'mcp' && workspaceId) { + } else if ( + (tool.type === 'mcp' || tool.type === MCP_SERVER_ADVANCED_TOOL_TYPE) && + workspaceId + ) { const serverId = tool.params?.serverId if (typeof serverId !== 'string' || serverId.trim() === '') continue + if (containsReference(serverId)) continue try { const result = await validateSelectorIds('mcp-server-selector', serverId, context) if (result.invalid.length > 0) { diff --git a/apps/sim/lib/workflows/subblocks/display.ts b/apps/sim/lib/workflows/subblocks/display.ts index 4c2b25d4a70..a4239f60da3 100644 --- a/apps/sim/lib/workflows/subblocks/display.ts +++ b/apps/sim/lib/workflows/subblocks/display.ts @@ -8,6 +8,7 @@ */ import { isRecordLike } from '@sim/utils/object' import { truncate } from '@sim/utils/string' +import { MCP_SERVER_ADVANCED_TOOL_TYPE } from '@/lib/mcp/shared' import type { FilterRule, SortRule } from '@/lib/table/types' import { DELETED_WORKFLOW_LABEL } from '@/lib/workflows/workflow-labels' import { getBlock } from '@/blocks' @@ -507,6 +508,10 @@ export function resolveStoredToolName( return storedTitle } + if (t.type === MCP_SERVER_ADVANCED_TOOL_TYPE) { + return storedTitle || 'MCP Server (Advanced)' + } + if (typeof t.type === 'string' && t.type) { const blockConfig = getBlockConfig(t.type) if (blockConfig?.name) return blockConfig.name diff --git a/packages/auth/src/principal.ts b/packages/auth/src/principal.ts index e47e9dd9262..7065e3b9993 100644 --- a/packages/auth/src/principal.ts +++ b/packages/auth/src/principal.ts @@ -64,6 +64,7 @@ interface DelegatedPrincipalBase { executionId?: string credentialId?: string credentialGroupId?: string + mcpServerId?: string } } @@ -231,6 +232,7 @@ function parseResourceScope(value: unknown): DelegatedPrincipal['resourceScope'] 'executionId', 'credentialId', 'credentialGroupId', + 'mcpServerId', ] as const requireExactKeys(scope, [], keys) const parsed: NonNullable = {} diff --git a/packages/sim-cli/src/generated/v2-api.ts b/packages/sim-cli/src/generated/v2-api.ts index 82a31c11c9f..39e50006994 100644 --- a/packages/sim-cli/src/generated/v2-api.ts +++ b/packages/sim-cli/src/generated/v2-api.ts @@ -389,6 +389,7 @@ type ApplyWorkflowOperationsBodyRef2 = | ApplyWorkflowOperationsBodyRef3 | ApplyWorkflowOperationsBodyRef4 | ApplyWorkflowOperationsBodyRef5 + | ApplyWorkflowOperationsBodyRef6 type ApplyWorkflowOperationsBodyRef3 = { type: string @@ -426,6 +427,14 @@ type ApplyWorkflowOperationsBodyRef5 = { usageControl?: 'auto' | 'force' | 'none' } +type ApplyWorkflowOperationsBodyRef6 = { + type: 'mcp-server-advanced' + params: { + serverId: string + } + usageControl?: 'auto' | 'force' | 'none' +} + export type ApplyWorkflowOperationsBody = { operations: Array atomic?: boolean From 2158fa2ba1ed7c70a3b66134889b7f5980650057 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Tue, 1 Sep 2026 17:53:52 -0700 Subject: [PATCH 06/10] feat(credential-groups): add managed MCP connector presets --- .../mcp-connectors/[connectorId]/route.ts | 50 ++ .../[groupId]/mcp-connectors/route.ts | 26 + .../credential-groups/enroll/[token]/page.tsx | 45 +- .../chat-context-kind-registry.tsx | 9 +- .../prompt-editor/use-prompt-editor.ts | 11 +- .../skills-menu-dropdown.tsx | 7 +- .../hooks/use-skill-auto-mention.ts | 7 +- .../[workspaceId]/home/hooks/use-chat.ts | 7 +- .../app/workspace/[workspaceId]/home/types.ts | 2 + .../settings/components/mcp/mcp.tsx | 88 ++- .../mcp-server-modal/mcp-server-selector.tsx | 5 + .../components/tool-input/tool-input.tsx | 21 +- .../components/credential-group-details.tsx | 204 ++++--- .../databricks-mcp-connector-modal.tsx | 165 +++++ apps/sim/hooks/mcp/use-mcp-tools.ts | 3 +- apps/sim/hooks/queries/credential-groups.ts | 81 +++ .../lib/api/contracts/credential-groups.ts | 93 ++- apps/sim/lib/api/contracts/mcp.ts | 8 + .../application/manage-groups.test.ts | 1 - .../application/manage-groups.ts | 28 +- .../application/manage-mcp-connectors.ts | 165 +++++ .../application/operations.ts | 24 + .../application/validation.ts | 21 - apps/sim/lib/credential-groups/enrollments.ts | 28 +- .../managed-mcp-connector-icons.ts | 12 + .../managed-mcp-connectors.test.ts | 42 ++ .../managed-mcp-connectors.ts | 108 ++++ .../credential-groups/managed-mcp-service.ts | 577 ++++++++++++++++++ .../lib/credential-groups/mcp-connections.ts | 6 + .../lib/credential-groups/mcp-oauth-state.ts | 26 + .../sim/lib/credential-groups/service.test.ts | 45 +- apps/sim/lib/credential-groups/service.ts | 171 +----- apps/sim/lib/credential-groups/types.ts | 4 +- apps/sim/lib/credentials/managed-mcp.ts | 14 + .../mcp/application/managed-connections.ts | 13 +- apps/sim/lib/mcp/application/use-cases.ts | 12 + .../lib/mcp/orchestration/server-lifecycle.ts | 9 + apps/sim/lib/mcp/types.ts | 2 + apps/sim/stores/panel/types.ts | 9 +- .../0317_credential_group_managed_mcp.sql | 23 + .../db/migrations/meta/0317_snapshot.json | 39 +- packages/db/schema.ts | 16 + 42 files changed, 1842 insertions(+), 385 deletions(-) create mode 100644 apps/sim/app/api/workspaces/[id]/credential-groups/[groupId]/mcp-connectors/[connectorId]/route.ts create mode 100644 apps/sim/app/api/workspaces/[id]/credential-groups/[groupId]/mcp-connectors/route.ts create mode 100644 apps/sim/ee/credential-groups/components/databricks-mcp-connector-modal.tsx create mode 100644 apps/sim/lib/credential-groups/application/manage-mcp-connectors.ts create mode 100644 apps/sim/lib/credential-groups/managed-mcp-connector-icons.ts create mode 100644 apps/sim/lib/credential-groups/managed-mcp-connectors.test.ts create mode 100644 apps/sim/lib/credential-groups/managed-mcp-connectors.ts create mode 100644 apps/sim/lib/credential-groups/managed-mcp-service.ts diff --git a/apps/sim/app/api/workspaces/[id]/credential-groups/[groupId]/mcp-connectors/[connectorId]/route.ts b/apps/sim/app/api/workspaces/[id]/credential-groups/[groupId]/mcp-connectors/[connectorId]/route.ts new file mode 100644 index 00000000000..ba093c0a3b3 --- /dev/null +++ b/apps/sim/app/api/workspaces/[id]/credential-groups/[groupId]/mcp-connectors/[connectorId]/route.ts @@ -0,0 +1,50 @@ +import { + deleteCredentialGroupMcpConnectorContract, + updateCredentialGroupMcpConnectorContract, +} from '@/lib/api/contracts/credential-groups' +import { + defineInternalJsonRoute, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { + deleteCredentialGroupMcpConnector, + updateCredentialGroupMcpConnector, +} from '@/lib/credential-groups/application/manage-mcp-connectors' +import { credentialGroupOperations } from '@/lib/credential-groups/application/operations' +import { createCredentialGroupInternalErrorPolicy } from '@/app/api/workspaces/[id]/credential-groups/error-policy' + +const rateLimit = internalRateLimits.none({ + reason: 'Preserve existing internal Credential Group settings behavior', +}) + +export const PATCH = defineInternalJsonRoute({ + contract: updateCredentialGroupMcpConnectorContract, + auth: internalSessionAuth, + operation: credentialGroupOperations.updateMcpConnector, + rateLimit, + errorPolicy: createCredentialGroupInternalErrorPolicy('Failed to update managed MCP connector'), + mapInput: ({ params, body }) => ({ + assertedWorkspaceId: params.id, + credentialGroupId: params.groupId, + connectorId: params.connectorId, + update: body, + }), + useCase: updateCredentialGroupMcpConnector, + present: ({ mcpServer }) => ({ mcpServer }), +}) + +export const DELETE = defineInternalJsonRoute({ + contract: deleteCredentialGroupMcpConnectorContract, + auth: internalSessionAuth, + operation: credentialGroupOperations.deleteMcpConnector, + rateLimit, + errorPolicy: createCredentialGroupInternalErrorPolicy('Failed to remove managed MCP connector'), + mapInput: ({ params }) => ({ + assertedWorkspaceId: params.id, + credentialGroupId: params.groupId, + connectorId: params.connectorId, + }), + useCase: deleteCredentialGroupMcpConnector, + present: () => ({ success: true as const }), +}) diff --git a/apps/sim/app/api/workspaces/[id]/credential-groups/[groupId]/mcp-connectors/route.ts b/apps/sim/app/api/workspaces/[id]/credential-groups/[groupId]/mcp-connectors/route.ts new file mode 100644 index 00000000000..71bb113245f --- /dev/null +++ b/apps/sim/app/api/workspaces/[id]/credential-groups/[groupId]/mcp-connectors/route.ts @@ -0,0 +1,26 @@ +import { createCredentialGroupMcpConnectorContract } from '@/lib/api/contracts/credential-groups' +import { + defineInternalJsonRoute, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { createCredentialGroupMcpConnector } from '@/lib/credential-groups/application/manage-mcp-connectors' +import { credentialGroupOperations } from '@/lib/credential-groups/application/operations' +import { createCredentialGroupInternalErrorPolicy } from '@/app/api/workspaces/[id]/credential-groups/error-policy' + +export const POST = defineInternalJsonRoute({ + contract: createCredentialGroupMcpConnectorContract, + auth: internalSessionAuth, + operation: credentialGroupOperations.createMcpConnector, + rateLimit: internalRateLimits.none({ + reason: 'Preserve existing internal Credential Group settings behavior', + }), + errorPolicy: createCredentialGroupInternalErrorPolicy('Failed to add managed MCP connector'), + mapInput: ({ params, body }) => ({ + assertedWorkspaceId: params.id, + credentialGroupId: params.groupId, + connector: body, + }), + useCase: createCredentialGroupMcpConnector, + present: ({ mcpServer }) => ({ mcpServer }), +}) diff --git a/apps/sim/app/credential-groups/enroll/[token]/page.tsx b/apps/sim/app/credential-groups/enroll/[token]/page.tsx index 0dd2dc8653e..25a2a5ba955 100644 --- a/apps/sim/app/credential-groups/enroll/[token]/page.tsx +++ b/apps/sim/app/credential-groups/enroll/[token]/page.tsx @@ -2,10 +2,10 @@ import { type ReactNode, Suspense } from 'react' import { Chip } from '@sim/emcn' import type { Metadata } from 'next' import { headers } from 'next/headers' -import { McpIcon } from '@/components/icons' import { asOrchestrationError } from '@/lib/core/orchestration/types' import { authenticateCredentialGroupEnrollment } from '@/lib/credential-groups/application/enrollment-auth' import { readPublicCredentialGroupEnrollment } from '@/lib/credential-groups/application/public-enrollment' +import { getManagedMcpConnectorIcon } from '@/lib/credential-groups/managed-mcp-connector-icons' import { getCredentialGroupProviderService } from '@/lib/credential-groups/providers' import { enforcePublicCredentialGroupIpRateLimit } from '@/lib/credential-groups/rate-limit' import { SupportFooter } from '@/app/(auth)/components' @@ -181,26 +181,29 @@ export default async function CredentialGroupEnrollmentPage({ /> ) })} - {enrollment.mcpServers.map((server) => ( - } - title={server.name} - description={ - server.connection?.status === 'connected' - ? 'Connected' - : server.connection - ? 'Reconnect required' - : server.description || 'Not connected' - } - trailing={ - - } - /> - ))} + {enrollment.mcpServers.map((server) => { + const ConnectorIcon = getManagedMcpConnectorIcon(server.managedConnectorId) + return ( + } + title={server.name} + description={ + server.connection?.status === 'connected' + ? 'Connected' + : server.connection + ? 'Reconnect required' + : server.description || 'Not connected' + } + trailing={ + + } + /> + ) + })} , + renderIcon: ({ context, className }) => { + const McpServerIcon = + context.kind === 'mcp' && context.managedConnectorId + ? getManagedMcpConnectorIcon(context.managedConnectorId) + : McpIcon + return + }, }, } diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/use-prompt-editor.ts b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/use-prompt-editor.ts index 823150e9255..7b8ca833ca2 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/use-prompt-editor.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/use-prompt-editor.ts @@ -33,7 +33,7 @@ import { restoreSkillTriggerText, SKILL_CHIP_TRIGGER, } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/utils' -import { type McpServer, useMcpServers } from '@/hooks/queries/mcp' +import { type McpServer, useMcpToolServers } from '@/hooks/queries/mcp' import { type SkillDefinition, useSkills } from '@/hooks/queries/skills' import type { ChatContext } from '@/stores/panel' @@ -165,7 +165,7 @@ export function usePromptEditor({ onPasteFiles, }: UsePromptEditorProps) { const { data: skills = [] } = useSkills(workspaceId) - const { data: allMcpServers = [] } = useMcpServers(workspaceId) + const { data: allMcpServers = [] } = useMcpToolServers(workspaceId) const mcpServers = useMemo( () => allMcpServers.filter((server) => server.enabled && server.workspaceId === workspaceId), [allMcpServers, workspaceId] @@ -527,7 +527,12 @@ export function usePromptEditor({ setValueState(newValue) } - addContextNotified({ kind: 'mcp', serverId: server.id, label: server.name }) + addContextNotified({ + kind: 'mcp', + serverId: server.id, + label: server.name, + ...(server.managedConnectorId ? { managedConnectorId: server.managedConnectorId } : {}), + }) }, [textareaRef, addContextNotified] ) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/skills-menu-dropdown/skills-menu-dropdown.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/skills-menu-dropdown/skills-menu-dropdown.tsx index a6190e1b4e4..25fbe9eef4d 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/skills-menu-dropdown/skills-menu-dropdown.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/skills-menu-dropdown/skills-menu-dropdown.tsx @@ -9,6 +9,7 @@ import { dropdownMenuRowClass, } from '@sim/emcn' import { AgentSkillsIcon, McpIcon } from '@/components/icons' +import { getManagedMcpConnectorIcon } from '@/lib/credential-groups/managed-mcp-connector-icons' import type { McpServer } from '@/hooks/queries/mcp' import type { SkillDefinition } from '@/hooks/queries/skills' @@ -201,6 +202,10 @@ export const SkillsMenuDropdown = React.memo( {filteredItems.length > 0 ? ( filteredItems.map((target, index) => { const isActive = index === activeIndex + const McpServerIcon = + target.kind === 'mcp' && target.item.managedConnectorId + ? getManagedMcpConnectorIcon(target.item.managedConnectorId) + : McpIcon return ( ) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/hooks/use-skill-auto-mention.ts b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/hooks/use-skill-auto-mention.ts index 6aa5006ba54..2ce6adcf83e 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/hooks/use-skill-auto-mention.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/hooks/use-skill-auto-mention.ts @@ -89,7 +89,12 @@ export function useSkillAutoMention({ for (const server of mcpServers) { const key = server.name.toLowerCase() if (!byName.has(key)) { - byName.set(key, { kind: 'mcp', serverId: server.id, label: server.name }) + byName.set(key, { + kind: 'mcp', + serverId: server.id, + label: server.name, + ...(server.managedConnectorId ? { managedConnectorId: server.managedConnectorId } : {}), + }) } } const names = [...byName.values()] diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts index 8a9a6399493..6114e8057bd 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts @@ -3876,7 +3876,12 @@ export function useChat( ...('folderId' in c && c.folderId ? { folderId: c.folderId } : {}), ...(c.kind === 'skill' && 'skillId' in c ? { skillId: c.skillId } : {}), ...(c.kind === 'integration' && 'blockType' in c ? { blockType: c.blockType } : {}), - ...(c.kind === 'mcp' && 'serverId' in c ? { serverId: c.serverId } : {}), + ...(c.kind === 'mcp' && 'serverId' in c + ? { + serverId: c.serverId, + ...(c.managedConnectorId ? { managedConnectorId: c.managedConnectorId } : {}), + } + : {}), ...(c.kind === 'file_selection' ? { fileName: c.fileName, diff --git a/apps/sim/app/workspace/[workspaceId]/home/types.ts b/apps/sim/app/workspace/[workspaceId]/home/types.ts index 9634055b5e5..983cc4eba67 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/types.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/types.ts @@ -1,3 +1,4 @@ +import type { ManagedMcpConnectorId } from '@/lib/credential-groups/managed-mcp-connectors' import type { ChatContext } from '@/stores/panel' import type { BrowserTextSelection, TerminalTextSelection } from '@/stores/panel/types' @@ -152,6 +153,7 @@ export interface ChatMessageContext { blockType?: string skillId?: string serverId?: string + managedConnectorId?: ManagedMcpConnectorId /** Selected passage for a `file_selection` context. */ text?: string /** Source file name for a `file_selection` context. */ diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/mcp/mcp.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/mcp/mcp.tsx index 55678e16cbc..29aff5fb184 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/mcp/mcp.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/mcp/mcp.tsx @@ -11,6 +11,7 @@ import { McpIcon } from '@/components/icons' import { canMutateWorkspaceSettingsSection } from '@/components/settings/navigation' import { requestJson } from '@/lib/api/client/request' import { getWorkflowStateContract } from '@/lib/api/contracts/workflows' +import { getManagedMcpConnectorIcon } from '@/lib/credential-groups/managed-mcp-connector-icons' import { getIssueBadgeLabel, getIssueBadgeVariant, @@ -35,6 +36,7 @@ import { import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section' import { useSettingsSearch } from '@/app/workspace/[workspaceId]/settings/components/use-settings-search' import { useMcpOauthPopup } from '@/hooks/mcp/use-mcp-oauth-popup' +import { useCredentialGroups } from '@/hooks/queries/credential-groups' import { type McpServer, type McpTool, @@ -75,6 +77,7 @@ interface ServerListItemProps { isLoadingTools?: boolean isRefreshing?: boolean discoveryError?: string | null + ownerName?: string onViewDetails: () => void onAuthorize: () => void } @@ -87,10 +90,14 @@ function ServerListItem({ isLoadingTools = false, isRefreshing = false, discoveryError = null, + ownerName, onViewDetails, onAuthorize, }: ServerListItemProps) { const transportLabel = formatTransportLabel(server.transport || 'http') + const ServerIcon = server.managedConnectorId + ? getManagedMcpConnectorIcon(server.managedConnectorId) + : McpIcon const toolsLabel = getServerToolsLabel( tools, server.connectionStatus, @@ -113,20 +120,22 @@ function ServerListItem({ const serverName = server.name || 'Unnamed server' // Transport rides on the description rather than beside the name — inside the // row's truncating title a long name would clip it away entirely. - const statusText = isConnecting - ? 'Waiting for authorization...' - : isRefreshing - ? 'Refreshing...' - : isLoadingTools && tools.length === 0 - ? 'Loading...' - : showDiscoveryError - ? discoveryError - : toolsLabel + const statusText = server.managedConnectorId + ? `Managed by ${ownerName ?? 'a Credential Group'}` + : isConnecting + ? 'Waiting for authorization...' + : isRefreshing + ? 'Refreshing...' + : isLoadingTools && tools.length === 0 + ? 'Loading...' + : showDiscoveryError + ? discoveryError + : toolsLabel return ( } - iconFilled + icon={} + iconFilled={!server.managedConnectorId} title={serverName} description={ <> @@ -145,7 +154,10 @@ function ServerListItem({ clickLabel={`Open ${serverName}`} navigable trailing={ - canManage && server.authType === 'oauth' && server.connectionStatus !== 'connected' ? ( + canManage && + !server.managedConnectorId && + server.authType === 'oauth' && + server.connectionStatus !== 'connected' ? ( {isConnecting ? 'Reopen authorization' : 'Authorize'} ) : undefined } @@ -194,6 +206,9 @@ export function MCP() { isLoading: serversLoading, error: serversError, } = useMcpServers(workspaceId) + const credentialGroups = useCredentialGroups( + workspacePermissions.canAdmin ? workspaceId : undefined + ) const { data: mcpToolsData = [], toolsStateByServer } = useMcpToolsQuery(workspaceId) const { data: storedTools = [], refetch: refetchStoredTools } = useStoredMcpTools(workspaceId, { enabled: selectedServerId !== null, @@ -276,6 +291,9 @@ export function MCP() { const filteredServers = (servers || []).filter((server) => server.name?.toLowerCase().includes(searchTerm.toLowerCase()) ) + const credentialGroupNameById = new Map( + credentialGroups.data?.credentialGroups.map((group) => [group.id, group.name] as const) ?? [] + ) const handleViewDetails = (serverId: string) => { setSelectedServerId(serverId) @@ -440,7 +458,7 @@ export function MCP() { back={{ text: 'MCP tools', icon: ArrowLeft, onSelect: handleBackToList }} title={server.name || 'Unnamed server'} actions={ - canEdit + canEdit && !server.managedConnectorId ? [ { text: refreshAction.text, @@ -474,6 +492,14 @@ export function MCP() { )} + {server.managedConnectorId && ( + + {server.credentialGroupId + ? (credentialGroupNameById.get(server.credentialGroupId) ?? 'Credential Group') + : 'Credential Group'} + + )} + {server.connectionStatus !== 'connected' && (

@@ -487,20 +513,23 @@ export function MCP() { )} - {canEdit && server.authType === 'oauth' && server.connectionStatus !== 'connected' && ( - -

- { - await startOauthForServer(server.id) - }} - > - {connectingOauthServers.has(server.id) ? 'Reopen authorization' : 'Authorize'} - -
-
- )} + {canEdit && + !server.managedConnectorId && + server.authType === 'oauth' && + server.connectionStatus !== 'connected' && ( + +
+ { + await startOauthForServer(server.id) + }} + > + {connectingOauthServers.has(server.id) ? 'Reopen authorization' : 'Authorize'} + +
+
+ )} @@ -702,6 +731,11 @@ export function MCP() { key={server.id} canManage={canEdit} server={server} + ownerName={ + server.credentialGroupId + ? credentialGroupNameById.get(server.credentialGroupId) + : undefined + } tools={tools} isConnecting={connectingOauthServers.has(server.id)} isLoadingTools={isLoadingTools} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/mcp-server-modal/mcp-server-selector.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/mcp-server-modal/mcp-server-selector.tsx index 535f8397526..8ebf1754340 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/mcp-server-modal/mcp-server-selector.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/mcp-server-modal/mcp-server-selector.tsx @@ -3,6 +3,8 @@ import { useEffect, useMemo, useState } from 'react' import { Combobox } from '@sim/emcn' import { useParams } from 'next/navigation' +import { McpIcon } from '@/components/icons' +import { getManagedMcpConnectorIcon } from '@/lib/credential-groups/managed-mcp-connector-icons' import { formatDisplayText } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/formatted-text' import { getWorkflowSearchLabelHighlight } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/workflow-search-highlight' import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value' @@ -47,6 +49,9 @@ export function McpServerSelector({ enabledServers.map((server) => ({ label: server.name, value: server.id, + icon: server.managedConnectorId + ? getManagedMcpConnectorIcon(server.managedConnectorId) + : McpIcon, })), [enabledServers] ) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/tool-input.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/tool-input.tsx index 1ea743650f9..496958e1d22 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/tool-input.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/tool-input.tsx @@ -16,6 +16,7 @@ import { ArrowLeft, ChevronRight, Server, Wrench, X } from '@sim/emcn/icons' import { createLogger } from '@sim/logger' import { useParams } from 'next/navigation' import { McpIcon, WorkflowIcon } from '@/components/icons' +import { getManagedMcpConnectorIcon } from '@/lib/credential-groups/managed-mcp-connector-icons' import { MCP_SERVER_ADVANCED_TOOL_TYPE } from '@/lib/mcp/shared' import { getIssueBadgeLabel, @@ -1127,6 +1128,9 @@ export const ToolInput = memo(function ToolInput({ ) { const tools = mcpToolsByServer.get(mcpServerDrilldown) ?? [] const server = mcpServers.find((candidate) => candidate.id === mcpServerDrilldown) + const ServerIcon = server?.managedConnectorId + ? getManagedMcpConnectorIcon(server.managedConnectorId) + : Server const serverName = tools[0]?.serverName || server?.name || 'Unknown Server' const allAlreadySelected = selectedTools.some( (tool) => @@ -1149,7 +1153,7 @@ export const ToolInput = memo(function ToolInput({ serverToolItems.push({ label: 'Use all available tools', value: `mcp-server-all-${mcpServerDrilldown}`, - iconElement: createToolIcon('var(--brand-agent)', Server), + iconElement: createToolIcon('var(--brand-agent)', ServerIcon), onSelect: () => { if (allAlreadySelected) return const filteredTools = selectedTools.filter( @@ -1315,11 +1319,14 @@ export const ToolInput = memo(function ToolInput({ const tools = mcpToolsByServer.get(serverId) ?? [] const serverName = tools[0]?.serverName || server?.name || 'Unknown Server' const toolCount = tools.length + const ServerIcon = server.managedConnectorId + ? getManagedMcpConnectorIcon(server.managedConnectorId) + : Server serverItems.push({ label: `${serverName} (${toolCount} tools)`, value: `mcp-server-folder-${serverId}`, - iconElement: createToolIcon('#6366F1', Server), + iconElement: createToolIcon('#6366F1', ServerIcon), suffixElement: , onSelect: () => { setMcpServerDrilldown(serverId) @@ -1496,6 +1503,14 @@ export const ToolInput = memo(function ToolInput({ : null const mcpTool = isMcpTool ? mcpTools.find((t) => t.id === tool.toolId) : null + const advancedMcpServer = isAdvancedMcpServer + ? mcpServers.find((server) => server.id === tool.params?.serverId) + : undefined + const McpFamilyIcon = mcpTool?.icon + ? mcpTool.icon + : advancedMcpServer?.managedConnectorId + ? getManagedMcpConnectorIcon(advancedMcpServer.managedConnectorId) + : McpIcon const mcpTileColor = mcpTool?.bgColor || 'var(--brand-agent)' const mcpToolSchema = isMcpTool ? tool.schema || mcpTool?.inputSchema : null @@ -1597,7 +1612,7 @@ export const ToolInput = memo(function ToolInput({ ) : isMcpFamily ? ( ) : isWorkflowTool ? ( diff --git a/apps/sim/ee/credential-groups/components/credential-group-details.tsx b/apps/sim/ee/credential-groups/components/credential-group-details.tsx index 6942b94158f..a480c2db33e 100644 --- a/apps/sim/ee/credential-groups/components/credential-group-details.tsx +++ b/apps/sim/ee/credential-groups/components/credential-group-details.tsx @@ -3,13 +3,18 @@ import { useState } from 'react' import { Chip, ChipConfirmModal, ChipInput, ChipTag, ChipTextarea, toast } from '@sim/emcn' import { getErrorMessage } from '@sim/utils/errors' -import { McpIcon } from '@/components/icons' import type { WorkspaceCredential } from '@/lib/api/contracts' import type { CredentialGroup, CredentialGroupOption, UpdateCredentialGroupBody, } from '@/lib/api/contracts/credential-groups' +import { getManagedMcpConnectorIcon } from '@/lib/credential-groups/managed-mcp-connector-icons' +import { + MANAGED_MCP_CONNECTOR_IDS, + MANAGED_MCP_CONNECTORS, + type ManagedMcpConnectorId, +} from '@/lib/credential-groups/managed-mcp-connectors' import { CREDENTIAL_GROUP_PROVIDER_IDS, type CredentialGroupProvider, @@ -27,8 +32,14 @@ import { } from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row' import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section' import { SettingRow } from '@/ee/components/setting-row' +import { DatabricksMcpConnectorModal } from '@/ee/credential-groups/components/databricks-mcp-connector-modal' import { SlackManagedUsersModal } from '@/ee/credential-groups/components/slack-managed-users-modal' -import { useCredentialGroups, useUpdateCredentialGroup } from '@/hooks/queries/credential-groups' +import { + useCreateCredentialGroupMcpConnector, + useCredentialGroups, + useDeleteCredentialGroupMcpConnector, + useUpdateCredentialGroup, +} from '@/hooks/queries/credential-groups' import { useWorkspaceCredentials } from '@/hooks/queries/credentials' import { useMcpServers } from '@/hooks/queries/mcp' @@ -73,6 +84,8 @@ export function CredentialGroupDetails({ onDescriptionChange, }: CredentialGroupDetailsProps) { const updateGroup = useUpdateCredentialGroup() + const createMcpConnector = useCreateCredentialGroupMcpConnector() + const deleteMcpConnector = useDeleteCredentialGroupMcpConnector() /** * Reads the same cache entry the list view already populated, so the deployment's configured * providers arrive without a second request. @@ -87,8 +100,13 @@ export function CredentialGroupDetails({ }) const [slackSetup, setSlackSetup] = useState<{ credentialId?: string } | null>(null) const [removingProvider, setRemovingProvider] = useState(null) + const [databricksSetupOpen, setDatabricksSetupOpen] = useState(false) + const [removingMcpConnector, setRemovingMcpConnector] = useState( + null + ) - const isUpdating = updateGroup.isPending + const isUpdating = + updateGroup.isPending || createMcpConnector.isPending || deleteMcpConnector.isPending const updateOptions = async ( options: NonNullable, @@ -108,19 +126,6 @@ export function CredentialGroupDetails({ } } - const updateMcpServers = async (mcpServerIds: string[], successMessage: string) => { - try { - await updateGroup.mutateAsync({ - workspaceId, - groupId: credentialGroup.id, - body: { mcpServerIds }, - }) - toast.success(successMessage) - } catch (error) { - toast.error(getErrorMessage(error, 'Could not update MCP servers')) - } - } - const addProvider = async (provider: CredentialGroupStandardOAuthProvider) => { const service = getCredentialGroupProviderService(provider) const existing = credentialGroup.options.map(toOptionUpdateInput) @@ -158,6 +163,35 @@ export function CredentialGroupDetails({ if (await updateOptions(options, `${service.name} removed`)) setRemovingProvider(null) } + const addMcpConnector = async (connectorId: Exclude) => { + try { + await createMcpConnector.mutateAsync({ + workspaceId, + groupId: credentialGroup.id, + body: { connectorId }, + }) + toast.success(`${MANAGED_MCP_CONNECTORS[connectorId].name} added`) + } catch (error) { + toast.error(getErrorMessage(error, 'Could not add managed MCP connector')) + } + } + + const handleRemoveMcpConnector = async () => { + if (!removingMcpConnector) return + const connector = MANAGED_MCP_CONNECTORS[removingMcpConnector] + try { + await deleteMcpConnector.mutateAsync({ + workspaceId, + groupId: credentialGroup.id, + connectorId: removingMcpConnector, + }) + toast.success(`${connector.name} removed`) + setRemovingMcpConnector(null) + } catch (error) { + toast.error(getErrorMessage(error, 'Could not remove managed MCP connector')) + } + } + /** * A provider whose OAuth client this deployment has not configured can never finish an * enrollment, so it is not offered — but one already on the group stays listed regardless, or @@ -177,23 +211,20 @@ export function CredentialGroupDetails({ if (!providerQuery) return true return getCredentialGroupProviderService(provider).name.toLowerCase().includes(providerQuery) }) - const linkedMcpServerIds = new Set(credentialGroup.mcpServers.map((server) => server.id)) - const mcpOwnershipReady = credentialGroups.isSuccess - const mcpServerOwnerById = new Map( - credentialGroups.data?.credentialGroups.flatMap((group) => - group.mcpServers.map((server) => [server.id, group] as const) - ) ?? [] - ) - const shownMcpServers = (mcpServers.data ?? []).filter((server) => { - const linked = linkedMcpServerIds.has(server.id) - if (!linked && (server.authType !== 'oauth' || !server.enabled || server.deletedAt)) - return false + const shownMcpConnectors = MANAGED_MCP_CONNECTOR_IDS.filter((connectorId) => { if (!providerQuery) return true + const connector = MANAGED_MCP_CONNECTORS[connectorId] return ( - server.name.toLowerCase().includes(providerQuery) || - server.description?.toLowerCase().includes(providerQuery) + connector.name.toLowerCase().includes(providerQuery) || + connector.description.toLowerCase().includes(providerQuery) ) }) + const databricksServerSummary = credentialGroup.mcpServers.find( + (server) => server.managedConnectorId === 'databricks' + ) + const databricksServer = databricksServerSummary + ? mcpServers.data?.find((server) => server.id === databricksServerSummary.id) + : undefined return ( <> @@ -316,57 +347,61 @@ export function CredentialGroupDetails({ - - {mcpServers.isPending ? ( - Loading OAuth MCP servers... - ) : shownMcpServers.length === 0 ? ( + + {shownMcpConnectors.length === 0 ? ( {providerSearch.trim() - ? `No OAuth MCP servers found matching "${providerSearch}"` - : 'No OAuth MCP servers are available. Add one in MCP settings first.'} + ? `No MCP apps found matching "${providerSearch}"` + : 'No managed MCP apps are available.'} ) : null}
- {shownMcpServers.map((server) => { - const linked = linkedMcpServerIds.has(server.id) - const owner = mcpServerOwnerById.get(server.id) - const assignedElsewhere = owner && owner.id !== credentialGroup.id + {shownMcpConnectors.map((connectorId) => { + const connector = MANAGED_MCP_CONNECTORS[connectorId] + const server = credentialGroup.mcpServers.find( + (candidate) => candidate.managedConnectorId === connectorId + ) + const ConnectorIcon = getManagedMcpConnectorIcon(connectorId) return ( } - title={server.name} - description={ - assignedElsewhere - ? `Used by ${owner.name}` - : server.description || 'Each invited person connects their own OAuth account' - } - badge={ - linked ? ( - Added - ) : assignedElsewhere ? ( - Unavailable - ) : undefined - } + key={connectorId} + icon={} + title={server?.name ?? connector.name} + description={connector.description} + badge={server ? Added : undefined} trailing={ - - void updateMcpServers( - linked - ? credentialGroup.mcpServers - .filter((candidate) => candidate.id !== server.id) - .map((candidate) => candidate.id) - : [ - ...credentialGroup.mcpServers.map((candidate) => candidate.id), - server.id, - ], - linked ? `${server.name} removed` : `${server.name} added` - ) - } - > - {linked ? 'Remove' : 'Add'} - + server ? ( + setDatabricksSetupOpen(true), + disabled: isUpdating || !databricksServer, + }, + ] + : []), + { + label: 'Remove', + destructive: true, + onSelect: () => setRemovingMcpConnector(connectorId), + disabled: isUpdating, + }, + ]} + /> + ) : ( + { + if (connectorId === 'databricks') setDatabricksSetupOpen(true) + else void addMcpConnector(connectorId) + }} + > + {connectorId === 'databricks' ? 'Set up' : 'Add'} + + ) } /> ) @@ -387,6 +422,14 @@ export function CredentialGroupDetails({ initialCredentialId={slackSetup?.credentialId} /> + + !open && !isUpdating && setRemovingProvider(null)} @@ -403,6 +446,23 @@ export function CredentialGroupDetails({ disabled: isUpdating, }} /> + + !open && !isUpdating && setRemovingMcpConnector(null)} + srTitle='Remove MCP app' + title={`Remove ${ + removingMcpConnector ? MANAGED_MCP_CONNECTORS[removingMcpConnector].name : 'MCP app' + }`} + defaultAction='confirm' + text='People will no longer be able to connect this app. Existing OAuth grants and saved tool metadata will be revoked.' + dismissLabel='Cancel' + confirm={{ + label: isUpdating ? 'Removing...' : 'Remove', + onClick: handleRemoveMcpConnector, + disabled: isUpdating, + }} + /> ) } diff --git a/apps/sim/ee/credential-groups/components/databricks-mcp-connector-modal.tsx b/apps/sim/ee/credential-groups/components/databricks-mcp-connector-modal.tsx new file mode 100644 index 00000000000..7908646500d --- /dev/null +++ b/apps/sim/ee/credential-groups/components/databricks-mcp-connector-modal.tsx @@ -0,0 +1,165 @@ +'use client' + +import { useState } from 'react' +import { + ChipModal, + ChipModalBody, + ChipModalError, + ChipModalField, + ChipModalFooter, + ChipModalHeader, + toast, +} from '@sim/emcn' +import { getErrorMessage } from '@sim/utils/errors' +import { DatabricksIcon } from '@/components/icons' +import type { McpServer } from '@/lib/api/contracts/mcp' +import { + useCreateCredentialGroupMcpConnector, + useUpdateCredentialGroupMcpConnector, +} from '@/hooks/queries/credential-groups' + +interface DatabricksMcpConnectorModalProps { + credentialGroupId: string + onOpenChange: (open: boolean) => void + open: boolean + server?: McpServer + workspaceId: string +} + +export function DatabricksMcpConnectorModal({ + credentialGroupId, + onOpenChange, + open, + server, + workspaceId, +}: DatabricksMcpConnectorModalProps) { + const createConnector = useCreateCredentialGroupMcpConnector() + const updateConnector = useUpdateCredentialGroupMcpConnector() + const [nameInput, setNameInput] = useState(null) + const [urlInput, setUrlInput] = useState(null) + const [clientIdInput, setClientIdInput] = useState(null) + const [clientSecret, setClientSecret] = useState('') + const name = nameInput ?? server?.name ?? 'Databricks' + const url = urlInput ?? server?.url ?? '' + const clientId = clientIdInput ?? server?.oauthClientId ?? '' + const pending = createConnector.isPending || updateConnector.isPending + const error = createConnector.error ?? updateConnector.error + + const reset = () => { + setNameInput(null) + setUrlInput(null) + setClientIdInput(null) + setClientSecret('') + createConnector.reset() + updateConnector.reset() + } + + const handleOpenChange = (nextOpen: boolean) => { + if (pending && !nextOpen) return + onOpenChange(nextOpen) + if (!nextOpen) reset() + } + + const handleSubmit = async () => { + if (!name.trim() || !url.trim() || !clientId.trim() || pending) return + try { + if (server) { + await updateConnector.mutateAsync({ + workspaceId, + groupId: credentialGroupId, + connectorId: 'databricks', + body: { + name: name.trim(), + url: url.trim(), + oauthClientId: clientId.trim(), + ...(clientSecret.trim() ? { oauthClientSecret: clientSecret.trim() } : {}), + }, + }) + } else { + await createConnector.mutateAsync({ + workspaceId, + groupId: credentialGroupId, + body: { + connectorId: 'databricks', + name: name.trim(), + url: url.trim(), + oauthClientId: clientId.trim(), + ...(clientSecret.trim() ? { oauthClientSecret: clientSecret.trim() } : {}), + }, + }) + } + toast.success(server ? 'Databricks updated' : 'Databricks added') + handleOpenChange(false) + } catch (submitError) { + toast.error(getErrorMessage(submitError, 'Could not save Databricks')) + } + } + + return ( + + handleOpenChange(false)} + closeDisabled={pending} + > + {server ? 'Edit Databricks MCP' : 'Add Databricks MCP'} + + + + + + + {error ? getErrorMessage(error) : null} + + handleOpenChange(false)} + cancelDisabled={pending} + primaryAction={{ + label: pending ? 'Saving...' : 'Save', + onClick: () => void handleSubmit(), + disabled: pending || !name.trim() || !url.trim() || !clientId.trim(), + }} + /> + + ) +} diff --git a/apps/sim/hooks/mcp/use-mcp-tools.ts b/apps/sim/hooks/mcp/use-mcp-tools.ts index a6e816b038b..cc5eb1d49b2 100644 --- a/apps/sim/hooks/mcp/use-mcp-tools.ts +++ b/apps/sim/hooks/mcp/use-mcp-tools.ts @@ -10,6 +10,7 @@ import { useCallback, useMemo } from 'react' import { createLogger } from '@sim/logger' import { useQueryClient } from '@tanstack/react-query' import { McpIcon } from '@/components/icons' +import { getManagedMcpConnectorIcon } from '@/lib/credential-groups/managed-mcp-connector-icons' import { createMcpToolId } from '@/lib/mcp/shared' import type { McpToolSchema } from '@/lib/mcp/types' import { mcpKeys, useMcpToolsQuery } from '@/hooks/queries/mcp' @@ -51,7 +52,7 @@ export function useMcpTools(workspaceId: string): UseMcpToolsResult { type: 'mcp' as const, inputSchema: tool.inputSchema, bgColor: '#6366F1', - icon: McpIcon, + icon: tool.managedConnectorId ? getManagedMcpConnectorIcon(tool.managedConnectorId) : McpIcon, })) }, [mcpToolsData]) diff --git a/apps/sim/hooks/queries/credential-groups.ts b/apps/sim/hooks/queries/credential-groups.ts index 4dd3c41077e..c7a82929492 100644 --- a/apps/sim/hooks/queries/credential-groups.ts +++ b/apps/sim/hooks/queries/credential-groups.ts @@ -6,8 +6,10 @@ import type { ContractBodyInput } from '@/lib/api/contracts' import { type CredentialGroupAccessResponse, createCredentialGroupContract, + createCredentialGroupMcpConnectorContract, deleteCredentialGroupContract, deleteCredentialGroupEnrollmentContract, + deleteCredentialGroupMcpConnectorContract, getCredentialGroupAccessContract, getCredentialGroupContract, inviteCredentialGroupEnrollmentsContract, @@ -15,6 +17,7 @@ import { startSlackCredentialGroupConfigurationContract, updateCredentialGroupAccessContract, updateCredentialGroupContract, + updateCredentialGroupMcpConnectorContract, } from '@/lib/api/contracts/credential-groups' import type { ContractJsonResponse } from '@/lib/api/contracts/types' import { mcpKeys } from '@/hooks/queries/mcp' @@ -213,6 +216,84 @@ export function useUpdateCredentialGroup() { }) } +function invalidateManagedMcpConnectorQueries( + queryClient: ReturnType, + workspaceId: string, + groupId: string +) { + return Promise.all([ + queryClient.invalidateQueries({ queryKey: credentialGroupKeys.list(workspaceId) }), + queryClient.invalidateQueries({ queryKey: credentialGroupKeys.detail(workspaceId, groupId) }), + queryClient.invalidateQueries({ queryKey: mcpKeys.serversList(workspaceId) }), + queryClient.invalidateQueries({ queryKey: mcpKeys.managedCatalogList(workspaceId) }), + invalidateSelectorQueries(queryClient), + ]) +} + +export function useCreateCredentialGroupMcpConnector() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: async ({ + workspaceId, + groupId, + body, + }: { + workspaceId: string + groupId: string + body: ContractBodyInput + }) => + requestJson(createCredentialGroupMcpConnectorContract, { + params: { id: workspaceId, groupId }, + body, + }), + onSettled: (_data, _error, variables) => + invalidateManagedMcpConnectorQueries(queryClient, variables.workspaceId, variables.groupId), + }) +} + +export function useUpdateCredentialGroupMcpConnector() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: async ({ + workspaceId, + groupId, + connectorId, + body, + }: { + workspaceId: string + groupId: string + connectorId: 'fireflies' | 'granola' | 'databricks' + body: ContractBodyInput + }) => + requestJson(updateCredentialGroupMcpConnectorContract, { + params: { id: workspaceId, groupId, connectorId }, + body, + }), + onSettled: (_data, _error, variables) => + invalidateManagedMcpConnectorQueries(queryClient, variables.workspaceId, variables.groupId), + }) +} + +export function useDeleteCredentialGroupMcpConnector() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: async ({ + workspaceId, + groupId, + connectorId, + }: { + workspaceId: string + groupId: string + connectorId: 'fireflies' | 'granola' | 'databricks' + }) => + requestJson(deleteCredentialGroupMcpConnectorContract, { + params: { id: workspaceId, groupId, connectorId }, + }), + onSettled: (_data, _error, variables) => + invalidateManagedMcpConnectorQueries(queryClient, variables.workspaceId, variables.groupId), + }) +} + export function useStartSlackCredentialGroupConfiguration() { return useMutation({ mutationFn: async ({ diff --git a/apps/sim/lib/api/contracts/credential-groups.ts b/apps/sim/lib/api/contracts/credential-groups.ts index be92b20b787..b96b71c8afd 100644 --- a/apps/sim/lib/api/contracts/credential-groups.ts +++ b/apps/sim/lib/api/contracts/credential-groups.ts @@ -7,6 +7,7 @@ import { CREDENTIAL_GROUP_WORKFLOW_CATALOG_LIMIT, CREDENTIAL_GROUP_WORKFLOW_NAME_MAX_LENGTH, } from '@/lib/credential-groups/limits' +import { MANAGED_MCP_CONNECTOR_IDS } from '@/lib/credential-groups/managed-mcp-connectors' import { CREDENTIAL_GROUP_PROVIDER_IDS, CREDENTIAL_GROUP_STANDARD_OAUTH_PROVIDER_IDS, @@ -26,6 +27,7 @@ export const credentialGroupOptionConfigurationStatusSchema = z.enum([ 'ready', 'needs_update', ]) +export const managedMcpConnectorIdSchema = z.enum(MANAGED_MCP_CONNECTOR_IDS) const credentialGroupOptionFields = { label: z.string().trim().min(1, 'Option label is required').max(100), @@ -78,6 +80,7 @@ export const credentialGroupMcpServerSchema = z.object({ description: z.string().nullable(), authType: z.string().min(1), enabled: z.boolean(), + managedConnectorId: managedMcpConnectorIdSchema, }) export const credentialGroupSchema = z.object({ @@ -143,26 +146,6 @@ export type CredentialGroupEnrollmentMcpConnection = z.output< > export type CredentialGroupEnrollmentDetail = z.output -const credentialGroupMcpServerIdsSchema = z - .array(z.string().min(1, 'MCP server ID is required').max(128)) - .max( - CREDENTIAL_GROUP_MCP_SERVER_LIMIT, - `Select at most ${CREDENTIAL_GROUP_MCP_SERVER_LIMIT} MCP servers` - ) - .superRefine((serverIds, ctx) => { - const seen = new Set() - for (const [index, serverId] of serverIds.entries()) { - if (seen.has(serverId)) { - ctx.addIssue({ - code: 'custom', - path: [index], - message: 'MCP server selections must be unique', - }) - } - seen.add(serverId) - } - }) - export const credentialGroupAccessPolicySchema = z .object({ revision: z.number().int().positive(), @@ -226,6 +209,10 @@ export const credentialGroupDetailParamsSchema = credentialGroupWorkspaceParamsS groupId: z.string().min(1, 'Credential group ID is required').max(128), }) +export const credentialGroupMcpConnectorParamsSchema = credentialGroupDetailParamsSchema.extend({ + connectorId: managedMcpConnectorIdSchema, +}) + export const credentialGroupEnrollmentParamsSchema = credentialGroupDetailParamsSchema.extend({ enrollmentId: z.string().min(1, 'Enrollment ID is required').max(128), }) @@ -320,7 +307,6 @@ export const createCredentialGroupBodySchema = z name: z.string().trim().min(1, 'Name is required').max(100), description: z.string().trim().max(500).optional(), options: z.array(credentialGroupOptionInputSchema).max(CREDENTIAL_GROUP_PROVIDER_IDS.length), - mcpServerIds: credentialGroupMcpServerIdsSchema.default([]), }) .strict() .superRefine((body, ctx) => { @@ -364,7 +350,6 @@ export const updateCredentialGroupBodySchema = z .array(credentialGroupOptionUpdateInputSchema) .max(CREDENTIAL_GROUP_PROVIDER_IDS.length) .optional(), - mcpServerIds: credentialGroupMcpServerIdsSchema.optional(), status: credentialGroupStatusSchema.optional(), }) .strict() @@ -407,6 +392,40 @@ export const updateCredentialGroupBodySchema = z export type UpdateCredentialGroupBody = z.input +export const createCredentialGroupMcpConnectorBodySchema = z.discriminatedUnion('connectorId', [ + z.object({ connectorId: z.literal('fireflies') }).strict(), + z.object({ connectorId: z.literal('granola') }).strict(), + z + .object({ + connectorId: z.literal('databricks'), + name: z.string().trim().min(1, 'Name is required').max(100), + url: z.string().trim().url('Enter a valid Databricks MCP URL').max(2048), + oauthClientId: z.string().trim().min(1, 'OAuth Client ID is required').max(512), + oauthClientSecret: z.string().trim().min(1).max(2048).optional(), + }) + .strict(), +]) + +export type CreateCredentialGroupMcpConnectorBody = z.input< + typeof createCredentialGroupMcpConnectorBodySchema +> + +export const updateCredentialGroupMcpConnectorBodySchema = z + .object({ + name: z.string().trim().min(1, 'Name is required').max(100).optional(), + url: z.string().trim().url('Enter a valid Databricks MCP URL').max(2048).optional(), + oauthClientId: z.string().trim().min(1, 'OAuth Client ID is required').max(512).optional(), + oauthClientSecret: z.string().trim().min(1).max(2048).nullable().optional(), + }) + .strict() + .refine((body) => Object.keys(body).length > 0, { + message: 'At least one field must be updated', + }) + +export type UpdateCredentialGroupMcpConnectorBody = z.input< + typeof updateCredentialGroupMcpConnectorBodySchema +> + const listCredentialGroupsResponseSchema = z.object({ credentialGroups: z.array(credentialGroupSchema), /** @@ -508,6 +527,36 @@ export const updateCredentialGroupContract = defineRouteContract({ }, }) +export const createCredentialGroupMcpConnectorContract = defineRouteContract({ + method: 'POST', + path: '/api/workspaces/[id]/credential-groups/[groupId]/mcp-connectors', + params: credentialGroupDetailParamsSchema, + body: createCredentialGroupMcpConnectorBodySchema, + response: { + mode: 'json', + status: 201, + schema: z.object({ mcpServer: credentialGroupMcpServerSchema }), + }, +}) + +export const updateCredentialGroupMcpConnectorContract = defineRouteContract({ + method: 'PATCH', + path: '/api/workspaces/[id]/credential-groups/[groupId]/mcp-connectors/[connectorId]', + params: credentialGroupMcpConnectorParamsSchema, + body: updateCredentialGroupMcpConnectorBodySchema, + response: { + mode: 'json', + schema: z.object({ mcpServer: credentialGroupMcpServerSchema }), + }, +}) + +export const deleteCredentialGroupMcpConnectorContract = defineRouteContract({ + method: 'DELETE', + path: '/api/workspaces/[id]/credential-groups/[groupId]/mcp-connectors/[connectorId]', + params: credentialGroupMcpConnectorParamsSchema, + response: { mode: 'json', schema: z.object({ success: z.literal(true) }) }, +}) + export const getCredentialGroupAccessContract = defineRouteContract({ method: 'GET', path: '/api/workspaces/[id]/credential-groups/[groupId]/access', diff --git a/apps/sim/lib/api/contracts/mcp.ts b/apps/sim/lib/api/contracts/mcp.ts index 3aa28c00a40..523cbc01e59 100644 --- a/apps/sim/lib/api/contracts/mcp.ts +++ b/apps/sim/lib/api/contracts/mcp.ts @@ -1,6 +1,7 @@ import { z } from 'zod' import { type ContractJsonResponse, defineRouteContract } from '@/lib/api/contracts/types' import { v2TimestampSchema } from '@/lib/api/contracts/v2/shared' +import { MANAGED_MCP_CONNECTOR_IDS } from '@/lib/credential-groups/managed-mcp-connectors' import type { McpToolSchema, McpToolSchemaProperty } from '@/lib/mcp/types' const MAX_MCP_REFRESH_SERVER_IDS = 100 @@ -53,6 +54,7 @@ export const mcpTransportSchema = z.enum(['streamable-http']) const mcpTransportResponseSchema = mcpTransportSchema.catch('streamable-http') export const mcpAuthTypeSchema = z.enum(['none', 'headers', 'oauth']) +export const managedMcpConnectorIdSchema = z.enum(MANAGED_MCP_CONNECTOR_IDS) const consecutiveFailuresSchema = z.preprocess( (value) => (typeof value === 'number' ? value : undefined), @@ -97,6 +99,7 @@ export const mcpToolSchema = z.object({ inputSchema: mcpToolInputSchema, serverId: z.string(), serverName: z.string(), + managedConnectorId: managedMcpConnectorIdSchema.optional(), }) export const storedMcpToolSchema = z.object({ @@ -144,6 +147,10 @@ export const mcpServerSchema = z oauthClientId: optionalStringFromNullableSchema, hasOauthClientSecret: z.boolean().optional(), credentialGroupId: optionalStringFromNullableSchema, + managedConnectorId: z.preprocess( + (value) => (value === null ? undefined : value), + managedMcpConnectorIdSchema.optional() + ), }) .passthrough() export type McpServer = z.output @@ -178,6 +185,7 @@ export const createMcpServerBodySchema = z workspaceId: z.string().optional(), oauthClientId: z.string().nullable().optional(), oauthClientSecret: z.string().nullable().optional(), + managedConnectorId: z.never().optional(), }) .passthrough() diff --git a/apps/sim/lib/credential-groups/application/manage-groups.test.ts b/apps/sim/lib/credential-groups/application/manage-groups.test.ts index d83cd2ffb5f..d871461244b 100644 --- a/apps/sim/lib/credential-groups/application/manage-groups.test.ts +++ b/apps/sim/lib/credential-groups/application/manage-groups.test.ts @@ -140,7 +140,6 @@ describe('Credential Group Settings application operations', () => { expect(mocks.create).toHaveBeenCalledWith('workspace-1', 'admin-1', { name: 'Support', options: [], - mcpServerIds: [], }) }) diff --git a/apps/sim/lib/credential-groups/application/manage-groups.ts b/apps/sim/lib/credential-groups/application/manage-groups.ts index 435d296c346..2ab02cb8528 100644 --- a/apps/sim/lib/credential-groups/application/manage-groups.ts +++ b/apps/sim/lib/credential-groups/application/manage-groups.ts @@ -17,9 +17,9 @@ import { CredentialGroupEnrollmentError, listCredentialGroupEnrollments, } from '@/lib/credential-groups/enrollments' +import { clearCredentialGroupMcpOAuthAttempts } from '@/lib/credential-groups/mcp-oauth-state' import { listConfiguredCredentialGroupProviders } from '@/lib/credential-groups/provider-availability' import { - CredentialGroupMcpServerError, createCredentialGroup, deleteCredentialGroup, getCredentialGroup, @@ -31,11 +31,9 @@ import type { UpdateCredentialGroupInput, } from '@/lib/credential-groups/types' import { evictMcpServerConnections } from '@/lib/mcp/connection-pool' +import { mcpService } from '@/lib/mcp/service' function throwCredentialGroupConflict(error: unknown): never { - if (error instanceof CredentialGroupMcpServerError) { - throw new OrchestrationError(error.code, error.message) - } if (getPostgresErrorCode(error) === '23505') { throw new OrchestrationError('conflict', 'A credential group with this name already exists') } @@ -179,7 +177,11 @@ export const deleteCredentialGroupSettings = defineAuthorizedWorkspaceUseCase({ await requireCredentialGroupSettingsAvailable(context.workspaceId) const result = await deleteCredentialGroup(context.workspaceId, context.credentialGroupId) if (!result.deleted) throw new OrchestrationError('not_found', 'Credential group not found') - return { success: true as const, retiredMcpConnectionIds: result.retiredMcpConnectionIds } + return { + success: true as const, + retiredMcpConnectionIds: result.retiredMcpConnectionIds, + retiredMcpServerIds: result.retiredMcpServerIds, + } }, projectAudit: ({ context }) => ({ action: AuditAction.CREDENTIAL_GROUP_UPDATED, @@ -188,10 +190,16 @@ export const deleteCredentialGroupSettings = defineAuthorizedWorkspaceUseCase({ resourceName: context.name, description: 'Deleted a Credential Group', }), - afterSuccess: ({ result }) => - Promise.all( - result.retiredMcpConnectionIds.map((connectionId) => + async afterSuccess({ context, result }) { + await mcpService.clearCache(context.workspaceId) + await clearCredentialGroupMcpOAuthAttempts(result.retiredMcpServerIds) + await Promise.all([ + ...result.retiredMcpServerIds.map((serverId) => + evictMcpServerConnections(serverId, 'credential_group_deleted') + ), + ...result.retiredMcpConnectionIds.map((connectionId) => evictMcpServerConnections(connectionId, 'credential_group_deleted') - ) - ).then(() => undefined), + ), + ]) + }, }) diff --git a/apps/sim/lib/credential-groups/application/manage-mcp-connectors.ts b/apps/sim/lib/credential-groups/application/manage-mcp-connectors.ts new file mode 100644 index 00000000000..e2974b868be --- /dev/null +++ b/apps/sim/lib/credential-groups/application/manage-mcp-connectors.ts @@ -0,0 +1,165 @@ +import { AuditAction, AuditResourceType } from '@sim/audit' +import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { + requireCredentialGroupSettingsAvailable, + resolveCredentialGroupSettingsContext, +} from '@/lib/credential-groups/application/context' +import { credentialGroupOperations } from '@/lib/credential-groups/application/operations' +import type { ManagedMcpConnectorId } from '@/lib/credential-groups/managed-mcp-connectors' +import { + type CreateManagedMcpConnectorInput, + createManagedMcpConnector, + deleteManagedMcpConnector, + ManagedMcpConnectorError, + type UpdateManagedMcpConnectorInput, + updateManagedMcpConnector, +} from '@/lib/credential-groups/managed-mcp-service' +import { clearCredentialGroupMcpOAuthAttempts } from '@/lib/credential-groups/mcp-oauth-state' +import { evictMcpServerConnections } from '@/lib/mcp/connection-pool' +import { mcpService } from '@/lib/mcp/service' + +interface ManagedMcpConnectorTargetInput { + assertedWorkspaceId: string + credentialGroupId: string +} + +function projectManagedMcpConnectorError(error: unknown): never { + if (error instanceof ManagedMcpConnectorError) { + throw new OrchestrationError( + error.code === 'bad_gateway' ? 'validation' : error.code, + error.message + ) + } + throw error +} + +async function applyManagedMcpConnectorEffects(params: { + workspaceId: string + serverIds: string[] + connectionIds: string[] + reason: string +}): Promise { + await mcpService.clearCache(params.workspaceId) + await clearCredentialGroupMcpOAuthAttempts(params.serverIds) + await Promise.all([ + ...params.serverIds.map((serverId) => evictMcpServerConnections(serverId, params.reason)), + ...params.connectionIds.map((connectionId) => + evictMcpServerConnections(connectionId, params.reason) + ), + ]) +} + +export interface CreateCredentialGroupMcpConnectorInput extends ManagedMcpConnectorTargetInput { + connector: CreateManagedMcpConnectorInput +} + +export const createCredentialGroupMcpConnector = defineAuthorizedWorkspaceUseCase({ + operation: credentialGroupOperations.createMcpConnector, + resolveContext: ({ input }: { input: CreateCredentialGroupMcpConnectorInput }) => + resolveCredentialGroupSettingsContext(input.credentialGroupId, input.assertedWorkspaceId), + authorizationOptions: {}, + async execute({ principal, input, context }) { + await requireCredentialGroupSettingsAvailable(context.workspaceId) + try { + return await createManagedMcpConnector({ + workspaceId: context.workspaceId, + credentialGroupId: context.credentialGroupId, + userId: principal.userId, + input: input.connector, + }) + } catch (error) { + projectManagedMcpConnectorError(error) + } + }, + projectAudit: ({ result }) => ({ + action: AuditAction.MCP_SERVER_ADDED, + resourceType: AuditResourceType.MCP_SERVER, + resourceId: result.mcpServer.id, + resourceName: result.mcpServer.name, + description: `Added managed MCP connector "${result.mcpServer.name}"`, + }), + afterSuccess: ({ context, result }) => + applyManagedMcpConnectorEffects({ + workspaceId: context.workspaceId, + serverIds: [], + connectionIds: [], + reason: 'managed connector added', + }), +}) + +export interface UpdateCredentialGroupMcpConnectorInput extends ManagedMcpConnectorTargetInput { + connectorId: ManagedMcpConnectorId + update: UpdateManagedMcpConnectorInput +} + +export const updateCredentialGroupMcpConnector = defineAuthorizedWorkspaceUseCase({ + operation: credentialGroupOperations.updateMcpConnector, + resolveContext: ({ input }: { input: UpdateCredentialGroupMcpConnectorInput }) => + resolveCredentialGroupSettingsContext(input.credentialGroupId, input.assertedWorkspaceId), + authorizationOptions: {}, + async execute({ input, context }) { + await requireCredentialGroupSettingsAvailable(context.workspaceId) + try { + return await updateManagedMcpConnector({ + workspaceId: context.workspaceId, + credentialGroupId: context.credentialGroupId, + connectorId: input.connectorId, + input: input.update, + }) + } catch (error) { + projectManagedMcpConnectorError(error) + } + }, + projectAudit: ({ result }) => ({ + action: AuditAction.MCP_SERVER_UPDATED, + resourceType: AuditResourceType.MCP_SERVER, + resourceId: result.mcpServer.id, + resourceName: result.mcpServer.name, + description: `Updated managed MCP connector "${result.mcpServer.name}"`, + }), + afterSuccess: ({ context, result }) => + applyManagedMcpConnectorEffects({ + workspaceId: context.workspaceId, + serverIds: result.resetMcpServerIds, + connectionIds: result.retiredMcpConnectionIds, + reason: 'managed connector configuration changed', + }), +}) + +export interface DeleteCredentialGroupMcpConnectorInput extends ManagedMcpConnectorTargetInput { + connectorId: ManagedMcpConnectorId +} + +export const deleteCredentialGroupMcpConnector = defineAuthorizedWorkspaceUseCase({ + operation: credentialGroupOperations.deleteMcpConnector, + resolveContext: ({ input }: { input: DeleteCredentialGroupMcpConnectorInput }) => + resolveCredentialGroupSettingsContext(input.credentialGroupId, input.assertedWorkspaceId), + authorizationOptions: {}, + async execute({ input, context }) { + await requireCredentialGroupSettingsAvailable(context.workspaceId) + try { + return await deleteManagedMcpConnector({ + workspaceId: context.workspaceId, + credentialGroupId: context.credentialGroupId, + connectorId: input.connectorId, + }) + } catch (error) { + projectManagedMcpConnectorError(error) + } + }, + projectAudit: ({ result }) => ({ + action: AuditAction.MCP_SERVER_REMOVED, + resourceType: AuditResourceType.MCP_SERVER, + resourceId: result.mcpServer.id, + resourceName: result.mcpServer.name, + description: `Removed managed MCP connector "${result.mcpServer.name}"`, + }), + afterSuccess: ({ context, result }) => + applyManagedMcpConnectorEffects({ + workspaceId: context.workspaceId, + serverIds: result.serverIds, + connectionIds: result.retiredMcpConnectionIds, + reason: 'managed connector removed', + }), +}) diff --git a/apps/sim/lib/credential-groups/application/operations.ts b/apps/sim/lib/credential-groups/application/operations.ts index c7c1e3132c0..633a7689349 100644 --- a/apps/sim/lib/credential-groups/application/operations.ts +++ b/apps/sim/lib/credential-groups/application/operations.ts @@ -72,6 +72,30 @@ export const credentialGroupOperations = { principalKinds: ['session'], }), // permission-group-exempt: workspace admin already decides this, and no group key names the credential-groups section + createMcpConnector: defineWorkspaceOperation({ + id: 'credential_groups.mcp_connectors.create', + minimumRole: 'admin', + workspaceApiKey: 'deny', + capability: 'none', + principalKinds: ['session'], + }), + // permission-group-exempt: workspace admin already decides this, and no group key names the credential-groups section + updateMcpConnector: defineWorkspaceOperation({ + id: 'credential_groups.mcp_connectors.update', + minimumRole: 'admin', + workspaceApiKey: 'deny', + capability: 'none', + principalKinds: ['session'], + }), + // permission-group-exempt: workspace admin already decides this, and no group key names the credential-groups section + deleteMcpConnector: defineWorkspaceOperation({ + id: 'credential_groups.mcp_connectors.delete', + minimumRole: 'admin', + workspaceApiKey: 'deny', + capability: 'none', + principalKinds: ['session'], + }), + // permission-group-exempt: workspace admin already decides this, and no group key names the credential-groups section inviteBatch: defineWorkspaceOperation({ id: 'credential_groups.invites.send_batch', minimumRole: 'admin', diff --git a/apps/sim/lib/credential-groups/application/validation.ts b/apps/sim/lib/credential-groups/application/validation.ts index f6f849f5cd4..2b2c6eb24ab 100644 --- a/apps/sim/lib/credential-groups/application/validation.ts +++ b/apps/sim/lib/credential-groups/application/validation.ts @@ -1,6 +1,5 @@ import { isValidEmailSyntax, normalizeEmail } from '@sim/utils/string' import { OrchestrationError } from '@/lib/core/orchestration/types' -import { CREDENTIAL_GROUP_MCP_SERVER_LIMIT } from '@/lib/credential-groups/limits' import { CREDENTIAL_GROUP_PROVIDER_IDS, isCredentialGroupProvider, @@ -65,23 +64,6 @@ function normalizeOption CREDENTIAL_GROUP_MCP_SERVER_LIMIT) { - throw new OrchestrationError( - 'validation', - `Select at most ${CREDENTIAL_GROUP_MCP_SERVER_LIMIT} MCP servers` - ) - } - const normalized = serverIds.map((serverId) => serverId.trim()) - if (normalized.some((serverId) => !serverId || serverId.length > 128)) { - throw new OrchestrationError('validation', 'Every MCP server ID must be valid') - } - if (new Set(normalized).size !== normalized.length) { - throw new OrchestrationError('validation', 'MCP server selections must be unique') - } - return normalized -} - export function validateCreateCredentialGroupInput( input: CreateCredentialGroupInput ): CreateCredentialGroupInput { @@ -104,7 +86,6 @@ export function validateCreateCredentialGroupInput( name, ...(description ? { description } : {}), options: input.options.map(normalizeOption), - mcpServerIds: validateMcpServerIds(input.mcpServerIds ?? []), } } @@ -123,12 +104,10 @@ export function validateUpdateCredentialGroupInput( throw new OrchestrationError('validation', 'Description must be at most 500 characters') } if (input.options) validateOptions(input.options) - const mcpServerIds = input.mcpServerIds ? validateMcpServerIds(input.mcpServerIds) : undefined return { ...(name ? { name } : {}), ...(input.description !== undefined ? { description: description || null } : {}), ...(input.options ? { options: input.options.map(normalizeOption) } : {}), - ...(mcpServerIds ? { mcpServerIds } : {}), ...(input.status ? { status: input.status } : {}), } } diff --git a/apps/sim/lib/credential-groups/enrollments.ts b/apps/sim/lib/credential-groups/enrollments.ts index fea1d15d3b4..76b18b7acdf 100644 --- a/apps/sim/lib/credential-groups/enrollments.ts +++ b/apps/sim/lib/credential-groups/enrollments.ts @@ -18,6 +18,8 @@ import { getCredentialGroupInvitationSubject } from '@/components/emails/subject import { getWorkspaceOwnerSubscriptionAccess } from '@/lib/billing/core/workspace-access' import { getBaseUrl } from '@/lib/core/utils/urls' import { isCredentialGroupsAvailable } from '@/lib/credential-groups/availability' +import type { ManagedMcpConnectorId } from '@/lib/credential-groups/managed-mcp-connectors' +import { getManagedMcpConnector } from '@/lib/credential-groups/managed-mcp-connectors' import { getCredentialGroupProviderAdapter } from '@/lib/credential-groups/provider-registry' import type { CredentialGroupProvider } from '@/lib/credential-groups/providers' import { @@ -94,6 +96,7 @@ export interface PublicCredentialGroupEnrollment { id: string name: string description: string | null + managedConnectorId: ManagedMcpConnectorId connection: { id: string status: 'connected' | 'needs_reauth' | 'revoked' @@ -907,7 +910,12 @@ async function buildPublicCredentialGroupEnrollment( ) ), db - .select({ id: mcpServers.id, name: mcpServers.name, description: mcpServers.description }) + .select({ + id: mcpServers.id, + name: mcpServers.name, + description: mcpServers.description, + managedConnectorId: mcpServers.managedConnectorId, + }) .from(mcpServers) .where( and( @@ -993,10 +1001,15 @@ async function buildPublicCredentialGroupEnrollment( }) ), mcpServers: linkedMcpServers.map((server) => { + if (!server.managedConnectorId) { + throw new Error(`Credential Group MCP server ${server.id} has no managed connector ID`) + } + const managedConnectorId = getManagedMcpConnector(server.managedConnectorId).id const connection = mcpConnectionByServerId.get(server.id) - if (!connection?.grantedAt) return { ...server, connection: null } + if (!connection?.grantedAt) return { ...server, managedConnectorId, connection: null } return { ...server, + managedConnectorId, connection: { id: connection.id, status: @@ -1118,7 +1131,12 @@ export async function getAuthorizedCredentialGroupMcpOAuthContext( const row = await resolveAuthorizedPublicEnrollmentRow(identity) if (!row) return null const [server] = await db - .select({ id: mcpServers.id, name: mcpServers.name, url: mcpServers.url }) + .select({ + id: mcpServers.id, + name: mcpServers.name, + url: mcpServers.url, + managedConnectorId: mcpServers.managedConnectorId, + }) .from(mcpServers) .where( and( @@ -1132,6 +1150,10 @@ export async function getAuthorizedCredentialGroupMcpOAuthContext( ) .limit(1) if (!server?.url) return null + if (!server.managedConnectorId) { + throw new Error(`Credential Group MCP server ${server.id} has no managed connector ID`) + } + getManagedMcpConnector(server.managedConnectorId) return { enrollmentId: row.enrollment.id, credentialGroupId: row.groupId, diff --git a/apps/sim/lib/credential-groups/managed-mcp-connector-icons.ts b/apps/sim/lib/credential-groups/managed-mcp-connector-icons.ts new file mode 100644 index 00000000000..f42806a8769 --- /dev/null +++ b/apps/sim/lib/credential-groups/managed-mcp-connector-icons.ts @@ -0,0 +1,12 @@ +import { DatabricksIcon, FirefliesIcon, GranolaIcon } from '@/components/icons' +import type { ManagedMcpConnectorId } from '@/lib/credential-groups/managed-mcp-connectors' + +export const MANAGED_MCP_CONNECTOR_ICONS = { + fireflies: FirefliesIcon, + granola: GranolaIcon, + databricks: DatabricksIcon, +} as const satisfies Record + +export function getManagedMcpConnectorIcon(connectorId: ManagedMcpConnectorId) { + return MANAGED_MCP_CONNECTOR_ICONS[connectorId] +} diff --git a/apps/sim/lib/credential-groups/managed-mcp-connectors.test.ts b/apps/sim/lib/credential-groups/managed-mcp-connectors.test.ts new file mode 100644 index 00000000000..13a4e0ba42b --- /dev/null +++ b/apps/sim/lib/credential-groups/managed-mcp-connectors.test.ts @@ -0,0 +1,42 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { + getManagedMcpConnector, + requireManagedMcpConnectorUrl, +} from '@/lib/credential-groups/managed-mcp-connectors' + +describe('managed MCP connectors', () => { + it('uses immutable URLs for fixed connectors', () => { + expect(requireManagedMcpConnectorUrl('fireflies')).toBe('https://api.fireflies.ai/mcp') + expect(requireManagedMcpConnectorUrl('granola')).toBe('https://mcp.granola.ai/mcp') + expect(() => requireManagedMcpConnectorUrl('fireflies', 'https://example.com/mcp')).toThrow( + 'Fireflies uses the fixed MCP URL' + ) + }) + + it.each([ + 'https://workspace.cloud.databricks.com/api/2.0/mcp/functions/catalog/schema', + 'https://workspace.azuredatabricks.net/api/2.0/mcp/vector-search/catalog/schema/index', + 'https://workspace.cloud.databricks.us/api/2.0/mcp/functions/catalog/schema', + 'https://example.databricksapps.com/mcp', + ])('accepts an official Databricks MCP URL: %s', (url) => { + expect(requireManagedMcpConnectorUrl('databricks', url)).toBe(url) + }) + + it.each([ + 'http://workspace.cloud.databricks.com/api/2.0/mcp/functions/catalog/schema', + 'https://workspace.cloud.databricks.com/not-mcp', + 'https://databricks.example.com/api/2.0/mcp/functions/catalog/schema', + 'https://workspace.cloud.databricks.com/api/2.0/mcp/functions/catalog/schema?token=secret', + ])('rejects a noncanonical Databricks MCP URL: %s', (url) => { + expect(() => requireManagedMcpConnectorUrl('databricks', url)).toThrow() + }) + + it('fails on connector IDs that are not in the registry', () => { + expect(() => getManagedMcpConnector('custom')).toThrow( + 'Unsupported managed MCP connector: custom' + ) + }) +}) diff --git a/apps/sim/lib/credential-groups/managed-mcp-connectors.ts b/apps/sim/lib/credential-groups/managed-mcp-connectors.ts new file mode 100644 index 00000000000..de4f379f169 --- /dev/null +++ b/apps/sim/lib/credential-groups/managed-mcp-connectors.ts @@ -0,0 +1,108 @@ +export const MANAGED_MCP_CONNECTOR_IDS = ['fireflies', 'granola', 'databricks'] as const + +export type ManagedMcpConnectorId = (typeof MANAGED_MCP_CONNECTOR_IDS)[number] + +interface FixedManagedMcpConnector { + id: Exclude + name: string + description: string + url: string + oauthClientRegistration: 'dynamic' +} + +interface DatabricksManagedMcpConnector { + id: 'databricks' + name: string + description: string + oauthClientRegistration: 'preregistered' +} + +export type ManagedMcpConnector = FixedManagedMcpConnector | DatabricksManagedMcpConnector + +export const MANAGED_MCP_CONNECTORS = { + fireflies: { + id: 'fireflies', + name: 'Fireflies', + description: 'Let each person connect their own Fireflies account', + url: 'https://api.fireflies.ai/mcp', + oauthClientRegistration: 'dynamic', + }, + granola: { + id: 'granola', + name: 'Granola', + description: 'Let each person connect their own Granola account', + url: 'https://mcp.granola.ai/mcp', + oauthClientRegistration: 'dynamic', + }, + databricks: { + id: 'databricks', + name: 'Databricks', + description: 'Let each person connect their own Databricks account', + oauthClientRegistration: 'preregistered', + }, +} as const satisfies Record + +const DATABRICKS_WORKSPACE_HOST_SUFFIXES = [ + '.cloud.databricks.com', + '.cloud.databricks.us', + '.cloud.databricks.mil', + '.azuredatabricks.net', + '.gcp.databricks.com', + '.databricks.com', +] as const + +const DATABRICKS_APP_HOST_SUFFIXES = [ + '.databricksapps.com', + '.databricksapps.us', + '.databricksapps.mil', +] as const + +export function isManagedMcpConnectorId(value: string): value is ManagedMcpConnectorId { + return MANAGED_MCP_CONNECTOR_IDS.some((connectorId) => connectorId === value) +} + +export function getManagedMcpConnector(connectorId: string): ManagedMcpConnector { + if (!isManagedMcpConnectorId(connectorId)) { + throw new Error(`Unsupported managed MCP connector: ${connectorId}`) + } + return MANAGED_MCP_CONNECTORS[connectorId] +} + +function hostnameHasSuffix(hostname: string, suffixes: readonly string[]): boolean { + return suffixes.some((suffix) => hostname.endsWith(suffix)) +} + +export function requireManagedMcpConnectorUrl( + connectorId: ManagedMcpConnectorId, + rawUrl?: string +): string { + const connector = getManagedMcpConnector(connectorId) + if ('url' in connector) { + if (rawUrl !== undefined && rawUrl !== connector.url) { + throw new Error(`${connector.name} uses the fixed MCP URL ${connector.url}`) + } + return connector.url + } + + if (!rawUrl?.trim()) throw new Error('Databricks MCP URL is required') + let url: URL + try { + url = new URL(rawUrl.trim()) + } catch { + throw new Error('Databricks MCP URL is invalid') + } + if (url.protocol !== 'https:' || url.username || url.password || url.search || url.hash) { + throw new Error('Databricks MCP URL must be a credential-free HTTPS URL') + } + + const hostname = url.hostname.toLowerCase() + const isWorkspaceHost = hostnameHasSuffix(hostname, DATABRICKS_WORKSPACE_HOST_SUFFIXES) + const isAppHost = hostnameHasSuffix(hostname, DATABRICKS_APP_HOST_SUFFIXES) + const isManagedServicePath = + url.pathname.startsWith('/api/2.0/mcp/') || url.pathname.startsWith('/ai-gateway/mcp-services/') + const isAppPath = url.pathname === '/mcp' || url.pathname === '/mcp/' + if ((!isWorkspaceHost || !isManagedServicePath) && (!isAppHost || !isAppPath)) { + throw new Error('Databricks MCP URL must point to an official Databricks MCP endpoint') + } + return url.toString().replace(/\/$/, '') +} diff --git a/apps/sim/lib/credential-groups/managed-mcp-service.ts b/apps/sim/lib/credential-groups/managed-mcp-service.ts new file mode 100644 index 00000000000..7d31e594d72 --- /dev/null +++ b/apps/sim/lib/credential-groups/managed-mcp-service.ts @@ -0,0 +1,577 @@ +import { db } from '@sim/db' +import { + credential, + credentialGroup, + credentialGroupEnrollment, + mcpServerOauth, + mcpServers, +} from '@sim/db/schema' +import { getPostgresErrorCode } from '@sim/utils/errors' +import { and, eq, inArray, isNull, ne } from 'drizzle-orm' +import { encryptSecret } from '@/lib/core/security/encryption' +import { + getManagedMcpConnector, + type ManagedMcpConnectorId, + requireManagedMcpConnectorUrl, +} from '@/lib/credential-groups/managed-mcp-connectors' +import type { DbOrTx } from '@/lib/db/types' +import { + McpDnsResolutionError, + McpDomainNotAllowedError, + McpSsrfError, + validateMcpDomain, + validateMcpServerSsrf, +} from '@/lib/mcp/domain-check' +import { generateMcpServerId } from '@/lib/mcp/utils' + +export class ManagedMcpConnectorError extends Error { + constructor( + message: string, + readonly code: 'validation' | 'not_found' | 'conflict' | 'forbidden' | 'bad_gateway' + ) { + super(message) + this.name = 'ManagedMcpConnectorError' + } +} + +export interface ManagedMcpConnectorSummary { + id: string + name: string + description: string | null + authType: string + enabled: boolean + managedConnectorId: ManagedMcpConnectorId +} + +export type CreateManagedMcpConnectorInput = + | { connectorId: 'fireflies' | 'granola' } + | { + connectorId: 'databricks' + name: string + url: string + oauthClientId: string + oauthClientSecret?: string + } + +export interface UpdateManagedMcpConnectorInput { + name?: string + url?: string + oauthClientId?: string + oauthClientSecret?: string | null +} + +export interface ManagedMcpConnectorMutationResult { + mcpServer: ManagedMcpConnectorSummary + retiredMcpConnectionIds: string[] + resetMcpServerIds: string[] +} + +function toSummary(row: typeof mcpServers.$inferSelect): ManagedMcpConnectorSummary { + if (!row.managedConnectorId) { + throw new Error(`Credential Group MCP server ${row.id} has no managed connector ID`) + } + const connector = getManagedMcpConnector(row.managedConnectorId) + return { + id: row.id, + name: row.name, + description: row.description, + authType: row.authType, + enabled: row.enabled, + managedConnectorId: connector.id, + } +} + +async function validateServerUrl(url: string): Promise { + try { + validateMcpDomain(url) + await validateMcpServerSsrf(url) + } catch (error) { + if (error instanceof McpDomainNotAllowedError || error instanceof McpSsrfError) { + throw new ManagedMcpConnectorError(error.message, 'forbidden') + } + if (error instanceof McpDnsResolutionError) { + throw new ManagedMcpConnectorError(error.message, 'bad_gateway') + } + throw error + } +} + +function resolveManagedMcpConnectorUrl( + connectorId: ManagedMcpConnectorId, + rawUrl?: string +): string { + try { + return requireManagedMcpConnectorUrl(connectorId, rawUrl) + } catch (error) { + if (error instanceof Error) { + throw new ManagedMcpConnectorError(error.message, 'validation') + } + throw error + } +} + +async function retireManagedMcpCredentials( + credentialGroupId: string, + mcpServerIds: string[], + executor: DbOrTx +): Promise { + if (mcpServerIds.length === 0) return [] + const enrollmentIds = executor + .select({ id: credentialGroupEnrollment.id }) + .from(credentialGroupEnrollment) + .where(eq(credentialGroupEnrollment.credentialGroupId, credentialGroupId)) + const retired = await executor + .update(credential) + .set({ + managedOauthStatus: 'revoked', + encryptedOauthTokenSet: null, + accessTokenExpiresAt: null, + mcpTools: null, + mcpToolsRefreshedAt: null, + revokedAt: new Date(), + updatedAt: new Date(), + }) + .where( + and( + eq(credential.type, 'managed_mcp'), + inArray(credential.credentialGroupEnrollmentId, enrollmentIds), + inArray(credential.mcpServerId, mcpServerIds) + ) + ) + .returning({ id: credential.id }) + return retired.map((row) => row.id) +} + +export async function retireManagedMcpServersForGroup( + workspaceId: string, + credentialGroupId: string, + executor: DbOrTx +): Promise<{ serverIds: string[]; connectionIds: string[] }> { + const servers = await executor + .select({ id: mcpServers.id }) + .from(mcpServers) + .where( + and( + eq(mcpServers.workspaceId, workspaceId), + eq(mcpServers.credentialGroupId, credentialGroupId), + isNull(mcpServers.deletedAt) + ) + ) + .for('update') + const serverIds = servers.map((server) => server.id) + if (serverIds.length === 0) return { serverIds: [], connectionIds: [] } + const connectionIds = await retireManagedMcpCredentials(credentialGroupId, serverIds, executor) + const now = new Date() + await executor + .update(mcpServers) + .set({ enabled: false, deletedAt: now, updatedAt: now }) + .where(inArray(mcpServers.id, serverIds)) + await executor.delete(mcpServerOauth).where(inArray(mcpServerOauth.mcpServerId, serverIds)) + return { serverIds, connectionIds } +} + +export async function createManagedMcpConnector(params: { + workspaceId: string + credentialGroupId: string + userId: string + input: CreateManagedMcpConnectorInput +}): Promise { + const connector = getManagedMcpConnector(params.input.connectorId) + const url = resolveManagedMcpConnectorUrl( + connector.id, + params.input.connectorId === 'databricks' ? params.input.url : undefined + ) + await validateServerUrl(url) + const serverId = generateMcpServerId(params.workspaceId, url) + const oauthClientId = + params.input.connectorId === 'databricks' ? params.input.oauthClientId.trim() : null + const oauthClientSecret = + params.input.connectorId === 'databricks' && params.input.oauthClientSecret + ? (await encryptSecret(params.input.oauthClientSecret)).encrypted + : null + const name = params.input.connectorId === 'databricks' ? params.input.name.trim() : connector.name + if (!name) + throw new ManagedMcpConnectorError('Managed MCP connector name is required', 'validation') + if (params.input.connectorId === 'databricks' && !oauthClientId) { + throw new ManagedMcpConnectorError('Databricks OAuth Client ID is required', 'validation') + } + + try { + const mcpServer = await db.transaction(async (tx) => { + const [group] = await tx + .select({ id: credentialGroup.id }) + .from(credentialGroup) + .where( + and( + eq(credentialGroup.id, params.credentialGroupId), + eq(credentialGroup.workspaceId, params.workspaceId) + ) + ) + .limit(1) + .for('update') + if (!group) throw new ManagedMcpConnectorError('Credential group not found', 'not_found') + + const [existingProvider] = await tx + .select({ id: mcpServers.id }) + .from(mcpServers) + .where( + and( + eq(mcpServers.workspaceId, params.workspaceId), + eq(mcpServers.credentialGroupId, params.credentialGroupId), + eq(mcpServers.managedConnectorId, connector.id), + isNull(mcpServers.deletedAt) + ) + ) + .limit(1) + if (existingProvider) { + throw new ManagedMcpConnectorError( + `${connector.name} is already configured for this Credential Group`, + 'conflict' + ) + } + + const [liveServerWithUrl] = await tx + .select({ id: mcpServers.id }) + .from(mcpServers) + .where( + and( + eq(mcpServers.workspaceId, params.workspaceId), + eq(mcpServers.url, url), + isNull(mcpServers.deletedAt) + ) + ) + .limit(1) + .for('update') + if (liveServerWithUrl) { + throw new ManagedMcpConnectorError( + 'An MCP server with this URL already exists. Remove it from MCP settings first.', + 'conflict' + ) + } + + const [existingUrl] = await tx + .select() + .from(mcpServers) + .where(and(eq(mcpServers.id, serverId), eq(mcpServers.workspaceId, params.workspaceId))) + .limit(1) + .for('update') + const now = new Date() + if (existingUrl) { + const [revived] = await tx + .update(mcpServers) + .set({ + credentialGroupId: params.credentialGroupId, + managedConnectorId: connector.id, + createdBy: params.userId, + name, + description: connector.description, + transport: 'streamable-http', + url, + authType: 'oauth', + oauthClientId, + oauthClientSecret, + headers: {}, + enabled: true, + connectionStatus: 'disconnected', + lastConnected: null, + lastError: null, + deletedAt: null, + updatedAt: now, + }) + .where(eq(mcpServers.id, serverId)) + .returning() + if (!revived) throw new Error('Managed MCP server revival returned no row') + return revived + } + + const [created] = await tx + .insert(mcpServers) + .values({ + id: serverId, + workspaceId: params.workspaceId, + credentialGroupId: params.credentialGroupId, + managedConnectorId: connector.id, + createdBy: params.userId, + name, + description: connector.description, + transport: 'streamable-http', + url, + authType: 'oauth', + oauthClientId, + oauthClientSecret, + headers: {}, + enabled: true, + connectionStatus: 'disconnected', + lastConnected: null, + createdAt: now, + updatedAt: now, + }) + .returning() + if (!created) throw new Error('Managed MCP server insert returned no row') + return created + }) + return { + mcpServer: toSummary(mcpServer), + retiredMcpConnectionIds: [], + resetMcpServerIds: [], + } + } catch (error) { + if (getPostgresErrorCode(error) === '23505') { + throw new ManagedMcpConnectorError( + `${connector.name} is already configured for this Credential Group`, + 'conflict' + ) + } + throw error + } +} + +export async function updateManagedMcpConnector(params: { + workspaceId: string + credentialGroupId: string + connectorId: ManagedMcpConnectorId + input: UpdateManagedMcpConnectorInput +}): Promise { + if (params.connectorId !== 'databricks') { + throw new ManagedMcpConnectorError( + 'Only Databricks connector settings can be changed', + 'validation' + ) + } + const current = await db + .select() + .from(mcpServers) + .where( + and( + eq(mcpServers.workspaceId, params.workspaceId), + eq(mcpServers.credentialGroupId, params.credentialGroupId), + eq(mcpServers.managedConnectorId, params.connectorId), + isNull(mcpServers.deletedAt) + ) + ) + .limit(1) + .then((rows) => rows[0]) + if (!current) throw new ManagedMcpConnectorError('Managed MCP connector not found', 'not_found') + const url = resolveManagedMcpConnectorUrl( + 'databricks', + params.input.url ?? current.url ?? undefined + ) + if (url !== current.url) await validateServerUrl(url) + const encryptedSecret = + params.input.oauthClientSecret === undefined + ? undefined + : params.input.oauthClientSecret === null + ? null + : (await encryptSecret(params.input.oauthClientSecret)).encrypted + + const result = await db.transaction(async (tx) => { + const [group] = await tx + .select({ id: credentialGroup.id }) + .from(credentialGroup) + .where( + and( + eq(credentialGroup.id, params.credentialGroupId), + eq(credentialGroup.workspaceId, params.workspaceId) + ) + ) + .limit(1) + .for('update') + if (!group) throw new ManagedMcpConnectorError('Credential group not found', 'not_found') + + const [locked] = await tx + .select() + .from(mcpServers) + .where( + and( + eq(mcpServers.id, current.id), + eq(mcpServers.workspaceId, params.workspaceId), + eq(mcpServers.credentialGroupId, params.credentialGroupId), + eq(mcpServers.managedConnectorId, 'databricks'), + isNull(mcpServers.deletedAt) + ) + ) + .limit(1) + .for('update') + if (!locked) throw new ManagedMcpConnectorError('Managed MCP connector not found', 'not_found') + const urlChanged = url !== locked.url + const targetServerId = generateMcpServerId(params.workspaceId, url) + if (urlChanged && targetServerId === locked.id) { + throw new Error(`MCP server ID collision for ${locked.id}`) + } + if (urlChanged) { + const [liveServerWithUrl] = await tx + .select({ id: mcpServers.id }) + .from(mcpServers) + .where( + and( + eq(mcpServers.workspaceId, params.workspaceId), + eq(mcpServers.url, url), + ne(mcpServers.id, locked.id), + isNull(mcpServers.deletedAt) + ) + ) + .limit(1) + .for('update') + if (liveServerWithUrl) { + throw new ManagedMcpConnectorError( + 'An MCP server with this URL already exists. Remove it from MCP settings first.', + 'conflict' + ) + } + } + const nextName = params.input.name?.trim() ?? locked.name + const nextOauthClientId = params.input.oauthClientId?.trim() ?? locked.oauthClientId + if (!nextName) { + throw new ManagedMcpConnectorError('Databricks name is required', 'validation') + } + if (!nextOauthClientId) { + throw new ManagedMcpConnectorError('Databricks OAuth Client ID is required', 'validation') + } + const nextOauthClientSecret = + encryptedSecret === undefined ? locked.oauthClientSecret : encryptedSecret + const changedCredentials = + urlChanged || nextOauthClientId !== locked.oauthClientId || encryptedSecret !== undefined + const retiredMcpConnectionIds = changedCredentials + ? await retireManagedMcpCredentials(params.credentialGroupId, [locked.id], tx) + : [] + if (changedCredentials) { + await tx.delete(mcpServerOauth).where(eq(mcpServerOauth.mcpServerId, locked.id)) + } + const now = new Date() + if (!urlChanged) { + const [updated] = await tx + .update(mcpServers) + .set({ + name: nextName, + oauthClientId: nextOauthClientId, + ...(encryptedSecret !== undefined ? { oauthClientSecret: encryptedSecret } : {}), + ...(changedCredentials + ? { connectionStatus: 'disconnected', lastConnected: null, lastError: null } + : {}), + updatedAt: now, + }) + .where(eq(mcpServers.id, locked.id)) + .returning() + if (!updated) throw new Error('Managed MCP server update returned no row') + return { + mcpServer: toSummary(updated), + retiredMcpConnectionIds, + resetMcpServerIds: changedCredentials ? [locked.id] : [], + } + } + + const [target] = await tx + .select() + .from(mcpServers) + .where(and(eq(mcpServers.id, targetServerId), eq(mcpServers.workspaceId, params.workspaceId))) + .limit(1) + .for('update') + if (target?.deletedAt === null) { + throw new ManagedMcpConnectorError( + 'An MCP server with this URL already exists. Remove it from MCP settings first.', + 'conflict' + ) + } + + await tx + .update(mcpServers) + .set({ enabled: false, deletedAt: now, updatedAt: now }) + .where(eq(mcpServers.id, locked.id)) + if (target) { + await tx.delete(mcpServerOauth).where(eq(mcpServerOauth.mcpServerId, target.id)) + } + const rowValues = { + credentialGroupId: params.credentialGroupId, + managedConnectorId: 'databricks' as const, + createdBy: locked.createdBy, + name: nextName, + description: getManagedMcpConnector('databricks').description, + transport: 'streamable-http', + url, + authType: 'oauth', + oauthClientId: nextOauthClientId, + oauthClientSecret: nextOauthClientSecret, + headers: {}, + enabled: true, + connectionStatus: 'disconnected', + lastConnected: null, + lastError: null, + deletedAt: null, + updatedAt: now, + } + const [replacement] = target + ? await tx.update(mcpServers).set(rowValues).where(eq(mcpServers.id, target.id)).returning() + : await tx + .insert(mcpServers) + .values({ + id: targetServerId, + workspaceId: params.workspaceId, + ...rowValues, + createdAt: now, + }) + .returning() + if (!replacement) throw new Error('Managed MCP server replacement returned no row') + return { + mcpServer: toSummary(replacement), + retiredMcpConnectionIds, + resetMcpServerIds: [locked.id, replacement.id], + } + }) + return result +} + +export async function deleteManagedMcpConnector(params: { + workspaceId: string + credentialGroupId: string + connectorId: ManagedMcpConnectorId +}): Promise<{ + mcpServer: ManagedMcpConnectorSummary + serverIds: string[] + retiredMcpConnectionIds: string[] +}> { + return db.transaction(async (tx) => { + const [group] = await tx + .select({ id: credentialGroup.id }) + .from(credentialGroup) + .where( + and( + eq(credentialGroup.id, params.credentialGroupId), + eq(credentialGroup.workspaceId, params.workspaceId) + ) + ) + .limit(1) + .for('update') + if (!group) throw new ManagedMcpConnectorError('Credential group not found', 'not_found') + + const [server] = await tx + .select() + .from(mcpServers) + .where( + and( + eq(mcpServers.workspaceId, params.workspaceId), + eq(mcpServers.credentialGroupId, params.credentialGroupId), + eq(mcpServers.managedConnectorId, params.connectorId), + isNull(mcpServers.deletedAt) + ) + ) + .limit(1) + .for('update') + if (!server) throw new ManagedMcpConnectorError('Managed MCP connector not found', 'not_found') + const retiredMcpConnectionIds = await retireManagedMcpCredentials( + params.credentialGroupId, + [server.id], + tx + ) + const now = new Date() + await tx + .update(mcpServers) + .set({ enabled: false, deletedAt: now, updatedAt: now }) + .where(eq(mcpServers.id, server.id)) + await tx.delete(mcpServerOauth).where(eq(mcpServerOauth.mcpServerId, server.id)) + return { + mcpServer: toSummary(server), + serverIds: [server.id], + retiredMcpConnectionIds, + } + }) +} diff --git a/apps/sim/lib/credential-groups/mcp-connections.ts b/apps/sim/lib/credential-groups/mcp-connections.ts index f7241050893..b3aeb1c3044 100644 --- a/apps/sim/lib/credential-groups/mcp-connections.ts +++ b/apps/sim/lib/credential-groups/mcp-connections.ts @@ -1,6 +1,7 @@ import { db } from '@sim/db' import { credential, credentialGroup, credentialGroupEnrollment, mcpServers } from '@sim/db/schema' import { and, asc, eq, gt, inArray, isNull, or, sql } from 'drizzle-orm' +import { getManagedMcpConnector } from '@/lib/credential-groups/managed-mcp-connectors' export const MAX_CREDENTIAL_GROUP_MCP_CONNECTION_PAGE_SIZE = 100 @@ -94,6 +95,7 @@ export async function listCredentialGroupMcpConnectionReferences({ displayName: credential.displayName, mcpServerId: mcpServers.id, mcpServerName: mcpServers.name, + managedConnectorId: mcpServers.managedConnectorId, hasToolSnapshot: sql`${credential.mcpTools} IS NOT NULL`, toolNames: sql`COALESCE(jsonb_path_query_array(${credential.mcpTools}, '$[*].name'), '[]'::jsonb)`.mapWith( @@ -135,6 +137,10 @@ export async function listCredentialGroupMcpConnectionReferences({ if (!row.hasToolSnapshot) { throw new Error(`Managed MCP connection ${row.id} has no tool snapshot`) } + if (!row.managedConnectorId) { + throw new Error(`Managed MCP server ${row.mcpServerId} has no connector ID`) + } + getManagedMcpConnector(row.managedConnectorId) if (row.toolNames.some((name) => typeof name !== 'string' || !name.trim())) { throw new Error(`Managed MCP connection ${row.id} has invalid tool metadata`) } diff --git a/apps/sim/lib/credential-groups/mcp-oauth-state.ts b/apps/sim/lib/credential-groups/mcp-oauth-state.ts index c9a12ffb78f..3db80dc64a0 100644 --- a/apps/sim/lib/credential-groups/mcp-oauth-state.ts +++ b/apps/sim/lib/credential-groups/mcp-oauth-state.ts @@ -15,6 +15,15 @@ redis.call('DEL', KEYS[1]) return value ` +const CLEAR_SERVER_ATTEMPTS_SCRIPT = ` +local keys = redis.call('SMEMBERS', KEYS[1]) +for _, key in ipairs(keys) do + redis.call('DEL', key) +end +redis.call('DEL', KEYS[1]) +return #keys +` + interface StoredCredentialGroupMcpOAuthAttempt { version: typeof MCP_OAUTH_ATTEMPT_VERSION enrollmentId: string @@ -45,6 +54,10 @@ function attemptKey(state: string): string { return `credential-group:mcp-oauth-attempt:${sha256Hex(state)}` } +function serverAttemptsKey(mcpServerId: string): string { + return `credential-group:mcp-oauth-attempts:${mcpServerId}` +} + function isStoredAttempt(value: unknown): value is StoredCredentialGroupMcpOAuthAttempt { if (!value || typeof value !== 'object') return false const candidate = value as Record @@ -96,6 +109,8 @@ export async function createCredentialGroupMcpOAuthAttempt(params: { 'NX' ) if (stored !== 'OK') throw new Error('Credential Group MCP OAuth state collision') + await redis.sadd(serverAttemptsKey(params.mcpServerId), attemptKey(params.state)) + await redis.pexpire(serverAttemptsKey(params.mcpServerId), MCP_OAUTH_ATTEMPT_TTL_MS) } export async function consumeCredentialGroupMcpOAuthAttempt( @@ -107,6 +122,7 @@ export async function consumeCredentialGroupMcpOAuthAttempt( if (typeof raw !== 'string') throw new Error('Credential Group MCP OAuth state is malformed') const parsed: unknown = JSON.parse(raw) if (!isStoredAttempt(parsed)) throw new Error('Credential Group MCP OAuth state is malformed') + await requireRedis().srem(serverAttemptsKey(parsed.mcpServerId), attemptKey(state)) if (Date.now() - parsed.createdAt > MCP_OAUTH_ATTEMPT_TTL_MS) return null const [codeVerifier, invitationToken] = await Promise.all([ decryptSecret(parsed.encryptedCodeVerifier), @@ -122,3 +138,13 @@ export async function consumeCredentialGroupMcpOAuthAttempt( createdAt: parsed.createdAt, } } + +export async function clearCredentialGroupMcpOAuthAttempts(mcpServerIds: string[]): Promise { + if (mcpServerIds.length === 0) return + const redis = requireRedis() + await Promise.all( + mcpServerIds.map((mcpServerId) => + redis.eval(CLEAR_SERVER_ATTEMPTS_SCRIPT, 1, serverAttemptsKey(mcpServerId)) + ) + ) +} diff --git a/apps/sim/lib/credential-groups/service.test.ts b/apps/sim/lib/credential-groups/service.test.ts index af6979af2ca..18a69bf89f1 100644 --- a/apps/sim/lib/credential-groups/service.test.ts +++ b/apps/sim/lib/credential-groups/service.test.ts @@ -19,7 +19,6 @@ vi.mock('@/lib/credential-groups/provider-registry', () => ({ })) import { - CredentialGroupMcpServerError, createCredentialGroup, deleteCredentialGroup, updateCredentialGroup, @@ -170,59 +169,19 @@ describe('Credential Group service', () => { ).rejects.toThrow('Required resource policy is missing') }) - it('rejects an OAuth MCP server already held by another Credential Group', async () => { - const existing = { - id: 'group-1', - workspaceId: 'workspace-1', - publicId: 'public-1', - name: 'Support accounts', - description: null, - options: [], - encryptedProviderConfiguration: null, - status: 'active' as const, - createdBy: 'user-1', - createdAt: new Date('2026-08-20T00:00:00.000Z'), - updatedAt: new Date('2026-08-20T00:00:00.000Z'), - } - queueTableRows(schemaMock.credentialGroup, [existing]) - queueTableRows(schemaMock.mcpServers, [ - { - id: 'mcp-server-1', - credentialGroupId: 'group-2', - authType: 'oauth', - enabled: true, - deletedAt: null, - }, - ]) - - await expect( - updateCredentialGroup('workspace-1', 'group-1', { - mcpServerIds: ['mcp-server-1'], - }) - ).rejects.toEqual( - new CredentialGroupMcpServerError( - 'MCP server mcp-server-1 is already assigned to another Credential Group', - 'conflict' - ) - ) - }) - it('deletes the policy and group in one locked transaction', async () => { queueTableRows(schemaMock.credentialGroup, [{ id: 'group-1' }]) - queueTableRows(schemaMock.credential, [{ id: 'mcp-cg-connection-1' }]) dbChainMockFns.returning .mockResolvedValueOnce([{ id: 'policy-1' }]) .mockResolvedValueOnce([{ id: 'group-1' }]) await expect(deleteCredentialGroup('workspace-1', 'group-1')).resolves.toEqual({ deleted: true, - retiredMcpConnectionIds: ['mcp-cg-connection-1'], + retiredMcpConnectionIds: [], + retiredMcpServerIds: [], }) expect(dbChainMockFns.for).toHaveBeenCalledWith('update') - expect(dbChainMockFns.set).toHaveBeenCalledWith( - expect.objectContaining({ credentialGroupId: null }) - ) expect(dbChainMockFns.delete).toHaveBeenCalledTimes(2) expect(dbChainMockFns.transaction).toHaveBeenCalledOnce() }) diff --git a/apps/sim/lib/credential-groups/service.ts b/apps/sim/lib/credential-groups/service.ts index c5980a9e7b7..c754b11a6d4 100644 --- a/apps/sim/lib/credential-groups/service.ts +++ b/apps/sim/lib/credential-groups/service.ts @@ -7,11 +7,13 @@ import { mcpServers, } from '@sim/db/schema' import { generateId } from '@sim/utils/id' -import { and, asc, desc, eq, inArray, isNull, or } from 'drizzle-orm' +import { and, asc, desc, eq, inArray, isNull } from 'drizzle-orm' import { credentialGroupWorkflowAccessPolicyCodec, requireDefaultCredentialGroupWorkflowAccessPolicy, } from '@/lib/credential-groups/application/workflow-access-policy' +import { getManagedMcpConnector } from '@/lib/credential-groups/managed-mcp-connectors' +import { retireManagedMcpServersForGroup } from '@/lib/credential-groups/managed-mcp-service' import { credentialGroupScopePolicyVersion } from '@/lib/credential-groups/provider-adapter' import { decryptCredentialGroupProviderConfiguration } from '@/lib/credential-groups/provider-configuration' import { getCredentialGroupProviderAdapter } from '@/lib/credential-groups/provider-registry' @@ -30,16 +32,6 @@ import { requireResourcePolicy, } from '@/lib/resource-policies/repository' -export class CredentialGroupMcpServerError extends Error { - constructor( - message: string, - readonly code: 'validation' | 'conflict' - ) { - super(message) - this.name = 'CredentialGroupMcpServerError' - } -} - interface CredentialGroupMutationResult { credentialGroup: CredentialGroupRecord retiredMcpConnectionIds: string[] @@ -48,134 +40,34 @@ interface CredentialGroupMutationResult { interface DeleteCredentialGroupResult { deleted: boolean retiredMcpConnectionIds: string[] + retiredMcpServerIds: string[] } async function listLinkedMcpServers( credentialGroupId: string, executor: DbOrTx = db ): Promise { - return executor + const rows = await executor .select({ id: mcpServers.id, name: mcpServers.name, description: mcpServers.description, authType: mcpServers.authType, enabled: mcpServers.enabled, + managedConnectorId: mcpServers.managedConnectorId, }) .from(mcpServers) .where(and(eq(mcpServers.credentialGroupId, credentialGroupId), isNull(mcpServers.deletedAt))) .orderBy(asc(mcpServers.name), asc(mcpServers.id)) -} - -async function syncCredentialGroupMcpServers( - workspaceId: string, - credentialGroupId: string, - requestedServerIds: string[], - executor: DbOrTx -): Promise { - const requestedScope = - requestedServerIds.length > 0 - ? or( - eq(mcpServers.credentialGroupId, credentialGroupId), - inArray(mcpServers.id, requestedServerIds) - ) - : eq(mcpServers.credentialGroupId, credentialGroupId) - const rows = await executor - .select({ - id: mcpServers.id, - credentialGroupId: mcpServers.credentialGroupId, - authType: mcpServers.authType, - enabled: mcpServers.enabled, - deletedAt: mcpServers.deletedAt, - }) - .from(mcpServers) - .where(and(eq(mcpServers.workspaceId, workspaceId), requestedScope)) - .for('update') - - const rowsById = new Map(rows.map((row) => [row.id, row])) - for (const serverId of requestedServerIds) { - const server = rowsById.get(serverId) - if (!server || server.deletedAt || !server.enabled) { - throw new CredentialGroupMcpServerError(`MCP server ${serverId} is unavailable`, 'validation') + return rows.map((row) => { + if (!row.managedConnectorId) { + throw new Error(`Credential Group MCP server ${row.id} has no managed connector ID`) } - if (server.authType !== 'oauth') { - throw new CredentialGroupMcpServerError(`MCP server ${serverId} must use OAuth`, 'validation') - } - if (server.credentialGroupId && server.credentialGroupId !== credentialGroupId) { - throw new CredentialGroupMcpServerError( - `MCP server ${serverId} is already assigned to another Credential Group`, - 'conflict' - ) - } - } - - const requested = new Set(requestedServerIds) - const removedServerIds = rows - .filter((row) => row.credentialGroupId === credentialGroupId && !requested.has(row.id)) - .map((row) => row.id) - const addedServerIds = requestedServerIds.filter( - (serverId) => rowsById.get(serverId)?.credentialGroupId !== credentialGroupId - ) - - let retiredMcpConnectionIds: string[] = [] - if (removedServerIds.length > 0) { - const enrollmentIds = executor - .select({ id: credentialGroupEnrollment.id }) - .from(credentialGroupEnrollment) - .where(eq(credentialGroupEnrollment.credentialGroupId, credentialGroupId)) - const retired = await executor - .update(credential) - .set({ - managedOauthStatus: 'revoked', - encryptedOauthTokenSet: null, - mcpTools: null, - mcpToolsRefreshedAt: null, - revokedAt: new Date(), - updatedAt: new Date(), - }) - .where( - and( - eq(credential.type, 'managed_mcp'), - inArray(credential.credentialGroupEnrollmentId, enrollmentIds), - inArray(credential.mcpServerId, removedServerIds) - ) - ) - .returning({ id: credential.id }) - retiredMcpConnectionIds = retired.map((row) => row.id) - await executor - .update(mcpServers) - .set({ credentialGroupId: null, updatedAt: new Date() }) - .where( - and( - eq(mcpServers.workspaceId, workspaceId), - eq(mcpServers.credentialGroupId, credentialGroupId), - inArray(mcpServers.id, removedServerIds) - ) - ) - } - - if (addedServerIds.length > 0) { - const assigned = await executor - .update(mcpServers) - .set({ credentialGroupId, updatedAt: new Date() }) - .where( - and( - eq(mcpServers.workspaceId, workspaceId), - inArray(mcpServers.id, addedServerIds), - isNull(mcpServers.credentialGroupId), - isNull(mcpServers.deletedAt) - ) - ) - .returning({ id: mcpServers.id }) - if (assigned.length !== addedServerIds.length) { - throw new CredentialGroupMcpServerError( - 'An MCP server was assigned to another Credential Group', - 'conflict' - ) + return { + ...row, + managedConnectorId: getManagedMcpConnector(row.managedConnectorId).id, } - } - - return retiredMcpConnectionIds + }) } function scopesEqual(left: string[], right: string[]): boolean { @@ -313,6 +205,7 @@ export async function listCredentialGroups(workspaceId: string): Promise row.id), + retiredMcpConnectionIds: retiredMcp.connectionIds, + retiredMcpServerIds: retiredMcp.serverIds, } }) } @@ -454,11 +332,6 @@ export async function updateCredentialGroup( .for('update') if (!existing) return null - const retiredMcpConnectionIds = - body.mcpServerIds === undefined - ? [] - : await syncCredentialGroupMcpServers(workspaceId, groupId, body.mcpServerIds, tx) - const nextOptions = body.options !== undefined ? await updateOptions(workspaceId, groupId, body.options, existing.options, tx) @@ -513,7 +386,7 @@ export async function updateCredentialGroup( } return { credentialGroup: await toCredentialGroup(updated, await listLinkedMcpServers(updated.id, tx)), - retiredMcpConnectionIds, + retiredMcpConnectionIds: [], } }) } diff --git a/apps/sim/lib/credential-groups/types.ts b/apps/sim/lib/credential-groups/types.ts index 0e7e21ce56a..8842f88c077 100644 --- a/apps/sim/lib/credential-groups/types.ts +++ b/apps/sim/lib/credential-groups/types.ts @@ -1,3 +1,4 @@ +import type { ManagedMcpConnectorId } from '@/lib/credential-groups/managed-mcp-connectors' import type { CredentialGroupProvider } from '@/lib/credential-groups/providers' interface CredentialGroupOptionInputBase { @@ -20,14 +21,12 @@ export interface CreateCredentialGroupInput { name: string description?: string options: CredentialGroupOptionInput[] - mcpServerIds?: string[] } export interface UpdateCredentialGroupInput { name?: string description?: string | null options?: CredentialGroupOptionUpdateInput[] - mcpServerIds?: string[] status?: 'active' | 'disabled' } @@ -37,6 +36,7 @@ export interface CredentialGroupMcpServer { description: string | null authType: string enabled: boolean + managedConnectorId: ManagedMcpConnectorId } interface CredentialGroupOptionBase { diff --git a/apps/sim/lib/credentials/managed-mcp.ts b/apps/sim/lib/credentials/managed-mcp.ts index bc7b60acae1..df57d76d215 100644 --- a/apps/sim/lib/credentials/managed-mcp.ts +++ b/apps/sim/lib/credentials/managed-mcp.ts @@ -14,6 +14,7 @@ import type { WorkspaceAuthorizationContext } from '@/lib/core/application' import { decryptSecret, encryptSecret } from '@/lib/core/security/encryption' import { isCredentialGroupsAvailable } from '@/lib/credential-groups/availability' import { lockCredentialGroupEnrollmentLifecycle } from '@/lib/credential-groups/enrollments' +import { getManagedMcpConnector } from '@/lib/credential-groups/managed-mcp-connectors' import { generateManagedMcpConnectionId } from '@/lib/mcp/utils' import { loadActiveWorkspaceApplicationContext } from '@/lib/workspaces/application/workspace-context' @@ -109,6 +110,7 @@ export async function loadManagedMcpCredentialApplicationContext( credentialGroupEnrollmentId: credentialGroupEnrollment.id, mcpServerId: mcpServers.id, mcpServerName: mcpServers.name, + managedConnectorId: mcpServers.managedConnectorId, }) .from(credential) .innerJoin( @@ -120,6 +122,10 @@ export async function loadManagedMcpCredentialApplicationContext( .where(and(eq(credential.id, credentialId), eq(credential.type, 'managed_mcp'))) .limit(1) if (!row) return null + if (!row.managedConnectorId) { + throw new Error(`Managed MCP server ${row.mcpServerId} has no connector ID`) + } + getManagedMcpConnector(row.managedConnectorId) const workspaceContext = await loadActiveWorkspaceApplicationContext(row.workspaceId) return workspaceContext ? { ...workspaceContext, ...row } : null } @@ -149,6 +155,7 @@ export async function loadManagedMcpRuntimeCredential( linkedCredentialGroupId: mcpServers.credentialGroupId, mcpServerId: mcpServers.id, mcpServerName: mcpServers.name, + managedConnectorId: mcpServers.managedConnectorId, }) .from(credential) .innerJoin( @@ -170,6 +177,10 @@ export async function loadManagedMcpRuntimeCredential( ) .limit(1) if (!row) throw new ManagedMcpCredentialError('Managed MCP credential not found', 404) + if (!row.managedConnectorId) { + throw new ManagedMcpCredentialError('Managed MCP connector metadata is missing', 500) + } + getManagedMcpConnector(row.managedConnectorId) if ( row.status !== 'active' || row.groupStatus !== 'active' || @@ -215,6 +226,7 @@ export async function persistManagedMcpCredential(params: { credentialGroupId: credentialGroupEnrollment.credentialGroupId, groupStatus: credentialGroup.status, linkedCredentialGroupId: mcpServers.credentialGroupId, + managedConnectorId: mcpServers.managedConnectorId, }) .from(credentialGroupEnrollment) .innerJoin( @@ -236,12 +248,14 @@ export async function persistManagedMcpCredential(params: { .for('update') if ( !source || + !source.managedConnectorId || source.groupStatus !== 'active' || !['invited', 'in_progress', 'completed'].includes(source.enrollmentStatus) || source.linkedCredentialGroupId !== source.credentialGroupId ) { throw new ManagedMcpCredentialError('Managed MCP connection is no longer available', 404) } + getManagedMcpConnector(source.managedConnectorId) const [existing] = await tx .select({ id: credential.id }) diff --git a/apps/sim/lib/mcp/application/managed-connections.ts b/apps/sim/lib/mcp/application/managed-connections.ts index 05b4b08198b..226d765fe15 100644 --- a/apps/sim/lib/mcp/application/managed-connections.ts +++ b/apps/sim/lib/mcp/application/managed-connections.ts @@ -4,6 +4,7 @@ import { and, asc, eq, inArray, isNull, sql } from 'drizzle-orm' import { getWorkspaceOwnerSubscriptionAccess } from '@/lib/billing/core/workspace-access' import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application' import { isCredentialGroupsAvailable } from '@/lib/credential-groups/availability' +import { getManagedMcpConnector } from '@/lib/credential-groups/managed-mcp-connectors' import { resolveMcpWorkspaceContext } from '@/lib/mcp/application/context' import { mcpServerOperations } from '@/lib/mcp/application/operations' import type { McpToolSchema } from '@/lib/mcp/types' @@ -52,6 +53,7 @@ export const listManagedMcpConnectionsUseCase = defineAuthorizedWorkspaceUseCase serverId: mcpServers.id, serverName: mcpServers.name, serverDescription: mcpServers.description, + managedConnectorId: mcpServers.managedConnectorId, email: credentialGroupEnrollment.email, toolSnapshotBytes: sql`COALESCE(octet_length(${credential.mcpTools}::text), 0)`.mapWith(Number), @@ -114,7 +116,14 @@ export const listManagedMcpConnectionsUseCase = defineAuthorizedWorkspaceUseCase if (!tools) { throw new Error(`Managed MCP connection ${row.id} changed while loading its tool snapshot`) } - return { ...row, tools } + if (!row.managedConnectorId) { + throw new Error(`Managed MCP server ${row.serverId} has no connector ID`) + } + return { + ...row, + managedConnectorId: getManagedMcpConnector(row.managedConnectorId).id, + tools, + } }) return { @@ -125,6 +134,7 @@ export const listManagedMcpConnectionsUseCase = defineAuthorizedWorkspaceUseCase ...(row.serverDescription ? { description: row.serverDescription } : {}), transport: 'streamable-http' as const, authType: 'oauth' as const, + managedConnectorId: row.managedConnectorId, enabled: true, connectionStatus: 'connected' as const, toolCount: row.tools.length, @@ -138,6 +148,7 @@ export const listManagedMcpConnectionsUseCase = defineAuthorizedWorkspaceUseCase inputSchema: requireMcpToolSchema(tool.inputSchema), serverId: row.id, serverName: `${row.serverName} — ${row.email}`, + managedConnectorId: row.managedConnectorId, })) }), } diff --git a/apps/sim/lib/mcp/application/use-cases.ts b/apps/sim/lib/mcp/application/use-cases.ts index f20fe618fb4..5eec48638a5 100644 --- a/apps/sim/lib/mcp/application/use-cases.ts +++ b/apps/sim/lib/mcp/application/use-cases.ts @@ -353,6 +353,12 @@ async function updateMcpServer(args: { input: UpdateMcpServerInput context: McpServerContext }): Promise { + if (args.context.server.managedConnectorId) { + throw new OrchestrationError( + 'conflict', + 'This MCP server is managed from its Credential Group settings' + ) + } const attribution = resolvePrincipalAttribution(args.principal, { workspaceBillingOwnerUserId: args.context.billedAccountUserId, }) @@ -439,6 +445,12 @@ export const deleteMcpServerUseCase = defineAuthorizedWorkspaceUseCase({ resolveMcpServerContext(input.workspaceId, input.serverId), authorizationOptions, async execute({ principal, input, context }) { + if (context.server.managedConnectorId) { + throw new OrchestrationError( + 'conflict', + 'This MCP server is managed from its Credential Group settings' + ) + } const attribution = resolvePrincipalAttribution(principal, { workspaceBillingOwnerUserId: context.billedAccountUserId, }) diff --git a/apps/sim/lib/mcp/orchestration/server-lifecycle.ts b/apps/sim/lib/mcp/orchestration/server-lifecycle.ts index b5d1b010d66..aa893f3fde4 100644 --- a/apps/sim/lib/mcp/orchestration/server-lifecycle.ts +++ b/apps/sim/lib/mcp/orchestration/server-lifecycle.ts @@ -170,6 +170,7 @@ export async function createMcpServer( authType: mcpServers.authType, oauthClientId: mcpServers.oauthClientId, oauthClientSecret: mcpServers.oauthClientSecret, + managedConnectorId: mcpServers.managedConnectorId, }) .from(mcpServers) .where(and(eq(mcpServers.id, serverId), eq(mcpServers.workspaceId, params.workspaceId))) @@ -177,6 +178,14 @@ export async function createMcpServer( const urlChanged = existingServer ? existingServer.url !== params.url : true + if (existingServer?.managedConnectorId) { + return { + success: false, + error: 'This MCP server is managed by a Credential Group', + errorCode: 'conflict', + } + } + if ( existingServer && existingServer.deletedAt === null && diff --git a/apps/sim/lib/mcp/types.ts b/apps/sim/lib/mcp/types.ts index c6d4e584666..e4a557329d9 100644 --- a/apps/sim/lib/mcp/types.ts +++ b/apps/sim/lib/mcp/types.ts @@ -1,4 +1,5 @@ import type { Tool } from '@modelcontextprotocol/sdk/types.js' +import type { ManagedMcpConnectorId } from '@/lib/credential-groups/managed-mcp-connectors' import type { ResolvedSecretTraceProvenanceV1 } from '@/executor/utils/resolved-secret-trace-registry' export type McpTransport = 'streamable-http' @@ -91,6 +92,7 @@ export interface McpTool extends Pick { inputSchema: McpToolSchema serverId: string serverName: string + managedConnectorId?: ManagedMcpConnectorId } export interface McpToolCall { diff --git a/apps/sim/stores/panel/types.ts b/apps/sim/stores/panel/types.ts index 42c4a8cfd54..ebab08f7bc0 100644 --- a/apps/sim/stores/panel/types.ts +++ b/apps/sim/stores/panel/types.ts @@ -1,3 +1,5 @@ +import type { ManagedMcpConnectorId } from '@/lib/credential-groups/managed-mcp-connectors' + /** * Available panel tabs */ @@ -89,4 +91,9 @@ export type ChatContext = | { kind: 'slash_command'; command: string; label: string } | { kind: 'integration'; blockType: string; label: string } | { kind: 'skill'; skillId: string; label: string } - | { kind: 'mcp'; serverId: string; label: string } + | { + kind: 'mcp' + serverId: string + label: string + managedConnectorId?: ManagedMcpConnectorId + } diff --git a/packages/db/migrations/0317_credential_group_managed_mcp.sql b/packages/db/migrations/0317_credential_group_managed_mcp.sql index 110986fe116..391f725d230 100644 --- a/packages/db/migrations/0317_credential_group_managed_mcp.sql +++ b/packages/db/migrations/0317_credential_group_managed_mcp.sql @@ -6,6 +6,7 @@ ALTER TABLE "credential" ADD COLUMN IF NOT EXISTS "mcp_server_id" text;--> state ALTER TABLE "credential" ADD COLUMN IF NOT EXISTS "mcp_tools" jsonb;--> statement-breakpoint ALTER TABLE "credential" ADD COLUMN IF NOT EXISTS "mcp_tools_refreshed_at" timestamp;--> statement-breakpoint ALTER TABLE "mcp_servers" ADD COLUMN IF NOT EXISTS "credential_group_id" text;--> statement-breakpoint +ALTER TABLE "mcp_servers" ADD COLUMN IF NOT EXISTS "managed_connector_id" text;--> statement-breakpoint -- PostgreSQL has no ADD CONSTRAINT IF NOT EXISTS, so replay guards are scoped to each table. -- NOT VALID keeps foreign-key installation to a metadata change before validation. @@ -29,6 +30,26 @@ DO $$ BEGIN END IF; END $$;--> statement-breakpoint ALTER TABLE "mcp_servers" VALIDATE CONSTRAINT "mcp_servers_credential_group_id_credential_group_id_fk";--> statement-breakpoint +DO $$ BEGIN + IF NOT EXISTS ( + SELECT 1 FROM "pg_constraint" + WHERE "conname" = 'mcp_servers_credential_group_managed_connector_check' + AND "conrelid" = '"mcp_servers"'::regclass + ) THEN + ALTER TABLE "mcp_servers" ADD CONSTRAINT "mcp_servers_credential_group_managed_connector_check" CHECK ("credential_group_id" IS NULL OR "managed_connector_id" IS NOT NULL) NOT VALID; + END IF; +END $$;--> statement-breakpoint +ALTER TABLE "mcp_servers" VALIDATE CONSTRAINT "mcp_servers_credential_group_managed_connector_check";--> statement-breakpoint +DO $$ BEGIN + IF NOT EXISTS ( + SELECT 1 FROM "pg_constraint" + WHERE "conname" = 'mcp_servers_managed_connector_oauth_check' + AND "conrelid" = '"mcp_servers"'::regclass + ) THEN + ALTER TABLE "mcp_servers" ADD CONSTRAINT "mcp_servers_managed_connector_oauth_check" CHECK ("managed_connector_id" IS NULL OR "auth_type" = 'oauth') NOT VALID; + END IF; +END $$;--> statement-breakpoint +ALTER TABLE "mcp_servers" VALIDATE CONSTRAINT "mcp_servers_managed_connector_oauth_check";--> statement-breakpoint DO $$ BEGIN IF NOT EXISTS ( @@ -87,4 +108,6 @@ DROP INDEX CONCURRENTLY IF EXISTS "credential_managed_mcp_enrollment_server_uniq CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS "credential_managed_mcp_enrollment_server_unique" ON "credential" USING btree ("credential_group_enrollment_id","mcp_server_id") WHERE "credential"."type" = 'managed_mcp';--> statement-breakpoint DROP INDEX CONCURRENTLY IF EXISTS "mcp_servers_credential_group_idx";--> statement-breakpoint CREATE INDEX CONCURRENTLY IF NOT EXISTS "mcp_servers_credential_group_idx" ON "mcp_servers" USING btree ("credential_group_id");--> statement-breakpoint +DROP INDEX CONCURRENTLY IF EXISTS "mcp_servers_credential_group_managed_connector_unique";--> statement-breakpoint +CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS "mcp_servers_credential_group_managed_connector_unique" ON "mcp_servers" USING btree ("credential_group_id","managed_connector_id") WHERE "credential_group_id" IS NOT NULL AND "managed_connector_id" IS NOT NULL AND "deleted_at" IS NULL;--> statement-breakpoint SET lock_timeout = '5s'; diff --git a/packages/db/migrations/meta/0317_snapshot.json b/packages/db/migrations/meta/0317_snapshot.json index 671e962ad5c..3399232c2ea 100644 --- a/packages/db/migrations/meta/0317_snapshot.json +++ b/packages/db/migrations/meta/0317_snapshot.json @@ -8398,6 +8398,12 @@ "primaryKey": false, "notNull": false }, + "managed_connector_id": { + "name": "managed_connector_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, "created_by": { "name": "created_by", "type": "text", @@ -8585,6 +8591,28 @@ "method": "btree", "with": {} }, + "mcp_servers_credential_group_managed_connector_unique": { + "name": "mcp_servers_credential_group_managed_connector_unique", + "columns": [ + { + "expression": "credential_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "managed_connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"mcp_servers\".\"credential_group_id\" IS NOT NULL AND \"mcp_servers\".\"managed_connector_id\" IS NOT NULL AND \"mcp_servers\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, "mcp_servers_workspace_deleted_partial_idx": { "name": "mcp_servers_workspace_deleted_partial_idx", "columns": [ @@ -8640,7 +8668,16 @@ "compositePrimaryKeys": {}, "uniqueConstraints": {}, "policies": {}, - "checkConstraints": {}, + "checkConstraints": { + "mcp_servers_credential_group_managed_connector_check": { + "name": "mcp_servers_credential_group_managed_connector_check", + "value": "\"mcp_servers\".\"credential_group_id\" IS NULL OR \"mcp_servers\".\"managed_connector_id\" IS NOT NULL" + }, + "mcp_servers_managed_connector_oauth_check": { + "name": "mcp_servers_managed_connector_oauth_check", + "value": "\"mcp_servers\".\"managed_connector_id\" IS NULL OR \"mcp_servers\".\"auth_type\" = 'oauth'" + } + }, "isRLSEnabled": false }, "public.member": { diff --git a/packages/db/schema.ts b/packages/db/schema.ts index 899b82592f5..cdfc08beb53 100644 --- a/packages/db/schema.ts +++ b/packages/db/schema.ts @@ -3638,6 +3638,7 @@ export const mcpServers = pgTable( (): AnyPgColumn => credentialGroup.id, { onDelete: 'set null' } ), + managedConnectorId: text('managed_connector_id'), // Track who created the server, but workspace owns it createdBy: text('created_by').references(() => user.id, { onDelete: 'set null' }), @@ -3684,6 +3685,21 @@ export const mcpServers = pgTable( table.enabled ), credentialGroupIdx: index('mcp_servers_credential_group_idx').on(table.credentialGroupId), + credentialGroupManagedConnectorUnique: uniqueIndex( + 'mcp_servers_credential_group_managed_connector_unique' + ) + .on(table.credentialGroupId, table.managedConnectorId) + .where( + sql`${table.credentialGroupId} IS NOT NULL AND ${table.managedConnectorId} IS NOT NULL AND ${table.deletedAt} IS NULL` + ), + credentialGroupManagedConnectorCheck: check( + 'mcp_servers_credential_group_managed_connector_check', + sql`${table.credentialGroupId} IS NULL OR ${table.managedConnectorId} IS NOT NULL` + ), + managedConnectorOauthCheck: check( + 'mcp_servers_managed_connector_oauth_check', + sql`${table.managedConnectorId} IS NULL OR ${table.authType} = 'oauth'` + ), // Soft delete pattern - workspace + not deleted (partial: only deleted rows) workspaceDeletedIdx: index('mcp_servers_workspace_deleted_partial_idx') From 5c10e8c7b801bd72c90be9d41bb3d6ae2d493206 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Tue, 1 Sep 2026 18:36:25 -0700 Subject: [PATCH 07/10] chore: refresh PR merge state From 6953d4db97c3db50989c27f8bebcf61da36d9ff7 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Tue, 1 Sep 2026 18:41:16 -0700 Subject: [PATCH 08/10] test(openapi): include advanced MCP agent tool --- scripts/openapi/documents.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/scripts/openapi/documents.test.ts b/scripts/openapi/documents.test.ts index a3e14d01498..d2c31bd694b 100644 --- a/scripts/openapi/documents.test.ts +++ b/scripts/openapi/documents.test.ts @@ -307,7 +307,7 @@ describe('generated OpenAPI documents', () => { ) }) - it('publishes Agent tools as named integration, custom, and MCP schemas', () => { + it('publishes Agent tools as integration, custom, MCP tool, and advanced MCP schemas', () => { const workflowsSpec = generatedDocument(workflowsOpenApiDocument) const schemas = (workflowsSpec.components as JsonObject).schemas as JsonObject const agentToolInput = schemas.AgentToolInput as JsonObject @@ -332,6 +332,7 @@ describe('generated OpenAPI documents', () => { { $ref: '#/components/schemas/AgentIntegrationTool' }, { $ref: '#/components/schemas/AgentCustomTool' }, { $ref: '#/components/schemas/AgentMcpTool' }, + { $ref: '#/components/schemas/AgentMcpServerAdvanced' }, ]) expect(agentToolInput).toEqual( expect.objectContaining({ type: 'array', maxItems: MAX_AGENT_TOOLS_PER_BLOCK }) From f1c9d34a9ee2671712bd8093f65b1ea295845ce4 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Tue, 1 Sep 2026 18:44:53 -0700 Subject: [PATCH 09/10] test(mcp): align mocks with scoped execution --- .../user-input/components/prompt-editor/prompt-editor.test.tsx | 2 +- .../components/prompt-editor/use-prompt-editor.test.tsx | 2 +- apps/sim/lib/credential-groups/mcp-oauth-state.test.ts | 3 +++ apps/sim/lib/credentials/application/credential-crud.test.ts | 1 + apps/sim/lib/internal/mcp/execute-tool.test.ts | 1 + 5 files changed, 7 insertions(+), 2 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/prompt-editor.test.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/prompt-editor.test.tsx index 63aa2852877..cc8c6431457 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/prompt-editor.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/prompt-editor.test.tsx @@ -7,7 +7,7 @@ import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' vi.mock('@/hooks/queries/skills', () => ({ useSkills: () => ({ data: [] }) })) -vi.mock('@/hooks/queries/mcp', () => ({ useMcpServers: () => ({ data: [] }) })) +vi.mock('@/hooks/queries/mcp', () => ({ useMcpToolServers: () => ({ data: [] }) })) vi.mock('@/blocks/integration-matcher', () => ({ getIntegrationMatcher: () => ({ regex: null, byName: new Map() }), })) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/use-prompt-editor.test.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/use-prompt-editor.test.tsx index 6c69d935511..407bbd11202 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/use-prompt-editor.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/use-prompt-editor.test.tsx @@ -6,7 +6,7 @@ import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' vi.mock('@/hooks/queries/skills', () => ({ useSkills: () => ({ data: [] }) })) -vi.mock('@/hooks/queries/mcp', () => ({ useMcpServers: () => ({ data: [] }) })) +vi.mock('@/hooks/queries/mcp', () => ({ useMcpToolServers: () => ({ data: [] }) })) vi.mock('@/blocks/integration-matcher', () => ({ getIntegrationMatcher: () => ({ regex: null, byName: new Map() }), })) diff --git a/apps/sim/lib/credential-groups/mcp-oauth-state.test.ts b/apps/sim/lib/credential-groups/mcp-oauth-state.test.ts index 013e289bd3f..e7e88b6e871 100644 --- a/apps/sim/lib/credential-groups/mcp-oauth-state.test.ts +++ b/apps/sim/lib/credential-groups/mcp-oauth-state.test.ts @@ -18,6 +18,9 @@ const { mockRedis, values } = vi.hoisted(() => { values.delete(key) return value }), + sadd: vi.fn(async () => 1), + srem: vi.fn(async () => 1), + pexpire: vi.fn(async () => 1), }, } }) diff --git a/apps/sim/lib/credentials/application/credential-crud.test.ts b/apps/sim/lib/credentials/application/credential-crud.test.ts index c90980159b9..0ba3a46a136 100644 --- a/apps/sim/lib/credentials/application/credential-crud.test.ts +++ b/apps/sim/lib/credentials/application/credential-crud.test.ts @@ -37,6 +37,7 @@ vi.mock('@/lib/credentials/queries', () => ({ vi.mock('@/lib/credentials/access', () => ({ getCredentialActorContext: mocks.getActor, canUseCredential: () => true, + requireOrdinaryCredentialType: (type: string) => type, })) vi.mock('@/lib/credentials/orchestration', () => ({ updateCredentialRecord: mocks.updateRecord, diff --git a/apps/sim/lib/internal/mcp/execute-tool.test.ts b/apps/sim/lib/internal/mcp/execute-tool.test.ts index c85e58471af..f858701bfa2 100644 --- a/apps/sim/lib/internal/mcp/execute-tool.test.ts +++ b/apps/sim/lib/internal/mcp/execute-tool.test.ts @@ -93,6 +93,7 @@ describe('executeMcpTool', () => { expect(mocks.createPrincipal).toHaveBeenCalledWith({ context: CONTEXT, audience: 'sim:mcp-servers', + resourceScope: { mcpServerId: 'mcp-server' }, }) expect(mocks.executeUseCase).toHaveBeenCalledWith({ principal: PRINCIPAL, From 9bd8d15bbbcf506f2ee67415ae2b56c3170bef28 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Tue, 1 Sep 2026 18:48:51 -0700 Subject: [PATCH 10/10] test(mcp): update managed connector fixtures --- .../handlers/agent/agent-handler.test.ts | 16 +++++++++++++--- .../credential-groups/mcp-connections.test.ts | 2 ++ apps/sim/lib/mcp/application/operations.test.ts | 1 + 3 files changed, 16 insertions(+), 3 deletions(-) diff --git a/apps/sim/executor/handlers/agent/agent-handler.test.ts b/apps/sim/executor/handlers/agent/agent-handler.test.ts index df48acbf8c4..1932e0f5fed 100644 --- a/apps/sim/executor/handlers/agent/agent-handler.test.ts +++ b/apps/sim/executor/handlers/agent/agent-handler.test.ts @@ -126,9 +126,19 @@ vi.mock('@/executor/utils/http', () => ({ /** Connected MCP servers every workspace-server lookup in this suite resolves. */ const MCP_SERVER_ROWS = [ - { id: 'mcp-search-server', connectionStatus: 'connected' }, - { id: 'same-server', connectionStatus: 'connected' }, - { id: 'mcp-legacy-server', connectionStatus: 'connected' }, + { + id: 'mcp-search-server', + connectionStatus: 'connected', + credentialGroupId: null, + enabled: true, + }, + { id: 'same-server', connectionStatus: 'connected', credentialGroupId: null, enabled: true }, + { + id: 'mcp-legacy-server', + connectionStatus: 'connected', + credentialGroupId: null, + enabled: true, + }, ] const mockReadAvailableCustomToolByIdOrTitleAsExecutor = vi.fn() diff --git a/apps/sim/lib/credential-groups/mcp-connections.test.ts b/apps/sim/lib/credential-groups/mcp-connections.test.ts index a56a41c0e35..87869655ef4 100644 --- a/apps/sim/lib/credential-groups/mcp-connections.test.ts +++ b/apps/sim/lib/credential-groups/mcp-connections.test.ts @@ -19,6 +19,7 @@ describe('listCredentialGroupMcpConnectionReferences', () => { displayName: 'Fireflies', mcpServerId: 'mcp-server-1', mcpServerName: 'Fireflies', + managedConnectorId: 'fireflies', hasToolSnapshot: true, toolNames: ['list_transcripts', 'get_transcript'], createdAt: new Date('2026-09-01T12:00:00.000Z'), @@ -80,6 +81,7 @@ describe('listCredentialGroupMcpConnectionReferences', () => { displayName: 'Fireflies', mcpServerId: 'mcp-server-1', mcpServerName: 'Fireflies', + managedConnectorId: 'fireflies', hasToolSnapshot: false, toolNames: [], createdAt: new Date('2026-09-01T12:00:00.000Z'), diff --git a/apps/sim/lib/mcp/application/operations.test.ts b/apps/sim/lib/mcp/application/operations.test.ts index f145601905f..4238e5c4b83 100644 --- a/apps/sim/lib/mcp/application/operations.test.ts +++ b/apps/sim/lib/mcp/application/operations.test.ts @@ -150,6 +150,7 @@ const EXPECTED_CAPABILITIES: Record = delete: 'mcp_tools.use', discoverTools: 'mcp_tools.use', executeTool: 'mcp_tools.use', + listManagedConnections: 'mcp_tools.use', listWorkflowDeployments: 'deploy.mcp', readWorkflowDeploymentServer: 'deploy.mcp', listWorkflowDeploymentTools: 'deploy.mcp',