Skip to content

Commit 61843fe

Browse files
authored
improvement(search): return a longer live read window when an MCP client asks for more chunks (#9823)
* improvement(search): return a longer live read window when an MCP client asks for more chunks * improvement(search): share one character budget across the documents of a scaled read call * docs(search): say where the scaled read budget stops bounding a call's size * improvement(search): size a scaled read window by its JSON-encoded length
1 parent 8d459fd commit 61843fe

4 files changed

Lines changed: 145 additions & 3 deletions

File tree

‎apps/sim/lib/knowledge/mcp/server.protocol.test.ts‎

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,11 +93,27 @@ describe('Search MCP protocol', () => {
9393
expect(JSON.parse(searchContent.text)).toMatchObject({
9494
results: [{ documentId, queryIndexes: [0] }],
9595
})
96-
const read = await client.callTool({ name: 'read_document', arguments: { documentId } })
96+
const read = await client.callTool({
97+
name: 'read_document',
98+
arguments: { documentId, limit: 8 },
99+
})
97100
expect(read.isError).not.toBe(true)
101+
expect(mocks.liveRead.mock.lastCall?.[0].input).toMatchObject({
102+
limit: 8,
103+
scaledWindow: { documentsInCall: 1 },
104+
})
98105
expect(read.content).toEqual([
99106
{ type: 'text', text: expect.stringContaining('Read evidence') },
100107
])
108+
const batchRead = await client.callTool({
109+
name: 'read_document',
110+
arguments: { documentIds: [documentId, `${documentId}b`], limit: 8 },
111+
})
112+
expect(batchRead.isError).not.toBe(true)
113+
expect(mocks.liveRead.mock.lastCall?.[0].input).toMatchObject({
114+
limit: 8,
115+
scaledWindow: { documentsInCall: 2 },
116+
})
101117
const offsetless = await client.callTool({
102118
name: 'search',
103119
arguments: {

‎apps/sim/lib/knowledge/mcp/server.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -298,6 +298,7 @@ export function createKnowledgeMcpServer(context: KnowledgeMcpContext): McpServe
298298
...options,
299299
documentId: id,
300300
organizationId,
301+
scaledWindow: { documentsInCall: documentId ? 1 : batch.length },
301302
resultSecretRegistry: registry,
302303
signal,
303304
},

‎apps/sim/lib/sim-search/live/application.test.ts‎

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1189,6 +1189,89 @@ describe('authorized live retrieval', () => {
11891189
})
11901190
).rejects.toBe('user_stop:test')
11911191
})
1192+
describe('read window', () => {
1193+
const content = 'x'.repeat(30_000)
1194+
async function readWindow(extra: Record<string, unknown>, text = content) {
1195+
const [result] = (await searchLiveKnowledge.execute({ principal, input })).results
1196+
if (!result) throw new Error('Expected the search fixture to return a result')
1197+
mocks.read.mockResolvedValue({ ...document, content: text })
1198+
const read = await readLiveDocument.execute({
1199+
principal,
1200+
input: {
1201+
workspaceId: 'workspace',
1202+
documentId: result.documentId,
1203+
limit: 3,
1204+
resultSecretRegistry: new ResolvedSecretTraceRegistry([]),
1205+
...extra,
1206+
},
1207+
})
1208+
const [chunk] = read.chunks
1209+
if (!chunk) throw new Error('Expected the read to return a window')
1210+
return {
1211+
length: chunk.content.length,
1212+
encoded: JSON.stringify(chunk.content).length - 2,
1213+
next: read.next,
1214+
}
1215+
}
1216+
1217+
const scaled = (documentsInCall: number) => ({ scaledWindow: { documentsInCall } })
1218+
1219+
it('returns a proportionally longer window when a scaling caller asks for more chunks', async () => {
1220+
const { length, next } = await readWindow({ limit: 8, ...scaled(1) })
1221+
expect(length).toBe(21_333)
1222+
expect(next).toEqual({ startChunkIndex: 0, startOffset: 21_333 })
1223+
})
1224+
1225+
it('continues a longer window from where the previous one ended', async () => {
1226+
const { length, next } = await readWindow({ limit: 8, ...scaled(1), startOffset: 21_333 })
1227+
expect(length).toBe(30_000 - 21_333)
1228+
expect(next).toBeNull()
1229+
})
1230+
1231+
it.each([
1232+
[5, 1, 13_333],
1233+
[8, 2, 20_000],
1234+
[8, 3, 13_333],
1235+
[8, 4, 10_000],
1236+
])(
1237+
'shares one call budget: limit %i across %i documents reads %i characters each',
1238+
async (limit, documentsInCall, expected) => {
1239+
expect((await readWindow({ limit, ...scaled(documentsInCall) })).length).toBe(expected)
1240+
}
1241+
)
1242+
1243+
it.each([
1244+
[1, 21_333],
1245+
[2, 20_000],
1246+
])(
1247+
'keeps a quote-heavy window within its share once JSON-encoded, across %i documents',
1248+
async (documentsInCall, share) => {
1249+
const quotes = '"'.repeat(30_000)
1250+
const { length, encoded, next } = await readWindow(
1251+
{ limit: 8, ...scaled(documentsInCall) },
1252+
quotes
1253+
)
1254+
expect(encoded).toBeLessThanOrEqual(share)
1255+
expect(length).toBeGreaterThanOrEqual(8_000)
1256+
expect(next).toEqual({ startChunkIndex: 0, startOffset: length })
1257+
}
1258+
)
1259+
1260+
it('keeps the standard window for quote-heavy text at the default limit', async () => {
1261+
const { length } = await readWindow({ limit: 3, ...scaled(1) }, '"'.repeat(30_000))
1262+
expect(length).toBe(8_000)
1263+
})
1264+
1265+
it.each([
1266+
['at the default limit', { limit: 3, ...scaled(1) }],
1267+
['below the default limit', { limit: 1, ...scaled(1) }],
1268+
['when the call budget is spread over five documents', { limit: 8, ...scaled(5) }],
1269+
['when the call budget is spread over ten documents', { limit: 8, ...scaled(10) }],
1270+
['for a caller that does not scale', { limit: 8 }],
1271+
])('keeps the standard window %s', async (_, extra) => {
1272+
expect((await readWindow(extra)).length).toBe(8_000)
1273+
})
1274+
})
11921275
it('refuses a chunk index other than zero before reading the document from its provider', async () => {
11931276
const [result] = (await searchLiveKnowledge.execute({ principal, input })).results
11941277
if (!result) throw new Error('Expected the search fixture to return a result')

‎apps/sim/lib/sim-search/live/application.ts‎

Lines changed: 44 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -287,8 +287,45 @@ export const LIVE_NOT_STARTED_MESSAGE =
287287
export const LIVE_OVER_ACCOUNT_LIMIT_MESSAGE = `Not searched: one search covers at most ${MAX_ACCOUNTS} accounts. Search this source or account alone.`
288288
/** Previews center on the query's longest matching term, like indexed passages. */
289289
const PREVIEW_CHARACTERS = 1800
290-
/** Characters one read returns, the same page budget indexed document reads use. */
290+
/** Characters one read returns at the default limit, the page budget indexed document reads use. */
291291
const READ_WINDOW_CHARACTERS = 8000
292+
/** The read limit {@link READ_WINDOW_CHARACTERS} is sized for, which is the default limit. */
293+
const READ_WINDOW_LIMIT = 3
294+
/**
295+
* Characters the documents of one scaled read call share. Through four documents it keeps the
296+
* call's result, with about a thousand characters of metadata per document, under the
297+
* 50,000-character size at which MCP clients such as Claude Code save a tool result to a file
298+
* rather than show it. From five documents the standard page per document is the larger, so
299+
* those calls read exactly what they read without scaling.
300+
*/
301+
const SCALED_READ_CALL_CHARACTERS = 40_000
302+
303+
/**
304+
* Where one read's window ends. A live document is one chunk, so a caller that scales the window
305+
* gets a longer one when it asks for more chunks than the default, bounded by an even share of the
306+
* call's budget across the documents it reads. The share counts the window as the result encodes
307+
* it, since JSON escapes quotes, backslashes and line breaks, so a longer window is shortened until
308+
* its encoded text fits. The window is never shorter than the standard page, which is also what a
309+
* read at or below the default limit, or from any other caller, returns.
310+
*/
311+
function readWindowEnd(
312+
content: string,
313+
start: number,
314+
{ limit, scaledWindow }: Pick<LiveReadInput, 'limit' | 'scaledWindow'>
315+
): number {
316+
const standardEnd = Math.min(content.length, start + READ_WINDOW_CHARACTERS)
317+
if (!scaledWindow || limit <= READ_WINDOW_LIMIT) return standardEnd
318+
const scaled = Math.floor((READ_WINDOW_CHARACTERS * limit) / READ_WINDOW_LIMIT)
319+
const share = Math.floor(SCALED_READ_CALL_CHARACTERS / Math.max(1, scaledWindow.documentsInCall))
320+
const budget = Math.min(scaled, share)
321+
let end = Math.min(content.length, start + budget)
322+
for (let attempt = 0; attempt < 3 && end > standardEnd; attempt++) {
323+
const encoded = JSON.stringify(content.slice(start, end)).length - 2
324+
if (encoded <= budget) return end
325+
end = start + Math.floor(((end - start) * budget) / encoded)
326+
}
327+
return standardEnd
328+
}
292329

293330
/**
294331
* Verifies candidates in rank order. A revoked grant fails the whole account; any other
@@ -980,6 +1017,11 @@ export type LiveReadInput = ResourceOwner & {
9801017
documentId: string
9811018
filters?: WorkspaceSearchFilters
9821019
limit: number
1020+
/**
1021+
* Lengthens the window for a limit above the default, sharing one call's budget across the
1022+
* documents it reads, as {@link readWindowEnd} sizes it.
1023+
*/
1024+
scaledWindow?: { documentsInCall: number }
9831025
startChunkIndex?: number
9841026
startOffset?: number
9851027
resultSecretRegistry: ResolvedSecretTraceRegistry
@@ -1125,7 +1167,7 @@ export const readLiveDocument = defineAuthorizedKnowledgeUseCase({
11251167
'validation',
11261168
'This document changed. Read again from the beginning'
11271169
)
1128-
const end = Math.min(content.length, start + READ_WINDOW_CHARACTERS)
1170+
const end = readWindowEnd(content, start, input)
11291171
return {
11301172
documentId: input.documentId,
11311173
knowledgeBaseId: '',

0 commit comments

Comments
 (0)