Skip to content

Commit 07b155c

Browse files
committed
fix(search): preserve byte-only MCP response limits
1 parent 96b3d0b commit 07b155c

4 files changed

Lines changed: 121 additions & 3 deletions

File tree

‎apps/sim/lib/core/utils/bounded-json.test.ts‎

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, expect, it, vi } from 'vitest'
2-
import { stringifyBoundedJson } from '@/lib/core/utils/bounded-json'
2+
import { isJsonWithinByteLimit, stringifyBoundedJson } from '@/lib/core/utils/bounded-json'
33

44
describe('bounded JSON', () => {
55
it.each([
@@ -13,11 +13,14 @@ describe('bounded JSON', () => {
1313
const bytes = Buffer.byteLength(json, 'utf8')
1414
expect(stringifyBoundedJson(value, bytes)).toBe(json)
1515
expect(stringifyBoundedJson(value, bytes - 1)).toBeUndefined()
16+
expect(isJsonWithinByteLimit(value, bytes)).toBe(true)
17+
expect(isJsonWithinByteLimit(value, bytes - 1)).toBe(false)
1618
})
1719

1820
it('rejects cycles, excessive depth, and excessive nodes', () => {
1921
const cyclic: Record<string, unknown> = {}
2022
cyclic.self = cyclic
23+
expect(isJsonWithinByteLimit(cyclic, 1024)).toBe(false)
2124
let deep: unknown = 'leaf'
2225
for (let index = 0; index < 66; index++) deep = { child: deep }
2326
for (const value of [
@@ -37,6 +40,7 @@ describe('bounded JSON', () => {
3740
const custom = Object.defineProperty({}, 'toJSON', { value: toJSON })
3841
for (const value of [accessor, custom, { output: new Uint8Array([1, 2, 3]) }]) {
3942
expect(stringifyBoundedJson(value, 1024)).toBeUndefined()
43+
expect(isJsonWithinByteLimit(value, 1024)).toBe(false)
4044
}
4145
expect(getter).not.toHaveBeenCalled()
4246
expect(toJSON).not.toHaveBeenCalled()
@@ -47,6 +51,7 @@ describe('bounded JSON', () => {
4751
const serialize = vi.spyOn(JSON, 'stringify')
4852
try {
4953
expect(stringifyBoundedJson(value, 1024)).toBeUndefined()
54+
expect(isJsonWithinByteLimit(value, 1024)).toBe(false)
5055
expect(serialize).not.toHaveBeenCalled()
5156
} finally {
5257
serialize.mockRestore()
@@ -61,6 +66,7 @@ describe('bounded JSON', () => {
6166
const serialize = vi.spyOn(JSON, 'stringify')
6267
try {
6368
expect(stringifyBoundedJson(value, 1024)).toBeUndefined()
69+
expect(isJsonWithinByteLimit(value, 1024)).toBe(false)
6470
expect(serialize).not.toHaveBeenCalled()
6571
} finally {
6672
serialize.mockRestore()
@@ -71,6 +77,7 @@ describe('bounded JSON', () => {
7177
const get = vi.fn(() => 'UNADMITTED')
7278
const value = new Proxy({ text: 'admitted' }, { get })
7379
expect(stringifyBoundedJson(value, 1024)).toBe('{"text":"admitted"}')
80+
expect(isJsonWithinByteLimit(value, 19)).toBe(true)
7481
expect(get).not.toHaveBeenCalled()
7582
})
7683

@@ -79,12 +86,21 @@ describe('bounded JSON', () => {
7986
const prototype = Object.create(Array.prototype, { 0: { get } })
8087
const value = Object.setPrototypeOf(Array(1), prototype)
8188
expect(stringifyBoundedJson(value, 1024)).toBe('[null]')
89+
expect(isJsonWithinByteLimit(value, 6)).toBe(true)
8290
expect(get).not.toHaveBeenCalled()
8391
})
8492

8593
it('allows repeated references without treating them as a cycle', () => {
8694
const result = { answer: 42 }
8795
const value = { rawResponse: result, modelResponse: result }
8896
expect(stringifyBoundedJson(value, 1024)).toBe(JSON.stringify(value))
97+
expect(isJsonWithinByteLimit(value, Buffer.byteLength(JSON.stringify(value)))).toBe(true)
98+
})
99+
100+
it('counts an ordinary toJSON data field without invoking custom serialization', () => {
101+
const value = { toJSON: 'ordinary JSON field', nested: [{}, [], { flag: true }] }
102+
const bytes = Buffer.byteLength(JSON.stringify(value))
103+
expect(isJsonWithinByteLimit(value, bytes)).toBe(true)
104+
expect(isJsonWithinByteLimit(value, bytes - 1)).toBe(false)
89105
})
90106
})

‎apps/sim/lib/core/utils/bounded-json.ts‎

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,95 @@ function quotedStringBytes(value: string, remaining: number): number | undefined
2121
return bytes <= remaining ? bytes : undefined
2222
}
2323

24+
/** Measures plain JSON iteratively without serialization, copying, or additional node/depth caps. */
25+
export function isJsonWithinByteLimit(value: unknown, maxBytes: number): boolean {
26+
if (!Number.isFinite(maxBytes) || maxBytes < 0 || value === undefined) return false
27+
const invalid = Symbol('invalid JSON')
28+
const ancestors = new WeakSet<object>()
29+
const stack: {
30+
value: object
31+
entries: Generator<[string | null, unknown]>
32+
count: number
33+
}[] = []
34+
let bytes = 0
35+
const omitted = (item: unknown) =>
36+
item === undefined || typeof item === 'function' || typeof item === 'symbol'
37+
function* entries(container: object): Generator<[string | null, unknown]> {
38+
if (Array.isArray(container)) {
39+
const length = Object.getOwnPropertyDescriptor(container, 'length')?.value
40+
if (typeof length !== 'number') {
41+
yield [null, invalid]
42+
return
43+
}
44+
for (let index = 0; index < length; index++) {
45+
const field = Object.getOwnPropertyDescriptor(container, index)
46+
if (field && !('value' in field)) {
47+
yield [null, invalid]
48+
return
49+
}
50+
yield [null, omitted(field?.value) ? null : field?.value]
51+
}
52+
} else {
53+
for (const key in container) {
54+
const field = Object.getOwnPropertyDescriptor(container, key)
55+
if (!field?.enumerable) continue
56+
if (!('value' in field)) {
57+
yield [key, invalid]
58+
return
59+
}
60+
if (!omitted(field.value)) yield [key, field.value]
61+
}
62+
}
63+
}
64+
try {
65+
let item: unknown = value
66+
for (;;) {
67+
if (typeof item === 'string') {
68+
const count = quotedStringBytes(item, maxBytes - bytes)
69+
if (count === undefined) return false
70+
bytes += count
71+
} else if (item === null) bytes += 4
72+
else if (typeof item === 'number') bytes += Number.isFinite(item) ? String(item).length : 4
73+
else if (typeof item === 'boolean') bytes += item ? 4 : 5
74+
else if (typeof item === 'object') {
75+
if (ancestors.has(item)) return false
76+
const prototype = Object.getPrototypeOf(item)
77+
if (!Array.isArray(item) && prototype !== Object.prototype && prototype !== null)
78+
return false
79+
const serializer = Object.getOwnPropertyDescriptor(item, 'toJSON')
80+
if (serializer && (!('value' in serializer) || typeof serializer.value === 'function'))
81+
return false
82+
bytes += 2
83+
ancestors.add(item)
84+
stack.push({ value: item, entries: entries(item), count: 0 })
85+
} else return false
86+
if (bytes > maxBytes) return false
87+
for (;;) {
88+
const frame = stack[stack.length - 1]
89+
if (!frame) return true
90+
const next = frame.entries.next()
91+
if (next.done) {
92+
ancestors.delete(frame.value)
93+
stack.pop()
94+
continue
95+
}
96+
if (frame.count++ > 0) bytes++
97+
const [key, child] = next.value
98+
if (key !== null) {
99+
const count = quotedStringBytes(key, maxBytes - bytes)
100+
if (count === undefined) return false
101+
bytes += count + 1
102+
}
103+
if (bytes > maxBytes) return false
104+
item = child
105+
break
106+
}
107+
}
108+
} catch {
109+
return false
110+
}
111+
}
112+
24113
/** Captures bounded plain JSON once, without executing accessors or serializing the source graph. */
25114
export function stringifyBoundedJson(value: unknown, maxBytes: number): string | undefined {
26115
let nodes = 0

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

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

66
const MAX_SEARCH_MCP_PAYLOAD_BYTES = 4 * 1024 * 1024
77

88
/** MCP text is untrusted provider data; malformed structured search output is never an empty success. */
99
export function managedMcpPayload(result: McpToolResult, label: string): unknown {
10-
if (stringifyBoundedJson(result, MAX_SEARCH_MCP_PAYLOAD_BYTES) === undefined)
10+
if (!isJsonWithinByteLimit(result, MAX_SEARCH_MCP_PAYLOAD_BYTES))
1111
throw new NativeSearchError(
1212
'unavailable',
1313
`${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
@@ -140,4 +140,17 @@ describe('managed search MCP read boundary', () => {
140140
serialize.mockRestore()
141141
}
142142
})
143+
144+
it.each(['wide', 'deep'] as const)(
145+
'preserves byte-small %s MCP responses without imposing capture limits',
146+
(shape) => {
147+
let content: unknown = shape === 'wide' ? Array.from({ length: 100_000 }, () => 0) : 'leaf'
148+
if (shape === 'deep') {
149+
for (let index = 0; index < 128; index++) content = { child: content }
150+
}
151+
const result = { structuredContent: content }
152+
expect(Buffer.byteLength(JSON.stringify(result), 'utf8')).toBeLessThan(4 * 1024 * 1024)
153+
expect(managedMcpPayload(result, 'Fireflies')).toEqual(content)
154+
}
155+
)
143156
})

0 commit comments

Comments
 (0)