Skip to content

Commit 943d184

Browse files
authored
fix(mcp): follow tools/list pagination instead of silently truncating (#5833)
* fix(mcp): follow tools/list pagination instead of silently truncating The SDK's listTools() returns a single page; a server that paginates via nextCursor was silently truncated to page one. Follow the cursor bounded by four independent budgets (50 pages / 1000 tools / 5 MB / 60s aggregate wall-clock) plus a repeated-cursor guard — a page cap alone can't stop a server returning a fresh cursor with no new tools. Partial results from earlier pages are kept when a later page fails; only a page-one failure throws. Matches the LibreChat capped-cursor-loop pattern; caps live in MCP_CLIENT_CONSTANTS. * fix(mcp): count UTF-8 bytes, keep empty partials, and log page-cap accurately Review fixes on the tools/list pagination loop: - Buffer.byteLength(..., 'utf8') instead of .length so non-ASCII schemas can't overshoot the 5 MB budget by counting UTF-16 code units. - Track pagesFetched (not tools.length) for the partial-success decision, so a valid-but-empty first page followed by a failing page returns [] instead of throwing and marking the server unhealthy. - Explicit reachedEnd/page-cap distinction so a natural finish on exactly MAX_PAGES isn't mislogged as truncated.
1 parent 64353f2 commit 943d184

3 files changed

Lines changed: 173 additions & 24 deletions

File tree

apps/sim/lib/mcp/client.test.ts

Lines changed: 76 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -177,7 +177,7 @@ describe('McpClient notification handler', () => {
177177
undefined,
178178
expect.objectContaining({
179179
timeout: 30_000,
180-
maxTotalTimeout: 60_000,
180+
maxTotalTimeout: expect.any(Number),
181181
resetTimeoutOnProgress: true,
182182
onprogress: expect.any(Function),
183183
})
@@ -196,10 +196,84 @@ describe('McpClient notification handler', () => {
196196

197197
expect(mockSdkListTools).toHaveBeenCalledWith(
198198
undefined,
199-
expect.objectContaining({ timeout: 60_000, maxTotalTimeout: 60_000 })
199+
expect.objectContaining({ timeout: 60_000, maxTotalTimeout: expect.any(Number) })
200200
)
201201
})
202202

203+
it('follows nextCursor pagination and aggregates all pages', async () => {
204+
mockSdkListTools
205+
.mockResolvedValueOnce({ tools: [{ name: 'a' }, { name: 'b' }], nextCursor: 'c1' })
206+
.mockResolvedValueOnce({ tools: [{ name: 'c' }], nextCursor: 'c2' })
207+
.mockResolvedValueOnce({ tools: [{ name: 'd' }] })
208+
const client = new McpClient({
209+
config: createConfig(),
210+
securityPolicy: { requireConsent: false, auditLevel: 'basic' },
211+
})
212+
213+
await client.connect()
214+
const tools = await client.listTools()
215+
216+
expect(tools.map((t) => t.name)).toEqual(['a', 'b', 'c', 'd'])
217+
expect(mockSdkListTools).toHaveBeenNthCalledWith(2, { cursor: 'c1' }, expect.anything())
218+
expect(mockSdkListTools).toHaveBeenNthCalledWith(3, { cursor: 'c2' }, expect.anything())
219+
})
220+
221+
it('stops paginating when the server repeats a cursor (loop guard)', async () => {
222+
mockSdkListTools.mockResolvedValue({ tools: [{ name: 'x' }], nextCursor: 'same' })
223+
const client = new McpClient({
224+
config: createConfig(),
225+
securityPolicy: { requireConsent: false, auditLevel: 'basic' },
226+
})
227+
228+
await client.connect()
229+
const tools = await client.listTools()
230+
231+
// Page 1 sets cursor 'same'; page 2 returns 'same' again → guard stops. Not 50 pages.
232+
expect(mockSdkListTools).toHaveBeenCalledTimes(2)
233+
expect(tools).toHaveLength(2)
234+
})
235+
236+
it('returns partial tools when a later page fails', async () => {
237+
mockSdkListTools
238+
.mockResolvedValueOnce({ tools: [{ name: 'a' }], nextCursor: 'c1' })
239+
.mockRejectedValueOnce(new Error('page 2 blew up'))
240+
const client = new McpClient({
241+
config: createConfig(),
242+
securityPolicy: { requireConsent: false, auditLevel: 'basic' },
243+
})
244+
245+
await client.connect()
246+
const tools = await client.listTools()
247+
248+
expect(tools.map((t) => t.name)).toEqual(['a'])
249+
})
250+
251+
it('keeps an empty partial (does not throw) when page one succeeds but a later page fails', async () => {
252+
// Page one is valid but empty with a cursor; page two fails. Page one succeeded, so
253+
// discovery must not fail the server — it returns [] rather than throwing.
254+
mockSdkListTools
255+
.mockResolvedValueOnce({ tools: [], nextCursor: 'c1' })
256+
.mockRejectedValueOnce(new Error('page 2 blew up'))
257+
const client = new McpClient({
258+
config: createConfig(),
259+
securityPolicy: { requireConsent: false, auditLevel: 'basic' },
260+
})
261+
262+
await client.connect()
263+
await expect(client.listTools()).resolves.toEqual([])
264+
})
265+
266+
it('throws when the very first page fails', async () => {
267+
mockSdkListTools.mockRejectedValueOnce(new Error('page 1 blew up'))
268+
const client = new McpClient({
269+
config: createConfig(),
270+
securityPolicy: { requireConsent: false, auditLevel: 'basic' },
271+
})
272+
273+
await client.connect()
274+
await expect(client.listTools()).rejects.toThrow()
275+
})
276+
203277
it('logs connection diagnostics without header values', async () => {
204278
const client = new McpClient({
205279
config: {

apps/sim/lib/mcp/client.ts

Lines changed: 91 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@ import {
55
LATEST_PROTOCOL_VERSION,
66
type ListToolsResult,
77
SUPPORTED_PROTOCOL_VERSIONS,
8-
type Tool,
98
ToolListChangedNotificationSchema,
109
} from '@modelcontextprotocol/sdk/types.js'
1110
import { createLogger } from '@sim/logger'
@@ -275,43 +274,113 @@ export class McpClient {
275274
const maxTotalTimeoutMs = MCP_CLIENT_CONSTANTS.LIST_TOOLS_MAX_TOTAL_TIMEOUT_MS
276275
const startedAt = Date.now()
277276

277+
// The SDK's `listTools()` returns a single page; a server that paginates via
278+
// `nextCursor` would otherwise be silently truncated to page one. Follow the
279+
// cursor, bounded by four independent budgets — pages, tool count, byte size,
280+
// and aggregate wall-clock — plus a repeated-cursor guard, since a page cap
281+
// alone can't stop a server that returns a fresh cursor with no new tools.
282+
const deadline = startedAt + maxTotalTimeoutMs
283+
const maxPages = MCP_CLIENT_CONSTANTS.LIST_TOOLS_MAX_PAGES
284+
const tools: McpTool[] = []
285+
const seenCursors = new Set<string>()
286+
let cursor: string | undefined
287+
let bytes = 0
288+
let pagesFetched = 0
289+
let truncated: string | undefined
290+
let reachedEnd = false
291+
278292
try {
279-
const result: ListToolsResult = await this.client.listTools(undefined, {
280-
// resetTimeoutOnProgress only takes effect when onprogress is supplied.
281-
timeout: idleTimeoutMs,
282-
maxTotalTimeout: maxTotalTimeoutMs,
283-
resetTimeoutOnProgress: true,
284-
onprogress: (progress) => {
285-
logger.debug(`Tool discovery progress from ${this.config.name}`, {
293+
for (let page = 0; page < maxPages; page++) {
294+
const remainingMs = deadline - Date.now()
295+
if (remainingMs <= 0) {
296+
truncated = 'aggregate timeout'
297+
break
298+
}
299+
const result: ListToolsResult = await this.client.listTools(
300+
cursor ? { cursor } : undefined,
301+
{
302+
// resetTimeoutOnProgress only takes effect when onprogress is supplied.
303+
timeout: Math.min(idleTimeoutMs, remainingMs),
304+
maxTotalTimeout: remainingMs,
305+
resetTimeoutOnProgress: true,
306+
onprogress: (progress) => {
307+
logger.debug(`Tool discovery progress from ${this.config.name}`, {
308+
serverId: this.config.id,
309+
progress: progress.progress,
310+
total: progress.total,
311+
})
312+
},
313+
}
314+
)
315+
pagesFetched++
316+
317+
if (!result.tools || !Array.isArray(result.tools)) {
318+
logger.warn(`Invalid tools response from server ${this.config.name}:`, result)
319+
reachedEnd = true
320+
break
321+
}
322+
323+
for (const tool of result.tools) {
324+
if (tools.length >= MCP_CLIENT_CONSTANTS.LIST_TOOLS_MAX_TOOLS) {
325+
truncated = 'tool count'
326+
break
327+
}
328+
bytes += Buffer.byteLength(JSON.stringify(tool), 'utf8')
329+
if (bytes > MCP_CLIENT_CONSTANTS.LIST_TOOLS_MAX_BYTES) {
330+
truncated = 'byte size'
331+
break
332+
}
333+
tools.push({
334+
name: tool.name,
335+
description: tool.description,
336+
inputSchema: tool.inputSchema as McpTool['inputSchema'],
286337
serverId: this.config.id,
287-
progress: progress.progress,
288-
total: progress.total,
338+
serverName: this.config.name,
289339
})
290-
},
291-
})
340+
}
341+
if (truncated) break
342+
343+
const next = result.nextCursor
344+
if (!next) {
345+
reachedEnd = true
346+
break // missing/empty cursor = end of results (spec)
347+
}
348+
if (seenCursors.has(next)) {
349+
truncated = 'repeated cursor'
350+
break
351+
}
352+
seenCursors.add(next)
353+
cursor = next
354+
}
292355

293-
if (!result.tools || !Array.isArray(result.tools)) {
294-
logger.warn(`Invalid tools response from server ${this.config.name}:`, result)
295-
return []
356+
// The loop can exhaust `maxPages` with a cursor still pending — that's a page-cap
357+
// truncation, distinct from a natural end (`reachedEnd`) or an explicit budget hit.
358+
if (!truncated && !reachedEnd) truncated = 'page cap'
359+
if (truncated) {
360+
logger.warn(`Tool discovery truncated for server ${this.config.name}`, {
361+
serverId: this.config.id,
362+
reason: truncated,
363+
toolsCollected: tools.length,
364+
pagesFetched,
365+
})
296366
}
297367

298-
return result.tools.map((tool: Tool) => ({
299-
name: tool.name,
300-
description: tool.description,
301-
inputSchema: tool.inputSchema as McpTool['inputSchema'],
302-
serverId: this.config.id,
303-
serverName: this.config.name,
304-
}))
368+
return tools
305369
} catch (error) {
306370
logger.error(`Failed to list tools from server ${this.config.name}`, {
307371
serverId: this.config.id,
308372
phase: 'tools/list',
309373
durationMs: Date.now() - startedAt,
310374
idleTimeoutMs,
311375
maxTotalTimeoutMs,
376+
pagesFetched,
377+
toolsCollected: tools.length,
312378
sessionIdPresent: Boolean(this.transport.sessionId),
313379
error: getMcpSafeErrorDiagnostics(error),
314380
})
381+
// At least one page succeeded → keep its (possibly empty) partial result rather than
382+
// failing discovery and marking the server unhealthy; only a page-one failure throws.
383+
if (pagesFetched > 0) return tools
315384
throw error
316385
}
317386
}

apps/sim/lib/mcp/utils.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,12 @@ export const MCP_CLIENT_CONSTANTS = {
5555
LIST_TOOLS_TIMEOUT_MS: 30_000,
5656
/** Hard ceiling for tools/list regardless of progress (SDK maxTotalTimeout safeguard). */
5757
LIST_TOOLS_MAX_TOTAL_TIMEOUT_MS: 60_000,
58+
/** Max `tools/list` pages followed via `nextCursor` before truncating (see fetch loop). */
59+
LIST_TOOLS_MAX_PAGES: 50,
60+
/** Max tools aggregated across all pages before truncating. */
61+
LIST_TOOLS_MAX_TOOLS: 1000,
62+
/** Max total tool-payload bytes aggregated across all pages before truncating. */
63+
LIST_TOOLS_MAX_BYTES: 5 * 1024 * 1024,
5864
FAILURE_CACHE_TTL_MS: 120_000,
5965
} as const
6066

0 commit comments

Comments
 (0)