Skip to content

Commit d072e72

Browse files
committed
feat(search): identify managed MCP accounts to allow several per connector
Read the signed-in account through each connector's documented identity tool during OAuth completion and key connections on it, sharing one set of account rules with OAuth providers. Notion, Granola, Fireflies, Coda, and HubSpot now allow several accounts; connectors with no identity tool stay at one.
1 parent bd7a262 commit d072e72

29 files changed

Lines changed: 33145 additions & 102 deletions

‎apps/docs/content/docs/search/connect-your-account.mdx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,9 +22,9 @@ After your admin adds a source in **Settings → Sources**, connect your own acc
2222
variant="overview"
2323
/>
2424

25-
To connect another account of the same app, such as Google Drive for two different work accounts, open the app and choose **Add account**, then pick the other account when the provider asks. You can connect up to five accounts per app, and Search looks across all of them. Slack accounts must belong to your organization's connected workspace. Managed connections such as Coda, Notion, Granola, and Fireflies currently support one account. Open an account to see its permissions and connection actions.
25+
To connect another account of the same app, such as Google Drive for two different work accounts, open the app and choose **Add account**, then pick the other account when the provider asks. You can connect up to five accounts per app, and Search looks across all of them. Slack accounts must belong to your organization's connected workspace. Zoom, Lucid, and Databricks allow one account because Sim cannot yet tell their accounts apart. Open an account to see its permissions and connection actions.
2626

27-
Reconnecting keeps the same account. To use a different one, add it as a new account or disconnect the old one first. Your organization and the provider must allow each account to authorize Sim.
27+
Reconnecting keeps the same account. To use a different one, add it as a new account or disconnect the old one first. If you connected Coda, Notion, Granola, Fireflies, or HubSpot before multiple accounts were available, reconnect that account once before adding another. Your organization and the provider must allow each account to authorize Sim.
2828

2929
The connection is usable when authorization completes; no initial indexing run is required. If a provider is missing, ask an admin to add it in **Sources**. `Not configured` means its app or deployment setup needs an admin's attention.
3030

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

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import {
1818
} from '@/lib/credential-groups/mcp-oauth-state'
1919
import { CredentialGroupOAuthStateVersionError } from '@/lib/credential-groups/oauth-attempt-version'
2020
import type { CredentialGroupOAuthFailure } from '@/lib/credential-groups/oauth-completion'
21+
import { CredentialGroupOAuthError } from '@/lib/credential-groups/provider-adapter'
2122
import { enforcePublicCredentialGroupIpRateLimit } from '@/lib/credential-groups/rate-limit'
2223
import {
2324
assertSafeOauthServerUrl,
@@ -162,7 +163,9 @@ async function completeManagedMcpCallback(params: {
162163
})
163164
} catch (error) {
164165
logger.error('Managed MCP OAuth callback failed', error)
165-
return failureRedirect('failed')
166+
return failureRedirect(
167+
error instanceof CredentialGroupOAuthError && error.failure ? error.failure : 'failed'
168+
)
166169
}
167170
}
168171

‎apps/sim/executor/handlers/credential/handler.test.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
} from '@sim/testing/mocks/executor-principal.mock'
55
import { beforeEach, describe, expect, it, vi } from 'vitest'
66
import { MAX_CREDENTIAL_GROUP_CREDENTIAL_PAGE_SIZE } from '@/lib/credential-groups/credentials'
7+
import { MAX_CREDENTIAL_GROUP_MCP_CONNECTION_PAGE_SIZE } from '@/lib/credential-groups/mcp-connections'
78
import type { ExecutionContext } from '@/executor/types'
89
import type { SerializedBlock } from '@/serializer/types'
910

@@ -123,7 +124,7 @@ describe('Credential organization operations', () => {
123124
input: expect.objectContaining({
124125
connectorId: 'fireflies',
125126
email: account.email,
126-
limit: 2,
127+
limit: MAX_CREDENTIAL_GROUP_MCP_CONNECTION_PAGE_SIZE,
127128
}),
128129
})
129130
)

‎apps/sim/executor/handlers/credential/handler.ts‎

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { CREDENTIAL_GROUP_DELEGATION_AUDIENCE } from '@/lib/credential-groups/ap
33
import { listCredentialGroupCredentials } from '@/lib/credential-groups/application/list-credentials'
44
import { listCredentialGroupMcpConnections } from '@/lib/credential-groups/application/list-mcp-connections'
55
import { MAX_CREDENTIAL_GROUP_CREDENTIAL_PAGE_SIZE } from '@/lib/credential-groups/credentials'
6+
import { MAX_CREDENTIAL_GROUP_MCP_CONNECTION_PAGE_SIZE } from '@/lib/credential-groups/mcp-connections'
67
import { resolveWorkflowCredentials } from '@/lib/credentials/application/resolve-workflow-credentials'
78
import { createExecutorPrincipalFromExecutionContext } from '@/lib/internal/principals/executor'
89
import type { BlockOutput } from '@/blocks/types'
@@ -135,26 +136,34 @@ export class CredentialBlockHandler implements BlockHandler {
135136
case 'find_organization_mcp_connection':
136137
case 'list_organization_mcp_connections': {
137138
const find = operation === 'find_organization_mcp_connection'
139+
const email = find
140+
? requireString(inputs.email, 'Email')
141+
: parseOptionalString(inputs.email, 'Email')
138142
const result = await listCredentialGroupMcpConnections.execute({
139143
principal,
140144
input: {
141145
workspaceId: ctx.workspaceId,
142-
email: find
143-
? requireString(inputs.email, 'Email')
144-
: parseOptionalString(inputs.email, 'Email'),
146+
email,
145147
connectorId: find
146148
? requireString(inputs.mcpProvider, 'MCP provider')
147149
: parseOptionalString(inputs.mcpProvider, 'MCP provider'),
148-
limit: find ? 2 : parseLimit(inputs.limit),
150+
limit: find ? MAX_CREDENTIAL_GROUP_MCP_CONNECTION_PAGE_SIZE : parseLimit(inputs.limit),
149151
cursor: find ? undefined : parseOptionalString(inputs.cursor, 'Cursor'),
150152
},
151153
})
152154
if (!find) return result
153-
if (result.mcpConnections.length !== 1 || result.hasMore)
155+
const matches =
156+
result.mcpConnections.length > 1
157+
? result.mcpConnections.filter(
158+
(connection) => connection.displayName.toLowerCase() === email?.toLowerCase()
159+
)
160+
: result.mcpConnections
161+
const [match] = matches
162+
if (!match || matches.length !== 1)
154163
throw new Error(
155-
`Expected exactly one organization MCP connection; found ${result.mcpConnections.length}${result.hasMore ? '+' : ''}. Check the email, provider, and connection status.`
164+
`Expected exactly one organization MCP connection; found ${result.mcpConnections.length}${result.hasMore ? '+' : ''}. Check the email, provider, and connection status. A person with several accounts of one provider needs List Organization MCP Connections to choose between them.`
156165
)
157-
return result.mcpConnections[0]!
166+
return match
158167
}
159168
default:
160169
throw new Error(`Unsupported Credential operation: ${operation}`)

‎apps/sim/lib/assistant-actions/tool-permissions.integration.ts‎

Lines changed: 20 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ import { readTestRedisUrl } from '@sim/db/testing/test-infrastructure'
2727
import { sha256Hex } from '@sim/security/hash'
2828
import { generateId } from '@sim/utils/id'
2929
import { toArray, toRecord } from '@sim/utils/object'
30-
import { eq, inArray } from 'drizzle-orm'
30+
import { and, eq, inArray, ne } from 'drizzle-orm'
3131
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
3232
import {
3333
claimOrganizationAssistantAction,
@@ -1045,13 +1045,13 @@ describe('Managed account identity across Assistant approvals', () => {
10451045
accepted: false,
10461046
},
10471047
{
1048-
name: 'create refuses another subject in a disconnected account slot',
1048+
name: 'create adds another subject separately, leaving the approval on the disconnected account',
10491049
provider: 'jira',
10501050
mode: 'restore',
10511051
changedSubject: true,
10521052
storedTenant: null,
10531053
returnedTenant: null,
1054-
accepted: false,
1054+
accepted: true,
10551055
},
10561056
{
10571057
name: 'create restores the same disconnected account',
@@ -1123,6 +1123,8 @@ describe('Managed account identity across Assistant approvals', () => {
11231123
if (!originalCredential || !originalEnrollment || !group || !configuredOption)
11241124
throw new Error('Managed account fixture is incomplete')
11251125

1126+
const addsSeparateAccount =
1127+
scenario.mode === 'new' || (scenario.mode === 'restore' && scenario.changedSubject)
11261128
const option =
11271129
scenario.mode === 'new'
11281130
? { ...configuredOption, id: generateId(), label: 'Second Jira account' }
@@ -1276,10 +1278,10 @@ describe('Managed account identity across Assistant approvals', () => {
12761278
if (scenario.accepted) {
12771279
const result = await completion
12781280
expect(result).toMatchObject({
1279-
created: scenario.mode === 'new',
1281+
created: addsSeparateAccount,
12801282
displayName: chosenEmail,
12811283
})
1282-
if (scenario.mode === 'new') expect(result.credentialId).not.toBe(selectedCredentialId)
1284+
if (addsSeparateAccount) expect(result.credentialId).not.toBe(selectedCredentialId)
12831285
else expect(result.credentialId).toBe(selectedCredentialId)
12841286
const [connected] = await db
12851287
.select()
@@ -1303,7 +1305,7 @@ describe('Managed account identity across Assistant approvals', () => {
13031305
await expect(completion).rejects.toMatchObject({ statusCode: 409 })
13041306
}
13051307

1306-
if (!scenario.accepted || scenario.mode === 'new') {
1308+
if (!scenario.accepted || addsSeparateAccount) {
13071309
const [unchanged] = await db
13081310
.select()
13091311
.from(credential)
@@ -1312,7 +1314,7 @@ describe('Managed account identity across Assistant approvals', () => {
13121314
}
13131315

13141316
if (pending) {
1315-
if (scenario.mode === 'restore' && !scenario.accepted) {
1317+
if (scenario.mode === 'restore' && (!scenario.accepted || addsSeparateAccount)) {
13161318
await expect(claim(pending.call)).rejects.toThrow()
13171319
expect(requests).toEqual([])
13181320
} else {
@@ -1347,13 +1349,22 @@ describe('Managed account identity across Assistant approvals', () => {
13471349
expect(receipts.receipts[0]?.account).toMatchObject({
13481350
credentialId: selectedCredentialId,
13491351
displayName:
1350-
scenario.accepted && scenario.mode !== 'new' ? chosenEmail : beforeOAuth.displayName,
1352+
scenario.accepted && !addsSeparateAccount ? chosenEmail : beforeOAuth.displayName,
13511353
})
13521354
}
13531355
} finally {
13541356
provider.mockRestore()
1357+
if (addsSeparateAccount) {
1358+
await db
1359+
.delete(credential)
1360+
.where(
1361+
and(
1362+
eq(credential.credentialGroupOptionId, option.id),
1363+
ne(credential.id, selectedCredentialId)
1364+
)
1365+
)
1366+
}
13551367
if (scenario.mode === 'new') {
1356-
await db.delete(credential).where(eq(credential.credentialGroupOptionId, option.id))
13571368
await db
13581369
.update(credentialGroup)
13591370
.set({ options: group.options })

‎apps/sim/lib/credential-groups/README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ Credential Groups remains available whenever its organization feature is enabled
1010

1111
An allowed workspace grants every normally authorized manual and deployed workflow access to every active contribution in this pool. There is no per-workflow resource-policy grant and no per-person filtering for workflow execution. Keep ordinary workspace/workflow authorization and deployment authority: an allowlist entry alone cannot authorize running a workflow. Nested workflows use their actual execution workspace. A workspace move, revocation, inactive enrollment, removed provider, disabled group, or unavailable org entitlement blocks subsequent use.
1212

13-
Standalone Chat uses the signed-in person’s own connections. Invited contributors do not need organization membership. Redemption requires a verified Sim email matching the invitation; the enrollment is then bound permanently to that user ID. An email change cannot transfer an enrollment. People can connect any provider account they can authorize, even when its email differs from their Sim or invitation email. One person may connect several accounts of a provider from **Integrations**, identified by the provider's subject and tenant, up to five live accounts per provider. This applies to every OAuth provider; Slack accounts must belong to the organization's configured workspace. Reconnect stays bound to the account it was started from; disconnecting releases the slot, and the same identity returning restores its credential. An invitation link only refreshes the account already connected. Managed MCP connections remain one per server because those credentials hold no provider identity. OAuth callbacks require the same verified signed-in user who started authorization. Search requires current organization membership and applies document permissions using the viewer's own verified provider identities; workspace access to the shared credential pool does not grant access to other people's Search documents.
13+
Standalone Chat uses the signed-in person’s own connections. Invited contributors do not need organization membership. Redemption requires a verified Sim email matching the invitation; the enrollment is then bound permanently to that user ID. An email change cannot transfer an enrollment. People can connect any provider account they can authorize, even when its email differs from their Sim or invitation email. One person may connect several accounts of a provider from **Integrations**, identified by the provider's subject and tenant, up to five live accounts per provider. This applies to every OAuth provider; Slack accounts must belong to the organization's configured workspace. Reconnect stays bound to the account it was started from; disconnecting releases the slot, and the same identity returning restores its credential. An invitation link only refreshes the account already connected. Managed MCP connections follow the same rules for connectors with a vendor-documented identity tool (Notion, Granola, Fireflies, Coda, HubSpot): the account is read through that tool during OAuth completion. An unidentified connection keeps working but blocks adding another until it is reconnected. Zoom, Lucid, and Databricks have no such tool and stay at one per server. OAuth callbacks require the same verified signed-in user who started authorization. Search requires current organization membership and applies document permissions using the viewer's own verified provider identities; workspace access to the shared credential pool does not grant access to other people's Search documents.
1414

1515
Disconnect revokes the local grant and invalidates pending invitation-based authorization. Administrators can revoke an enrollment; the person cannot restore it themselves. Removing workspace access stops future authorized calls, but cannot recall a provider request already in flight or erase data already returned to a workflow. Full-pool sharing includes public, scheduled, and webhook deployments that otherwise pass workflow authorization.
1616

0 commit comments

Comments
 (0)