From 96b3d0b9edb1a4922cd3059cd22609edd4a2294e Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 30 Sep 2026 18:15:04 -0700 Subject: [PATCH 1/3] fix(search): recheck Zoom approval and bound MCP serialization --- apps/sim/.env.example | 1 + .../search-mcp-setup.integration.ts | 60 ++++++++++++++++++- apps/sim/lib/sim-search/live/README.md | 2 +- .../sim-search/live/managed-mcp-payload.ts | 3 +- .../lib/sim-search/live/managed-mcp.test.ts | 13 ++++ apps/sim/lib/sim-search/live/member-setup.ts | 5 ++ 6 files changed, 81 insertions(+), 3 deletions(-) diff --git a/apps/sim/.env.example b/apps/sim/.env.example index 4d7756fc3a3..d72d0f1a8cc 100644 --- a/apps/sim/.env.example +++ b/apps/sim/.env.example @@ -269,5 +269,6 @@ CRON_SECRET=your_cron_secret # Use `openssl rand -hex 32` to generate. Authentic # Zoom member search: separate General OAuth app to preserve workflow grants. # Register ${NEXT_PUBLIC_APP_URL}/api/mcp/oauth/callback with the two meeting read scopes. +# ZOOM_SEARCH=false # Off-AppConfig fallback; set true to enable for eligible organization-owned scopes # ZOOM_MCP_CLIENT_ID= # ZOOM_MCP_CLIENT_SECRET= diff --git a/apps/sim/lib/knowledge/__integration__/search-mcp-setup.integration.ts b/apps/sim/lib/knowledge/__integration__/search-mcp-setup.integration.ts index e5ce46f2bb5..f1a99f53971 100644 --- a/apps/sim/lib/knowledge/__integration__/search-mcp-setup.integration.ts +++ b/apps/sim/lib/knowledge/__integration__/search-mcp-setup.integration.ts @@ -10,6 +10,7 @@ import { } from '@sim/db/schema' import * as dns from '@sim/security/dns' import { createSessionPrincipal } from '@sim/testing/factories/principal.factory' +import { createDeferred } from '@sim/testing/helpers/deferred' import { getPostgresErrorCode } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import { toRecord } from '@sim/utils/object' @@ -18,7 +19,7 @@ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } import { listSearchIntegrationsContract } from '@/lib/api/contracts/knowledge/search-integrations' import { env } from '@/lib/core/config/env' import { createOrganizationAccountsGroup } from '@/lib/credential-groups/workspace-accounts' -import { tryAcquireAdvisoryXactLock } from '@/lib/db/advisory-locks' +import { acquireAdvisoryXactLock, tryAcquireAdvisoryXactLock } from '@/lib/db/advisory-locks' import { approveSearchIntegration, listSearchIntegrations, @@ -195,6 +196,63 @@ describe('atomic organization live Search MCP setup', () => { } ) + it('rejects Zoom approval when rollout is disabled while waiting for the accounts lock', async () => { + const group = await db.transaction((tx) => + createOrganizationAccountsGroup(tx, ids.organization, ids.owner) + ) + await db.insert(mcpServers).values({ + id: generateId(), + organizationId: ids.organization, + credentialGroupId: group.id, + managedConnectorId: 'zoom', + name: 'Zoom', + transport: 'streamable-http', + url: 'https://mcp.zoom.us/mcp/meeting/streamable', + authType: 'oauth', + enabled: true, + createdBy: ids.owner, + }) + Object.assign(env, { ZOOM_SEARCH: true }) + const before = await snapshot() + const locked = createDeferred() + const release = createDeferred() + const blocker = db.transaction(async (tx) => { + await acquireAdvisoryXactLock( + tx, + 'search_accounts', + `search-accounts:organization:${ids.organization}` + ) + const [connection] = await tx.execute<{ pid: number }>(sql`SELECT pg_backend_pid() AS pid`) + locked.resolve(connection.pid) + await release.promise + }) + const blockerPid = await locked.promise + const attempt = approve('zoom').catch((error: unknown) => error) + try { + await vi.waitFor( + async () => { + const [state] = await db.execute<{ waiting: boolean }>(sql` + SELECT EXISTS ( + SELECT 1 FROM pg_stat_activity + WHERE ${blockerPid} = ANY(pg_blocking_pids(pid)) + ) AS waiting + `) + expect(state.waiting).toBe(true) + }, + { timeout: 5_000 } + ) + Object.assign(env, { ZOOM_SEARCH: false }) + } finally { + release.resolve() + await blocker + await attempt + } + expect(await attempt).toMatchObject({ code: 'forbidden' }) + expect(await snapshot()).toEqual(before) + Object.assign(env, { ZOOM_SEARCH: true }) + await expect(approve('zoom')).resolves.toMatchObject({ approved: true }) + }) + it('resolves the sign-in server before the approval takes the accounts lock', async () => { const lockHeldDuringLookup: boolean[] = [] vi.mocked(dns.resolveHostAddresses).mockImplementationOnce(async () => { diff --git a/apps/sim/lib/sim-search/live/README.md b/apps/sim/lib/sim-search/live/README.md index 44a94b4e185..ccde37b1e19 100644 --- a/apps/sim/lib/sim-search/live/README.md +++ b/apps/sim/lib/sim-search/live/README.md @@ -159,7 +159,7 @@ Zoom Search defaults off for organization-scoped rollout. Enable selected organi } ``` -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. +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. ### Shared invariants diff --git a/apps/sim/lib/sim-search/live/managed-mcp-payload.ts b/apps/sim/lib/sim-search/live/managed-mcp-payload.ts index 34f69e0a608..5f942b42fc0 100644 --- a/apps/sim/lib/sim-search/live/managed-mcp-payload.ts +++ b/apps/sim/lib/sim-search/live/managed-mcp-payload.ts @@ -1,4 +1,5 @@ import { isRecordLike } from '@sim/utils/object' +import { stringifyBoundedJson } from '@/lib/core/utils/bounded-json' import type { McpToolResult } from '@/lib/mcp/types' import { NativeSearchError } from '@/lib/sim-search/live/http' @@ -6,7 +7,7 @@ const MAX_SEARCH_MCP_PAYLOAD_BYTES = 4 * 1024 * 1024 /** MCP text is untrusted provider data; malformed structured search output is never an empty success. */ export function managedMcpPayload(result: McpToolResult, label: string): unknown { - if (Buffer.byteLength(JSON.stringify(result), 'utf8') > MAX_SEARCH_MCP_PAYLOAD_BYTES) + if (stringifyBoundedJson(result, MAX_SEARCH_MCP_PAYLOAD_BYTES) === undefined) throw new NativeSearchError( 'unavailable', `${label} response exceeded the search size limit. Narrow the query.` diff --git a/apps/sim/lib/sim-search/live/managed-mcp.test.ts b/apps/sim/lib/sim-search/live/managed-mcp.test.ts index 9fe3c98d2fc..ff5e1d23792 100644 --- a/apps/sim/lib/sim-search/live/managed-mcp.test.ts +++ b/apps/sim/lib/sim-search/live/managed-mcp.test.ts @@ -127,4 +127,17 @@ describe('managed search MCP read boundary', () => { expect((failure as NativeSearchError).message).toContain('existing Granola account') expect((failure as NativeSearchError).message).not.toContain('private-provider-detail') }) + + it('rejects escaped MCP payload overflow before allocating its JSON representation', () => { + const result = { structuredContent: { ['\u0000'.repeat(800_000)]: 'value' } } + const serialize = vi.spyOn(JSON, 'stringify').mockImplementation(() => { + throw new Error('Oversized payload reached serialization') + }) + try { + expect(() => managedMcpPayload(result, 'Fireflies')).toThrow('size limit') + expect(serialize).not.toHaveBeenCalled() + } finally { + serialize.mockRestore() + } + }) }) diff --git a/apps/sim/lib/sim-search/live/member-setup.ts b/apps/sim/lib/sim-search/live/member-setup.ts index 7e6246c3744..82ea2a9585e 100644 --- a/apps/sim/lib/sim-search/live/member-setup.ts +++ b/apps/sim/lib/sim-search/live/member-setup.ts @@ -109,6 +109,11 @@ export async function addOrganizationSearchMcpProvider( ) .limit(1) if (existing) { + if (!(await isSearchProviderEnabled(provider, { kind: 'organization', organizationId }))) + throw new OrchestrationError( + 'forbidden', + 'Zoom Search is not available for this organization' + ) if (!existing.enabled) throw new OrchestrationError( 'validation', From 07b155cafa65b4d920b9c22a87712b34822d96be Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 30 Sep 2026 18:34:15 -0700 Subject: [PATCH 2/3] fix(search): preserve byte-only MCP response limits --- apps/sim/lib/core/utils/bounded-json.test.ts | 18 +++- apps/sim/lib/core/utils/bounded-json.ts | 89 +++++++++++++++++++ .../sim-search/live/managed-mcp-payload.ts | 4 +- .../lib/sim-search/live/managed-mcp.test.ts | 13 +++ 4 files changed, 121 insertions(+), 3 deletions(-) diff --git a/apps/sim/lib/core/utils/bounded-json.test.ts b/apps/sim/lib/core/utils/bounded-json.test.ts index b2b03581b3f..0f156d907d8 100644 --- a/apps/sim/lib/core/utils/bounded-json.test.ts +++ b/apps/sim/lib/core/utils/bounded-json.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest' -import { stringifyBoundedJson } from '@/lib/core/utils/bounded-json' +import { isJsonWithinByteLimit, stringifyBoundedJson } from '@/lib/core/utils/bounded-json' describe('bounded JSON', () => { it.each([ @@ -13,11 +13,14 @@ describe('bounded JSON', () => { const bytes = Buffer.byteLength(json, 'utf8') expect(stringifyBoundedJson(value, bytes)).toBe(json) expect(stringifyBoundedJson(value, bytes - 1)).toBeUndefined() + expect(isJsonWithinByteLimit(value, bytes)).toBe(true) + expect(isJsonWithinByteLimit(value, bytes - 1)).toBe(false) }) it('rejects cycles, excessive depth, and excessive nodes', () => { const cyclic: Record = {} cyclic.self = cyclic + expect(isJsonWithinByteLimit(cyclic, 1024)).toBe(false) let deep: unknown = 'leaf' for (let index = 0; index < 66; index++) deep = { child: deep } for (const value of [ @@ -37,6 +40,7 @@ describe('bounded JSON', () => { const custom = Object.defineProperty({}, 'toJSON', { value: toJSON }) for (const value of [accessor, custom, { output: new Uint8Array([1, 2, 3]) }]) { expect(stringifyBoundedJson(value, 1024)).toBeUndefined() + expect(isJsonWithinByteLimit(value, 1024)).toBe(false) } expect(getter).not.toHaveBeenCalled() expect(toJSON).not.toHaveBeenCalled() @@ -47,6 +51,7 @@ describe('bounded JSON', () => { const serialize = vi.spyOn(JSON, 'stringify') try { expect(stringifyBoundedJson(value, 1024)).toBeUndefined() + expect(isJsonWithinByteLimit(value, 1024)).toBe(false) expect(serialize).not.toHaveBeenCalled() } finally { serialize.mockRestore() @@ -61,6 +66,7 @@ describe('bounded JSON', () => { const serialize = vi.spyOn(JSON, 'stringify') try { expect(stringifyBoundedJson(value, 1024)).toBeUndefined() + expect(isJsonWithinByteLimit(value, 1024)).toBe(false) expect(serialize).not.toHaveBeenCalled() } finally { serialize.mockRestore() @@ -71,6 +77,7 @@ describe('bounded JSON', () => { const get = vi.fn(() => 'UNADMITTED') const value = new Proxy({ text: 'admitted' }, { get }) expect(stringifyBoundedJson(value, 1024)).toBe('{"text":"admitted"}') + expect(isJsonWithinByteLimit(value, 19)).toBe(true) expect(get).not.toHaveBeenCalled() }) @@ -79,6 +86,7 @@ describe('bounded JSON', () => { const prototype = Object.create(Array.prototype, { 0: { get } }) const value = Object.setPrototypeOf(Array(1), prototype) expect(stringifyBoundedJson(value, 1024)).toBe('[null]') + expect(isJsonWithinByteLimit(value, 6)).toBe(true) expect(get).not.toHaveBeenCalled() }) @@ -86,5 +94,13 @@ describe('bounded JSON', () => { const result = { answer: 42 } const value = { rawResponse: result, modelResponse: result } expect(stringifyBoundedJson(value, 1024)).toBe(JSON.stringify(value)) + expect(isJsonWithinByteLimit(value, Buffer.byteLength(JSON.stringify(value)))).toBe(true) + }) + + it('counts an ordinary toJSON data field without invoking custom serialization', () => { + const value = { toJSON: 'ordinary JSON field', nested: [{}, [], { flag: true }] } + const bytes = Buffer.byteLength(JSON.stringify(value)) + expect(isJsonWithinByteLimit(value, bytes)).toBe(true) + expect(isJsonWithinByteLimit(value, bytes - 1)).toBe(false) }) }) diff --git a/apps/sim/lib/core/utils/bounded-json.ts b/apps/sim/lib/core/utils/bounded-json.ts index 55678f5cb3a..4dc75189d4c 100644 --- a/apps/sim/lib/core/utils/bounded-json.ts +++ b/apps/sim/lib/core/utils/bounded-json.ts @@ -21,6 +21,95 @@ function quotedStringBytes(value: string, remaining: number): number | undefined return bytes <= remaining ? bytes : undefined } +/** Measures plain JSON iteratively without serialization, copying, or additional node/depth caps. */ +export function isJsonWithinByteLimit(value: unknown, maxBytes: number): boolean { + if (!Number.isFinite(maxBytes) || maxBytes < 0 || value === undefined) return false + const invalid = Symbol('invalid JSON') + const ancestors = new WeakSet() + const stack: { + value: object + entries: Generator<[string | null, unknown]> + count: number + }[] = [] + let bytes = 0 + const omitted = (item: unknown) => + item === undefined || typeof item === 'function' || typeof item === 'symbol' + function* entries(container: object): Generator<[string | null, unknown]> { + if (Array.isArray(container)) { + const length = Object.getOwnPropertyDescriptor(container, 'length')?.value + if (typeof length !== 'number') { + yield [null, invalid] + return + } + for (let index = 0; index < length; index++) { + const field = Object.getOwnPropertyDescriptor(container, index) + if (field && !('value' in field)) { + yield [null, invalid] + return + } + yield [null, omitted(field?.value) ? null : field?.value] + } + } else { + for (const key in container) { + const field = Object.getOwnPropertyDescriptor(container, key) + if (!field?.enumerable) continue + if (!('value' in field)) { + yield [key, invalid] + return + } + if (!omitted(field.value)) yield [key, field.value] + } + } + } + try { + let item: unknown = value + for (;;) { + if (typeof item === 'string') { + const count = quotedStringBytes(item, maxBytes - bytes) + if (count === undefined) return false + bytes += count + } else if (item === null) bytes += 4 + else if (typeof item === 'number') bytes += Number.isFinite(item) ? String(item).length : 4 + else if (typeof item === 'boolean') bytes += item ? 4 : 5 + else if (typeof item === 'object') { + if (ancestors.has(item)) return false + const prototype = Object.getPrototypeOf(item) + if (!Array.isArray(item) && prototype !== Object.prototype && prototype !== null) + return false + const serializer = Object.getOwnPropertyDescriptor(item, 'toJSON') + if (serializer && (!('value' in serializer) || typeof serializer.value === 'function')) + return false + bytes += 2 + ancestors.add(item) + stack.push({ value: item, entries: entries(item), count: 0 }) + } else return false + if (bytes > maxBytes) return false + for (;;) { + const frame = stack[stack.length - 1] + if (!frame) return true + const next = frame.entries.next() + if (next.done) { + ancestors.delete(frame.value) + stack.pop() + continue + } + if (frame.count++ > 0) bytes++ + const [key, child] = next.value + if (key !== null) { + const count = quotedStringBytes(key, maxBytes - bytes) + if (count === undefined) return false + bytes += count + 1 + } + if (bytes > maxBytes) return false + item = child + break + } + } + } catch { + return false + } +} + /** Captures bounded plain JSON once, without executing accessors or serializing the source graph. */ export function stringifyBoundedJson(value: unknown, maxBytes: number): string | undefined { let nodes = 0 diff --git a/apps/sim/lib/sim-search/live/managed-mcp-payload.ts b/apps/sim/lib/sim-search/live/managed-mcp-payload.ts index 5f942b42fc0..e97d98f97d6 100644 --- a/apps/sim/lib/sim-search/live/managed-mcp-payload.ts +++ b/apps/sim/lib/sim-search/live/managed-mcp-payload.ts @@ -1,5 +1,5 @@ import { isRecordLike } from '@sim/utils/object' -import { stringifyBoundedJson } from '@/lib/core/utils/bounded-json' +import { isJsonWithinByteLimit } from '@/lib/core/utils/bounded-json' import type { McpToolResult } from '@/lib/mcp/types' import { NativeSearchError } from '@/lib/sim-search/live/http' @@ -7,7 +7,7 @@ const MAX_SEARCH_MCP_PAYLOAD_BYTES = 4 * 1024 * 1024 /** MCP text is untrusted provider data; malformed structured search output is never an empty success. */ export function managedMcpPayload(result: McpToolResult, label: string): unknown { - if (stringifyBoundedJson(result, MAX_SEARCH_MCP_PAYLOAD_BYTES) === undefined) + if (!isJsonWithinByteLimit(result, MAX_SEARCH_MCP_PAYLOAD_BYTES)) throw new NativeSearchError( 'unavailable', `${label} response exceeded the search size limit. Narrow the query.` diff --git a/apps/sim/lib/sim-search/live/managed-mcp.test.ts b/apps/sim/lib/sim-search/live/managed-mcp.test.ts index ff5e1d23792..a72d87d2c6a 100644 --- a/apps/sim/lib/sim-search/live/managed-mcp.test.ts +++ b/apps/sim/lib/sim-search/live/managed-mcp.test.ts @@ -140,4 +140,17 @@ describe('managed search MCP read boundary', () => { serialize.mockRestore() } }) + + it.each(['wide', 'deep'] as const)( + 'preserves byte-small %s MCP responses without imposing capture limits', + (shape) => { + let content: unknown = shape === 'wide' ? Array.from({ length: 100_000 }, () => 0) : 'leaf' + if (shape === 'deep') { + for (let index = 0; index < 128; index++) content = { child: content } + } + const result = { structuredContent: content } + expect(Buffer.byteLength(JSON.stringify(result), 'utf8')).toBeLessThan(4 * 1024 * 1024) + expect(managedMcpPayload(result, 'Fireflies')).toEqual(content) + } + ) }) From 03bebe154cce4b911c0f1295598029a73159887a Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 30 Sep 2026 18:43:19 -0700 Subject: [PATCH 3/3] fix(search): reject inherited JSON serializers --- apps/sim/lib/core/utils/bounded-json.test.ts | 16 ++++++++++++++++ apps/sim/lib/core/utils/bounded-json.ts | 9 ++++++--- 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/apps/sim/lib/core/utils/bounded-json.test.ts b/apps/sim/lib/core/utils/bounded-json.test.ts index 0f156d907d8..4f43667db81 100644 --- a/apps/sim/lib/core/utils/bounded-json.test.ts +++ b/apps/sim/lib/core/utils/bounded-json.test.ts @@ -103,4 +103,20 @@ describe('bounded JSON', () => { expect(isJsonWithinByteLimit(value, bytes)).toBe(true) expect(isJsonWithinByteLimit(value, bytes - 1)).toBe(false) }) + + it.each(['method', 'accessor'] as const)( + 'rejects an inherited toJSON %s without invoking it or ignoring an own shadow', + (kind) => { + const serialize = vi.fn(() => 'x'.repeat(2048)) + const prototype = Object.create(Array.prototype, { + toJSON: kind === 'method' ? { value: serialize } : { get: serialize }, + }) + const value = Object.setPrototypeOf([], Object.create(prototype)) + expect(isJsonWithinByteLimit(value, 1024)).toBe(false) + expect(serialize).not.toHaveBeenCalled() + Object.defineProperty(value, 'toJSON', { value: null }) + expect(isJsonWithinByteLimit(value, 2)).toBe(true) + expect(serialize).not.toHaveBeenCalled() + } + ) }) diff --git a/apps/sim/lib/core/utils/bounded-json.ts b/apps/sim/lib/core/utils/bounded-json.ts index 4dc75189d4c..c0522d0ddc5 100644 --- a/apps/sim/lib/core/utils/bounded-json.ts +++ b/apps/sim/lib/core/utils/bounded-json.ts @@ -76,9 +76,12 @@ export function isJsonWithinByteLimit(value: unknown, maxBytes: number): boolean const prototype = Object.getPrototypeOf(item) if (!Array.isArray(item) && prototype !== Object.prototype && prototype !== null) return false - const serializer = Object.getOwnPropertyDescriptor(item, 'toJSON') - if (serializer && (!('value' in serializer) || typeof serializer.value === 'function')) - return false + for (let owner: object | null = item; owner; owner = Object.getPrototypeOf(owner)) { + const serializer = Object.getOwnPropertyDescriptor(owner, 'toJSON') + if (!serializer) continue + if (!('value' in serializer) || typeof serializer.value === 'function') return false + break + } bytes += 2 ancestors.add(item) stack.push({ value: item, entries: entries(item), count: 0 })