mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-27 11:20:42 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - MCP discovery publishes tools that setup and access profiles use. > - Large catalogs currently write one row at a time. > - Repeated page entries can produce duplicate results, and profile writes can partly commit. > - This pull request publishes tools in bounded chunks and makes profile synchronization atomic. > - The result supports large catalogs before connector runtime hooks are enabled. ## Linked Issues or Issue Description **What happened?** Catalog refresh performs one database write per tool. Repeated tool names across discovery pages appear more than once in the result. Default-profile synchronization uses separate statements, so failure can leave a profile without its binding. OAuth callbacks also acquire connection/application locks in the reverse order from publication and setup, allowing a concurrent refresh to deadlock. **Expected behavior** Publish at most 500 tools per catalog statement. Keep one result per tool name, using its last descriptor. Preserve existing IDs, first-seen timestamps, disabled actions, and unchanged review decisions. Commit or roll back default-profile changes together. OAuth persistence must acquire application then connection locks before secret or connection writes. **Steps to reproduce** 1. Refresh a synthetic MCP catalog with 2,500 tools. 2. Return the same tool on two discovery pages with different descriptions. 3. Fail the profile-binding insert during default-profile creation. 4. Overlap personal, organization, or managed OAuth persistence with setup finalization or discovery of a new tool. **Additional context** This is the first feature extracted from #16503. The separate test-only maintenance PRs #16601 and #16610 are merged. This PR now targets master and is the active review. Standing assignments #16603, tool definitions #16604, and access/credential hooks #16605 remain stacked drafts without review requests. ## What Changed - Deduplicate discovery results by tool name and keep the last descriptor. - Upsert catalog rows in transactions of at most 500 tools. - Preserve row identity and operator-disabled status on conflict. Determine default-access candidates from the rows read under each publication lock, so an overlapping refresh cannot restore an action turned Off after another refresh finishes. - Lock the application before the connection during publication, setup finalization, removal, and all three OAuth persistence paths. The OAuth ordering fix is included here so this PR is independently safe; it does not depend on #16605. - Synchronize the managed default profile, its entries, bindings, and managed policies in one transaction. - Add PostgreSQL publication tests and document partial-failure recovery. - Target the existing callback failure injection at profile creation instead of a transaction ordinal. ## Verification - `pnpm exec vitest run server/src/__tests__/tool-catalog-publication.test.ts server/src/__tests__/tool-access-service.test.ts`: 436 tests passed with the OAuth and overlapping-refresh corrections on top of the merged #16610 fix. - The new tests exercise real PostgreSQL with 250 and 2,500 tools, repeated discovery pages, preserved choices, lock contention, and failures between chunks and during profile creation. - Four selected regression cases fail with the production source restored to the parent commit. The implementation was then restored and rechecked. - Six additional real-PostgreSQL regressions cover personal, organization, and managed OAuth persistence against both setup finalization and new-tool discovery. All six reproduce PostgreSQL deadlock `40P01` with the OAuth ordering fix removed; all six pass with it restored. Discovery uses synthetic credential headers to isolate publication locking from credential rotation. - Apex initially returned 3/5 on `0d6013a9649388041975406fe4b0247cd0e695aa`, identifying an overlapping-refresh permission race. Two new PostgreSQL regressions cover both absent and quarantined tools; both prove an agent goes from deny back to allow without the correction, and remains denied with it. The test pauses one refresh after its initial catalog snapshot, completes another refresh, turns the action Off through the service, and resumes the stale refresh. - `pnpm --filter @paperclipai/server typecheck`: passed. - `node scripts/check-maps.mjs`: passed (structural validation only). - `git diff --check`: passed. - Reviewed the complete diff for secrets and personal data before push. - Earlier hosted run 38975428064 failed the maximum-size newline skill-audit watchdog; other interrupted jobs reported runner shutdown/cancellation. The original watchdog failure did not identify whether startup or auditing timed out, so its exact cause remains unknown. The separate merged #16610 isolates the audit deadline from module startup and preserves the audit hang guard. This branch is rebased onto that fix. - Full local build and repository-wide tests were not run. At the previous head `0d6013a9649388041975406fe4b0247cd0e695aa`, all 55 hosted checks succeeded (two optional Storybook checks skipped), and standard Greptile returned 5/5 before Apex identified the permission race above. At corrected head `dc120d2bc879c54c14347db30e74c753bcff61e6`, all 55 hosted checks succeeded (two optional Storybook checks skipped), and a fresh standard review returned 5/5 with no open findings. The second Apex review also returned 5/5 on that exact head (successful Apex check completed 2026-10-16T23:36:44Z). Both earlier findings are addressed, there are no open review threads, and the branch is mergeable against master. ## Risks Each successful catalog chunk remains committed if a later operation fails. A retry completes publication. Provider discovery occurs outside database locks. Default-profile changes commit separately from the catalog chunks and roll back together on failure. Tests use a controlled MCP response; they do not qualify a live provider. There is no schema migration or new plugin API in this PR. ## Model Used OpenAI Codex, GPT-6, with reasoning, repository inspection, code execution, and testing. The exact deployment model ID and context window are not exposed in this session. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
206 lines
15 KiB
TypeScript
206 lines
15 KiB
TypeScript
import { randomUUID } from "node:crypto";
|
|
import { and, eq, sql } from "drizzle-orm";
|
|
import { agents, companies, createDb, toolApplications, toolConnections, toolCatalogEntries, toolProfiles, toolProfileBindings, toolProfileEntries, type Db } from "@paperclipai/db";
|
|
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
|
|
import { toolAccessService } from "../services/tool-access.js";
|
|
import { toolAccessPolicyService } from "../services/tool-access-policy.js";
|
|
|
|
const support = await getEmbeddedPostgresTestSupport();
|
|
const describeDatabase = support.supported ? describe : describe.skip;
|
|
|
|
describeDatabase("catalog publication", () => {
|
|
let database: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
|
|
let db: Db;
|
|
let companyId: string;
|
|
let applicationId: string;
|
|
let connectionId: string;
|
|
beforeAll(async () => { database = await startEmbeddedPostgresTestDatabase("paperclip-catalog-publication-"); db = createDb(database.connectionString); }, 90_000);
|
|
afterAll(async () => { await database?.cleanup(); });
|
|
afterEach(() => vi.restoreAllMocks());
|
|
beforeEach(async () => {
|
|
companyId = randomUUID();
|
|
await db.insert(companies).values({ id: companyId, name: "Catalog fixture", issuePrefix: `C${companyId.slice(0, 6)}` });
|
|
const [application] = await db.insert(toolApplications).values({ companyId, name: "Fixture MCP", type: "mcp_http" }).returning(); applicationId = application.id;
|
|
const [connection] = await db.insert(toolConnections).values({ companyId, applicationId, name: "Fixture connection", uid: randomUUID(), transport: "mcp_remote", authKind: "none", enabled: true, status: "active", config: { url: "https://mcp.example.test/mcp" } }).returning(); connectionId = connection.id;
|
|
});
|
|
|
|
const serviceWithTools = (tools: unknown[]) => toolAccessService(db, {
|
|
remoteHttpEndpointLookup: async () => [{ address: "8.8.8.8", family: 4 }],
|
|
remoteHttpRequest: async (_url, init) => {
|
|
const body = JSON.parse(String(init?.body));
|
|
if (body.method === "notifications/initialized") return new Response(null, { status: 202 });
|
|
return new Response(JSON.stringify({ jsonrpc: "2.0", id: body.id, result: body.method === "initialize" ? { protocolVersion: "2025-06-18" } : { tools } }), { headers: { "content-type": "application/json" } });
|
|
},
|
|
});
|
|
it.each([250, 2500])("publishes a real %i-tool catalog in bounded chunks and preserves row identities", async count => {
|
|
const tools = Array.from({ length: count }, (_, index) => ({ name: `search_${index}`, description: "Search fixture records", inputSchema: { type: "object", properties: {} }, annotations: { readOnlyHint: true } }));
|
|
await db.update(toolConnections).set({ config: { url: "https://mcp.example.test/mcp" } }).where(eq(toolConnections.id, connectionId));
|
|
const service = toolAccessService(db, {
|
|
remoteHttpEndpointLookup: async () => [{ address: "8.8.8.8", family: 4 }],
|
|
remoteHttpRequest: async (_url, init) => {
|
|
const body = JSON.parse(String(init?.body));
|
|
if (body.method === "notifications/initialized") return new Response(null, { status: 202 });
|
|
return new Response(JSON.stringify({ jsonrpc: "2.0", id: body.id, result: body.method === "initialize" ? { protocolVersion: "2025-06-18" } : { tools } }), { headers: { "content-type": "application/json" } });
|
|
},
|
|
});
|
|
let catalogWrites = 0;
|
|
const transaction = db.transaction.bind(db);
|
|
const transactionSpy = vi.spyOn(db, "transaction").mockImplementation((async (operation, config) => transaction(async tx => {
|
|
const insert = tx.insert.bind(tx);
|
|
const insertSpy = vi.spyOn(tx, "insert").mockImplementation(((table: Parameters<typeof tx.insert>[0]) => {
|
|
if (table === toolCatalogEntries) catalogWrites += 1;
|
|
return insert(table);
|
|
}) as typeof tx.insert);
|
|
try { return await operation(tx); }
|
|
finally { insertSpy.mockRestore(); }
|
|
}, config)) as typeof db.transaction);
|
|
expect((await service.refreshCatalog(connectionId)).catalog).toHaveLength(count);
|
|
expect(catalogWrites).toBe(Math.ceil(count / 500));
|
|
const first = await db.select().from(toolCatalogEntries).where(eq(toolCatalogEntries.connectionId, connectionId)).orderBy(toolCatalogEntries.toolName);
|
|
expect((await service.refreshCatalog(connectionId)).catalog).toHaveLength(count);
|
|
expect(catalogWrites).toBe(2 * Math.ceil(count / 500));
|
|
transactionSpy.mockRestore();
|
|
const second = await db.select().from(toolCatalogEntries).where(eq(toolCatalogEntries.connectionId, connectionId)).orderBy(toolCatalogEntries.toolName);
|
|
expect(second.map(row => [row.id, row.firstSeenAt, row.status])).toEqual(first.map(row => [row.id, row.firstSeenAt, row.status]));
|
|
});
|
|
it("refreshes known tools from overlapping MCP pages using the last descriptor", async () => {
|
|
await db.update(toolConnections).set({ config: { url: "https://mcp.example.test/mcp" } }).where(eq(toolConnections.id, connectionId));
|
|
let overlap = false;
|
|
const descriptor = (description: string) => ({ name: "search", description, inputSchema: { type: "object" }, annotations: { readOnlyHint: true } });
|
|
const service = toolAccessService(db, {
|
|
remoteHttpEndpointLookup: async () => [{ address: "8.8.8.8", family: 4 }],
|
|
remoteHttpRequest: async (_url, init) => {
|
|
const body = JSON.parse(String(init?.body));
|
|
if (body.method === "notifications/initialized") return new Response(null, { status: 202 });
|
|
const result = body.method === "initialize" ? { protocolVersion: "2025-06-18" }
|
|
: body.params?.cursor ? { tools: [descriptor("Last page")] }
|
|
: { tools: [descriptor("First page")], ...(overlap ? { nextCursor: "overlap" } : {}) };
|
|
return new Response(JSON.stringify({ jsonrpc: "2.0", id: body.id, result }), { headers: { "content-type": "application/json" } });
|
|
},
|
|
});
|
|
await service.refreshCatalog(connectionId);
|
|
const [first] = await db.select().from(toolCatalogEntries).where(eq(toolCatalogEntries.connectionId, connectionId));
|
|
overlap = true;
|
|
expect((await service.refreshCatalog(connectionId)).catalog).toHaveLength(1);
|
|
const rows = await db.select().from(toolCatalogEntries).where(eq(toolCatalogEntries.connectionId, connectionId));
|
|
expect(rows).toHaveLength(1);
|
|
expect(rows[0]).toMatchObject({ id: first.id, firstSeenAt: first.firstSeenAt, description: "Last page" });
|
|
});
|
|
|
|
it("preserves operator-disabled tools and unchanged pending reviews", async () => {
|
|
const tools = ["off", "review"].map(name => ({ name, description: "Initial tool", inputSchema: { type: "object" }, annotations: { readOnlyHint: true } }));
|
|
const service = serviceWithTools(tools);
|
|
await service.refreshCatalog(connectionId);
|
|
await db.update(toolConnections).set({ config: { url: "https://mcp.example.test/mcp", quarantineNewEntries: true } }).where(eq(toolConnections.id, connectionId));
|
|
await db.update(toolCatalogEntries).set({ status: "disabled", quarantineReason: null }).where(and(eq(toolCatalogEntries.connectionId, connectionId), eq(toolCatalogEntries.toolName, "off")));
|
|
const reviewedAt = new Date("2020-01-01T00:00:00Z");
|
|
await db.update(toolCatalogEntries).set({ status: "quarantined", quarantineReason: "pending_review", quarantinedAt: reviewedAt }).where(and(eq(toolCatalogEntries.connectionId, connectionId), eq(toolCatalogEntries.toolName, "review")));
|
|
const before = await db.select().from(toolCatalogEntries).where(eq(toolCatalogEntries.connectionId, connectionId)).orderBy(toolCatalogEntries.toolName);
|
|
const result = await service.refreshCatalog(connectionId);
|
|
expect(result.catalog.map(entry => [entry.toolName, entry.status])).toEqual([["off", "disabled"], ["review", "quarantined"]]);
|
|
const after = await db.select().from(toolCatalogEntries).where(eq(toolCatalogEntries.connectionId, connectionId)).orderBy(toolCatalogEntries.toolName);
|
|
expect(after.map(row => [row.id, row.firstSeenAt, row.status, row.quarantinedAt, row.quarantineReason])).toEqual(before.map(row => [row.id, row.firstSeenAt, row.status, row.quarantinedAt, row.quarantineReason]));
|
|
});
|
|
|
|
it.each(["new", "quarantined"] as const)("does not restore an Off action after overlapping refreshes of a %s tool", async state => {
|
|
await serviceWithTools([]).refreshCatalog(connectionId);
|
|
if (state === "quarantined") await db.insert(toolCatalogEntries).values({
|
|
companyId, applicationId, connectionId, name: "search", toolName: "search", riskLevel: "read",
|
|
status: "quarantined", versionHash: "fixture-version", schemaHash: "fixture-schema", quarantineReason: "pending_review", quarantinedAt: new Date("2020-01-01T00:00:00Z"),
|
|
});
|
|
const [agent] = await db.insert(agents).values({ companyId, name: "Catalog permission fixture", role: "engineer", adapterType: "process", adapterConfig: {}, runtimeConfig: {} }).returning();
|
|
const service = serviceWithTools([{ name: "search", annotations: { readOnlyHint: true } }]);
|
|
const transaction = db.transaction.bind(db);
|
|
let paused = false;
|
|
let release!: () => void;
|
|
const resume = new Promise<void>(resolve => { release = resolve; });
|
|
const transactionSpy = vi.spyOn(db, "transaction").mockImplementation((async (operation, config) => {
|
|
if (!paused) { paused = true; await resume; }
|
|
return transaction(operation, config);
|
|
}) as typeof db.transaction);
|
|
const later = Promise.allSettled([service.refreshCatalog(connectionId, undefined, { enableAllByDefault: true })]);
|
|
try {
|
|
await vi.waitFor(() => expect(paused).toBe(true), { timeout: 5000 });
|
|
const first = await service.refreshCatalog(connectionId, undefined, { enableAllByDefault: true });
|
|
const entry = first.catalog[0];
|
|
const decide = () => toolAccessPolicyService(db).decide({ companyId, actor: { actorType: "agent", actorId: agent.id }, request: { connectionId, catalogEntryId: entry.id, toolName: entry.toolName, arguments: {} } });
|
|
await expect(decide()).resolves.toMatchObject({ decision: "allow", reasonCode: "allow_profile" });
|
|
const [selection] = await db.select().from(toolProfileEntries).where(eq(toolProfileEntries.catalogEntryId, entry.id));
|
|
await service.deleteProfileEntry(selection.id);
|
|
await expect(decide()).resolves.toMatchObject({ decision: "deny" });
|
|
release();
|
|
const [result] = await later;
|
|
if (result.status === "rejected") throw result.reason;
|
|
await expect(decide()).resolves.toMatchObject({ decision: "deny" });
|
|
expect(await db.select().from(toolCatalogEntries).where(eq(toolCatalogEntries.connectionId, connectionId))).toHaveLength(1);
|
|
expect(await db.select().from(toolProfileEntries).where(eq(toolProfileEntries.catalogEntryId, entry.id))).toHaveLength(0);
|
|
} finally { release(); await later; transactionSpy.mockRestore(); }
|
|
}, 15000);
|
|
|
|
it("keeps committed chunks and rolls back a failed default-profile publication", async () => {
|
|
const service = serviceWithTools(Array.from({ length: 501 }, (_, index) => ({ name: `search_${index}`, annotations: { readOnlyHint: true } })));
|
|
const transaction = db.transaction.bind(db);
|
|
let catalogWrites = 0;
|
|
let failProfile = false;
|
|
const transactionSpy = vi.spyOn(db, "transaction").mockImplementation((async (operation, config) => transaction(async tx => {
|
|
const insert = tx.insert.bind(tx);
|
|
const insertSpy = vi.spyOn(tx, "insert").mockImplementation(((table: Parameters<typeof tx.insert>[0]) => {
|
|
if (table === toolCatalogEntries && ++catalogWrites === 2) throw new Error("second chunk failed");
|
|
if (table === toolProfileBindings && failProfile) throw new Error("profile binding failed");
|
|
return insert(table);
|
|
}) as typeof tx.insert);
|
|
try { return await operation(tx); }
|
|
finally { insertSpy.mockRestore(); }
|
|
}, config)) as typeof db.transaction);
|
|
await expect(service.refreshCatalog(connectionId)).rejects.toThrow("second chunk failed");
|
|
expect(await db.select().from(toolCatalogEntries).where(eq(toolCatalogEntries.connectionId, connectionId))).toHaveLength(500);
|
|
failProfile = true;
|
|
await expect(service.refreshCatalog(connectionId)).rejects.toThrow("profile binding failed");
|
|
expect(await db.select().from(toolCatalogEntries).where(eq(toolCatalogEntries.connectionId, connectionId))).toHaveLength(501);
|
|
expect(await db.select().from(toolProfiles).where(eq(toolProfiles.companyId, companyId))).toHaveLength(0);
|
|
expect(await db.select().from(toolProfileBindings).where(eq(toolProfileBindings.companyId, companyId))).toHaveLength(0);
|
|
transactionSpy.mockRestore();
|
|
await service.refreshCatalog(connectionId);
|
|
expect(await db.select().from(toolProfiles).where(eq(toolProfiles.companyId, companyId))).toHaveLength(1);
|
|
});
|
|
|
|
it("waits for application publication before changing catalog rows", async () => {
|
|
const tools = [{ name: "search", description: "Initial search", annotations: { readOnlyHint: true } }];
|
|
const service = serviceWithTools(tools);
|
|
await service.refreshCatalog(connectionId);
|
|
tools[0].description = "Changed search";
|
|
let locked!: () => void;
|
|
let release!: () => void;
|
|
const held = new Promise<void>(resolve => { locked = resolve; });
|
|
const resume = new Promise<void>(resolve => { release = resolve; });
|
|
const blocker = db.transaction(async tx => {
|
|
await tx.select().from(toolApplications).where(eq(toolApplications.id, applicationId)).for("no key update");
|
|
locked(); await resume;
|
|
});
|
|
await held;
|
|
const transaction = db.transaction.bind(db);
|
|
let started!: (pid: number) => void;
|
|
const begun = new Promise<number>(resolve => { started = resolve; });
|
|
const transactionSpy = vi.spyOn(db, "transaction").mockImplementation((async (operation, config) => transaction(async tx => {
|
|
await tx.execute(sql`SET LOCAL lock_timeout = '8s'`);
|
|
const [backend] = await tx.execute(sql`SELECT pg_backend_pid() AS pid`);
|
|
started(Number(backend.pid));
|
|
return operation(tx);
|
|
}, config)) as typeof db.transaction);
|
|
const refresh = Promise.allSettled([service.refreshCatalog(connectionId)]);
|
|
try {
|
|
const pid = await begun;
|
|
await vi.waitFor(async () => {
|
|
const [activity] = await db.execute(sql`SELECT wait_event_type FROM pg_stat_activity WHERE pid = ${pid}`);
|
|
expect(activity.wait_event_type).toBe("Lock");
|
|
}, { timeout: 5000 });
|
|
expect((await db.select().from(toolCatalogEntries).where(eq(toolCatalogEntries.connectionId, connectionId)))[0].description).toBe("Initial search");
|
|
} finally { release(); await blocker; }
|
|
const [result] = await refresh;
|
|
if (result.status === "rejected") throw result.reason;
|
|
transactionSpy.mockRestore();
|
|
expect((await db.select().from(toolCatalogEntries).where(eq(toolCatalogEntries.connectionId, connectionId)))[0].description).toBe("Changed search");
|
|
}, 15000);
|
|
});
|