Merge 3e243efac2 into c9e3bb7ca4
This commit is contained in:
commit
076a1afc19
|
|
@ -5605,6 +5605,214 @@ 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("fails catalog refresh if remote MCP tools/list pagination encounters an HTTP error on later pages", async () => {
|
||||
const company = await createCompany(db);
|
||||
const service = createTestToolAccessService(db);
|
||||
|
||||
vi.stubGlobal("fetch", async (_input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const payload = JSON.parse(String(init?.body ?? "{}"));
|
||||
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",
|
||||
},
|
||||
});
|
||||
}
|
||||
return new Response("Internal Server Error", { status: 500, statusText: "Internal Server Error" });
|
||||
}
|
||||
return mcpHttpResponse({ jsonrpc: "2.0", id: payload.id, result: { tools: [] } });
|
||||
});
|
||||
|
||||
await expect(service.connectGalleryApp(company.id, {
|
||||
link: "https://error-paginated.example/mcp",
|
||||
name: "Error Paginated MCP",
|
||||
}, { actorType: "user", actorId: "board" })).rejects.toMatchObject({
|
||||
status: 502,
|
||||
});
|
||||
});
|
||||
|
||||
it("fails catalog refresh if remote MCP tools/list pagination exceeds the maximum page limit while nextCursor remains", async () => {
|
||||
const company = await createCompany(db);
|
||||
const service = createTestToolAccessService(db);
|
||||
let page = 0;
|
||||
|
||||
vi.stubGlobal("fetch", async (_input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const payload = JSON.parse(String(init?.body ?? "{}"));
|
||||
if (payload.method === "tools/list") {
|
||||
page++;
|
||||
return mcpHttpResponse({
|
||||
jsonrpc: "2.0",
|
||||
id: payload.id,
|
||||
result: {
|
||||
tools: [{ name: `tool_page_${page}`, annotations: { readOnlyHint: true } }],
|
||||
nextCursor: `cursor-token-page-${page + 1}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
return mcpHttpResponse({ jsonrpc: "2.0", id: payload.id, result: { tools: [] } });
|
||||
});
|
||||
|
||||
await expect(service.connectGalleryApp(company.id, {
|
||||
link: "https://infinite-paginated.example/mcp",
|
||||
name: "Infinite Paginated MCP",
|
||||
}, { actorType: "user", actorId: "board" })).rejects.toMatchObject({
|
||||
status: 502,
|
||||
});
|
||||
});
|
||||
|
||||
it("fails catalog refresh if remote MCP tools/list returns cyclic pagination cursor", async () => {
|
||||
const company = await createCompany(db);
|
||||
const service = createTestToolAccessService(db);
|
||||
|
||||
vi.stubGlobal("fetch", async (_input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const payload = JSON.parse(String(init?.body ?? "{}"));
|
||||
if (payload.method === "tools/list") {
|
||||
return mcpHttpResponse({
|
||||
jsonrpc: "2.0",
|
||||
id: payload.id,
|
||||
result: {
|
||||
tools: [{ name: "tool_loop", annotations: { readOnlyHint: true } }],
|
||||
nextCursor: "same-cursor-cycle",
|
||||
},
|
||||
});
|
||||
}
|
||||
return mcpHttpResponse({ jsonrpc: "2.0", id: payload.id, result: { tools: [] } });
|
||||
});
|
||||
|
||||
await expect(service.connectGalleryApp(company.id, {
|
||||
link: "https://cyclic-paginated.example/mcp",
|
||||
name: "Cyclic Paginated MCP",
|
||||
}, { actorType: "user", actorId: "board" })).rejects.toMatchObject({
|
||||
status: 502,
|
||||
});
|
||||
});
|
||||
|
||||
it("uses refreshed Composio endpoint and headers on subsequent paginated pages after initial 401", async () => {
|
||||
const company = await createCompany(db);
|
||||
const { child } = await createComposioParentAndChild(db, company.id);
|
||||
let sessionCount = 0;
|
||||
const client = fakeComposioClient(() => "ACTIVE");
|
||||
client.createSession = vi.fn(async () => {
|
||||
sessionCount++;
|
||||
return {
|
||||
session_id: `session-${sessionCount}`,
|
||||
mcp: {
|
||||
url: `https://composio.test/mcp-${sessionCount}`,
|
||||
headers: { "x-composio-token": `token-${sessionCount}` },
|
||||
},
|
||||
};
|
||||
});
|
||||
const service = createTestToolAccessService(db, {
|
||||
composioClientFactory: () => client,
|
||||
});
|
||||
|
||||
const requests: Array<{ url: string; token: string | null; cursor: unknown }> = [];
|
||||
vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = String(input);
|
||||
const headers = new Headers(init?.headers);
|
||||
const payload = JSON.parse(String(init?.body ?? "{}"));
|
||||
requests.push({
|
||||
url,
|
||||
token: headers.get("x-composio-token"),
|
||||
cursor: payload.params?.cursor,
|
||||
});
|
||||
|
||||
if (url === "https://composio.test/mcp-1") {
|
||||
return new Response("Unauthorized", { status: 401, statusText: "Unauthorized" });
|
||||
}
|
||||
if (url === "https://composio.test/mcp-2") {
|
||||
if (!payload.params?.cursor) {
|
||||
return mcpHttpResponse({
|
||||
jsonrpc: "2.0",
|
||||
id: payload.id,
|
||||
result: {
|
||||
tools: [{ name: "composio_tool_1", annotations: { readOnlyHint: true } }],
|
||||
nextCursor: "cursor-page-2",
|
||||
},
|
||||
});
|
||||
}
|
||||
if (payload.params.cursor === "cursor-page-2") {
|
||||
return mcpHttpResponse({
|
||||
jsonrpc: "2.0",
|
||||
id: payload.id,
|
||||
result: {
|
||||
tools: [{ name: "composio_tool_2", annotations: { readOnlyHint: true } }],
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
return mcpHttpResponse({ jsonrpc: "2.0", id: payload.id, result: { tools: [] } });
|
||||
});
|
||||
|
||||
const refresh = await service.refreshCatalog(child.id);
|
||||
expect(requests).toEqual([
|
||||
{ url: "https://composio.test/mcp-1", token: "token-1", cursor: undefined },
|
||||
{ url: "https://composio.test/mcp-2", token: "token-2", cursor: undefined },
|
||||
{ url: "https://composio.test/mcp-2", token: "token-2", cursor: "cursor-page-2" },
|
||||
]);
|
||||
expect(refresh.catalog.map((entry) => entry.toolName)).toEqual(
|
||||
expect.arrayContaining(["composio_tool_1", "composio_tool_2"]),
|
||||
);
|
||||
});
|
||||
|
||||
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");
|
||||
|
|
|
|||
|
|
@ -6733,33 +6733,37 @@ export function toolAccessService(
|
|||
const composioSession = composioChild
|
||||
? await composioSessions.ensureSession(connection.id)
|
||||
: null;
|
||||
let headers = composioSession?.headers ??
|
||||
let headers =
|
||||
composioSession?.headers ??
|
||||
credentialHeaders ?? {
|
||||
...projectedConnectionHeaders(connection),
|
||||
...(await resolveCredentialHeaders(connection, actor)),
|
||||
};
|
||||
const endpoint =
|
||||
let 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>) =>
|
||||
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({
|
||||
|
|
@ -6767,6 +6771,7 @@ export function toolAccessService(
|
|||
headers,
|
||||
requestId: "paperclip-catalog-refresh",
|
||||
});
|
||||
activeHeaders = sessionHeaders;
|
||||
response = await sendToolsList(sessionHeaders);
|
||||
} else {
|
||||
response = await sendToolsList(headers);
|
||||
|
|
@ -6782,6 +6787,9 @@ export function toolAccessService(
|
|||
});
|
||||
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.
|
||||
|
|
@ -6811,9 +6819,12 @@ export function toolAccessService(
|
|||
const refreshed = await composioSessions.ensureSession(connection.id, {
|
||||
force: true,
|
||||
});
|
||||
response = await requestRemoteHttpEndpoint(new URL(refreshed.url), {
|
||||
endpoint = refreshed.url;
|
||||
headers = refreshed.headers;
|
||||
activeHeaders = refreshed.headers;
|
||||
response = await sendRemote({
|
||||
method: "POST",
|
||||
headers: mcpHttpRequestHeaders(refreshed.headers),
|
||||
headers: mcpHttpRequestHeaders(activeHeaders),
|
||||
body: JSON.stringify({
|
||||
jsonrpc: "2.0",
|
||||
id: "paperclip-catalog-refresh-retry",
|
||||
|
|
@ -6842,6 +6853,7 @@ export function toolAccessService(
|
|||
forceRefresh: true,
|
||||
})),
|
||||
};
|
||||
activeHeaders = headers;
|
||||
response = await sendToolsList(headers);
|
||||
}
|
||||
if (
|
||||
|
|
@ -6855,6 +6867,7 @@ export function toolAccessService(
|
|||
forceRefresh: true,
|
||||
})),
|
||||
};
|
||||
activeHeaders = headers;
|
||||
response = await sendToolsList(headers);
|
||||
if (
|
||||
response.status === 401 &&
|
||||
|
|
@ -6936,13 +6949,87 @@ export function toolAccessService(
|
|||
await response.text(),
|
||||
response.headers.get("content-type"),
|
||||
);
|
||||
const result = asRecord(asRecord(payload).result);
|
||||
const payloadTools = asRecord(payload).tools;
|
||||
let result = asRecord(asRecord(payload).result);
|
||||
let payloadTools = asRecord(payload).tools;
|
||||
const tools: unknown[] = Array.isArray(result.tools)
|
||||
? result.tools
|
||||
? [...result.tools]
|
||||
: Array.isArray(payloadTools)
|
||||
? payloadTools
|
||||
? [...payloadTools]
|
||||
: [];
|
||||
let nextCursor =
|
||||
typeof result.nextCursor === "string" && result.nextCursor.trim()
|
||||
? result.nextCursor.trim()
|
||||
: null;
|
||||
let pageCount = 1;
|
||||
const maxPages = 50;
|
||||
const seenCursors = new Set<string>();
|
||||
|
||||
while (nextCursor && pageCount < maxPages) {
|
||||
seenCursors.add(nextCursor);
|
||||
pageCount++;
|
||||
const pageResponse = await sendToolsList(activeHeaders, nextCursor);
|
||||
if (!pageResponse.ok) {
|
||||
throw new HttpError(
|
||||
502,
|
||||
`Remote app returned HTTP ${pageResponse.status} during tools/list pagination`,
|
||||
{
|
||||
status: pageResponse.status,
|
||||
},
|
||||
);
|
||||
}
|
||||
const pagePayload = parseMcpHttpResponseBody(
|
||||
await pageResponse.text(),
|
||||
pageResponse.headers.get("content-type"),
|
||||
);
|
||||
const pageRecord = asRecord(pagePayload);
|
||||
if (pageRecord.error !== undefined) {
|
||||
const errorDetails = asRecord(pageRecord.error);
|
||||
const errorMessage =
|
||||
typeof errorDetails.message === "string"
|
||||
? errorDetails.message
|
||||
: `code ${errorDetails.code ?? "unknown"}`;
|
||||
throw new HttpError(
|
||||
502,
|
||||
`Remote app returned an error during tools/list pagination: ${errorMessage}`,
|
||||
{
|
||||
code: "mcp_pagination_failed",
|
||||
},
|
||||
);
|
||||
}
|
||||
result = asRecord(pageRecord.result);
|
||||
payloadTools = pageRecord.tools;
|
||||
const pageTools: unknown[] = Array.isArray(result.tools)
|
||||
? result.tools
|
||||
: Array.isArray(payloadTools)
|
||||
? payloadTools
|
||||
: [];
|
||||
tools.push(...pageTools);
|
||||
const candidateCursor =
|
||||
typeof result.nextCursor === "string" && result.nextCursor.trim()
|
||||
? result.nextCursor.trim()
|
||||
: null;
|
||||
if (candidateCursor && seenCursors.has(candidateCursor)) {
|
||||
throw new HttpError(
|
||||
502,
|
||||
"Remote app returned cyclic pagination cursor during tools/list",
|
||||
{
|
||||
code: "pagination_cycle_detected",
|
||||
},
|
||||
);
|
||||
}
|
||||
nextCursor = candidateCursor;
|
||||
}
|
||||
|
||||
if (nextCursor) {
|
||||
throw new HttpError(
|
||||
502,
|
||||
`Remote app tools/list pagination stopped before all tools were discovered (exceeded limit of ${maxPages} pages)`,
|
||||
{
|
||||
code: "pagination_limit_exceeded",
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return tools
|
||||
.map((tool) => normalizeToolDescriptor(tool))
|
||||
.filter((tool): tool is McpToolDescriptor => Boolean(tool));
|
||||
|
|
|
|||
Loading…
Reference in New Issue