diff --git a/doc/connections/GENERIC-REMOTE-MCP.md b/doc/connections/GENERIC-REMOTE-MCP.md index bbec052655e..f9b8b6e93e3 100644 --- a/doc/connections/GENERIC-REMOTE-MCP.md +++ b/doc/connections/GENERIC-REMOTE-MCP.md @@ -50,6 +50,18 @@ step through review, access and install. Reads are enabled for review; state-changing actions start off; newly discovered actions are quarantined until reviewed. That is the same treatment a curated connection gets. +For **Just me**, the first probe of a new URL with no supplied credentials runs +before a personal authorization exists. If the server requires OAuth, Paperclip +creates the personal grant only after sign-in succeeds. If the server is public, +Paperclip creates a personal grant with no credentials after the probe succeeds. +That successful public probe saves the draft identity and its grant. A later +catalog-refresh failure leaves them available for retry instead of undoing a +grant that another setup attempt may already be using. +Catalog and default-profile writes after that probe are atomic: a failure rolls +back that step while retaining the established draft identity. +Later health checks still require the user's authorization and return an +actionable `422` error when it is missing. + ### Advanced authentication Collapsed by default. Open it when the server's docs are specific: @@ -177,9 +189,10 @@ through this page with either a key or browser sign-in. ## Verifying Deterministic coverage lives in -`server/src/__tests__/generic-mcp-connection.test.ts`, which stands up an -in-process MCP server plus authorization server. It needs no network and no -vendor credentials, and every case connects by URL without naming a gallery app. +`server/src/__tests__/generic-mcp-connection.test.ts` uses a simulated MCP/OAuth +provider and a real loopback HTTP server for the personal public-URL regression. +It needs no vendor credentials. Tests create an isolated PostgreSQL database +and close their servers after use. A credentialed vendor smoke (for example live PostHog OAuth) may be recorded by QA but is not required for deterministic verification. diff --git a/server/src/__tests__/generic-mcp-connection.test.ts b/server/src/__tests__/generic-mcp-connection.test.ts index d16e564be0d..09fb4aae4f5 100644 --- a/server/src/__tests__/generic-mcp-connection.test.ts +++ b/server/src/__tests__/generic-mcp-connection.test.ts @@ -1,4 +1,6 @@ import { randomUUID } from "node:crypto"; +import { once } from "node:events"; +import { createServer } from "node:http"; import { Writable } from "node:stream"; import express from "express"; import pino from "pino"; @@ -409,7 +411,7 @@ describeEmbeddedPostgres("generic remote MCP connections", () => { expect(profiles).toEqual([]); }); - it("vaults a credential-bearing MCP URL and never returns or logs its token", async () => { + it.each(["organization", "user"] as const)("vaults a credential-bearing MCP URL for %s and never returns or logs its token", async (grantKind) => { const secretUrl = `${MCP_URL}?token=zapier-secret®ion=us`; const publicUrl = `${MCP_URL}?region=us`; const requests: string[] = []; @@ -432,7 +434,7 @@ describeEmbeddedPostgres("generic remote MCP connections", () => { const response = await request(app) .post(`/api/companies/${company.id}/tools/apps/connect`) - .send({ link: secretUrl, name: "Token URL fixture" }) + .send({ link: secretUrl, name: "Token URL fixture", grantKind }) .expect(201); expect(requests).toContain(secretUrl); @@ -448,8 +450,18 @@ describeEmbeddedPostgres("generic remote MCP connections", () => { response.body.connectionId, )); expect(connection!.config.url).toBe(publicUrl); - expect(connection!.credentialSecretRefs).toEqual([ - expect.objectContaining({ configPath: "remote.url", label: "MCP server URL" }), + const expectedRefs = [expect.objectContaining({ configPath: "remote.url", label: "MCP server URL" })]; + expect(connection!.credentialSecretRefs).toEqual(grantKind === "user" ? [] : expectedRefs); + const grants = await db.select().from(connectionGrants).where(eq( + connectionGrants.connectionId, + response.body.connectionId, + )); + expect(grants).toEqual([ + expect.objectContaining({ + kind: grantKind, + credentialSecretRefs: expectedRefs, + ...(grantKind === "user" ? { subjectUserId: "board-user" } : {}), + }), ]); expect(await db.select().from(companySecrets)).toHaveLength(1); expect(JSON.stringify(await db.select().from(activityLog))).not.toContain("zapier-secret"); @@ -911,6 +923,281 @@ describeEmbeddedPostgres("generic remote MCP connections", () => { .toEqual(["oauth.access_token", "oauth.refresh_token"]); }); + it("discovers OAuth for a personal URL connection before its user grant exists", async () => { + const fixture = installMcpOAuthFixture({ auth: "oauth" }); + const company = await createCompany(db); + const app = createRouteApp(db); + vi.stubEnv("PAPERCLIP_PUBLIC_URL", PUBLIC_BASE_URL); + const actor = { actorType: "user" as const, actorId: "board-user" }; + + const response = await request(app) + .post(`/api/companies/${company.id}/tools/apps/connect`) + .send({ + link: MCP_URL, + name: "Fixture personal OAuth", + grantKind: "user", + }); + expect(response.status, JSON.stringify(response.body)).toBe(201); + const connected = response.body; + + expect(connected.auth).toMatchObject({ + kind: "oauth", + issuer: ISSUER, + resource: MCP_URL, + startUrl: expect.any(String), + }); + expect(fixture.requestsTo("/mcp")[0]!.headers.authorization).toBeUndefined(); + await expect( + db + .select() + .from(connectionGrants) + .where(eq(connectionGrants.connectionId, connected.connectionId)), + ).resolves.toHaveLength(0); + + const authorizationUrl = new URL(connected.auth.startUrl); + const code = fixture.issueAuthorizationCode(connected.auth.startUrl); + await request(app) + .get("/api/tools/oauth/callback") + .set("Accept", "text/html") + .query({ state: authorizationUrl.searchParams.get("state")!, code, iss: ISSUER }) + .expect(303); + + const grants = await db + .select() + .from(connectionGrants) + .where(eq(connectionGrants.connectionId, connected.connectionId)); + expect(grants).toEqual([ + expect.objectContaining({ + kind: "user", + subjectUserId: actor.actorId, + status: "active", + }), + ]); + }); + + it("does not let another user take over an archived personal URL connection", async () => { + const fixture = installMcpOAuthFixture({ auth: "oauth" }); + const company = await createCompany(db); + const service = toolAccessService(db); + const input = { link: MCP_URL, name: "Archived personal URL", grantKind: "user" as const }; + const first = await service.connectGalleryApp(company.id, input, { actorType: "user", actorId: "board-user" }); + await db.update(toolConnections).set({ status: "archived" }).where(eq(toolConnections.id, first.connectionId)); + await db.update(toolApplications).set({ status: "archived" }).where(eq(toolApplications.id, first.application.id)); + await db.insert(companyMemberships).values({ + companyId: company.id, principalType: "user", principalId: "other-user", status: "active", membershipRole: "admin", + }); + fixture.fetchMock.mockClear(); + + await expect(service.connectGalleryApp(company.id, input, { + actorType: "user", actorId: "other-user", + })).rejects.toMatchObject({ status: 403, message: "Only the existing personal identity can reconnect this connection" }); + + expect(fixture.fetchMock).not.toHaveBeenCalled(); + await expect(db.select().from(connectionGrants)).resolves.toHaveLength(0); + await expect(service.getConnection(first.connectionId)).resolves.toMatchObject({ + status: "archived", createdByUserId: "board-user", + }); + const resumed = await service.connectGalleryApp(company.id, input, { actorType: "user", actorId: "board-user" }); + expect(resumed.connectionId).toBe(first.connectionId); + expect(resumed.auth).toMatchObject({ kind: "oauth" }); + }); + + it("creates one personal grant when two public URL setup retries probe concurrently", async () => { + const fixture = installMcpOAuthFixture({ auth: "public" }); + const company = await createCompany(db); + const service = toolAccessService(db); + const actor = { actorType: "user" as const, actorId: "board-user" }; + const first = await service.connectGalleryApp(company.id, { + link: MCP_URL, name: "Concurrent personal URL", grantKind: "user", + }, actor); + await service.connectGalleryApp(company.id, { + link: MCP_URL, grantKind: "user", resumeConnectionId: first.connectionId, + }, actor); + // Model an interrupted draft with catalog/defaults but no personal grant. + // This isolates the grant race from first-time catalog/profile insertion. + await db.delete(connectionGrants).where(eq(connectionGrants.connectionId, first.connectionId)); + await db.delete(toolAccessAuditEvents).where(eq(toolAccessAuditEvents.connectionId, first.connectionId)); + fixture.fetchMock.mockRestore(); + let probes = 0; + let release!: () => void; + const bothProbed = new Promise((resolve) => { release = resolve; }); + vi.spyOn(globalThis, "fetch").mockImplementation(async () => { + probes += 1; + if (probes === 2) release(); + await bothProbed; + return jsonResponse({ jsonrpc: "2.0", id: "paperclip-catalog-refresh", result: { tools: FIXTURE_TOOLS } }); + }); + + const results = await Promise.allSettled([0, 1].map(() => service.connectGalleryApp(company.id, { + link: MCP_URL, grantKind: "user", resumeConnectionId: first.connectionId, + }, actor))); + + for (const result of results) { + if (result.status === "rejected") throw result.reason; + } + expect(results.map((result) => result.status)).toEqual(["fulfilled", "fulfilled"]); + await expect(db.select().from(connectionGrants).where(eq(connectionGrants.connectionId, first.connectionId))) + .resolves.toEqual([expect.objectContaining({ kind: "user", subjectUserId: actor.actorId, status: "active", credentialSecretRefs: [] })]); + await expect(db.select().from(toolAccessAuditEvents).where(and( + eq(toolAccessAuditEvents.connectionId, first.connectionId), + eq(toolAccessAuditEvents.action, "connection_grant.created"), + ))).resolves.toHaveLength(1); + }); + + it("keeps a successful personal setup when the grant-creating retry later fails", async () => { + const fixture = installMcpOAuthFixture({ auth: "public" }); + const company = await createCompany(db); + const service = toolAccessService(db); + const actor = { actorType: "user" as const, actorId: "board-user" }; + const first = await service.connectGalleryApp(company.id, { + link: MCP_URL, name: "Personal retry rollback", grantKind: "user", + }, actor); + const retryInput = { link: MCP_URL, grantKind: "user" as const, resumeConnectionId: first.connectionId }; + await service.connectGalleryApp(company.id, retryInput, actor); + await db.delete(connectionGrants).where(eq(connectionGrants.connectionId, first.connectionId)); + await db.update(toolConnections).set({ status: "archived" }).where(eq(toolConnections.id, first.connectionId)); + await db.update(toolApplications).set({ status: "archived", archivedAt: new Date() }).where(eq(toolApplications.id, first.application.id)); + fixture.fetchMock.mockRestore(); + let calls = 0; + let catalogStarted!: () => void; + const catalogPending = new Promise((resolve) => { catalogStarted = resolve; }); + let failCatalog!: () => void; + const releaseCatalog = new Promise((resolve) => { failCatalog = resolve; }); + vi.spyOn(globalThis, "fetch").mockImplementation(async () => { + calls += 1; + if (calls === 2) { + // The first retry has created its grant but has not finished setup. + catalogStarted(); + await releaseCatalog; + throw new Error("first retry catalog unavailable"); + } + return jsonResponse({ jsonrpc: "2.0", id: "paperclip-catalog-refresh", result: { tools: FIXTURE_TOOLS } }); + }); + const failure = service.connectGalleryApp(company.id, { + link: MCP_URL, name: "Personal retry rollback", grantKind: "user", + }, actor).then(() => null, (error: unknown) => error); + await Promise.race([catalogPending, failure.then((error) => { throw error ?? new Error("Retry finished before the catalog probe"); })]); + try { + const successfulRetry = await service.connectGalleryApp(company.id, retryInput, actor); + expect(successfulRetry.connectionId).toBe(first.connectionId); + } finally { + failCatalog(); + } + expect(await failure).toMatchObject({ status: 502 }); + await expect(db.select().from(connectionGrants).where(eq(connectionGrants.connectionId, first.connectionId))) + .resolves.toEqual([expect.objectContaining({ kind: "user", subjectUserId: actor.actorId, status: "active", credentialSecretRefs: [] })]); + await expect(service.getConnection(first.connectionId)).resolves.toMatchObject({ status: "draft", credentialPolicy: "per_user" }); + const [application] = await db.select().from(toolApplications).where(eq(toolApplications.id, first.application.id)); + expect(application.status).toBe("draft"); + await expect(service.checkHealth(first.connectionId, actor)).resolves.toMatchObject({ connection: { healthStatus: "ok" } }); + }); + + it("rolls back partial catalog and profile writes without removing the established public identity", async () => { + const fixture = installMcpOAuthFixture({ auth: "public" }); + const company = await createCompany(db); + const service = toolAccessService(db); + const actor = { actorType: "user" as const, actorId: "board-user" }; + const input = { link: MCP_URL, name: "Personal atomic catalog", grantKind: "user" as const }; + const first = await service.connectGalleryApp(company.id, input, actor); + const catalogBefore = await db.select().from(toolCatalogEntries).where(eq(toolCatalogEntries.connectionId, first.connectionId)); + await db.delete(connectionGrants).where(eq(connectionGrants.connectionId, first.connectionId)); + await db.update(toolConnections).set({ status: "archived" }).where(eq(toolConnections.id, first.connectionId)); + await db.update(toolApplications).set({ status: "archived", archivedAt: new Date() }).where(eq(toolApplications.id, first.application.id)); + fixture.fetchMock.mockRestore(); + vi.spyOn(globalThis, "fetch").mockImplementation(async () => jsonResponse({ + jsonrpc: "2.0", id: "paperclip-catalog-refresh", + result: { tools: [...FIXTURE_TOOLS, { name: "new_tool", description: "Partial catalog addition" }] }, + })); + await db.execute(sql` + CREATE FUNCTION test_personal_profile_failure() RETURNS trigger LANGUAGE plpgsql AS $$ + BEGIN RAISE EXCEPTION 'personal profile fixture failure'; END $$ + `); + await db.execute(sql` + CREATE TRIGGER test_personal_profile_failure BEFORE INSERT ON tool_profile_entries + FOR EACH ROW EXECUTE FUNCTION test_personal_profile_failure() + `); + try { + await expect(service.connectGalleryApp(company.id, input, actor)).rejects.toThrow(); + await expect(db.select().from(toolCatalogEntries).where(eq(toolCatalogEntries.connectionId, first.connectionId))) + .resolves.toEqual(catalogBefore); + await expect(db.select().from(toolProfiles).where(eq(toolProfiles.companyId, company.id))).resolves.toHaveLength(0); + await expect(db.select().from(toolProfileBindings).where(eq(toolProfileBindings.companyId, company.id))).resolves.toHaveLength(0); + await expect(db.select().from(connectionGrants).where(eq(connectionGrants.connectionId, first.connectionId))) + .resolves.toEqual([expect.objectContaining({ kind: "user", status: "active", credentialSecretRefs: [] })]); + await expect(service.getConnection(first.connectionId)).resolves.toMatchObject({ status: "draft", credentialPolicy: "per_user" }); + } finally { + await db.execute(sql`DROP TRIGGER test_personal_profile_failure ON tool_profile_entries`); + await db.execute(sql`DROP FUNCTION test_personal_profile_failure()`); + } + const retry = await service.connectGalleryApp(company.id, { ...input, resumeConnectionId: first.connectionId }, actor); + expect(retry.catalog).toHaveLength(3); + await expect(db.select().from(toolProfiles).where(eq(toolProfiles.companyId, company.id))).resolves.toHaveLength(1); + await expect(db.select().from(toolProfileBindings).where(eq(toolProfileBindings.companyId, company.id))).resolves.toHaveLength(1); + }); + + it("creates an empty user grant after a personal public URL probe succeeds", async () => { + // Use a real loopback MCP server here: neither fetch nor the transport is mocked. + const receivedMethods: string[] = []; + const mcpServer = createServer(async (req, res) => { + const chunks: Buffer[] = []; + for await (const chunk of req) chunks.push(Buffer.from(chunk)); + const body = JSON.parse(Buffer.concat(chunks).toString("utf8")); + receivedMethods.push(body.method); + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ jsonrpc: "2.0", id: body.id, result: { tools: FIXTURE_TOOLS } })); + }); + mcpServer.listen(0, "127.0.0.1"); + await once(mcpServer, "listening"); + const address = mcpServer.address(); + if (!address || typeof address === "string") throw new Error("Missing MCP fixture port"); + try { + const company = await createCompany(db); + const app = createRouteApp(db, { + deploymentMode: "local_trusted", + deploymentExposure: "private", + }); + const actor = { actorType: "user" as const, actorId: "board-user" }; + + const response = await request(app) + .post(`/api/companies/${company.id}/tools/apps/connect`) + .send({ + link: `http://127.0.0.1:${address.port}/mcp`, + name: "Fixture personal public", + grantKind: "user", + }); + expect(response.status, JSON.stringify(response.body)).toBe(201); + const connected = response.body; + expect(receivedMethods).toContain("tools/list"); + + expect(connected.connection).toMatchObject({ + status: "draft", + credentialPolicy: "per_user", + }); + expect(connected.catalog.map((entry: { toolName: string }) => entry.toolName).sort()).toEqual([ + "create_insight", + "list_insights", + ]); + await expect( + db + .select() + .from(connectionGrants) + .where(eq(connectionGrants.connectionId, connected.connectionId)), + ).resolves.toEqual([ + expect.objectContaining({ + kind: "user", + subjectUserId: actor.actorId, + credentialSecretRefs: [], + status: "active", + }), + ]); + } finally { + await new Promise((resolve, reject) => { + mcpServer.close((error) => error ? reject(error) : resolve()); + mcpServer.closeAllConnections(); + }); + } + }); + it("completes organization OAuth with a single database connection", async () => { const fixture = installMcpOAuthFixture({ auth: "oauth" }); const company = await createCompany(db); diff --git a/server/src/__tests__/tool-access-service.test.ts b/server/src/__tests__/tool-access-service.test.ts index 11c1ec3f793..af6aea1e879 100644 --- a/server/src/__tests__/tool-access-service.test.ts +++ b/server/src/__tests__/tool-access-service.test.ts @@ -13184,6 +13184,39 @@ describeEmbeddedPostgres("tool access service", () => { await expect(db.select().from(toolCatalogEntries)).resolves.toHaveLength(0); }); + it("keeps missing personal authorization as a client-actionable health failure", async () => { + const company = await createCompany(db); + const service = createTestToolAccessService(db); + const { connection } = await createRemoteToolFixture(db, company.id); + await db + .delete(connectionGrants) + .where(eq(connectionGrants.connectionId, connection.id)); + await db + .update(toolConnections) + .set({ + credentialPolicy: "per_user", + createdByUserId: "board", + }) + .where(eq(toolConnections.id, connection.id)); + + await expect( + service.checkHealth(connection.id, { + actorType: "user", + actorId: "board", + }), + ).rejects.toMatchObject({ + status: 422, + details: expect.objectContaining({ + code: "user_authorization_required", + }), + }); + + await expect(service.getConnection(connection.id)).resolves.toMatchObject({ + healthStatus: "error", + healthMessage: "This connection needs the current user's authorization", + }); + }); + it("reuses and revives an existing application when connecting with applicationId", async () => { const company = await createCompany(db); const service = createTestToolAccessService(db); diff --git a/server/src/services/tool-access.ts b/server/src/services/tool-access.ts index 0192c64e6b9..4cac2ca499b 100644 --- a/server/src/services/tool-access.ts +++ b/server/src/services/tool-access.ts @@ -2775,6 +2775,7 @@ function healthFailureHttpStatus(failure: { code: string; }): number { if (failure.status === "missing_secret") return 422; + if (failure.code === "user_authorization_required") return 422; if (failure.code === "composio_api_key_rejected") return 422; if (failure.code === "tool_connection_transport_unsupported") return 422; if (failure.code.endsWith("_endpoint_rejected")) return 422; @@ -2805,6 +2806,9 @@ function sanitizeHttpFailure(error: unknown): { } if (error instanceof HttpError) { const code = asRecord(error.details).code; + if (code === "user_authorization_required") { + return { status: "error", message: error.message, code }; + } if (code === "tool_connection_transport_unsupported") { return { status: "error", message: error.message, code }; } @@ -7537,6 +7541,7 @@ export function toolAccessService( async function checkConnectionHealth( connectionId: string, actor?: ActorInfo, + options: { allowUnauthenticatedProbe?: boolean } = {}, ): Promise { const connection = await getConnectionRow(connectionId); if (connection.connectionPurpose === "ai") return { connection: toConnection(connection), runtimeSlot: null }; @@ -7584,12 +7589,21 @@ export function toolAccessService( await validateAgentMailConnection(connection); } else if (connection.transport === "mcp_remote") { await assertComposioConnectedAccountActive(connection); + const canProbeWithoutAuthorization = + options.allowUnauthenticatedProbe === true && + connection.status === "draft" && + connection.authKind === "none" && + connection.credentialPolicy === "per_user" && + actor?.actorType === "user" && + actor.actorId === connection.createdByUserId; const credentialHeaders = connection.credentialSource === "vercel_connect" ? await resolveCredentialHeaders(connection, actor, { forceRefresh: true, }) - : undefined; + : canProbeWithoutAuthorization + ? {} + : undefined; await remoteTools(connection, credentialHeaders, actor); } else if (isComposioConnection(connection)) { await validateComposioConnection(connection); @@ -12789,6 +12803,7 @@ export function toolAccessService( previous: typeof connectionGrants.$inferSelect | null; current: typeof connectionGrants.$inferSelect; } | null = null; + let personalPublicSetupEstablished = false; try { const credentialFields = @@ -13238,9 +13253,24 @@ export function toolAccessService( }; } + // A pasted MCP URL starts with unknown auth. For a personal connection, + // there is deliberately no empty active grant yet, so probe this one + // creator-owned draft without credentials. A 401 can then discover OAuth + // and leave grant creation to the callback; a public endpoint gets an + // empty personal grant below so later catalog refreshes use the same + // identity policy. + const unauthenticatedPersonalProbe = Boolean( + !galleryEntry && + genericAuthKind === "none" && + credentialSecretRefs.length === 0 && + personalIdentityUserId && + !retainedPersonalIdentity?.grant, + ); let health: ToolConnectionHealthCheckResult; try { - health = await checkConnectionHealth(connectionRow.id, actor); + health = await checkConnectionHealth(connectionRow.id, actor, { + allowUnauthenticatedProbe: unauthenticatedPersonalProbe, + }); } catch (error) { if ( !galleryEntry && @@ -13288,6 +13318,52 @@ export function toolAccessService( } throw error; } + if (unauthenticatedPersonalProbe) { + const personalConnectionId = connectionRow.id; + const grant = await db.transaction(async (tx) => { + const [createdGrant] = await tx + .insert(connectionGrants) + .values({ + companyId, + connectionId: personalConnectionId, + kind: "user", + subjectUserId: personalIdentityUserId!, + credentialSecretRefs: [], + status: "active", + isDefault: false, + createdByUserId: personalIdentityUserId!, + }) + .onConflictDoNothing() + .returning(); + if (createdGrant) { + await tx.insert(toolAccessAuditEvents).values({ + companyId, + connectionId: personalConnectionId, + actorType: "user", + actorId: personalIdentityUserId!, + action: "connection_grant.created", + outcome: "success", + reasonCode: "personal_identity_created", + details: { kind: "user", credentialSecretRefCount: 0 }, + }); + } + return createdGrant; + }); + if (!grant) { + // Another setup attempt may have created this user's grant while + // both probes were in flight. Reuse only an active personal grant; + // never overwrite credentials, revive a revoked grant, or claim its + // audit/rollback ownership in this attempt. + const existingGrant = await vaultGrantForConnection(connectionRow, actor); + if (existingGrant?.kind !== "user" || existingGrant.subjectUserId !== personalIdentityUserId) { + throw conflict("The personal credential changed during setup. Please try again."); + } + } + // A successful public probe establishes this identity, like OAuth + // consent does. Keep its draft and grant if catalog/default discovery + // later fails: another retry may already be using the committed grant. + personalPublicSetupEstablished = true; + } if (galleryEntry?.slug === COMPOSIO_GALLERY_KEY) { const [application] = await db .select() @@ -13306,10 +13382,22 @@ export function toolAccessService( }; } const restoreDraftDefaults = Boolean(revivedConnectionPrevious); - const refresh = await refreshCatalog(connectionRow.id, actor, { + const refreshOptions = { enableAllByDefault: restoreDraftDefaults, restoreDraftDefaults, - }); + }; + const catalogConnectionId = connectionRow.id; + // Keep the established public identity outside this transaction while + // making catalog, defaults, and their audits one all-or-nothing step. + const refresh: ToolCatalogRefreshResult = personalPublicSetupEstablished + ? await db.transaction(async (tx): Promise => + toolAccessService(tx as unknown as Db, options).refreshCatalog( + catalogConnectionId, + actor, + refreshOptions, + ), + ) + : await refreshCatalog(catalogConnectionId, actor, refreshOptions); const [application] = await db .select() .from(toolApplications) @@ -13328,6 +13416,7 @@ export function toolAccessService( }, }; } catch (error) { + if (personalPublicSetupEstablished) throw error; let identityRollbackError: unknown = null; let preserveConcurrentRevival = false; if (connectionRow && revivedConnectionPrevious) {