Skip to content

Commit 96b3d0b

Browse files
committed
fix(search): recheck Zoom approval and bound MCP serialization
1 parent 55d877b commit 96b3d0b

6 files changed

Lines changed: 81 additions & 3 deletions

File tree

‎apps/sim/.env.example‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -269,5 +269,6 @@ CRON_SECRET=your_cron_secret # Use `openssl rand -hex 32` to generate. Authentic
269269

270270
# Zoom member search: separate General OAuth app to preserve workflow grants.
271271
# Register ${NEXT_PUBLIC_APP_URL}/api/mcp/oauth/callback with the two meeting read scopes.
272+
# ZOOM_SEARCH=false # Off-AppConfig fallback; set true to enable for eligible organization-owned scopes
272273
# ZOOM_MCP_CLIENT_ID=
273274
# ZOOM_MCP_CLIENT_SECRET=

‎apps/sim/lib/knowledge/__integration__/search-mcp-setup.integration.ts‎

Lines changed: 59 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
} from '@sim/db/schema'
1111
import * as dns from '@sim/security/dns'
1212
import { createSessionPrincipal } from '@sim/testing/factories/principal.factory'
13+
import { createDeferred } from '@sim/testing/helpers/deferred'
1314
import { getPostgresErrorCode } from '@sim/utils/errors'
1415
import { generateId } from '@sim/utils/id'
1516
import { toRecord } from '@sim/utils/object'
@@ -18,7 +19,7 @@ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi }
1819
import { listSearchIntegrationsContract } from '@/lib/api/contracts/knowledge/search-integrations'
1920
import { env } from '@/lib/core/config/env'
2021
import { createOrganizationAccountsGroup } from '@/lib/credential-groups/workspace-accounts'
21-
import { tryAcquireAdvisoryXactLock } from '@/lib/db/advisory-locks'
22+
import { acquireAdvisoryXactLock, tryAcquireAdvisoryXactLock } from '@/lib/db/advisory-locks'
2223
import {
2324
approveSearchIntegration,
2425
listSearchIntegrations,
@@ -195,6 +196,63 @@ describe('atomic organization live Search MCP setup', () => {
195196
}
196197
)
197198

199+
it('rejects Zoom approval when rollout is disabled while waiting for the accounts lock', async () => {
200+
const group = await db.transaction((tx) =>
201+
createOrganizationAccountsGroup(tx, ids.organization, ids.owner)
202+
)
203+
await db.insert(mcpServers).values({
204+
id: generateId(),
205+
organizationId: ids.organization,
206+
credentialGroupId: group.id,
207+
managedConnectorId: 'zoom',
208+
name: 'Zoom',
209+
transport: 'streamable-http',
210+
url: 'https://mcp.zoom.us/mcp/meeting/streamable',
211+
authType: 'oauth',
212+
enabled: true,
213+
createdBy: ids.owner,
214+
})
215+
Object.assign(env, { ZOOM_SEARCH: true })
216+
const before = await snapshot()
217+
const locked = createDeferred<number>()
218+
const release = createDeferred<void>()
219+
const blocker = db.transaction(async (tx) => {
220+
await acquireAdvisoryXactLock(
221+
tx,
222+
'search_accounts',
223+
`search-accounts:organization:${ids.organization}`
224+
)
225+
const [connection] = await tx.execute<{ pid: number }>(sql`SELECT pg_backend_pid() AS pid`)
226+
locked.resolve(connection.pid)
227+
await release.promise
228+
})
229+
const blockerPid = await locked.promise
230+
const attempt = approve('zoom').catch((error: unknown) => error)
231+
try {
232+
await vi.waitFor(
233+
async () => {
234+
const [state] = await db.execute<{ waiting: boolean }>(sql`
235+
SELECT EXISTS (
236+
SELECT 1 FROM pg_stat_activity
237+
WHERE ${blockerPid} = ANY(pg_blocking_pids(pid))
238+
) AS waiting
239+
`)
240+
expect(state.waiting).toBe(true)
241+
},
242+
{ timeout: 5_000 }
243+
)
244+
Object.assign(env, { ZOOM_SEARCH: false })
245+
} finally {
246+
release.resolve()
247+
await blocker
248+
await attempt
249+
}
250+
expect(await attempt).toMatchObject({ code: 'forbidden' })
251+
expect(await snapshot()).toEqual(before)
252+
Object.assign(env, { ZOOM_SEARCH: true })
253+
await expect(approve('zoom')).resolves.toMatchObject({ approved: true })
254+
})
255+
198256
it('resolves the sign-in server before the approval takes the accounts lock', async () => {
199257
const lockHeldDuringLookup: boolean[] = []
200258
vi.mocked(dns.resolveHostAddresses).mockImplementationOnce(async () => {

‎apps/sim/lib/sim-search/live/README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -159,7 +159,7 @@ Zoom Search defaults off for organization-scoped rollout. Enable selected organi
159159
}
160160
```
161161

162-
Only the canonical organization ID participates in this rollout check. For local or self-hosted deployments, `ZOOM_SEARCH=true` enables Zoom Search globally; leave that boolean fallback off for an organization-targeted rollout. Setup, enrollment and retrieval enforce the flag. The dedicated Zoom MCP Search connector is gated wherever it is invoked, including generic MCP tools; the standard workflow Zoom OAuth/tools remain available. Disabling the flag preserves saved grants and conversations while denying subsequent Search use; existing approvals can still be removed and connected accounts disconnected. Other providers retain the shared Search and credential-group availability policies without a separate provider rollout gate.
162+
Only the canonical organization ID participates in this rollout check. For local or self-hosted deployments, `ZOOM_SEARCH=true` enables Zoom Search for all otherwise eligible organization-owned scopes; personal workspaces without an organization cannot use managed connected accounts. Leave that boolean fallback off for an organization-targeted rollout. Setup, enrollment and retrieval enforce the flag. The dedicated Zoom MCP Search connector is gated wherever it is invoked, including generic MCP tools; the standard workflow Zoom OAuth/tools remain available. Disabling the flag preserves saved grants and conversations while denying subsequent Search use; existing approvals can still be removed and connected accounts disconnected. Other providers retain the shared Search and credential-group availability policies without a separate provider rollout gate.
163163

164164
### Shared invariants
165165

‎apps/sim/lib/sim-search/live/managed-mcp-payload.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
import { isRecordLike } from '@sim/utils/object'
2+
import { stringifyBoundedJson } from '@/lib/core/utils/bounded-json'
23
import type { McpToolResult } from '@/lib/mcp/types'
34
import { NativeSearchError } from '@/lib/sim-search/live/http'
45

56
const MAX_SEARCH_MCP_PAYLOAD_BYTES = 4 * 1024 * 1024
67

78
/** MCP text is untrusted provider data; malformed structured search output is never an empty success. */
89
export function managedMcpPayload(result: McpToolResult, label: string): unknown {
9-
if (Buffer.byteLength(JSON.stringify(result), 'utf8') > MAX_SEARCH_MCP_PAYLOAD_BYTES)
10+
if (stringifyBoundedJson(result, MAX_SEARCH_MCP_PAYLOAD_BYTES) === undefined)
1011
throw new NativeSearchError(
1112
'unavailable',
1213
`${label} response exceeded the search size limit. Narrow the query.`

‎apps/sim/lib/sim-search/live/managed-mcp.test.ts‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,4 +127,17 @@ describe('managed search MCP read boundary', () => {
127127
expect((failure as NativeSearchError).message).toContain('existing Granola account')
128128
expect((failure as NativeSearchError).message).not.toContain('private-provider-detail')
129129
})
130+
131+
it('rejects escaped MCP payload overflow before allocating its JSON representation', () => {
132+
const result = { structuredContent: { ['\u0000'.repeat(800_000)]: 'value' } }
133+
const serialize = vi.spyOn(JSON, 'stringify').mockImplementation(() => {
134+
throw new Error('Oversized payload reached serialization')
135+
})
136+
try {
137+
expect(() => managedMcpPayload(result, 'Fireflies')).toThrow('size limit')
138+
expect(serialize).not.toHaveBeenCalled()
139+
} finally {
140+
serialize.mockRestore()
141+
}
142+
})
130143
})

‎apps/sim/lib/sim-search/live/member-setup.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,11 @@ export async function addOrganizationSearchMcpProvider(
109109
)
110110
.limit(1)
111111
if (existing) {
112+
if (!(await isSearchProviderEnabled(provider, { kind: 'organization', organizationId })))
113+
throw new OrchestrationError(
114+
'forbidden',
115+
'Zoom Search is not available for this organization'
116+
)
112117
if (!existing.enabled)
113118
throw new OrchestrationError(
114119
'validation',

0 commit comments

Comments
 (0)