{"record":{"id":"721021929c0a4d41","repo":"different-ai/openwork","slug":"mcp-tool-discovery","errorCode":"MCP_TOOL_DISCOVERY","errorMessage":"Tool catalog repeated a cursor","messagePattern":"Tool catalog repeated a cursor","errorType":"exception","errorClass":"ProbeFailure","httpStatus":null,"severity":"error","filePath":"packages/enterprise-mcp-mock-server/src/testing/probe.ts","lineNumber":801,"sourceCode":"    }, \"MCP_INITIALIZED\", overallDeadline)\n    await discardResponseBody(initializedResponse, \"MCP_INITIALIZED\", \"mcp_lifecycle\")\n    if (initializedResponse.status === 404) {\n      throw new ProbeFailure(\"CONTINUITY_SESSION\", \"mcp_session_expired\", \"MCP session expired after initialize\")\n    }\n    if (initializedResponse.status !== 202) {\n      throw new ProbeFailure(\"MCP_INITIALIZED\", \"mcp_lifecycle\", `Initialized notification returned HTTP ${initializedResponse.status}`)\n    }\n    recordPassed(phases, \"MCP_INITIALIZED\", startedAt, \"Initialized notification received HTTP 202\")\n\n    startedAt = Date.now()\n    const toolNames = new Set<string>()\n    const discoveredTools: MockTool[] = []\n    const cursors = new Set<string>()\n    let cursor: string | undefined\n    let catalogComplete = false\n    for (let page = 0; page < 25; page += 1) {\n      if (cursor) {\n        if (cursors.has(cursor)) throw new ProbeFailure(\"MCP_TOOL_DISCOVERY\", \"mcp_pagination_loop\", \"Tool catalog repeated a cursor\")\n        cursors.add(cursor)\n      }\n      const listRawResponse = await fetchStep(mcpUrl, {\n          method: \"POST\",\n          headers: sessionHeaders,\n          body: JSON.stringify({ jsonrpc: \"2.0\", id: 10 + page, method: \"tools/list\", params: cursor ? { cursor } : {} }),\n        }, \"MCP_TOOL_DISCOVERY\", overallDeadline)\n      if (listRawResponse.status === 403) {\n        const message = await safeHttpErrorMessage(listRawResponse, \"PROVIDER_AUTHORIZATION\", \"provider_per_user_403\")\n        throw new ProbeFailure(\"PROVIDER_AUTHORIZATION\", \"provider_per_user_403\", `${message} (HTTP 403)`)\n      }\n      const listResponse = await expectOk(listRawResponse, \"MCP_TOOL_DISCOVERY\")\n      const envelope = await parseRpc(listResponse, \"MCP_TOOL_DISCOVERY\")\n      if (envelope.id !== 10 + page) {\n        throw new ProbeFailure(\"MCP_TOOL_DISCOVERY\", \"mcp_tools_discovery\", \"tools/list response JSON-RPC id did not match the request\")\n      }\n      if (envelope.error) throw new ProbeFailure(\"MCP_TOOL_DISCOVERY\", \"mcp_tools_discovery\", envelope.error.message)\n      const result = parseAt(","sourceCodeStart":783,"sourceCodeEnd":819,"githubUrl":"https://github.com/different-ai/openwork/blob/2b7df46e8ae1517d64c896c7793d2d52ec845669/packages/enterprise-mcp-mock-server/src/testing/probe.ts#L783-L819","documentation":"Thrown at the top of the tools/list pagination loop when the cursor about to be sent was already used, i.e. the server handed back a page cursor it previously issued, which would loop forever. The probe keeps a Set of seen cursors and fails fast with an mcp_pagination_loop classification instead of paging indefinitely (the hard cap is 25 pages).","triggerScenarios":"During paginated tools/list discovery, the server's nextCursor from page N equals a cursor already issued on an earlier page, so the probe detects a cycle before sending the next request.","commonSituations":"Server pagination implementation that regenerates cursors deterministically (e.g. cursor always encodes page 0); cursor not advancing after the last full page; server returning the same nextCursor when tool list is unchanged; off-by-one that never advances the offset.","solutions":["Fix the server so each page returns a strictly new nextCursor, omitting it on the final page","Return nextCursor: undefined/null when no more pages remain","Test pagination with a tool count that exceeds one page to confirm cursor advancement"],"exampleFix":"// before (server)\nreturn { tools: page, nextCursor: encode(pageIndex) } // same cursor when page empty\n// after (server)\nreturn hasMore ? { tools: page, nextCursor: encode(pageIndex + 1) } : { tools: page }","handlingStrategy":"validation","validationCode":"const seen = new Set<string>()\nif (cursor && seen.has(cursor)) throw new Error(\"pagination cursor repeated\")\nseen.add(cursor)","typeGuard":"function isFreshCursor(c: string | undefined, seen: Set<string>): c is string {\n  return typeof c === \"string\" && !seen.has(c)\n}","tryCatchPattern":"try {\n  await probeEnterpriseMcpMockServer(scenario)\n} catch (e) {\n  if (e instanceof ProbeFailure && e.code === \"MCP_TOOL_DISCOVERY\") {\n    console.error(\"Tool discovery failed:\", e.message)\n  }\n}","preventionTips":["Omit nextCursor on the final page instead of repeating a cursor","Test pagination with more tools than one page holds","Generate cursors from monotonically advancing offsets"],"tags":["mcp","pagination","tools-list","infinite-loop"],"backgroundTag":"pagination-loop","analyzedSha":"2b7df46e8ae1517d64c896c7793d2d52ec845669","analyzedAt":"2026-09-01T07:59:23.713Z","contentChangedAt":null,"schemaVersion":2},"datasetVersion":"2026-09-08T10:18:20.063Z"}