fix(tools): paginate remote MCP tools/list using nextCursor

Follow MCP specification for cursor-based pagination on tools/list requests.
When an MCP server returns a nextCursor token, iteratively request
subsequent pages using { cursor: nextCursor } up to a 50-page guardrail
and aggregate all discovered tool descriptors.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Jón Levy 2026-09-09 16:32:28 +00:00
parent fac07b42ad
commit ca6fecedee
No known key found for this signature in database
GPG Key ID: 397E4D775F694BF3
2 changed files with 87 additions and 12 deletions

View File

@ -4170,6 +4170,60 @@ describeEmbeddedPostgres("tool access service", () => {
.toBe(requests.filter(({ method }) => method === "initialize").length);
});
it("paginates remote MCP tools/list using nextCursor until all pages are retrieved", async () => {
const company = await createCompany(db);
const service = createTestToolAccessService(db);
const requests: Array<{ method: string; params: Record<string, unknown> }> = [];
vi.stubGlobal("fetch", async (_input: RequestInfo | URL, init?: RequestInit) => {
const payload = JSON.parse(String(init?.body ?? "{}"));
requests.push({ method: payload.method, params: payload.params ?? {} });
if (payload.method === "tools/list") {
if (!payload.params?.cursor) {
return mcpHttpResponse({
jsonrpc: "2.0",
id: payload.id,
result: {
tools: [{ name: "tool_page_1", annotations: { readOnlyHint: true } }],
nextCursor: "cursor-token-page-2",
},
});
}
if (payload.params.cursor === "cursor-token-page-2") {
return mcpHttpResponse({
jsonrpc: "2.0",
id: payload.id,
result: {
tools: [{ name: "tool_page_2", annotations: { readOnlyHint: true } }],
},
});
}
}
return mcpHttpResponse({
jsonrpc: "2.0",
id: payload.id,
result: { tools: [] },
});
});
const result = await service.connectGalleryApp(company.id, {
link: "https://paginated.example/mcp",
name: "Paginated MCP",
}, { actorType: "user", actorId: "board" });
expect(requests).toEqual([
{ method: "tools/list", params: {} },
{ method: "tools/list", params: { cursor: "cursor-token-page-2" } },
{ method: "tools/list", params: {} },
{ method: "tools/list", params: { cursor: "cursor-token-page-2" } },
]);
expect(result.actions.readOnly).toEqual(expect.arrayContaining([
expect.objectContaining({ toolName: "tool_page_1", riskLevel: "read" }),
expect.objectContaining({ toolName: "tool_page_2", riskLevel: "read" }),
]));
});
it("serves persisted MCP actions until the cache expires and then refreshes them", async () => {
const company = await createCompany(db);
let currentTime = new Date("2026-08-20T12:00:00.000Z");

View File

@ -4972,22 +4972,21 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {}
const endpoint = composioSession?.url ?? await resolvedRemoteEndpoint(connection, actor);
// Pinned to the address the guard approved: `config.url` is operator-supplied,
// so a second DNS resolution here would reopen the rebinding window that
// PAP-17098 closed for the OAuth endpoints.
const listRequestBody = JSON.stringify({
jsonrpc: "2.0",
id: "paperclip-catalog-refresh",
method: "tools/list",
params: {},
});
const sendRemote = (init: RequestInit) => requestRemoteHttpEndpoint(new URL(endpoint), init);
const sendToolsList = (requestHeaders: Record<string, string>) => sendRemote({
const sendToolsList = (requestHeaders: Record<string, string>, cursor?: string | null) => sendRemote({
method: "POST",
// MCP Streamable HTTP requires advertising that we accept both a JSON body
// and an SSE stream; spec-compliant servers 406 without it (see mcp-http.ts).
headers: mcpHttpRequestHeaders(requestHeaders),
body: listRequestBody,
body: JSON.stringify({
jsonrpc: "2.0",
id: "paperclip-catalog-refresh",
method: "tools/list",
params: cursor ? { cursor } : {},
}),
});
let usedInitializedSession = connection.config.mcpSessionRequired === true;
let activeHeaders = headers;
let response: Response;
if (usedInitializedSession) {
const sessionHeaders = await initializeMcpHttpSession({
@ -4995,6 +4994,7 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {}
headers,
requestId: "paperclip-catalog-refresh",
});
activeHeaders = sessionHeaders;
response = await sendToolsList(sessionHeaders);
} else {
response = await sendToolsList(headers);
@ -5010,6 +5010,9 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {}
});
response = await sendToolsList(sessionHeaders);
usedInitializedSession = response.ok;
if (response.ok) {
activeHeaders = sessionHeaders;
}
} catch {
// Preserve the original HTTP failure below when this was not an MCP
// session requirement after all.
@ -5055,6 +5058,7 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {}
...projectedConnectionHeaders(connection),
...await resolveVercelCredentialHeaders(connection, grant, { forceRefresh: true }),
};
activeHeaders = headers;
response = await sendToolsList(headers);
}
if (
@ -5066,6 +5070,7 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {}
...projectedConnectionHeaders(connection),
...await resolveCredentialHeaders(connection, actor, { forceRefresh: true }),
};
activeHeaders = headers;
response = await sendToolsList(headers);
if (response.status === 401 && isPaperclipCloudConnectorStrategy(oauthConfig(connection).strategy)) {
const grant = await vaultGrantForConnection(connection, actor);
@ -5126,9 +5131,25 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {}
throw new HttpError(502, `Remote app returned HTTP ${response.status}`, { status: response.status });
}
const payload = parseMcpHttpResponseBody(await response.text(), response.headers.get("content-type"));
const result = asRecord(asRecord(payload).result);
const payloadTools = asRecord(payload).tools;
const tools: unknown[] = Array.isArray(result.tools) ? result.tools : Array.isArray(payloadTools) ? payloadTools : [];
let result = asRecord(asRecord(payload).result);
let payloadTools = asRecord(payload).tools;
const tools: unknown[] = Array.isArray(result.tools) ? [...result.tools] : Array.isArray(payloadTools) ? [...payloadTools] : [];
let nextCursor = typeof result.nextCursor === "string" && result.nextCursor.trim() ? result.nextCursor.trim() : null;
let pageCount = 1;
const maxPages = 50;
while (nextCursor && pageCount < maxPages) {
pageCount++;
const pageResponse = await sendToolsList(activeHeaders, nextCursor);
if (!pageResponse.ok) break;
const pagePayload = parseMcpHttpResponseBody(await pageResponse.text(), pageResponse.headers.get("content-type"));
result = asRecord(asRecord(pagePayload).result);
payloadTools = asRecord(pagePayload).tools;
const pageTools: unknown[] = Array.isArray(result.tools) ? result.tools : Array.isArray(payloadTools) ? payloadTools : [];
tools.push(...pageTools);
nextCursor = typeof result.nextCursor === "string" && result.nextCursor.trim() ? result.nextCursor.trim() : null;
}
return tools.map((tool) => normalizeToolDescriptor(tool)).filter((tool): tool is McpToolDescriptor => Boolean(tool));
}