import { restConnectorFor } from "./connectors/rest-registry.js"; import { createConnectionCredentialValidation } from "./tool-access/connections/credential-validation.js"; import { createConnectionGrants } from "./tool-access/connections/grants.js"; import { createConnectionInstalls } from "./tool-access/connections/installs.js"; import { createConnectionRemoval } from "./tool-access/connections/removal.js"; import { createToolRemoteHttp } from "./tool-access/remote-http.js"; import { classifyRisk, normalizeToolDescriptor, type McpToolDescriptor } from "./tool-access/catalog/descriptors.js"; export { classifyRisk, isGmailToolPermanentlyBlocked, isGoogleWorkspaceToolAllowed, type McpToolDescriptor } from "./tool-access/catalog/descriptors.js"; import { toCatalogEntry } from "./tool-access/catalog/projection.js"; import { toConnection, toConnectionInstall } from "./tool-access/connections/projection.js"; import { isOAuthEndpointRejection } from "./tool-access/catalog/failures.js"; import { createCatalogDefaultAccess, toPolicy } from "./tool-access/catalog/default-access.js"; import { createToolCatalogDiscovery } from "./tool-access/catalog/discovery.js"; import { createConnectionHealth } from "./tool-access/catalog/health.js"; import { createToolAppAttention } from "./tool-access/catalog/attention.js"; import { createToolCatalogPublication } from "./tool-access/catalog/publication.js"; import { createToolCatalogReader } from "./tool-access/catalog/reader.js"; import { createAggregatorAccounts } from "./tool-access/catalog/aggregator-accounts.js"; import { actorBinding } from "./tool-access/actor.js"; import { toApplication } from "./tool-access/applications/projection.js"; import { groupedActions } from "./tool-access/catalog/action-summary.js"; import { createConnectionSetupPolicy, assertSupportedConnection } from "./tool-access/connections/setup-policy.js"; import { createOrganizationGrantStore } from "./tool-access/connections/organization-grant.js"; import { createPersonalConnectionIdentity } from "./tool-access/connections/personal-identity.js"; import { createOAuthStateStore } from "./tool-access/oauth/state.js"; import { createOAuthCatalogCompletion } from "./tool-access/oauth/catalog-completion.js"; import { createOAuthAuthorizationStart } from "./tool-access/oauth/authorization-start.js"; import { createDirectOAuthCallback } from "./tool-access/oauth/direct-callback.js"; import { createCloudOAuthCallback } from "./tool-access/oauth/cloud-callback.js"; import { createVercelOAuthCallback } from "./tool-access/oauth/vercel-callback.js"; import { createOAuthAccessFinalization } from "./tool-access/oauth/access-finalization.js"; import { createVercelCredentialService, vercelConnectHttpError } from "./tool-access/vercel-credentials.js"; import { createConnectionCredentialAccess } from "./tool-access/credential-access.js"; import { createOAuthCredentialStore, actorForSecret } from "./tool-access/oauth/credential-store.js"; import { managedConnectorProfile } from "./tool-access/oauth/client-settings.js"; import { createOAuthClientRegistration } from "./tool-access/oauth/client-registration.js"; import { createOAuthRefreshLeases } from "./tool-access/oauth/refresh-leases.js"; import { createLegacyOAuthRefresh } from "./tool-access/oauth/connection-refresh.js"; import { createOAuthGrantRefresh } from "./tool-access/oauth/grant-refresh.js"; import { createGitHubGrantContinuity } from "./tool-access/github-grant-continuity.js"; import { createToolRuntimeHealth } from "./tool-access/runtime/health.js"; import { createToolRuntimeSlots } from "./tool-access/runtime/slots.js"; import { createToolActivityService } from "./tool-access/activity/service.js"; import { createToolActionReviewQueue } from "./tool-access/activity/review-queue.js"; import { createToolAccessAudit } from "./tool-access/audit.js"; import { createConnectionTokenBroker } from "./tool-access/token-broker/service.js"; import { createToolProfileService } from "./tool-access/profiles/service.js"; import { createToolProfileResolver } from "./tool-access/profiles/resolution.js"; import { toProfile, toProfileEntry, toProfileBinding } from "./tool-access/profiles/projection.js"; import { lockManagedAppPolicies, lockToolAssignmentAgents } from "./tool-access/assignment-locks.js"; export { lockToolConnectionRuntimeInputs } from "./tool-access/assignment-locks.js"; import type { ActorInfo } from "./tool-access/types.js"; import { normalizeKey, connectionUid } from "./tool-access/identifiers.js"; import { createOAuthEndpointPolicy } from "./tool-access/oauth/endpoint-policy.js"; import { createOAuthDiscovery } from "./tool-access/oauth/discovery.js"; import { createOAuthClientProtocol } from "./tool-access/oauth/client.js"; import type { OAuthClientRegistrationSource } from "./tool-access/oauth/types.js"; export { OAUTH_CLIENT_ID_METADATA_DOCUMENT_PATH, resolveOAuthClientIdMetadataDocumentUrl, oauthClientIdMetadataDocument } from "./tool-access/oauth/client-metadata.js"; import { connectionMethodFor, connectionMethodForConnection, credentialFieldsFor, normalizeConnectionMethodConfig, projectedConnectionToolInputSchema } from "./tool-access/connection-methods.js"; export { normalizeConnectionMethodConfig, projectedConnectionHeaders, projectConnectionMethodToolArguments, projectConnectionMethodToolInputSchema, projectedConnectionToolArguments, projectedConnectionToolInputSchema, } from "./tool-access/connection-methods.js"; import { loadGitHubTokenRepositories } from "./tool-access/github-access-discovery.js"; export { loadGitHubGrantMetadata, loadGitHubTokenRepositories } from "./tool-access/github-access-discovery.js"; import { stableHash } from "./tool-catalog-hash.js"; import { NATIVE_PROVIDER_SLUGS, nativeProviderCatalogFor } from "./native-provider-catalogs.js"; import { connectorToolPolicy } from "./connectors/tool-policy.js"; import { notifyChatEndpointWork } from "./chat-work-notifications.js"; import { defaultConnectionAgentInstructions } from "@paperclipai/shared"; import { isBrowserUseConnection } from "./browser-use-client.js"; import { COGNEE_STDIO_TEMPLATE } from "./cognee-connection.js"; import { isRemoteMcpConnectorMethod } from "@paperclipai/shared"; import { githubBotRequest } from "./chat-github-client.js"; import { syncConnectionCredentialBindings } from "./connection-credential-bindings.js"; import { emitConnectionCreated, emitConnectionUpdated, } from "./connector-telemetry.js"; import { canBrowseProjectRepositoryGrant, mergeProjectRepository } from "./project-repositories.js"; import { measureRunPhase } from "./execution-preparation-timing.js"; import { randomUUID } from "node:crypto"; import { readFileSync } from "node:fs"; import { and, asc, desc, eq, inArray, max, ne, sql } from "drizzle-orm"; import type { Db } from "@paperclipai/db"; import { agents, connectionGrantMembers, connectionGrants, companyMemberships, principalPermissionGrants, userSecretDefinitions, plugins, toolAccessAuditEvents, toolApplications, toolCatalogEntries, toolConnectionInstalls, toolConnections, toolStdioCommandTemplates, toolCallEvents, toolPolicies, toolProfileBindings, toolProfileEntries, toolProfiles } from "@paperclipai/db"; import type { AppDefinition, ConnectionGrantKind, CreateToolApplication, CreateToolConnection, ConnectToolApp, ConnectToolAppResult, CreateToolStdioCommandTemplate, FinishToolApp, FinishToolAppResult, CreateToolProfileBindingForProfile, CreateToolProfileEntryForProfile, DeploymentExposure, DeploymentMode, ImportMcpJson, McpConnectionCredentialRef, McpJsonImportPreview, ToolApplication, ToolCatalogEntry, ToolCatalogRefreshResult, ToolConnection, ToolConnectionInstall, ToolConnectionHealthCheckResult, ToolConnectionAuthKind, ToolConnectionCredentialPolicy, ToolConnectionCredentialSource, ToolConnectionTransport, ToolAppMetadataPreflightResult, ToolExampleInstallResult, ToolExampleSmokeCheck, ToolExampleSmokeResult, ToolExampleSummary, ToolProfileBinding, ToolProfileEntry, ToolPolicyDecision, ToolStdioCommandTemplate, UpdateToolApplication, UpdateToolConnection, VercelConnectCredentialReference } from "@paperclipai/shared"; import { getAppDefinitionForUrl, getAvailableConnectionMethods, getConnectableAppDefinition, recommendedDefaultsForApp } from "@paperclipai/shared"; import { checkMcpRemoteHeaderName, checkMcpRemoteHeaderValue, mcpRemoteHeaderNameFromConfigPath, mcpRemoteHeaderRejectionMessage, } from "@paperclipai/shared"; import { badRequest, conflict, forbidden, HttpError, notFound, unprocessable, } from "../errors.js"; import { isUniqueViolation } from "../db-errors.js"; import { forgetMcpHttpSessions } from "./mcp-http.js"; import { type RemoteHttpEndpointLookup } from "./remote-http-endpoint-guard.js"; import { REMOTE_URL_SECRET_CONFIG_PATH, remoteUrlCredentialMatchesPublicUrl, splitRemoteUrlCredential, } from "./remote-url-credentials.js"; import { secretService } from "./secrets.js"; import { connectionCredentialConfigPath as credentialRefConfigPath, validateConnectionGrantSecretOwnership, writeConnectionCredential } from "./connection-credentials.js"; import type { ConfigureRailwaySsh, RailwaySshSetup } from "@paperclipai/shared"; import { generateRailwaySshKey, RAILWAY_SSH_SECRET_PATH, validateRailwayKnownHosts } from "./railway-ssh.js"; import { isRailwayConnection } from "./railway.js"; import { toolAccessPolicyService } from "./tool-access-policy.js"; import { googleChatToolDescription } from "./google-chat-tool-policy.js"; import { gmailArchiveToolDescription } from "./gmail-archive-tool-policy.js"; import { createToolRuntimeSupervisor } from "./tool-runtime-supervisor.js"; import { appWithPaperclipCloudConnectorAvailability, paperclipCloudConnectorCapabilitiesFromEnv, createPaperclipCloudConnector, isPaperclipCloudConnectorStrategy, paperclipCloudConnectorConfigFromEnv, type PaperclipCloudConnector } from "./paperclip-cloud-connector.js"; import { createVercelConnectClient, deriveVercelConnectSubject, vercelConnectIntegrationStatus, type VercelConnectClient } from "./vercel-connect.js"; type ToolAccessServiceOptions = { deploymentMode?: DeploymentMode; deploymentExposure?: DeploymentExposure; trustedLocalStdioRuntimeHost?: string | null; now?: () => Date; /** How long persisted remote MCP action discovery remains fresh. */ catalogCacheTtlMs?: number; /** Test seam for deciding whether an OAuth client metadata URL is publicly resolvable. */ oauthClientMetadataLookup?: RemoteHttpEndpointLookup; /** Test seam for deterministic remote endpoint resolution. Production uses DNS. */ remoteHttpEndpointLookup?: RemoteHttpEndpointLookup; /** Test seam for protocol fixtures. Production uses the DNS-pinned transport. */ remoteHttpRequest?: (url: string, init: RequestInit) => Promise; /** Smaller reviewed catalog for deterministic discovery fixtures. */ composioAppToolkits?: readonly string[]; /** Test seam for the centrally registered Gmail OAuth broker. */ paperclipCloudConnector?: PaperclipCloudConnector | null; /** @deprecated Use paperclipCloudConnector. */ paperclipIdGmailConnector?: PaperclipCloudConnector | null; /** Test seam for Vercel Connect without live vendor traffic. */ vercelConnectClient?: VercelConnectClient | null; }; const GOOGLE_SHEETS_SPREADSHEET_SCHEMA = { type: "object", properties: { spreadsheetId: { type: "string", minLength: 1 }, }, required: ["spreadsheetId"], }; const GOOGLE_SHEETS_RANGE_SCHEMA = { type: "object", properties: { spreadsheetId: { type: "string", minLength: 1 }, range: { type: "string", minLength: 1, maxLength: 500 }, }, required: ["spreadsheetId", "range"], }; const GOOGLE_SHEETS_VALUE_ROWS_SCHEMA = { type: "array", minItems: 1, items: { type: "array", items: { anyOf: [ { type: "string" }, { type: "number" }, { type: "boolean" }, { type: "null" }, ], }, }, }; const GOOGLE_SHEETS_WRITE_VALUES_SCHEMA = { type: "object", properties: { spreadsheetId: { type: "string", minLength: 1 }, range: { type: "string", minLength: 1, maxLength: 500 }, values: GOOGLE_SHEETS_VALUE_ROWS_SCHEMA, valueInputOption: { type: "string", enum: ["RAW", "USER_ENTERED"], default: "RAW", }, }, required: ["spreadsheetId", "range", "values"], }; function schemaHasInputProperties(schema: unknown): boolean { if (!schema || typeof schema !== "object" || Array.isArray(schema)) return false; const properties = (schema as Record).properties; return Boolean( properties && typeof properties === "object" && !Array.isArray(properties) && Object.keys(properties).length > 0, ); } const APPROVED_STDIO_TEMPLATES: Record< string, { name: string; command?: string | null; args?: string[]; envKeys?: string[]; tools: McpToolDescriptor[]; } > = { "paperclip.echo-calculator-time": { name: "Paperclip Echo / Calculator / Time fixture", tools: [ { name: "echo", description: "Return the provided message.", inputSchema: { type: "object", properties: { message: { type: "string" } }, required: ["message"], }, annotations: { readOnlyHint: true }, }, { name: "add", description: "Add two numbers.", inputSchema: { type: "object", properties: { a: { type: "number" }, b: { type: "number" } }, required: ["a", "b"], }, annotations: { readOnlyHint: true }, }, { name: "now", description: "Return the current server time.", inputSchema: { type: "object", properties: {} }, annotations: { readOnlyHint: true }, }, { name: "fail_with_code", description: "Deterministically fail with a requested status code.", inputSchema: { type: "object", properties: { code: { type: "number" } }, required: ["code"], }, annotations: { readOnlyHint: true }, }, ], }, "paperclip.synthetic-todo-kv": { name: "Paperclip Synthetic Todo / KV fixture", tools: [ { name: "list_items", description: "List synthetic todo items.", annotations: { readOnlyHint: true }, }, { name: "create_item", description: "Create a synthetic todo item.", annotations: { readOnlyHint: false }, }, { name: "mark_done", description: "Mark a synthetic todo item done.", annotations: { readOnlyHint: false }, }, { name: "delete_item", description: "Delete a synthetic todo item.", annotations: { destructiveHint: true }, }, { name: "get_value", description: "Read a synthetic KV value.", annotations: { readOnlyHint: true }, }, { name: "set_value", description: "Write a synthetic KV value.", annotations: { readOnlyHint: false }, }, ], }, "paperclip.cognee-cloud": COGNEE_STDIO_TEMPLATE, "paperclip.google-sheets": { name: "Google Sheets", command: "paperclip-google-sheets-mcp-server", args: [], envKeys: [ "GOOGLE_SHEETS_SERVICE_ACCOUNT_JSON", "GOOGLE_SHEETS_SERVICE_ACCOUNT_JSON_PATH", "GOOGLE_SHEETS_ALLOWED_SPREADSHEET_IDS", ], tools: [ { name: "list_spreadsheets", description: "List the Google Sheets spreadsheets configured in this connection allowlist.", inputSchema: { type: "object", properties: {} }, annotations: { readOnlyHint: true }, }, { name: "get_spreadsheet_info", description: "Get spreadsheet metadata and sheet tab information for an allowlisted spreadsheet.", inputSchema: GOOGLE_SHEETS_SPREADSHEET_SCHEMA, annotations: { readOnlyHint: true }, }, { name: "read_values", description: "Read cell values from an allowlisted spreadsheet range.", inputSchema: GOOGLE_SHEETS_RANGE_SCHEMA, annotations: { readOnlyHint: true }, }, { name: "search_rows", description: "Search rows in an allowlisted spreadsheet range.", inputSchema: { type: "object", properties: { spreadsheetId: { type: "string", minLength: 1 }, range: { type: "string", minLength: 1, maxLength: 500 }, query: { type: "string", minLength: 1 }, caseSensitive: { type: "boolean", default: false }, maxResults: { type: "integer", minimum: 1, maximum: 500, default: 50, }, }, required: ["spreadsheetId", "range", "query"], }, annotations: { readOnlyHint: true }, }, { name: "append_rows", description: "Append rows to an allowlisted spreadsheet range.", inputSchema: GOOGLE_SHEETS_WRITE_VALUES_SCHEMA, annotations: { readOnlyHint: false }, }, { name: "update_values", description: "Update values in an allowlisted spreadsheet range.", inputSchema: GOOGLE_SHEETS_WRITE_VALUES_SCHEMA, annotations: { readOnlyHint: false }, }, { name: "add_sheet_tab", description: "Add a sheet tab to an allowlisted spreadsheet.", inputSchema: { type: "object", properties: { spreadsheetId: { type: "string", minLength: 1 }, title: { type: "string", minLength: 1, maxLength: 100 }, rowCount: { type: "integer", minimum: 1, maximum: 1000000 }, columnCount: { type: "integer", minimum: 1, maximum: 18278 }, }, required: ["spreadsheetId", "title"], }, annotations: { readOnlyHint: false }, }, { name: "clear_values", description: "Clear values in an allowlisted spreadsheet range.", inputSchema: GOOGLE_SHEETS_RANGE_SCHEMA, annotations: { destructiveHint: true }, }, { name: "delete_rows", description: "Delete rows from an allowlisted spreadsheet tab.", inputSchema: { type: "object", properties: { spreadsheetId: { type: "string", minLength: 1 }, sheetId: { type: "integer", minimum: 0 }, startIndex: { type: "integer", minimum: 0 }, endIndex: { type: "integer", minimum: 1 }, }, required: ["spreadsheetId", "sheetId", "startIndex", "endIndex"], }, annotations: { destructiveHint: true }, }, ], }, }; const GOOGLE_SHEETS_GALLERY_KEY = "google-sheets"; const GOOGLE_SHEETS_TEMPLATE_ID = "paperclip.google-sheets"; const GOOGLE_SHEETS_ALLOWED_SPREADSHEET_IDS_ENV = "GOOGLE_SHEETS_ALLOWED_SPREADSHEET_IDS"; type ToolExampleDefinition = { id: string; title: string; description: string; applicationKey: string; applicationName: string; applicationDescription: string; connectionName: string; templateId: keyof typeof APPROVED_STDIO_TEMPLATES; profileKey: string; profileName: string; profileDescription: string; }; const TOOL_EXAMPLES: ToolExampleDefinition[] = [ { id: "safe-read-only-todo-kv", title: "Safe read-only Todo / KV fixture", description: "Installs a deterministic local MCP fixture and grants only its read-only catalog entries.", applicationKey: "paperclip.examples.safe-read-only-todo-kv", applicationName: "Paperclip example: Safe read-only Todo / KV", applicationDescription: "Deterministic MCP fixture for first-run tool governance checks.", connectionName: "Paperclip example: Safe read-only Todo / KV", templateId: "paperclip.synthetic-todo-kv", profileKey: "paperclip.examples.safe-read-only-todo-kv.profile", profileName: "Example safe read-only tools", profileDescription: "Allows only the read-only tools from the Paperclip Todo / KV example fixture.", }, ]; function asRecord(value: unknown): Record { if (value && typeof value === "object" && !Array.isArray(value)) return value as Record; return {}; } export function googleSheetsRobotEmailFromEnv( env: NodeJS.ProcessEnv = process.env, ): | { available: true; robotEmail: string } | { available: false; reason: string } { const inlineOrPath = env.GOOGLE_SHEETS_SERVICE_ACCOUNT_JSON?.trim(); const explicitPath = env.GOOGLE_SHEETS_SERVICE_ACCOUNT_JSON_PATH?.trim(); if (!inlineOrPath && !explicitPath) { return { available: false, reason: "Google Sheets is not available on this instance yet.", }; } try { const raw = explicitPath ? readFileSync(explicitPath, "utf8") : inlineOrPath!.startsWith("{") ? inlineOrPath! : readFileSync(inlineOrPath!, "utf8"); const parsed = JSON.parse(raw) as { client_email?: unknown }; if (typeof parsed.client_email === "string" && parsed.client_email.trim()) { return { available: true, robotEmail: parsed.client_email.trim() }; } } catch { return { available: false, reason: "Google Sheets is not available on this instance yet.", }; } return { available: false, reason: "Google Sheets is not available on this instance yet.", }; } function googleSheetsAllowedSpreadsheetIds( configValues: Record | undefined, ): string[] { const raw = configValues?.allowedSpreadsheetIds; const values = Array.isArray(raw) ? raw : typeof raw === "string" ? raw.split(/[\n,]/g) : []; return Array.from( new Set(values.map((value) => String(value).trim()).filter(Boolean)), ); } function isGoogleSheetsConnectionConfig( configValues: Record | undefined, ): boolean { return ( configValues?.sourceTemplateKey === GOOGLE_SHEETS_GALLERY_KEY || configValues?.templateId === GOOGLE_SHEETS_TEMPLATE_ID ); } function normalizeGoogleSheetsConnectionConfig( configValues: Record, ): Record { if (!isGoogleSheetsConnectionConfig(configValues)) return configValues; const allowedSpreadsheetIds = googleSheetsAllowedSpreadsheetIds(configValues); if (allowedSpreadsheetIds.length === 0) { throw badRequest("Paste at least one Google Sheets link."); } return { ...configValues, allowedSpreadsheetIds, env: { ...asRecord(configValues.env), [GOOGLE_SHEETS_ALLOWED_SPREADSHEET_IDS_ENV]: allowedSpreadsheetIds.join(","), }, }; } // Detects a Postgres foreign_key_violation (SQLSTATE 23503) raised by the // tool_connections.application_id constraint — i.e. an application delete that lost the race to // a concurrently-created connection now that the FK is ON DELETE RESTRICT. Walks the error and // its `cause` since the driver may wrap the original pg error. function isToolConnectionForeignKeyViolation(error: unknown): boolean { const records: Record[] = []; let current: unknown = error; for ( let depth = 0; depth < 4 && current && typeof current === "object"; depth += 1 ) { const record = current as Record; records.push(record); current = record.cause; } return records.some((record) => { const code = typeof record.code === "string" ? record.code : null; const constraint = typeof record.constraint === "string" ? record.constraint : typeof record.constraint_name === "string" ? record.constraint_name : null; const message = typeof record.message === "string" ? record.message : ""; return ( code === "23503" && (constraint === "tool_connections_application_id_tool_applications_id_fk" || /tool_connections/.test(constraint ?? "") || /tool_connections/.test(message)) ); }); } function toCatalogEntryForConnection( row: typeof toolCatalogEntries.$inferSelect, connection: typeof toolConnections.$inferSelect, ): ToolCatalogEntry { const rawCatalogEntry = toCatalogEntry(row); const catalogEntry = { ...rawCatalogEntry, inputSchema: projectedConnectionToolInputSchema( connection, rawCatalogEntry.inputSchema ?? {}, row.toolName, ), description: gmailArchiveToolDescription( connection, row.toolName, googleChatToolDescription(connection, row.toolName, rawCatalogEntry.description), ), }; if ( connection.transport === "local_stdio" && asRecord(connection.config).templateId === GOOGLE_SHEETS_TEMPLATE_ID && !schemaHasInputProperties(catalogEntry.inputSchema) ) { const templateTool = APPROVED_STDIO_TEMPLATES[ GOOGLE_SHEETS_TEMPLATE_ID ].tools.find((tool) => tool.name === row.toolName); if (schemaHasInputProperties(templateTool?.inputSchema)) { return { ...catalogEntry, inputSchema: templateTool!.inputSchema! }; } } return catalogEntry; } function builtInStdioTemplate( templateId: string, ): ToolStdioCommandTemplate | null { const template = APPROVED_STDIO_TEMPLATES[templateId]; if (!template) return null; return { templateId, name: template.name, title: template.name, description: null, status: "active", source: "built_in", command: template.command ?? null, args: template.args ?? [], envKeys: template.envKeys ?? [], tools: template.tools.map((tool) => ({ name: tool.name, title: tool.title ?? null, description: tool.description ?? null, inputSchema: tool.inputSchema ?? { type: "object", properties: {} }, annotations: tool.annotations ?? {}, })), }; } function toStdioCommandTemplate( row: typeof toolStdioCommandTemplates.$inferSelect, ): ToolStdioCommandTemplate { return { id: row.id, companyId: row.companyId, templateId: row.templateKey, name: row.name, title: row.name, description: row.description, status: row.status, source: "admin", command: row.command, args: row.args ?? [], envKeys: row.envKeys ?? [], tools: (row.tools ?? []) .map((tool) => normalizeToolDescriptor(tool)) .filter((tool): tool is McpToolDescriptor => Boolean(tool)) .map((tool) => ({ name: tool.name, title: tool.title ?? null, description: tool.description ?? null, inputSchema: tool.inputSchema ?? { type: "object", properties: {} }, annotations: tool.annotations ?? {}, })), createdByAgentId: row.createdByAgentId, createdByUserId: row.createdByUserId, disabledAt: row.disabledAt, createdAt: row.createdAt, updatedAt: row.updatedAt, }; } function connectionSetupMutationFingerprint( row: typeof toolConnections.$inferSelect, ): string { return stableHash({ name: row.name, transport: row.transport, status: row.status, enabled: row.enabled, agentInstructions: row.agentInstructions, config: row.config, transportConfig: row.transportConfig, credentialRefs: row.credentialRefs, credentialSecretRefs: row.credentialSecretRefs, credentialSource: row.credentialSource, externalCredential: row.externalCredential, credentialPolicy: row.credentialPolicy, }); } function readStdioTemplateId(config: Record): string { const templateId = config.templateId; if (typeof templateId !== "string" || templateId.trim().length === 0) { throw badRequest( "Local stdio MCP connections must use an approved templateId", ); } return templateId.trim(); } async function gitHubReadGrantAccess(db: Db, companyId: string, connectionId: string, userId: string | null, localTrusted: boolean) { const [connection] = await db.select().from(toolConnections).where(and(eq(toolConnections.companyId, companyId), eq(toolConnections.id, connectionId))); if (!connection || !connection.enabled || connection.status !== "active" || asRecord(connection.config).sourceTemplateKey !== "github") { throw forbidden("GitHub connection is unavailable. Choose a connection you can use."); } const [membership] = userId ? await db.select().from(companyMemberships).where(and(eq(companyMemberships.companyId, companyId), eq(companyMemberships.principalId, userId), eq(companyMemberships.principalType, "user"), eq(companyMemberships.status, "active"))) : []; if (!localTrusted && (!membership || membership.membershipRole === "viewer")) throw forbidden("GitHub access requires an active company member."); const grants = await db.select().from(connectionGrants).where(and(eq(connectionGrants.companyId, companyId), eq(connectionGrants.connectionId, connectionId))); const members = await db.select().from(connectionGrantMembers).where(eq(connectionGrantMembers.companyId, companyId)); const candidates = grants.filter(grant => !(grant.kind === "organization" && ["per_user", "per_agent"].includes(connection.credentialPolicy)) && canBrowseProjectRepositoryGrant({ grant, userId, activeMember: localTrusted || Boolean(membership), audience: members.filter(member => member.grantId === grant.id).map(member => member.subjectId), })); const allowed: typeof candidates = []; for (const grant of candidates) { const ref = grant.credentialSecretRefs.find(ref => ref.configPath === "oauth.access_token" || /authorization|token|api_key/i.test(ref.configPath)); if (!ref) continue; try { await validateConnectionGrantSecretOwnership(db, connection, grant, ref); allowed.push(grant); } catch (error) { if (!(error instanceof HttpError && asRecord(error.details).code === "grant_credential_invalid")) throw error; } } const legacyShared = !grants.length && connection.credentialPolicy === "shared"; if (!allowed.length && !legacyShared) throw forbidden("Choose a GitHub connection with an active authorization you can use."); return { connection, allowed, legacyShared }; } export function toolAccessService( db: Db, options: ToolAccessServiceOptions = {}, ) { const secrets = secretService(db); const remoteHttp = createToolRemoteHttp(options); const { allowPrivateRemoteEndpoints, assertRemoteHttpUrlAllowed, fetchRemoteHttpUrl, assertRemoteEndpointAllowed, assertRemoteConnectionEndpointsAllowed, requestRemoteHttpEndpoint } = remoteHttp; const { assertOAuthEndpointUrl, safeOAuthEndpointUrl } = createOAuthEndpointPolicy(allowPrivateRemoteEndpoints); const { oauthEndpointsForConnection, discoverOAuthEndpoints, preflightConnectionMetadata } = createOAuthDiscovery({ fetchRemoteHttpUrl, assertRemoteEndpointAllowed, assertOAuthEndpointUrl, safeOAuthEndpointUrl, }); const { requestOAuthClientRegistration, exchangeOAuthToken } = createOAuthClientProtocol({ fetchRemoteHttpUrl, assertOAuthEndpointUrl, }); const policySvc = toolAccessPolicyService(db); const now = options.now ?? (() => new Date()); const assertSecretRefs = createConnectionCredentialValidation(db); const connectionInstalls = createConnectionInstalls(db, { now, getConnectionRow, assertOptionalAgent }); const { resolveInstalledConnectionsForAgent, listConnectionInstalls } = connectionInstalls; const audit = createToolAccessAudit(db); const ensureDefaultOrganizationGrant = createOrganizationGrantStore(db); const { mintConnectionTokenForAgent, loadBrokerRunContext } = createConnectionTokenBroker(db, { now, secrets, policySvc, audit, getConnectionRow, ensureDefaultOrganizationGrant }); const profileService = createToolProfileService(db, now); const profileDetails = profileService.getProfile; const profileResolver = createToolProfileResolver(db, { assertOptionalAgent, resolveInstalledConnectionsForAgent, toCatalogEntry }); const configuredCloudConnector = options.paperclipCloudConnector ?? options.paperclipIdGmailConnector; const connectorWasProvided = options.paperclipCloudConnector !== undefined || options.paperclipIdGmailConnector !== undefined; let cachedCloudConnector = configuredCloudConnector ?? null; const currentCloudConnector = (): PaperclipCloudConnector | null => { if (cachedCloudConnector || connectorWasProvided) return cachedCloudConnector; const config = paperclipCloudConnectorConfigFromEnv(); cachedCloudConnector = config ? createPaperclipCloudConnector({ config, now: () => now().getTime() }) : null; return cachedCloudConnector; }; async function appForConnectionSetup(app: AppDefinition): Promise { if (!app.methods.some((method) => isPaperclipCloudConnectorStrategy(method.oauthStrategy))) { return app; } const profiles = connectorWasProvided ? (await currentCloudConnector()?.getCapabilities() ?? []) : await paperclipCloudConnectorCapabilitiesFromEnv(); return appWithPaperclipCloudConnectorAvailability(app, profiles); } const vercelConnect = options.vercelConnectClient === undefined ? createVercelConnectClient() : options.vercelConnectClient; const runtimeSupervisor = createToolRuntimeSupervisor(db, options); const connectionGrantsService = createConnectionGrants(db, { getConnectionRow, assertSecretRefs, vercelConnect }); const removeConnection = createConnectionRemoval(db, { options, secrets, runtimeSupervisor, vercelConnect, getConnectionRow }); const credentialStore = createOAuthCredentialStore(db, secrets); const { syncCredentialBindings, annotateSavedOAuthClientSecret, oauthClientForConnection, createOrRotateOAuthSecret, getOAuthGrantRow, resolveOAuthGrantSecret } = credentialStore; const ensureOAuthClient = createOAuthClientRegistration(db, { now, options, store: credentialStore, getConnectionRow, requestOAuthClientRegistration }); const refreshLeases = createOAuthRefreshLeases(db, now, getConnectionRow, getOAuthGrantRow); const maybeRefreshOAuthCredentials = createLegacyOAuthRefresh(db, { now, secrets, store: credentialStore, leases: refreshLeases, getConnectionRow, exchangeOAuthToken }); const refreshOAuthGrantCredentials = createOAuthGrantRefresh(db, { now, store: credentialStore, leases: refreshLeases, currentCloudConnector, getConnectionRow, exchangeOAuthToken }); const { refreshManagedGitHubGrantAccess, sweepGitHubConnectionContinuity } = createGitHubGrantContinuity(db, { now, currentCloudConnector, refreshOAuthGrantCredentials, resolveOAuthGrantSecret }); const vercelCredentials = createVercelCredentialService(db, now, vercelConnect); const { resolveVercelCredentialHeaders, vercelGrantForConnection } = vercelCredentials; const credentialAccess = createConnectionCredentialAccess(db, { secrets, audit, vercel: vercelCredentials, resolveOAuthGrantSecret, maybeRefreshOAuthCredentials, refreshOAuthGrantCredentials }); const { vaultGrantForConnection, resolveCredentialHeaders, resolvedRemoteEndpoint } = credentialAccess; const setupPolicy = createConnectionSetupPolicy(db); const { assertConnectorConnectionSetupEnabled, assertExperimentalConnectorSetupEnabled } = setupPolicy; const fixedPersonalIdentityForReconnect = createPersonalConnectionIdentity(db); const runtimeSlots = createToolRuntimeSlots(db, runtimeSupervisor); const { ensureRuntimeSlot } = runtimeSlots; const defaultCatalogAccess = createCatalogDefaultAccess(db); const { enableCatalogEntriesByDefault, upsertAskFirstPolicies } = defaultCatalogAccess; const catalogDiscovery = createToolCatalogDiscovery(db, { now, audit, secrets, credentials: credentialAccess, vercel: vercelCredentials, vercelConnect, setupPolicy, requestRemoteHttpEndpoint, resolveOAuthGrantSecret, discoverOAuthEndpoints, localTools }); const connectionHealth = createConnectionHealth(db, { now, audit, getConnectionRow, discovery: catalogDiscovery, setupPolicy, resolveCredentialHeaders, refreshManagedGitHubGrantAccess, ensureRuntimeSlot, stdioTemplateId }); const { updateConnectionHealth, checkConnectionHealth, sweepConnectionHealth } = connectionHealth; const refreshCatalog = createToolCatalogPublication(db, { now, audit, getConnectionRow, discoverTools: catalogDiscovery.discoverTools, assertConnectorConnectionSetupEnabled, updateConnectionHealth, vaultGrantForConnection, ensureRuntimeSlot, enableCatalogEntriesByDefault }); const catalogReader = createToolCatalogReader(db, options, now, getConnectionRow, refreshCatalog, toCatalogEntryForConnection); const listAppsNeedingAttention = createToolAppAttention(db, now); const aggregatorAccounts = createAggregatorAccounts(db, { options, secrets, audit, credentials: credentialAccess, getConnectionRow, requestRemoteHttpEndpoint, remoteTools: catalogDiscovery.remoteTools }); const oauthState = createOAuthStateStore(db); const { peekOAuthState } = oauthState; const finishOAuthCatalogWithRecommendedDefaults = createOAuthCatalogCompletion(db, finishGalleryAppConnection); const startOAuth = createOAuthAuthorizationStart(db, { now, getConnectionRow, setupPolicy, fixedPersonalIdentityForReconnect, currentCloudConnector, vercelConnect, ensureOAuthClient, oauthEndpointsForConnection, assertOAuthEndpointUrl }); const completeOAuthCallback = createDirectOAuthCallback(db, { now, getConnectionRow, state: oauthState, store: credentialStore, setupPolicy, refreshCatalog, finishOAuthCatalogWithRecommendedDefaults, checkConnectionHealth, ensureDefaultOrganizationGrant, oauthEndpointsForConnection, exchangeOAuthToken }); const completePaperclipCloudConnectorCallback = createCloudOAuthCallback(db, { now, getConnectionRow, state: oauthState, store: credentialStore, refreshCatalog, finishOAuthCatalogWithRecommendedDefaults, currentCloudConnector }); const completeVercelConnectCallback = createVercelOAuthCallback(db, { now, getConnectionRow, state: oauthState, refreshCatalog, finishOAuthCatalogWithRecommendedDefaults, vercelConnect }); const finalizeOAuthAccess = createOAuthAccessFinalization(db, { getConnectionRow, secrets, store: credentialStore, assertAgentsInCompany, finishGalleryAppConnection }); const runtimeHealth = createToolRuntimeHealth(db, options, now, trustedRuntimeHost); const toolActivity = createToolActivityService(db, getConnectionRow); const toolActionReviewQueue = createToolActionReviewQueue(db); function trustedRuntimeHost() { return ( options.trustedLocalStdioRuntimeHost ?? process.env.PAPERCLIP_TRUSTED_MCP_RUNTIME_HOST ?? process.env.PAPERCLIP_TOOL_RUNTIME_TRUSTED_HOST ?? null ); } function assertLocalStdioCanBeEnabled( transport: ToolConnectionTransport, enabled: boolean, config: Record = {}, ) { if (config.templateId === "paperclip.cognee-cloud") return; if ( transport === "local_stdio" && enabled && options.deploymentMode === "authenticated" && options.deploymentExposure === "public" && !trustedRuntimeHost() ) { throw unprocessable( "Local stdio MCP connections cannot be enabled in authenticated public deployments without a trusted runtime host", ); } } async function getAdminStdioTemplate(companyId: string, templateId: string) { return db .select() .from(toolStdioCommandTemplates) .where( and( eq(toolStdioCommandTemplates.companyId, companyId), eq(toolStdioCommandTemplates.templateKey, templateId), ), ) .limit(1) .then((rows) => rows[0] ?? null); } async function resolveStdioTemplate( companyId: string, configOrTemplateId: Record | string, ) { const templateId = typeof configOrTemplateId === "string" ? configOrTemplateId.trim() : readStdioTemplateId(configOrTemplateId); const builtIn = builtInStdioTemplate(templateId); if (builtIn) return builtIn; const adminTemplate = await getAdminStdioTemplate(companyId, templateId); if (!adminTemplate || adminTemplate.status !== "active") { throw badRequest( "Local stdio MCP connections must use an approved templateId", ); } return toStdioCommandTemplate(adminTemplate); } async function stdioTemplateId( companyId: string, config: Record, ): Promise { return (await resolveStdioTemplate(companyId, config)).templateId; } async function assertApplication(companyId: string, applicationId: string) { const [row] = await db .select() .from(toolApplications) .where( and( eq(toolApplications.id, applicationId), eq(toolApplications.companyId, companyId), ), ); if (!row) throw notFound("Tool application not found"); return row; } async function assertOptionalAgent( companyId: string, agentId: string | null | undefined, label: string, ) { if (!agentId) return; const [row] = await db .select({ id: agents.id }) .from(agents) .where(and(eq(agents.id, agentId), eq(agents.companyId, companyId))); if (!row) throw unprocessable(`${label} must belong to the same company`); } async function assertOptionalPlugin(pluginId: string | null | undefined) { if (!pluginId) return; const [row] = await db .select({ id: plugins.id }) .from(plugins) .where(eq(plugins.id, pluginId)); if (!row) throw unprocessable("Tool application plugin was not found"); } async function assertGoogleSheetsSpreadsheetOwnership( companyId: string, config: Record, options: { excludeConnectionId?: string } = {}, ) { if (!isGoogleSheetsConnectionConfig(config)) return; const allowedSpreadsheetIds = googleSheetsAllowedSpreadsheetIds(config); if (allowedSpreadsheetIds.length === 0) return; const allowed = new Set(allowedSpreadsheetIds); const rows = await db .select({ id: toolConnections.id, companyId: toolConnections.companyId, config: toolConnections.config, }) .from(toolConnections) .where(ne(toolConnections.status, "archived")); const conflictingSpreadsheetIds = new Set(); for (const row of rows) { if (row.id === options.excludeConnectionId || row.companyId === companyId) continue; if (!isGoogleSheetsConnectionConfig(row.config)) continue; for (const spreadsheetId of googleSheetsAllowedSpreadsheetIds( row.config, )) { if (allowed.has(spreadsheetId)) conflictingSpreadsheetIds.add(spreadsheetId); } } if (conflictingSpreadsheetIds.size > 0) { throw conflict( "Google Sheets spreadsheet is already connected to another company.", { code: "google_sheets_spreadsheet_already_bound", spreadsheetIds: Array.from(conflictingSpreadsheetIds).sort(), }, ); } } async function assertCatalogEntry( companyId: string, catalogEntryId: string | null | undefined, ) { if (!catalogEntryId) return; const [row] = await db .select({ id: toolCatalogEntries.id }) .from(toolCatalogEntries) .where( and( eq(toolCatalogEntries.id, catalogEntryId), eq(toolCatalogEntries.companyId, companyId), ), ); if (!row) throw unprocessable( "Tool profile catalog entry selector must belong to the same company", ); } async function getConnectionRow(idOrUid: string, companyId?: string) { const identifier = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test( idOrUid, ) ? eq(toolConnections.id, idOrUid) : eq(toolConnections.uid, idOrUid); const where = companyId ? and(identifier, eq(toolConnections.companyId, companyId)) : identifier; const [row] = await db.select().from(toolConnections).where(where); if (!row) throw notFound("Tool connection not found"); return row; } async function localTools( connection: typeof toolConnections.$inferSelect, ): Promise { const template = await resolveStdioTemplate( connection.companyId, connection.config, ); return template.tools.map((tool) => ({ name: tool.name, title: tool.title ?? null, description: tool.description ?? null, inputSchema: tool.inputSchema ?? { type: "object", properties: {} }, annotations: tool.annotations ?? {}, })); } async function annotateAiGrantHealth(connections: ToolConnection[]) { const aiConnections = connections.filter(connection => connection.connectionPurpose === "ai"); if (!aiConnections.length) return; const grants = await db.select({ connectionId: connectionGrants.connectionId, status: connectionGrants.status }) .from(connectionGrants).where(and(eq(connectionGrants.companyId, aiConnections[0].companyId), inArray(connectionGrants.connectionId, aiConnections.map(connection => connection.id)))); for (const connection of aiConnections) { const identities = grants.filter(grant => grant.connectionId === connection.id); if (identities.length && identities.every(grant => grant.status === "revoked")) { connection.healthStatus = "missing_secret"; connection.healthMessage = "This credential was revoked. Reconnect the account to restore access."; connection.requiresReauthorization = true; } } } async function annotateGitHubAuthorization( connections: ToolConnection[], viewerUserId?: string, ) { const github = connections.filter( (connection) => asRecord(connection.config).sourceTemplateKey === "github", ); if (!github.length) return; const grants = await db .select({ connectionId: connectionGrants.connectionId, status: connectionGrants.status, kind: connectionGrants.kind, subjectUserId: connectionGrants.subjectUserId, }) .from(connectionGrants) .where( and( eq(connectionGrants.companyId, github[0].companyId), inArray( connectionGrants.connectionId, github.map((connection) => connection.id), ), ), ); for (const connection of github) { const userId = viewerUserId ?? connection.createdByUserId; const eligible = grants.filter( (grant) => grant.connectionId === connection.id && (connection.credentialPolicy !== "per_user" || (grant.kind === "user" && grant.subjectUserId === userId)), ); connection.requiresReauthorization = !eligible.some( (grant) => grant.status === "active", ); } } function findExample(exampleId: string): ToolExampleDefinition { const definition = TOOL_EXAMPLES.find( (example) => example.id === exampleId, ); if (!definition) throw notFound("Tool example not found"); return definition; } function localStdioInstallBlocker(): string | null { return options.deploymentMode === "authenticated" && options.deploymentExposure === "public" && !trustedRuntimeHost() ? "Local stdio examples require a trusted MCP runtime host in authenticated public deployments." : null; } function exampleToolSummaries( definition: ToolExampleDefinition, ): ToolExampleSummary["fixture"]["tools"] { return APPROVED_STDIO_TEMPLATES[definition.templateId].tools.map((tool) => { const riskLevel = classifyRisk(tool); return { name: tool.name, description: tool.description ?? null, riskLevel, readOnly: riskLevel === "read", }; }); } async function exampleRows( companyId: string, definition: ToolExampleDefinition, ) { const [application] = await db .select() .from(toolApplications) .where( and( eq(toolApplications.companyId, companyId), eq(toolApplications.applicationKey, definition.applicationKey), ), ); const [connection] = await db .select() .from(toolConnections) .where( and( eq(toolConnections.companyId, companyId), eq(toolConnections.name, definition.connectionName), ), ); const [profile] = await db .select() .from(toolProfiles) .where( and( eq(toolProfiles.companyId, companyId), eq(toolProfiles.profileKey, definition.profileKey), ), ); const [profileBinding] = profile ? await db .select() .from(toolProfileBindings) .where( and( eq(toolProfileBindings.companyId, companyId), eq(toolProfileBindings.profileId, profile.id), eq(toolProfileBindings.targetType, "company"), eq(toolProfileBindings.targetId, companyId), ), ) : []; const catalog = connection ? await db .select() .from(toolCatalogEntries) .where( and( eq(toolCatalogEntries.companyId, companyId), eq(toolCatalogEntries.connectionId, connection.id), ), ) .orderBy(asc(toolCatalogEntries.toolName)) : []; return { application: application ?? null, connection: connection ?? null, profile: profile ?? null, profileBinding: profileBinding ?? null, catalog, }; } function exampleSummary( definition: ToolExampleDefinition, rows: Awaited>, ): ToolExampleSummary { const blocker = localStdioInstallBlocker(); const tools = exampleToolSummaries(definition); const installed = Boolean( rows.application && rows.connection && rows.profile && rows.profileBinding && rows.connection.status !== "archived" && rows.profile.status !== "archived", ); return { id: definition.id, title: definition.title, description: definition.description, fixture: { transport: "local_stdio", templateId: definition.templateId, available: Boolean(APPROVED_STDIO_TEMPLATES[definition.templateId]), tools, }, safeDefaultProfile: { profileKey: definition.profileKey, name: definition.profileName, defaultAction: "deny", allowedToolNames: tools .filter((tool) => tool.readOnly) .map((tool) => tool.name), }, install: { installed, canInstall: !blocker, reason: blocker, applicationId: rows.application?.id ?? null, connectionId: rows.connection?.id ?? null, profileId: rows.profile?.id ?? null, profileBindingId: rows.profileBinding?.id ?? null, }, }; } async function upsertExampleApplication( companyId: string, definition: ToolExampleDefinition, existing: typeof toolApplications.$inferSelect | null, ) { const metadata = { ...(existing?.metadata ?? {}), source: "paperclip_example", exampleId: definition.id, safeDefault: true, }; if (existing) { const [updated] = await db .update(toolApplications) .set({ name: definition.applicationName, description: definition.applicationDescription, type: "mcp_stdio", status: "active", metadata, archivedAt: null, updatedAt: new Date(), }) .where(eq(toolApplications.id, existing.id)) .returning(); return { row: updated, created: false }; } const [created] = await db .insert(toolApplications) .values({ companyId, applicationKey: definition.applicationKey, name: definition.applicationName, description: definition.applicationDescription, type: "mcp_stdio", status: "active", metadata, }) .returning(); return { row: created, created: true }; } async function upsertExampleConnection( companyId: string, definition: ToolExampleDefinition, applicationId: string, existing: typeof toolConnections.$inferSelect | null, ) { const config = { templateId: definition.templateId, exampleId: definition.id, safeDefault: true, quarantineNewEntries: true, }; if (existing) { const [updated] = await db .update(toolConnections) .set({ applicationId, name: definition.connectionName, transport: "local_stdio", status: "active", enabled: true, config, transportConfig: config, credentialRefs: [], credentialSecretRefs: [], updatedAt: new Date(), }) .where(eq(toolConnections.id, existing.id)) .returning(); await ensureDefaultOrganizationGrant(updated); await syncCredentialBindings(updated); await ensureRuntimeSlot(updated); emitConnectionUpdated(updated, existing, "example"); return { row: updated, created: false }; } const connectionId = randomUUID(); const [created] = await db .insert(toolConnections) .values({ id: connectionId, companyId, applicationId, name: definition.connectionName, uid: connectionUid( "paperclip", definition.connectionName, connectionId, ), connectionKind: "managed", transport: "local_stdio", status: "active", enabled: true, config, transportConfig: config, credentialRefs: [], credentialSecretRefs: [], }) .returning(); await ensureDefaultOrganizationGrant(created); await syncCredentialBindings(created); await ensureRuntimeSlot(created); emitConnectionCreated(created, "example"); return { row: created, created: true }; } async function upsertExampleProfile( companyId: string, definition: ToolExampleDefinition, existing: typeof toolProfiles.$inferSelect | null, ) { const metadata = { ...(existing?.metadata ?? {}), source: "paperclip_example", exampleId: definition.id, safeDefault: true, }; if (existing) { const [updated] = await db .update(toolProfiles) .set({ name: definition.profileName, description: definition.profileDescription, status: "active", defaultAction: "deny", metadata, updatedAt: new Date(), }) .where(eq(toolProfiles.id, existing.id)) .returning(); return { row: updated, created: false }; } const [created] = await db .insert(toolProfiles) .values({ companyId, profileKey: definition.profileKey, name: definition.profileName, description: definition.profileDescription, status: "active", defaultAction: "deny", metadata, }) .returning(); return { row: created, created: true }; } async function syncExampleProfileEntries( companyId: string, profileId: string, catalog: ToolCatalogEntry[], ): Promise { await db .delete(toolProfileEntries) .where( and( eq(toolProfileEntries.companyId, companyId), eq(toolProfileEntries.profileId, profileId), ), ); const readEntries = catalog.filter( (entry) => entry.riskLevel === "read" && entry.status === "active", ); if (readEntries.length === 0) return []; const rows = await db .insert(toolProfileEntries) .values( readEntries.map((entry) => ({ companyId, profileId, selectorType: "catalog_entry" as const, effect: "include" as const, applicationId: entry.applicationId, connectionId: entry.connectionId, catalogEntryId: entry.id, toolName: entry.toolName, riskLevel: entry.riskLevel, conditions: { source: "paperclip_example" }, })), ) .returning(); return rows.map(toProfileEntry); } async function upsertExampleProfileBinding( companyId: string, profileId: string, existing: typeof toolProfileBindings.$inferSelect | null, actor?: ActorInfo, ): Promise { const metadata = { ...(existing?.metadata ?? {}), source: "paperclip_example", safeDefault: true, }; if (existing) { const [updated] = await db .update(toolProfileBindings) .set({ priority: 100, metadata, updatedAt: new Date() }) .where(eq(toolProfileBindings.id, existing.id)) .returning(); return toProfileBinding(updated); } const [created] = await db .insert(toolProfileBindings) .values({ companyId, profileId, targetType: "company", targetId: companyId, priority: 100, metadata, createdByAgentId: actor?.actorType === "agent" ? (actor.actorId ?? null) : null, createdByUserId: actor?.actorType === "user" ? (actor.actorId ?? null) : null, }) .returning(); return toProfileBinding(created); } async function exampleSmokeActor(companyId: string, actor?: ActorInfo) { const [agent] = await db .select({ id: agents.id }) .from(agents) .where(eq(agents.companyId, companyId)) .limit(1); if (agent) { return { actorType: "agent" as const, actorId: agent.id, agentId: agent.id, }; } const actorType = actor?.actorType === "user" ? ("user" as const) : ("system" as const); return { actorType, actorId: actor?.actorId ?? "example-smoke", agentId: null, }; } function sampleArguments(toolName: string): Record { if (toolName === "get_value") return { key: "project" }; if (toolName === "set_value") return { key: "project", value: "paperclip" }; if (toolName === "create_item") return { title: "Smoke test item" }; if (toolName === "mark_done" || toolName === "delete_item") return { id: "todo-1" }; return {}; } async function runSmokeDecisionCheck(input: { companyId: string; actor: Awaited>; connection: ToolConnection; catalogEntry: ToolCatalogEntry; expectedDecision: ToolPolicyDecision; name: string; }): Promise { const decisionInput = { companyId: input.companyId, actor: input.actor, request: { applicationId: input.connection.applicationId, connectionId: input.connection.id, catalogEntryId: input.catalogEntry.id, toolName: input.catalogEntry.toolName, arguments: sampleArguments(input.catalogEntry.toolName), }, }; const decision = await policySvc.decide(decisionInput); const auditResult = await policySvc.writeAudit( decisionInput, decision, "policy_decision", ); return { name: input.name, ok: decision.decision === input.expectedDecision, toolName: input.catalogEntry.toolName, expectedDecision: input.expectedDecision, decision: decision.decision, reasonCode: decision.reasonCode, explanation: decision.explanation, auditEventId: auditResult.legacyAuditEvent.id, toolCallEventId: auditResult.toolCallEvent.id, }; } function defaultLinkName(link: string): string { try { const url = new URL(link); const host = url.host.replace(/^www\./, ""); const path = url.pathname === "/" ? "" : url.pathname.replace(/\/+$/, ""); return `${host}${path}`.slice(0, 160) || "MCP app"; } catch { return "MCP app"; } } function linkCredentialFields(credentialValues: Record) { const fields: Array<{ label: string; configPath: string; required: boolean; placement: "header"; key: string; prefix: string | null; }> = []; if (credentialValues["credentials.authorization"]?.trim()) { fields.push({ label: "App key", configPath: "credentials.authorization", required: false, placement: "header", key: "Authorization", prefix: "Bearer ", }); } for (const configPath of Object.keys(credentialValues).sort()) { if (!configPath.startsWith("headers.")) continue; const headerName = mcpRemoteHeaderNameFromConfigPath(configPath); if (!headerName) throw badRequest("Header names cannot be blank.", { code: "mcp_header_rejected", }); // The API schema already rejected unsafe headers, but this is the last // point before a name becomes a real outbound request header — and the // normalized paste-config path lands here too — so re-check rather than // trust the caller. const nameCheck = checkMcpRemoteHeaderName(headerName); if (!nameCheck.ok) { throw badRequest( mcpRemoteHeaderRejectionMessage(headerName, nameCheck.reason!), { code: "mcp_header_rejected", headerName, }, ); } const valueCheck = checkMcpRemoteHeaderValue( credentialValues[configPath] ?? "", ); if (!valueCheck.ok) { throw badRequest( mcpRemoteHeaderRejectionMessage(headerName, valueCheck.reason!), { code: "mcp_header_rejected", headerName, }, ); } fields.push({ label: headerName, configPath, required: true, placement: "header", key: headerName, prefix: null, }); } return fields; } function nextAvailableConnectionName( requestedName: string, existingNames: readonly string[], ): string { const base = requestedName.trim() || "Custom app"; const used = new Set( existingNames.map((candidate) => candidate.trim().toLocaleLowerCase()), ); const unsuffixed = base.slice(0, 160); if (!used.has(unsuffixed.toLocaleLowerCase())) return unsuffixed; for (let index = 2; index < 10_000; index += 1) { const suffix = ` (${index})`; const candidate = `${base.slice(0, 160 - suffix.length).trimEnd()}${suffix}`; if (!used.has(candidate.toLocaleLowerCase())) return candidate; } return `${base.slice(0, 151).trimEnd()} (${randomUUID().slice(0, 6)})`; } async function connectGalleryApp( companyId: string, input: ConnectToolApp, actor?: ActorInfo, ): Promise { const definition = input.galleryKey ? getConnectableAppDefinition(input.galleryKey) : null; if (input.galleryKey && !definition) throw notFound("Tool app gallery entry not found"); const galleryEntry = definition ? await appForConnectionSetup(definition) : null; let existingApplication: typeof toolApplications.$inferSelect | null = null; let requestedResumeConnection: typeof toolConnections.$inferSelect | null = null; const requestedConnectionId = input.resumeConnectionId ?? input.reconnectConnectionId; if (requestedConnectionId) { const [connection] = await db .select() .from(toolConnections) .where( and( eq(toolConnections.id, requestedConnectionId), eq(toolConnections.companyId, companyId), ), ); if (!connection) throw notFound("Incomplete app connection not found"); assertSupportedConnection(connection); if (input.resumeConnectionId && connection.status !== "draft") { throw conflict("Only an incomplete app connection can resume setup", { code: "connection_setup_not_incomplete", }); } if ( input.applicationId && input.applicationId !== connection.applicationId ) { throw badRequest("The app and draft connection do not match"); } const [application] = await db .select() .from(toolApplications) .where( and( eq(toolApplications.id, connection.applicationId), eq(toolApplications.companyId, companyId), ), ); if (!application) throw notFound("App not found"); const source = asRecord(connection.config).sourceTemplateKey ?? asRecord(connection.transportConfig).sourceTemplateKey ?? asRecord(application.metadata).sourceTemplateKey ?? asRecord(application.metadata).source; if ( input.reconnectConnectionId && ((galleryEntry && source !== galleryEntry.slug) || (!galleryEntry && typeof source === "string" && getConnectableAppDefinition(source))) ) { throw badRequest("Reconnect must preserve the configured provider"); } requestedResumeConnection = connection; existingApplication = application; } else if (input.applicationId) { const [row] = await db .select() .from(toolApplications) .where( and( eq(toolApplications.id, input.applicationId), eq(toolApplications.companyId, companyId), ), ); if (!row) throw notFound("App not found"); existingApplication = row; } else { // Removal and interrupted setup intentionally retain their // application/connection rows. A fresh gallery click has no // applicationId, so recover that retained identity by its company-unique // name and source instead of inserting a duplicate application that the // unique index rejects. Active applications are excluded: connecting a // second account still requires its own name/application identity. const requestedName = input.name ?? galleryEntry?.name ?? defaultLinkName(input.link ?? ""); const recoverableApplicationStatuses = galleryEntry ? (["draft", "archived"] as const) : (["archived"] as const); const [recoverableApplication] = await db .select() .from(toolApplications) .where( and( eq(toolApplications.companyId, companyId), eq(toolApplications.name, requestedName), inArray(toolApplications.status, recoverableApplicationStatuses), ), ) .orderBy(desc(toolApplications.updatedAt)) .limit(1); const recoverableSource = recoverableApplication?.metadata ? (recoverableApplication.metadata.sourceTemplateKey ?? recoverableApplication.metadata.galleryKey ?? recoverableApplication.metadata.source) : null; const requestedSource = galleryEntry?.slug ?? (input.link ? "link" : null); if ( recoverableApplication && requestedSource && recoverableSource === requestedSource ) { existingApplication = recoverableApplication; } } await assertConnectorConnectionSetupEnabled(requestedResumeConnection ?? {}, existingApplication?.applicationKey); const requestedName = input.name ?? existingApplication?.name ?? galleryEntry?.name ?? defaultLinkName(input.link ?? ""); // Compatibility for the original Sheets robot flow, whose clients predate // method selection and identify the method by its spreadsheet allowlist. const inferredMethodKey = !input.connectionMethodKey && galleryEntry?.slug === "google-sheets" && Array.isArray(input.configValues?.allowedSpreadsheetIds) ? "local" : input.connectionMethodKey; if (!galleryEntry && input.connectionMethodKey) throw badRequest("Connection method selection requires a gallery app"); if ( galleryEntry && getAvailableConnectionMethods(galleryEntry).filter( (candidate) => candidate.purpose !== "channel" && candidate.purpose !== "ai" && candidate.transport !== "chat_sdk" && candidate.transport !== "runtime_auth", ).length > 1 && !inferredMethodKey ) { throw badRequest("Choose a connection method for this app"); } const method = galleryEntry ? connectionMethodFor(galleryEntry, inferredMethodKey) : null; const remoteMcpConnector = isRemoteMcpConnectorMethod(galleryEntry?.slug, method?.key); await assertExperimentalConnectorSetupEnabled(galleryEntry?.slug, Boolean( input.reconnectConnectionId && requestedResumeConnection?.status !== "draft" && requestedResumeConnection?.config.sourceTemplateKey === galleryEntry?.slug, )); if (galleryEntry && input.link) { const acceptsProviderGeneratedUrl = method?.transport === "mcp_remote" && method.auth === "none" && !method.defaults?.serverUrl && !method.defaults?.serverUrlTemplate; if (!acceptsProviderGeneratedUrl && !remoteMcpConnector) { throw badRequest( `${galleryEntry.name} does not accept a provider-generated connection URL`, ); } if (!(remoteMcpConnector && galleryEntry.slug === "executor") && !getAppDefinitionForUrl(input.link, [galleryEntry])) { throw badRequest( `That connection URL does not belong to ${galleryEntry.name}`, ); } } if (requestedResumeConnection && galleryEntry) { const storedConnectionSource = requestedResumeConnection.config?.sourceTemplateKey ?? requestedResumeConnection.transportConfig?.sourceTemplateKey; const storedApplicationSource = existingApplication?.metadata?.sourceTemplateKey ?? existingApplication?.metadata?.galleryKey; if ( storedConnectionSource !== galleryEntry.slug && storedApplicationSource !== galleryEntry.slug ) { throw badRequest( "The selected provider does not match this incomplete connection", ); } } // Reconnect is not a second identity decision. Removed connections retain // their row precisely so the next credential can be attached to the same // identity and history. Resolve that retained row before interpreting the // request so a client default cannot silently turn a personal connection // into an organization connection (or vice versa). const canResumeInterruptedDraft = Boolean(requestedResumeConnection) || (!input.applicationId && Boolean(galleryEntry) && existingApplication?.status === "draft"); const retainedConnectionStatuses = canResumeInterruptedDraft ? (["draft", "archived"] as const) : (["archived"] as const); const [recoveredConnection] = !requestedResumeConnection && existingApplication ? await db .select() .from(toolConnections) .where( and( eq(toolConnections.companyId, companyId), eq(toolConnections.applicationId, existingApplication.id), inArray(toolConnections.status, retainedConnectionStatuses), ), ) .orderBy(desc(toolConnections.updatedAt)) .limit(1) : [undefined]; let retainedConnection = requestedResumeConnection ?? recoveredConnection; const transport = method?.transport ?? "mcp_remote"; const applicationType = transport === "rest_api" ? "rest_api" : transport === "mcp_remote" ? "mcp_http" : transport === "local_stdio" ? "mcp_stdio" : null; if ((retainedConnection && retainedConnection.transport !== transport) || (existingApplication && applicationType && existingApplication.type !== applicationType)) { if (requestedResumeConnection || input.applicationId) { throw conflict("Create a separate app connection to change its connection method transport.", { code: "connection_transport_change_not_supported", }); } // A new gallery connection may use another transport for the same app. // Do not revive an archived MCP identity as REST (or the reverse): the // application's type and any sibling connections belong to that identity. retainedConnection = undefined; existingApplication = null; } let applicationName = existingApplication?.name ?? requestedName; let name = retainedConnection?.name ?? requestedName; if (!existingApplication) { const applicationNames = await db .select({ name: toolApplications.name }) .from(toolApplications) .where(eq(toolApplications.companyId, companyId)); applicationName = nextAvailableConnectionName( requestedName, applicationNames.map((row) => row.name), ); name = applicationName; } else if (!retainedConnection) { const connectionNames = await db .select({ name: toolConnections.name }) .from(toolConnections) .where( and( eq(toolConnections.companyId, companyId), eq(toolConnections.applicationId, existingApplication.id), ), ); name = nextAvailableConnectionName( requestedName, connectionNames.map((row) => row.name), ); } const previousGrantKind: ConnectionGrantKind | null = retainedConnection ? retainedConnection.credentialPolicy === "per_user" ? "user" : retainedConnection.credentialPolicy === "per_agent" ? "agent" : "organization" : null; // An explicit resume/application reconnect continues the retained identity. // A fresh gallery connect may still reuse an archived row for stable history // and company-unique naming, but an explicit Access choice is a new identity // decision and must replace the archived policy. Without this distinction, // removing a dedicated-agent connection and reconnecting the default personal // account leaves `per_agent` behind and the OAuth callback cannot persist its // user grant. const retainsIdentity = Boolean( retainedConnection && (retainedConnection.status === "draft" || requestedResumeConnection || input.applicationId), ); const retainedGrantKind = retainsIdentity ? previousGrantKind : null; // The route can authorize an explicit resume before entering the service, // but name/source recovery happens here. Do not let a caller submit a // personal grant choice to pass the route and then inherit an implicitly // recovered organization identity. Local implicit mode is already the // unrestricted instance operator; every authenticated user must still hold // current connection-manager authority before this retained row is touched. if ( previousGrantKind && input.grantKind && previousGrantKind !== input.grantKind && actor?.actorType === "user" && actor.actorSource !== "local_implicit" ) { const actorUserId = actor.actorId; const [membership] = actorUserId ? await db .select({ membershipRole: companyMemberships.membershipRole, }) .from(companyMemberships) .where( and( eq(companyMemberships.companyId, companyId), eq(companyMemberships.principalType, "user"), eq(companyMemberships.principalId, actorUserId), eq(companyMemberships.status, "active"), ), ) .limit(1) : []; const roleCanManage = membership?.membershipRole === "owner" || membership?.membershipRole === "admin"; const [explicitManagerGrant] = roleCanManage || !actorUserId ? [] : await db .select({ id: principalPermissionGrants.id, }) .from(principalPermissionGrants) .where( and( eq(principalPermissionGrants.companyId, companyId), eq(principalPermissionGrants.principalType, "user"), eq(principalPermissionGrants.principalId, actorUserId), eq( principalPermissionGrants.permissionKey, "tools:manage_connections", ), ), ) .limit(1); if (!roleCanManage && !explicitManagerGrant) { throw forbidden( "Only a company owner, admin, or member with connection-manager permission can change this connection's credential identity.", ); } } const requestedGrantKind = retainedGrantKind ?? input.grantKind ?? (method?.grantKinds?.length === 1 ? method.grantKinds[0]! : "organization"); if (method?.grantKinds && !method.grantKinds.includes(requestedGrantKind)) { throw badRequest( `${galleryEntry?.name ?? "This app"} supports only ${method.grantKinds.join(" or ")} credentials`, ); } const dedicatedAgentId = requestedGrantKind === "agent" ? (input.subjectAgentId ?? null) : null; if (dedicatedAgentId) { const [subjectAgent] = await db .select({ id: agents.id }) .from(agents) .where( and(eq(agents.id, dedicatedAgentId), eq(agents.companyId, companyId)), ) .limit(1); if (!subjectAgent) throw badRequest( "Dedicated GitHub identity requires an agent in this company", ); } const credentialSource: ToolConnectionCredentialSource = input.credentialSource ?? "paperclip_vault"; if ( retainedConnection && retainedConnection.credentialSource !== credentialSource ) { throw conflict( "Changing credential source requires a new app connection", { code: "credential_source_migration_not_supported", }, ); } let externalCredential: VercelConnectCredentialReference | null = null; if (credentialSource === "vercel_connect") { const integration = vercelConnectIntegrationStatus(); if (!integration.enabled || !integration.configured || !vercelConnect) { throw unprocessable( "Vercel Connect setup is not available on this Paperclip instance", { code: "vercel_connect_unavailable", }, ); } if ( !galleryEntry || !method || method.transport !== "mcp_remote" || method.auth === "none" ) { throw badRequest( "Vercel Connect is available only for reviewed remote MCP app methods", ); } const reviewed = method.credentialSources?.vercelConnect; if (!reviewed) { throw unprocessable( `${galleryEntry.name} has not been reviewed for Vercel Connect`, { code: "vercel_connect_method_not_reviewed", }, ); } const expectedPrincipalMode = method.auth === "oauth" ? "user" : "app"; if (!reviewed.principalModes.includes(expectedPrincipalMode)) { throw unprocessable( "This connector principal mode has not been reviewed for this app", { code: "vercel_connect_principal_not_reviewed", }, ); } if ( expectedPrincipalMode === "app" && requestedGrantKind !== "organization" ) { throw badRequest( "App-subject Vercel connectors can only back an organization identity", ); } let metadata; try { metadata = await vercelConnect.getConnectorMetadata( input.vercelConnect!.connector, ); } catch (error) { throw vercelConnectHttpError(error); } const service = metadata.service.trim().toLowerCase(); if ( !reviewed.services.map((value) => value.toLowerCase()).includes(service) ) { throw badRequest( `That Vercel connector is for ${metadata.service}, not ${galleryEntry.name}`, { code: "vercel_connect_service_mismatch", }, ); } if (expectedPrincipalMode === "app") { const [connectorInUse] = await db .select({ id: toolConnections.id }) .from(toolConnections) .where( and( eq(toolConnections.credentialSource, "vercel_connect"), ne(toolConnections.status, "archived"), sql`${toolConnections.externalCredential}->>'connectorUid' = ${metadata.uid}`, ...(retainedConnection ? [ne(toolConnections.id, retainedConnection.id)] : []), ), ) .limit(1); if (connectorInUse) { throw conflict( "App-subject Vercel connectors are dedicated to one Paperclip connection. Create or attach a separate connector in Vercel.", { code: "vercel_connect_app_connector_in_use", }, ); } } externalCredential = { provider: "vercel_connect", connectorId: metadata.id, connectorUid: metadata.uid, service: metadata.service, connectorType: metadata.type, principalMode: expectedPrincipalMode, headerName: reviewed.header.name, headerPrefix: reviewed.header.prefix ?? null, scopes: [...reviewed.scopes], }; } const isGoogleSheetsRobotMethod = galleryEntry?.slug === GOOGLE_SHEETS_GALLERY_KEY && method?.key === "local"; const normalizedMethodConfig = isGoogleSheetsRobotMethod || !method ? null : normalizeConnectionMethodConfig(method, input.configValues ?? asRecord(asRecord(retainedConnection?.config).methodConfig)); const remoteUrlCredential = transport === "mcp_remote" && input.link ? splitRemoteUrlCredential(input.link) : null; const baseConfig = (transport === "mcp_remote" || transport === "rest_api") ? { url: (remoteMcpConnector ? remoteUrlCredential?.publicUrl : undefined) ?? normalizedMethodConfig?.url ?? method?.defaults?.serverUrl ?? remoteUrlCredential?.publicUrl ?? input.link ?? "", } : { templateId: method?.defaults?.templateKey }; let config: Record = galleryEntry ? { ...baseConfig, sourceTemplateKey: galleryEntry.slug, connectionMethodKey: method?.key, methodConfig: normalizedMethodConfig?.values ?? {}, ...(method?.defaults?.mcpSessionRequired === true ? { mcpSessionRequired: true } : {}), // Grant-backed setup keeps the full discovered catalog selectable; // the wizard projects the app's action defaults into policies at // finish time instead of using catalog quarantine as access state. quarantineNewEntries: connectorToolPolicy(galleryEntry.slug).quarantineNewEntries, ...(remoteMcpConnector ? { mcpSessionRequired: true, mcpAuthMode: input.authMode ?? "auto", mcpPreserveAccess: Boolean(retainedConnection && (retainedConnection.status === "active" || asRecord(retainedConnection.config).mcpPreserveAccess === true)), } : {}), ...(galleryEntry.slug === "posthog" ? { safeDefault: true } : {}), // Telem.AI attributes each search to the Paperclip company, agent, // run and issue, so its catalog connection forwards those context // headers by default (the gateway still drops empty values). A // reconnect keeps the policy the operator saved on the connection. ...(galleryEntry.slug === "telem" ? { headerPolicy: asRecord(retainedConnection?.config).headerPolicy ?? { metadata: { forward: ["company_id", "issue_id", "agent_id", "run_id", "project_id", "correlation_id"], }, }, } : {}), } : { ...baseConfig, quarantineNewEntries: false, unverifiedServer: true }; if (method && isPaperclipCloudConnectorStrategy(method.oauthStrategy)) { const connectorProfile = method.connectorProfile; const profile = managedConnectorProfile(connectorProfile); if (!profile) throw badRequest("This app has an invalid managed connector profile"); config.oauth = { strategy: method.oauthStrategy, provider: profile.provider, connectorProfile: profile.id, resource: method.defaults?.serverUrl, scopes: [...profile.scopes], }; config.quarantineNewEntries = true; } const acceptsCustomerOAuthClient = remoteMcpConnector || (method?.auth === "oauth" && method.ownershipModes.includes("customer")); if (galleryEntry && input.oauthClient && !acceptsCustomerOAuthClient) { throw badRequest( `${galleryEntry.name} does not accept customer-owned OAuth client credentials`, ); } // A pasted URL or an explicitly customer-owned curated method may arrive // with a client the operator preregistered in the provider's console. Record // the client id now; the secret becomes an encrypted Paperclip secret below. if (input.oauthClient) { config.oauth = { clientId: input.oauthClient.clientId.trim(), clientRegistrationSource: "manual" satisfies OAuthClientRegistrationSource, clientCompanyId: companyId, }; } if (isGoogleSheetsRobotMethod) { const availability = googleSheetsRobotEmailFromEnv(); if (!availability.available) { throw unprocessable(availability.reason, { code: "google_sheets_unavailable", }); } const allowedSpreadsheetIds = googleSheetsAllowedSpreadsheetIds( input.configValues, ); if (allowedSpreadsheetIds.length === 0) { throw badRequest("Paste at least one Google Sheets link."); } config.allowedSpreadsheetIds = allowedSpreadsheetIds; config.robotEmail = availability.robotEmail; config.env = { [GOOGLE_SHEETS_ALLOWED_SPREADSHEET_IDS_ENV]: allowedSpreadsheetIds.join(","), }; config = normalizeGoogleSheetsConnectionConfig(config); await assertGoogleSheetsSpreadsheetOwnership(companyId, config); } if (transport === "mcp_remote") await assertRemoteConnectionEndpointsAllowed(config); if (transport === "local_stdio") await stdioTemplateId(companyId, config); assertLocalStdioCanBeEnabled(transport, false); const credentialValues = input.credentialValues ?? {}; // A curated method declares its auth kind. A pasted URL declares one only // under Advanced authentication; on the simple path it starts from what the // operator supplied and is upgraded to `oauth` when discovery proves the // endpoint needs sign-in (see `remoteTools` and `startOAuth`). const genericAuthKind: ToolConnectionAuthKind = (remoteMcpConnector ? undefined : method?.auth) ?? (input.authMode === "oauth" || input.oauthClient ? "oauth" : input.authMode === "bearer" || input.authMode === "custom_headers" ? "api_key" : input.authMode === "none" ? "none" : Object.keys(credentialValues).length > 0 ? "api_key" : "none"); const credentialSecretRefs: CreateToolConnection["credentialSecretRefs"] = []; const credentialRefs: McpConnectionCredentialRef[] = []; const createdSecretIds: string[] = []; const createdDefinitionIds: string[] = []; // "Just me" needs a named board user to own the consent. An agent actor // cannot hold a personal identity, and silently falling back to a shared // credential is exactly the mis-scoping the design forbids, so refuse. const personalIdentityUserId = requestedGrantKind === "user" ? actor?.actorType === "user" && actor.actorId ? actor.actorId : null : null; if (requestedGrantKind === "user" && !personalIdentityUserId) { throw badRequest( "Connecting an app as yourself requires a signed-in user", ); } const retainedPersonalIdentity = retainedConnection?.credentialPolicy === "per_user" ? await fixedPersonalIdentityForReconnect( retainedConnection, personalIdentityUserId ?? undefined, actor, ) : null; const retainedConfig = asRecord(retainedConnection?.config); const retainedMethodKey = retainedConfig.connectionMethodKey; const retainedSource = retainedConfig.sourceTemplateKey ?? asRecord(retainedConnection?.transportConfig).sourceTemplateKey; // Setup forms never receive stored secret values. Treat an omitted value as // "keep the existing secret" only while resuming the exact same curated // provider and method. This prevents a retry from detaching a client secret // or API key, without carrying credentials across a method/provider change. const canRetainCredentialMaterial = Boolean( retainedConnection && previousGrantKind === requestedGrantKind && galleryEntry && retainedSource === galleryEntry.slug && retainedMethodKey === method?.key && (!remoteMcpConnector || (baseConfig.url === retainedConfig.url && genericAuthKind === retainedConnection.authKind)), ); const retainedCredentialSecretRefs = canRetainCredentialMaterial ? (retainedPersonalIdentity?.grant?.credentialSecretRefs ?? retainedConnection?.credentialSecretRefs ?? []) : []; const credentialPolicy: ToolConnectionCredentialPolicy = requestedGrantKind === "user" ? "per_user" : requestedGrantKind === "agent" ? "per_agent" : "shared"; const connectionOwnership = isPaperclipCloudConnectorStrategy( method?.oauthStrategy, ) ? "platform_shared" : "customer"; let applicationRow: typeof toolApplications.$inferSelect | null = null; let connectionRow: typeof toolConnections.$inferSelect | null = null; let revivedConnectionPrevious: typeof toolConnections.$inferSelect | null = retainedConnection ?? null; let revivedGrantMutation: { previous: typeof connectionGrants.$inferSelect | null; current: typeof connectionGrants.$inferSelect; } | null = null; let personalPublicSetupEstablished = false; try { const credentialFields = credentialSource === "vercel_connect" ? [] : galleryEntry && !remoteMcpConnector ? credentialFieldsFor(galleryEntry, method?.key) : linkCredentialFields({ ...Object.fromEntries(retainedCredentialSecretRefs.filter((ref) => ref.configPath.startsWith("headers.") || ref.configPath === "credentials.authorization").map((ref) => [ref.configPath, "retained"])), ...credentialValues }); for (const field of credentialFields) { const value = credentialValues[field.configPath]; const retainedSecretRef = retainedCredentialSecretRefs.find( (ref) => ref.configPath === field.configPath, ); if (!value && retainedSecretRef) { credentialSecretRefs.push(retainedSecretRef); if (field.placement === "header" && field.key) { credentialRefs.push({ name: field.configPath, secretId: retainedSecretRef.secretId, version: retainedSecretRef.versionSelector ?? "latest", placement: "header", key: field.key, prefix: field.prefix ?? null, }); } continue; } if (!value && field.required !== false) { throw badRequest(`Missing credential value for ${field.configPath}`); } if (!value) continue; const written = await db.transaction((tx) => writeConnectionCredential(tx, { companyId, connectionName: name, configPath: field.configPath, label: field.label, value, ownerUserId: personalIdentityUserId, actor: actorForSecret(actor), })); const { secret } = written; if (written.definitionId) createdDefinitionIds.push(written.definitionId); createdSecretIds.push(secret.id); credentialSecretRefs.push({ secretId: secret.id, versionSelector: "latest", configPath: field.configPath, required: field.required ?? true, label: field.label, }); if (field.placement === "header" && field.key) { credentialRefs.push({ name: field.configPath, secretId: secret.id, version: "latest", placement: "header", key: field.key, prefix: field.prefix ?? null, }); } } if (!remoteUrlCredential?.secretUrl && canRetainCredentialMaterial && baseConfig.url === retainedConfig.url) { const retainedUrl = retainedCredentialSecretRefs.find((ref) => ref.configPath === REMOTE_URL_SECRET_CONFIG_PATH); if (retainedUrl) { credentialSecretRefs.push(retainedUrl); credentialRefs.push({ name: REMOTE_URL_SECRET_CONFIG_PATH, secretId: retainedUrl.secretId, version: retainedUrl.versionSelector ?? "latest", placement: "url", key: "url", prefix: null }); } } if (remoteUrlCredential?.secretUrl) { const written = await db.transaction((tx) => writeConnectionCredential(tx, { companyId, connectionName: name, configPath: REMOTE_URL_SECRET_CONFIG_PATH, label: "MCP server URL", value: remoteUrlCredential.secretUrl!, ownerUserId: personalIdentityUserId, actor: actorForSecret(actor), })); const { secret } = written; if (written.definitionId) createdDefinitionIds.push(written.definitionId); createdSecretIds.push(secret.id); credentialSecretRefs.push({ secretId: secret.id, versionSelector: "latest", configPath: REMOTE_URL_SECRET_CONFIG_PATH, required: true, label: "MCP server URL", }); credentialRefs.push({ name: REMOTE_URL_SECRET_CONFIG_PATH, secretId: secret.id, version: "latest", placement: "url", key: "url", prefix: null, }); } // A preregistered OAuth client secret is not a request header — it is only // ever sent to the token endpoint — so it gets a secret ref with no // credential ref, keeping it out of `projectedConnectionHeaders`. if (input.oauthClient?.clientSecret) { const secret = await secrets.create( companyId, { name: `${name} OAuth client secret ${randomUUID().slice(0, 8)}`, key: `tool_app.${randomUUID()}.oauth_client_secret`, provider: "local_encrypted", value: input.oauthClient.clientSecret, description: `OAuth client secret for ${name}.`, }, actorForSecret(actor), ); createdSecretIds.push(secret.id); credentialSecretRefs.push({ secretId: secret.id, versionSelector: "latest", configPath: "oauth.client_secret", required: false, label: "OAuth client secret", }); } else if (input.oauthClient) { const retainedOAuth = asRecord(retainedConfig.oauth); const clientIdUnchanged = retainedOAuth.clientId === input.oauthClient.clientId.trim(); const retainedClientSecretRef = clientIdUnchanged ? retainedCredentialSecretRefs.find( (ref) => ref.configPath === "oauth.client_secret", ) : undefined; if (retainedClientSecretRef) credentialSecretRefs.push(retainedClientSecretRef); } const safeApplicationDescription = galleryEntry?.description ?? `Connected app at ${remoteUrlCredential?.publicUrl ?? input.link}`; if (existingApplication) { if (existingApplication.status !== "active") { [applicationRow] = await db .update(toolApplications) .set({ status: "draft", archivedAt: null, ...(!galleryEntry ? { description: safeApplicationDescription } : {}), updatedAt: new Date(), }) .where(eq(toolApplications.id, existingApplication.id)) .returning(); } else { applicationRow = existingApplication; } } else { // Name selection is optimistic because multiple setup requests can // legitimately begin at the same time. The company/name unique index // is the authority: if another request wins after our read, refresh the // names and retry with the next suffix instead of surfacing a conflict // the user never asked to resolve. for (let attempt = 0; attempt < 10 && !applicationRow; attempt += 1) { try { [applicationRow] = await db .insert(toolApplications) .values({ companyId, applicationKey: `app-gallery:${galleryEntry?.slug ?? "link"}:${randomUUID()}`, name: applicationName, description: safeApplicationDescription, type: transport === "rest_api" && (["browser-use-cloud", ...NATIVE_PROVIDER_SLUGS].includes(galleryEntry?.slug ?? "") || Boolean(restConnectorFor({ transport, config }))) ? "rest_api" : transport === "mcp_remote" ? "mcp_http" : "mcp_stdio", status: "draft", metadata: galleryEntry ? { sourceTemplateKey: galleryEntry.slug, galleryKey: galleryEntry.slug, } : { source: "link" }, }) .returning(); } catch (error) { if (!isUniqueViolation(error, "tool_applications_company_name_uq")) throw error; const applicationNames = await db .select({ name: toolApplications.name }) .from(toolApplications) .where(eq(toolApplications.companyId, companyId)); applicationName = nextAvailableConnectionName( requestedName, applicationNames.map((row) => row.name), ); name = applicationName; } } if (!applicationRow) { throw conflict( "Paperclip could not allocate a unique connection name", { code: "tool_access_name_allocation_exhausted", }, ); } } await assertSecretRefs(companyId, [ ...credentialRefs, ...credentialSecretRefs, ]); // Reconnecting an app revives its most recent archived connection instead // of inserting a fresh row: keeps the connection id (and its activity // history) stable and avoids the unique (company, name) constraint. // A personal credential never becomes the connection's shared secret: the // row carries the header shape only, and the secret refs go to the user // grant below. `ensureDefaultOrganizationGrant` copies this list, so // leaving it empty is what keeps the secret off an organization grant. const connectionCredentialSecretRefs = personalIdentityUserId || dedicatedAgentId ? [] : credentialSecretRefs; if (revivedConnectionPrevious) { forgetMcpHttpSessions(revivedConnectionPrevious.id); [connectionRow] = await db .update(toolConnections) .set({ name, authKind: genericAuthKind, transport, status: "draft", enabled: false, agentInstructions: input.agentInstructions !== undefined ? input.agentInstructions : retainedConnection ? retainedConnection.agentInstructions : defaultConnectionAgentInstructions(galleryEntry?.agentInstructions), config, transportConfig: config, credentialRefs, credentialSecretRefs: connectionCredentialSecretRefs, credentialSource, externalCredential, credentialPolicy, updatedAt: new Date(), }) .where(eq(toolConnections.id, revivedConnectionPrevious.id)) .returning(); } else { const connectionId = randomUUID(); [connectionRow] = await db .insert(toolConnections) .values({ id: connectionId, companyId, applicationId: applicationRow.id, name, uid: connectionUid( applicationRow.applicationKey ?? applicationRow.name, name, connectionId, ), connectionKind: "managed", ownership: connectionOwnership, authKind: genericAuthKind, credentialSource, externalCredential, transport, status: "draft", enabled: false, agentInstructions: input.agentInstructions !== undefined ? input.agentInstructions : retainedConnection ? retainedConnection.agentInstructions : defaultConnectionAgentInstructions(galleryEntry?.agentInstructions), config, transportConfig: config, credentialRefs, credentialSecretRefs: connectionCredentialSecretRefs, credentialPolicy, createdByAgentId: actor?.actorType === "agent" ? (actor.actorId ?? null) : null, createdByUserId: actor?.actorType === "user" ? (actor.actorId ?? null) : null, }) .returning(); } if (revivedConnectionPrevious) { emitConnectionUpdated( connectionRow, revivedConnectionPrevious, "gallery", ); } else { emitConnectionCreated(connectionRow, "gallery"); } if (personalIdentityUserId) { // "Just me" (PAP-17835 seam #4). The credential is committed straight to // the caller's own grant; the connection row keeps only the header // *shape* in `credentialRefs` (which the gateway reads for placement and // then resolves against the acting user's grant) and no shared secret // refs at all. Two consequences the design requires: // - `ensureDefaultOrganizationGrant` is skipped, so no organization // grant is created first and later "moved" to a user grant. It stays // absent until someone explicitly connects an organization identity, // which is what makes "Organization identity · Not connected" // truthful rather than a silent fallback. // - the credential never lands in `connection.credentialSecretRefs`, // which is what an organization grant would have copied. // // OAuth is the exception: no credential exists yet at connect time, and // the callback upserts this same (connection, user, subject) grant with // the tokens. Pre-creating an empty `active` grant there would render as // "Connected" with nothing behind it, so the grant is left to the // callback and only the organization grant is suppressed. if (credentialSecretRefs.length > 0) { let changedGrant: typeof connectionGrants.$inferSelect; let previousGrant: typeof connectionGrants.$inferSelect | null = null; if (retainedPersonalIdentity?.grant) { const [grantSnapshot] = await db .select({ grant: connectionGrants, // Preserve PostgreSQL microseconds for the optimistic update; // a JavaScript Date truncates them and falsely reports a race. updatedAtVersion: sql`${connectionGrants.updatedAt}::text`, }) .from(connectionGrants) .where(eq(connectionGrants.id, retainedPersonalIdentity.grant.id)) .limit(1); const currentGrant = grantSnapshot?.grant; if (!currentGrant) throw conflict( "The personal credential changed during setup. Please try again.", ); previousGrant = currentGrant; [changedGrant] = await db .update(connectionGrants) .set({ credentialSecretRefs, status: "active", revokedAt: null, revokedByAgentId: null, revokedByUserId: null, updatedAt: new Date(), }) .where( and( eq(connectionGrants.id, currentGrant.id), sql`${connectionGrants.updatedAt} = ${grantSnapshot!.updatedAtVersion}::timestamptz`, ), ) .returning(); if (!changedGrant) throw conflict( "The personal credential changed during setup. Please try again.", ); } else { [changedGrant] = await db .insert(connectionGrants) .values({ companyId, connectionId: connectionRow.id, kind: "user", subjectUserId: personalIdentityUserId, credentialSecretRefs, status: "active", isDefault: false, createdByUserId: personalIdentityUserId, }) .returning(); if (!changedGrant) throw new Error("Failed to create personal connection grant"); } if (revivedConnectionPrevious) { revivedGrantMutation = { previous: previousGrant, current: changedGrant, }; } await db.insert(toolAccessAuditEvents).values({ companyId, connectionId: connectionRow.id, actorType: "user", actorId: personalIdentityUserId, action: retainedPersonalIdentity?.grant ? "connection_grant.updated" : "connection_grant.created", outcome: "success", reasonCode: retainedPersonalIdentity?.grant ? "personal_identity_reconnected" : "personal_identity_created", details: { kind: "user", credentialSecretRefCount: credentialSecretRefs.length, }, }); } } else if (dedicatedAgentId) { // Managed OAuth creates the credential-bearing grant in the callback. // Keep the connection free of organization secrets from the outset. } else { const organizationGrant = await ensureDefaultOrganizationGrant( connectionRow, db, revivedConnectionPrevious ? (mutation) => { revivedGrantMutation = mutation; } : undefined, ); if (credentialSource === "vercel_connect") { const derived = deriveVercelConnectSubject({ credential: externalCredential!, connectionId: connectionRow.id, companyId, grantKind: "organization", }); const updatedOrganizationGrant = await db.transaction(async (tx) => { const [lockedGrant] = await tx .select() .from(connectionGrants) .where(eq(connectionGrants.id, organizationGrant.id)) .limit(1) .for("update"); if ( !lockedGrant || lockedGrant.updatedAt.getTime() !== organizationGrant.updatedAt.getTime() ) { throw conflict( "The organization credential changed during setup. Please try again.", ); } const [updated] = await tx .update(connectionGrants) .set({ externalCredential: { provider: "vercel_connect", subjectType: externalCredential!.principalMode, ...(derived.subjectId ? { subjectId: derived.subjectId } : {}), }, credentialSecretRefs: [], updatedAt: now(), }) .where(eq(connectionGrants.id, organizationGrant.id)) .returning(); return updated; }); if (!updatedOrganizationGrant) { throw conflict( "The organization credential changed during setup. Please try again.", ); } if (revivedGrantMutation) { revivedGrantMutation = { previous: revivedGrantMutation.previous, current: updatedOrganizationGrant, }; } } } await syncCredentialBindings( connectionRow, personalIdentityUserId || dedicatedAgentId ? credentialSecretRefs : [], ); await ensureRuntimeSlot(connectionRow); if (input.saveDraft && remoteMcpConnector) { return { connectionId: connectionRow.id, application: toApplication(applicationRow), connection: toConnection(connectionRow), catalog: [], actions: { readOnly: [], canMakeChanges: [] }, suggestedDefaults: { access: "all_agents", askFirstRiskLevels: [] } }; } if (galleryEntry && method?.auth === "oauth") { const suggestedDefaults = recommendedDefaultsForApp( galleryEntry, method.key, ); return { connectionId: connectionRow.id, application: toApplication(applicationRow), connection: toConnection(connectionRow), catalog: [], actions: { readOnly: [], canMakeChanges: [] }, suggestedDefaults: dedicatedAgentId ? { ...suggestedDefaults, access: { agentIds: [dedicatedAgentId] } } : suggestedDefaults, auth: { kind: "oauth", startUrl: null }, }; } // 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 || remoteMcpConnector) && genericAuthKind === "none" && credentialSecretRefs.length === 0 && personalIdentityUserId && !retainedPersonalIdentity?.grant, ); let health: ToolConnectionHealthCheckResult; try { health = await checkConnectionHealth(connectionRow.id, actor, { allowUnauthenticatedProbe: unauthenticatedPersonalProbe, }); } catch (error) { if ( (!galleryEntry || remoteMcpConnector) && input.authMode !== "none" && input.authMode !== "bearer" && input.authMode !== "custom_headers" && error instanceof HttpError && asRecord(error.details).code === "oauth_challenge" ) { const [oauthConnection] = await db .select() .from(toolConnections) .where(eq(toolConnections.id, connectionRow.id)); const endpoints = await discoverOAuthEndpoints(oauthConnection).catch( (discoveryError: unknown) => { // "This server advertised an address Paperclip refuses to open" is a // refusal, not a failed discovery: keep it instead of collapsing it // into the generic sign-in-required error. if (isOAuthEndpointRejection(discoveryError)) throw discoveryError; return null; }, ); if (!endpoints) throw error; return { connectionId: oauthConnection.id, application: toApplication(applicationRow), connection: toConnection(oauthConnection), catalog: [], actions: { readOnly: [], canMakeChanges: [] }, suggestedDefaults: { access: "all_agents", askFirstRiskLevels: [], }, // The endpoint asked for authorization and discovery found a real // authorization server, so the wizard can offer "Sign in to // continue" instead of a dead end. The caller starts the flow and // fills in `startUrl`/`registrationSource`; it only needs the issuer // and resource here to show which server the operator is about to // trust. auth: { kind: "oauth", startUrl: null, issuer: endpoints.issuer ?? null, resource: endpoints.resource ?? null, }, }; } 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; } const restoreDraftDefaults = Boolean(revivedConnectionPrevious); const refreshOptions = { enableAllByDefault: restoreDraftDefaults && !remoteMcpConnector, 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) .where(eq(toolApplications.id, applicationRow.id)); return { connectionId: refresh.connection.id, application: toApplication(application), connection: refresh.connection, catalog: refresh.catalog, actions: groupedActions(refresh.catalog), suggestedDefaults: galleryEntry ? recommendedDefaultsForApp(galleryEntry, method?.key) : { access: "all_agents", askFirstRiskLevels: [], }, }; } catch (error) { if (personalPublicSetupEstablished) throw error; let identityRollbackError: unknown = null; let preserveConcurrentRevival = false; if (connectionRow && revivedConnectionPrevious) { const attemptedConnection = connectionRow; try { await db.transaction(async (tx) => { if (revivedGrantMutation) await tx.select({ id: connectionGrants.id }).from(connectionGrants) .where(and(eq(connectionGrants.id, revivedGrantMutation.current.id), eq(connectionGrants.companyId, companyId))) .for("update"); const [latestConnection] = await tx .select() .from(toolConnections) .where( and( eq(toolConnections.id, revivedConnectionPrevious.id), eq(toolConnections.companyId, companyId), ), ) .limit(1) .for("update"); // Health/catalog failures from this setup may update only health // fields and `updatedAt`, so compare the identity/configuration // fields this attempt owned. If another request changed any of // those fields, its newer connection state is authoritative. const connectionMutationIsStillCurrent = Boolean( latestConnection && connectionSetupMutationFingerprint(latestConnection) === connectionSetupMutationFingerprint(attemptedConnection), ); if (connectionMutationIsStillCurrent) { await tx .update(toolConnections) .set({ agentInstructions: revivedConnectionPrevious.agentInstructions, name: revivedConnectionPrevious.name, transport: revivedConnectionPrevious.transport, status: revivedConnectionPrevious.status, enabled: revivedConnectionPrevious.enabled, config: revivedConnectionPrevious.config, transportConfig: revivedConnectionPrevious.transportConfig, credentialRefs: revivedConnectionPrevious.credentialRefs, credentialSecretRefs: revivedConnectionPrevious.credentialSecretRefs, credentialSource: revivedConnectionPrevious.credentialSource, externalCredential: revivedConnectionPrevious.externalCredential, credentialPolicy: revivedConnectionPrevious.credentialPolicy, updatedAt: new Date(), }) .where(eq(toolConnections.id, revivedConnectionPrevious.id)); } else { preserveConcurrentRevival = true; } if (connectionMutationIsStillCurrent && revivedGrantMutation) { const { previous, current } = revivedGrantMutation; const [latestGrant] = await tx .select() .from(connectionGrants) .where(eq(connectionGrants.id, current.id)) .limit(1) .for("update"); const mutationIsStillCurrent = Boolean( latestGrant && latestGrant.updatedAt.getTime() === current.updatedAt.getTime(), ); // A grant manager may have changed this grant while provider // setup was in flight. Restore/delete only the exact version this // attempt wrote; a newer version is authoritative and remains // untouched. if (previous && mutationIsStillCurrent) { await tx .update(connectionGrants) .set({ kind: previous.kind, subjectUserId: previous.subjectUserId, subjectAgentId: previous.subjectAgentId, providerTenant: previous.providerTenant, credentialSecretRefs: previous.credentialSecretRefs, externalCredential: previous.externalCredential, status: previous.status, isDefault: previous.isDefault, createdByAgentId: previous.createdByAgentId, createdByUserId: previous.createdByUserId, revokedAt: previous.revokedAt, revokedByAgentId: previous.revokedByAgentId, revokedByUserId: previous.revokedByUserId, lastUsedAt: previous.lastUsedAt, updatedAt: previous.updatedAt, }) .where(eq(connectionGrants.id, current.id)); } else if (!previous && mutationIsStillCurrent) { await tx .delete(connectionGrants) .where(eq(connectionGrants.id, current.id)); } } }); } catch (rollbackError) { identityRollbackError = rollbackError; // The attempted identity and its grants may no longer agree. Keep the // connection unusable until a manager explicitly reconnects it, and // surface the restoration failure instead of returning only the // original provider error. try { await db .update(toolConnections) .set({ status: "draft", enabled: false, healthStatus: "error", healthMessage: "Connection identity restoration failed. Reconnect this app to continue.", lastError: "connection_identity_rollback_failed", updatedAt: new Date(), }) .where( and( eq(toolConnections.id, revivedConnectionPrevious.id), eq(toolConnections.companyId, companyId), ), ); } catch (quarantineError) { identityRollbackError = new AggregateError( [rollbackError, quarantineError], "Connection identity rollback and quarantine both failed", ); } } } else if (connectionRow) { await db .delete(toolConnections) .where(eq(toolConnections.id, connectionRow.id)) .catch(() => undefined); } if ( !preserveConcurrentRevival && applicationRow && !existingApplication ) { await db .delete(toolApplications) .where(eq(toolApplications.id, applicationRow.id)) .catch(() => undefined); } else if ( !preserveConcurrentRevival && existingApplication && applicationRow && applicationRow.status !== existingApplication.status ) { await db .update(toolApplications) .set({ status: existingApplication.status, archivedAt: existingApplication.archivedAt, updatedAt: new Date(), }) .where(eq(toolApplications.id, existingApplication.id)) .catch(() => undefined); } if (!preserveConcurrentRevival) { for (const secretId of createdSecretIds) { await secrets.remove(secretId).catch(() => undefined); } for (const definitionId of createdDefinitionIds) { await db.delete(userSecretDefinitions).where(eq(userSecretDefinitions.id, definitionId)).catch(() => undefined); } } if (identityRollbackError) { throw new HttpError( 500, "Connection setup failed and its prior identity could not be restored.", { code: "connection_identity_rollback_failed", }, ); } throw error; } } async function assertCatalogEntriesForConnection( companyId: string, connectionId: string, catalogEntryIds: string[], ): Promise> { const uniqueIds = [...new Set(catalogEntryIds)]; if (uniqueIds.length === 0) return []; const rows = await db .select() .from(toolCatalogEntries) .where( and( eq(toolCatalogEntries.companyId, companyId), eq(toolCatalogEntries.connectionId, connectionId), inArray(toolCatalogEntries.id, uniqueIds), ), ); if (rows.length !== uniqueIds.length) { throw unprocessable( "All selected catalog entries must belong to this app connection", ); } return rows; } async function assertAgentsInCompany(companyId: string, agentIds: string[]) { if (agentIds.length === 0) return; const rows = await db .select({ id: agents.id }) .from(agents) .where( and( eq(agents.companyId, companyId), inArray(agents.id, [...new Set(agentIds)]), ), ); if (rows.length !== new Set(agentIds).size) { throw unprocessable( "All app access agent ids must belong to the same company", ); } } async function finishGalleryAppConnection( companyId: string, connectionId: string, input: FinishToolApp, actor?: ActorInfo, ): Promise { const connection = await getConnectionRow(connectionId, companyId); await assertConnectorConnectionSetupEnabled(connection); if (connection.status === "archived") throw conflict("Archived app connections cannot be finished"); if (connection.config.mcpPreserveAccess === true && isRemoteMcpConnectorMethod(connection.config.sourceTemplateKey, connection.config.connectionMethodKey)) { const [profile] = await db.select().from(toolProfiles).where(and( eq(toolProfiles.companyId, companyId), eq(toolProfiles.profileKey, `app:${connection.id}`), )).limit(1); if (!profile) throw conflict("The saved connection permissions could not be found"); const config = { ...connection.config }; delete config.mcpPreserveAccess; const updated = await db.transaction(async (tx) => { await tx.select({ id: toolApplications.id }).from(toolApplications) .where(and(eq(toolApplications.id, connection.applicationId), eq(toolApplications.companyId, companyId))) .for("update"); const [row] = await tx.update(toolConnections).set({ config, transportConfig: config, status: "active", enabled: true, ...(input.agentInstructions !== undefined ? { agentInstructions: input.agentInstructions } : {}), updatedAt: new Date() }) .where(and(eq(toolConnections.id, connection.id), eq(toolConnections.companyId, companyId))).returning(); await tx.update(toolApplications).set({ status: "active", updatedAt: new Date() }).where(and(eq(toolApplications.id, connection.applicationId), eq(toolApplications.companyId, companyId))); return row; }); const details = await profileDetails(profile.id, companyId); const policies = (await db.select().from(toolPolicies).where(eq(toolPolicies.companyId, companyId))).filter((policy) => asRecord(policy.config).connectionId === connection.id); return { connection: toConnection(updated), profile: toProfile(profile), profileEntries: details.entries, profileBindings: details.bindings, policies: policies.map(toPolicy) }; } const enabledIds = [ ...new Set([ ...input.enabledCatalogEntryIds, ...input.askFirstCatalogEntryIds, ]), ]; const requestedReviewedIds = input.reviewedCatalogEntryIds ?? []; const reviewedIds = [...new Set(requestedReviewedIds)]; if (reviewedIds.length !== requestedReviewedIds.length) { throw badRequest( "Action review decisions must not contain duplicate catalogEntryId values", ); } const enabledRows = await assertCatalogEntriesForConnection( companyId, connection.id, enabledIds, ); const askFirstRows = await assertCatalogEntriesForConnection( companyId, connection.id, input.askFirstCatalogEntryIds, ); if (enabledRows.some((entry) => entry.status === "disabled" && !(entry.quarantineReason === "mcp_tool_removed" && isRemoteMcpConnectorMethod(connection.config.sourceTemplateKey, connection.config.connectionMethodKey)))) { throw badRequest("Disabled actions cannot be enabled"); } if (reviewedIds.length > 0) { await assertCatalogEntriesForConnection( companyId, connection.id, reviewedIds, ); const quarantinedRows = await db .select({ id: toolCatalogEntries.id }) .from(toolCatalogEntries) .where( and( eq(toolCatalogEntries.companyId, companyId), eq(toolCatalogEntries.connectionId, connection.id), eq(toolCatalogEntries.status, "quarantined"), ), ); const reviewedIdSet = new Set(reviewedIds); if ( quarantinedRows.length !== reviewedIdSet.size || quarantinedRows.some((entry) => !reviewedIdSet.has(entry.id)) ) { throw badRequest( "Action review decisions must cover every currently quarantined action exactly once", ); } } if (input.access !== "all_agents") await assertAgentsInCompany(companyId, input.access.agentIds); const entries: CreateToolProfileEntryForProfile[] = enabledRows.map( (entry) => ({ selectorType: "catalog_entry", effect: "include", catalogEntryId: entry.id, connectionId: connection.id, applicationId: connection.applicationId, }), ); const profileKey = `app:${connection.id}`; const bindingInputs: CreateToolProfileBindingForProfile[] = input.access === "all_agents" ? [ { targetType: "company", targetId: companyId, priority: 100, metadata: { source: "app_gallery_finish" }, }, ] : [...new Set(input.access.agentIds)].map((agentId) => ({ targetType: "agent" as const, targetId: agentId, priority: 100, metadata: { source: "app_gallery_finish" }, })); const transactionResult = await db.transaction(async (tx) => { await tx.select({ id: toolApplications.id }).from(toolApplications) .where(and(eq(toolApplications.id, connection.applicationId), eq(toolApplications.companyId, companyId))) .for("update"); const [lockedConnection] = await tx .select({ id: toolConnections.id, status: toolConnections.status, enabled: toolConnections.enabled }) .from(toolConnections) .where( and( eq(toolConnections.id, connectionId), eq(toolConnections.companyId, companyId), ), ) .for("update"); if (!lockedConnection) throw notFound("Tool connection not found"); const [existingProfile] = await tx .select() .from(toolProfiles) .where( and( eq(toolProfiles.companyId, companyId), eq(toolProfiles.profileKey, profileKey), ), ) .limit(1).for("no key update"); await lockManagedAppPolicies(tx, companyId, connection.id); const existingBindings = existingProfile ? await tx.select().from(toolProfileBindings) .where(and(eq(toolProfileBindings.companyId, companyId), eq(toolProfileBindings.profileId, existingProfile.id))) : []; const existingInstalls = await tx.select().from(toolConnectionInstalls) .where(and(eq(toolConnectionInstalls.companyId, companyId), eq(toolConnectionInstalls.connectionId, connection.id))); const companyTargets = lockedConnection.status !== "active" || !lockedConnection.enabled || (existingProfile && existingProfile.status !== "active") ? [{ targetType: "company", targetId: companyId }] : []; await lockToolAssignmentAgents(tx, companyId, [...existingBindings, ...bindingInputs, ...existingInstalls, ...companyTargets]); // A renamed connection can leave its old profile name in use. Resolve // names against profiles too, including when finalizing an installed draft. const otherProfiles = await tx .select({ name: toolProfiles.name }) .from(toolProfiles) .where(and( eq(toolProfiles.companyId, companyId), ne(toolProfiles.profileKey, profileKey), )); const profileName = nextAvailableConnectionName( connection.name, otherProfiles.map((profile) => profile.name), ); let profileId: string; if (existingProfile) { if (input.preserveExistingAccess) { for (const prior of existingBindings) if ( !bindingInputs.some( (binding) => binding.targetType === prior.targetType && binding.targetId === prior.targetId, ) ) bindingInputs.push({ targetType: prior.targetType, targetId: prior.targetId, priority: prior.priority, metadata: prior.metadata, }); const priorEntries = await tx .select() .from(toolProfileEntries) .where(eq(toolProfileEntries.profileId, existingProfile.id)); for (const prior of priorEntries) if ( !entries.some( (entry) => entry.catalogEntryId && entry.catalogEntryId === prior.catalogEntryId, ) ) entries.push({ selectorType: prior.selectorType, effect: prior.effect, applicationId: prior.applicationId, connectionId: prior.connectionId, catalogEntryId: prior.catalogEntryId, toolName: prior.toolName, riskLevel: prior.riskLevel, conditions: prior.conditions, }); } await tx .delete(toolProfileBindings) .where( and( eq(toolProfileBindings.companyId, companyId), eq(toolProfileBindings.profileId, existingProfile.id), ), ); await tx .delete(toolProfileEntries) .where( and( eq(toolProfileEntries.companyId, companyId), eq(toolProfileEntries.profileId, existingProfile.id), ), ); if (entries.length > 0) { await tx.insert(toolProfileEntries).values( entries.map((entry) => ({ companyId, profileId: existingProfile.id, selectorType: entry.selectorType, effect: entry.effect ?? "include", applicationId: entry.applicationId ?? null, connectionId: entry.connectionId ?? null, catalogEntryId: entry.catalogEntryId ?? null, toolName: entry.toolName ?? null, riskLevel: entry.riskLevel ?? null, conditions: entry.conditions ?? null, })), ); } const [updated] = await tx .update(toolProfiles) .set({ name: profileName, description: `Access profile for ${connection.name}.`, status: "active", defaultAction: "deny", metadata: { source: "app_gallery_finish", connectionId: connection.id, }, updatedAt: new Date(), }) .where(eq(toolProfiles.id, existingProfile.id)) .returning(); profileId = updated.id; } else { const [created] = await tx .insert(toolProfiles) .values({ companyId, profileKey, name: profileName, description: `Access profile for ${connection.name}.`, status: "active", defaultAction: "deny", metadata: { source: "app_gallery_finish", connectionId: connection.id, }, }) .returning(); if (entries.length > 0) { await tx.insert(toolProfileEntries).values( entries.map((entry) => ({ companyId, profileId: created.id, selectorType: entry.selectorType, effect: entry.effect ?? "include", applicationId: entry.applicationId ?? null, connectionId: entry.connectionId ?? null, catalogEntryId: entry.catalogEntryId ?? null, toolName: entry.toolName ?? null, riskLevel: entry.riskLevel ?? null, conditions: entry.conditions ?? null, })), ); } profileId = created.id; } const profileBindings: ToolProfileBinding[] = []; // These connectors expose one agent-access choice. Commit installation // reach and permission bindings together, including an empty selection. if (isRemoteMcpConnectorMethod(connection.config.sourceTemplateKey, connection.config.connectionMethodKey)) { const desired = new Map(bindingInputs.flatMap((binding) => binding.targetType === "company" || binding.targetType === "agent" ? [[`${binding.targetType}:${binding.targetId}`, { targetType: binding.targetType, targetId: binding.targetId }] as const] : [])); const removed = existingInstalls.filter((install) => !desired.has(`${install.targetType}:${install.targetId}`)); const added = [...desired.values()].filter((binding) => !existingInstalls.some((install) => install.targetType === binding.targetType && install.targetId === binding.targetId)); if (removed.length) await tx.delete(toolConnectionInstalls).where(inArray(toolConnectionInstalls.id, removed.map((install) => install.id))); if (added.length) await tx.insert(toolConnectionInstalls).values(added.map((binding) => ({ companyId, connectionId: connection.id, targetType: binding.targetType, targetId: binding.targetId, createdByAgentId: actor?.actorType === "agent" ? (actor.actorId ?? null) : null, createdByUserId: actor?.actorType === "user" ? (actor.actorId ?? null) : null, }))); if (added.length || removed.length) await tx.insert(toolAccessAuditEvents).values({ companyId, connectionId: connection.id, actorType: actor?.actorType ?? "system", actorId: actor?.actorId ?? null, action: "connection_installs.changed", outcome: "success", reasonCode: "installs_changed", details: { added: added.map(({ targetType, targetId }) => ({ targetType, targetId })), removed: removed.map(({ targetType, targetId }) => ({ targetType, targetId })) }, }); } for (const bindingInput of bindingInputs) { const [binding] = await tx .insert(toolProfileBindings) .values({ companyId, profileId, targetType: bindingInput.targetType, targetId: bindingInput.targetId, priority: bindingInput.priority ?? 100, metadata: bindingInput.metadata ?? {}, createdByAgentId: actor?.actorType === "agent" ? (actor.actorId ?? null) : null, createdByUserId: actor?.actorType === "user" ? (actor.actorId ?? null) : null, }) .returning(); profileBindings.push(toProfileBinding(binding)); } const reviewedAt = new Date(); if (reviewedIds.length > 0) { await tx .update(toolCatalogEntries) .set({ status: "active", reviewedAt, reviewedByAgentId: actor?.actorType === "agent" ? (actor.actorId ?? null) : null, reviewedByUserId: actor?.actorType === "user" ? (actor.actorId ?? null) : null, quarantinedAt: null, quarantineReason: null, updatedAt: reviewedAt, }) .where( and( eq(toolCatalogEntries.companyId, companyId), eq(toolCatalogEntries.connectionId, connection.id), inArray(toolCatalogEntries.id, reviewedIds), eq(toolCatalogEntries.status, "quarantined"), ), ); } if (enabledIds.length > 0) { await tx .update(toolCatalogEntries) .set({ status: "active", reviewedAt, reviewedByAgentId: actor?.actorType === "agent" ? (actor.actorId ?? null) : null, reviewedByUserId: actor?.actorType === "user" ? (actor.actorId ?? null) : null, quarantinedAt: null, quarantineReason: null, updatedAt: reviewedAt, }) .where( and( eq(toolCatalogEntries.companyId, companyId), inArray(toolCatalogEntries.id, enabledIds), ne(toolCatalogEntries.status, "quarantined"), ne(toolCatalogEntries.status, "disabled"), ), ); } const policies = await upsertAskFirstPolicies( { companyId, connection, askFirstEntries: askFirstRows, disableStale: !input.preserveExistingAccess, actor, }, tx, ); const [updatedConnection] = await tx .update(toolConnections) .set({ status: "active", enabled: true, ...(input.agentInstructions !== undefined ? { agentInstructions: input.agentInstructions } : {}), updatedAt: new Date() }) .where(eq(toolConnections.id, connection.id)) .returning(); await tx .update(toolApplications) .set({ status: "active", updatedAt: new Date() }) .where(eq(toolApplications.id, connection.applicationId)); return { profileId, profileBindings, policies, updatedConnection }; }); emitConnectionUpdated( transactionResult.updatedConnection, connection, "gallery", ); const details = await profileDetails( transactionResult.profileId, companyId, ); return { connection: toConnection(transactionResult.updatedConnection), profile: { id: details.id, companyId: details.companyId, profileKey: details.profileKey, name: details.name, description: details.description, status: details.status, defaultAction: details.defaultAction, newToolsReviewedAt: details.newToolsReviewedAt, metadata: details.metadata, createdAt: details.createdAt, updatedAt: details.updatedAt, }, profileEntries: details.entries, profileBindings: transactionResult.profileBindings, policies: transactionResult.policies, }; } /** * Replace the credential(s) on an existing connection and re-run the health * check — the "Replace key" / reconnect flow (M7, PAP-10859). Rotates the * secret in place when a ref already exists so the connection keeps its * profile, policies, and catalog; creates a fresh secret only when the field * had none (e.g. a link connection added a key after the fact). */ async function reconnectGalleryApp( connectionId: string, companyId: string, input: { credentialValues: Record }, actor?: ActorInfo, ): Promise { const connection = await getConnectionRow(connectionId, companyId); assertSupportedConnection(connection); await assertConnectorConnectionSetupEnabled(connection); await assertExperimentalConnectorSetupEnabled(connection.config.sourceTemplateKey, connection.status !== "draft"); if (connection.status === "archived") throw conflict("Archived app connections cannot be reconnected"); if (connection.credentialSource === "vercel_connect") { throw conflict( "Manage this connector in Vercel Connect, then run a Paperclip health check to verify it.", { code: "vercel_connect_managed_externally", manageUrl: vercelConnectIntegrationStatus().manageUrl, }, ); } const sourceTemplateKey = typeof connection.config.sourceTemplateKey === "string" ? connection.config.sourceTemplateKey : null; const galleryEntry = sourceTemplateKey ? getConnectableAppDefinition(sourceTemplateKey) : null; const storedCredentialFields = connection.credentialRefs .filter((ref) => ref.placement === "header" || ref.placement === "url") .map((ref) => ({ label: ref.placement === "url" ? "MCP server URL" : ref.prefix === "Bearer " ? "App key" : ref.key ?? ref.name, configPath: credentialRefConfigPath(ref), helpUrl: "", required: false, placement: ref.placement, key: ref.key, prefix: ref.prefix, })); const galleryCredentialFields = galleryEntry ? credentialFieldsFor(galleryEntry, connectionMethodForConnection(galleryEntry, connection).key) : []; const credentialFields = galleryCredentialFields.length > 0 ? galleryCredentialFields : storedCredentialFields.length > 0 ? storedCredentialFields : [ { label: "App key", configPath: "credentials.authorization", helpUrl: "", required: false, placement: "header" as const, key: "Authorization", prefix: "Bearer ", }, ]; const providedFields = credentialFields.filter( (field) => (input.credentialValues[field.configPath]?.trim().length ?? 0) > 0, ); if (providedFields.length === 0) throw badRequest("Enter a replacement credential to reconnect this app"); const personalIdentity = await fixedPersonalIdentityForReconnect( connection, undefined, actor, ); const credentialSecretRefs = [ ...(personalIdentity?.grant?.credentialSecretRefs ?? connection.credentialSecretRefs), ]; const credentialRefs: McpConnectionCredentialRef[] = [ ...(connection.credentialRefs ?? []), ]; const updated = await db.transaction(async (tx) => { await tx.select({ id: connectionGrants.id }).from(connectionGrants) .where(and(eq(connectionGrants.companyId, companyId), eq(connectionGrants.connectionId, connection.id))) .orderBy(connectionGrants.id).for("update"); await tx.select({ id: toolApplications.id }).from(toolApplications) .where(and(eq(toolApplications.id, connection.applicationId), eq(toolApplications.companyId, companyId))) .for("update"); await tx.select({ id: toolConnections.id }).from(toolConnections) .where(and(eq(toolConnections.id, connection.id), eq(toolConnections.companyId, companyId))) .for("update"); for (const field of providedFields) { const value = input.credentialValues[field.configPath]!.trim(); if (field.placement === "url" && !remoteUrlCredentialMatchesPublicUrl(String(connection.config.url ?? ""), value)) { throw badRequest("The replacement server URL must use the same endpoint as this connection.", { code: "mcp_remote_url_credential_mismatch", }); } const existing = credentialSecretRefs.find( (ref) => ref.configPath === field.configPath, ); const { secret } = await writeConnectionCredential(tx, { companyId, connectionName: connection.name, configPath: field.configPath, label: field.label, value, ownerUserId: personalIdentity?.subjectUserId, existingRef: existing, actor: actorForSecret(actor), }); const nextRef = { secretId: secret.id, versionSelector: "latest" as const, configPath: field.configPath, required: field.required ?? true, label: field.label }; const refIndex = credentialSecretRefs.findIndex((ref) => ref.configPath === field.configPath); if (refIndex >= 0) credentialSecretRefs[refIndex] = nextRef; else credentialSecretRefs.push(nextRef); if ((field.placement === "header" && field.key) || field.placement === "url") { const nextCredentialRef = { name: field.configPath, secretId: secret.id, version: "latest", placement: field.placement, key: field.key ?? "url", prefix: field.prefix ?? null, } satisfies McpConnectionCredentialRef; const existingCredentialRefIndex = credentialRefs.findIndex( (ref) => credentialRefConfigPath(ref) === field.configPath, ); if (existingCredentialRefIndex >= 0) credentialRefs[existingCredentialRefIndex] = nextCredentialRef; else credentialRefs.push(nextCredentialRef); } } const updatedAt = new Date(); if (personalIdentity) { const grantValues = { credentialSecretRefs, status: "active" as const, revokedAt: null, revokedByAgentId: null, revokedByUserId: null, updatedAt, }; if (personalIdentity.grant) { await tx .update(connectionGrants) .set(grantValues) .where(eq(connectionGrants.id, personalIdentity.grant.id)); } else { await tx.insert(connectionGrants).values({ companyId: connection.companyId, connectionId: connection.id, kind: "user", subjectUserId: personalIdentity.subjectUserId, ...grantValues, isDefault: false, createdByUserId: personalIdentity.subjectUserId, }); } } const [nextConnection] = await tx .update(toolConnections) .set({ credentialRefs, // Personal reconnect rotates the existing user's grant. The // connection-level organization slot stays exactly as it was. credentialSecretRefs: personalIdentity ? connection.credentialSecretRefs : credentialSecretRefs, lastError: null, updatedAt, }) .where(eq(toolConnections.id, connection.id)) .returning(); await syncConnectionCredentialBindings(tx, nextConnection, personalIdentity ? credentialSecretRefs : []); return nextConnection; }); const health = await checkConnectionHealth(updated.id, actor); const refresh = await refreshCatalog(updated.id, actor, { enableAllByDefault: true, }); return { ...health, connection: refresh.connection }; } async function preflightGalleryAppMetadata( galleryKey: string, methodKey?: string | null, ): Promise { const app = getConnectableAppDefinition(galleryKey); if (!app || app.availability?.available === false) throw notFound("App not found"); const method = connectionMethodFor(app, methodKey); await assertExperimentalConnectorSetupEnabled(app.slug); if (method.transport !== "mcp_remote" || !method.defaults?.serverUrl) { throw unprocessable( "This app method does not use a hosted remote MCP endpoint", ); } const serverUrl = await assertRemoteHttpUrlAllowed( method.defaults.serverUrl, ); return preflightConnectionMetadata(app, method, method.defaults, serverUrl, () => options.now?.() ?? new Date()); } return { /** Identity-only verification: no bot installation/repository access required. */ verifyPersonalGitHubIdentity: async (companyId: string, connectionId: string, userId: string) => { const [connection] = await db.select().from(toolConnections).where(and(eq(toolConnections.companyId, companyId), eq(toolConnections.id, connectionId), eq(toolConnections.enabled, true))); if (!connection || (connection.config.sourceTemplateKey !== "github" && connection.transportConfig?.sourceTemplateKey !== "github")) throw notFound("GitHub connection not found"); const [ownGrant] = await db.select().from(connectionGrants).where(and(eq(connectionGrants.companyId, companyId), eq(connectionGrants.connectionId, connectionId), eq(connectionGrants.kind, "user"), eq(connectionGrants.subjectUserId, userId), eq(connectionGrants.status, "active"))); if (!ownGrant) throw forbidden("Connect your own GitHub account first. Shared and agent connections cannot prove your identity."); const actor = { actorType: "user" as const, actorId: userId }; const grant = await refreshOAuthGrantCredentials({ companyId, connectionId, grantId: ownGrant.id, actor }); const accessRef = grant.credentialSecretRefs.find(ref => ref.configPath === "oauth.access_token"); if (!accessRef) throw unprocessable("Reconnect your personal GitHub connection"); const { value } = await resolveOAuthGrantSecret(connection, grant, accessRef, actor, undefined); const identity = await githubBotRequest<{ id?: number; login?: string; avatar_url?: string }>(fetch, value, "/user"); if (!Number.isSafeInteger(identity.id) || !identity.id || !identity.login || !/^[a-zA-Z0-9][a-zA-Z0-9-]{0,38}$/.test(identity.login)) throw unprocessable("GitHub returned an invalid account identity"); const [current] = await db.select({ id: connectionGrants.id }).from(connectionGrants).where(and(eq(connectionGrants.id, grant.id), eq(connectionGrants.companyId, companyId), eq(connectionGrants.subjectUserId, userId), eq(connectionGrants.status, "active"))); if (!current) throw forbidden("Your GitHub connection was revoked. Connect it again."); return { githubUserId: String(identity.id), login: identity.login, avatarUrl: identity.avatar_url ?? null, connectionId, grantId: grant.id }; }, preflightGalleryAppMetadata, approvedStdioTemplates: async ( companyId: string, ): Promise => { const adminTemplates = await db .select() .from(toolStdioCommandTemplates) .where(eq(toolStdioCommandTemplates.companyId, companyId)) .orderBy(asc(toolStdioCommandTemplates.templateKey)); return [ ...Object.keys(APPROVED_STDIO_TEMPLATES) .sort() .map((templateId) => builtInStdioTemplate(templateId)!), ...adminTemplates.map(toStdioCommandTemplate), ]; }, createStdioCommandTemplate: async ( companyId: string, input: CreateToolStdioCommandTemplate, actor?: ActorInfo, ): Promise => { if (builtInStdioTemplate(input.templateId)) { throw conflict( "A built-in stdio template already uses this templateId", ); } const existing = await getAdminStdioTemplate(companyId, input.templateId); if (existing) throw conflict("A stdio command template already uses this templateId"); const tools = input.tools .map((tool) => normalizeToolDescriptor(tool)) .filter((tool): tool is McpToolDescriptor => Boolean(tool)); const [row] = await db .insert(toolStdioCommandTemplates) .values({ companyId, templateKey: input.templateId, name: input.name, description: input.description ?? null, status: "active", command: input.command, args: input.args, envKeys: input.envKeys, tools, createdByAgentId: actor?.actorType === "agent" ? (actor.actorId ?? null) : null, createdByUserId: actor?.actorType === "user" ? (actor.actorId ?? null) : null, }) .returning(); return toStdioCommandTemplate(row); }, disableStdioCommandTemplate: async ( companyId: string, templateId: string, ): Promise => { if (builtInStdioTemplate(templateId)) throw unprocessable("Built-in stdio templates cannot be disabled"); const existing = await getAdminStdioTemplate(companyId, templateId); if (!existing) throw notFound("Stdio command template not found"); if (existing.status === "disabled") return toStdioCommandTemplate(existing); const at = now(); const [row] = await db .update(toolStdioCommandTemplates) .set({ status: "disabled", disabledAt: at, updatedAt: at }) .where( and( eq(toolStdioCommandTemplates.companyId, companyId), eq(toolStdioCommandTemplates.templateKey, templateId), ), ) .returning(); return toStdioCommandTemplate(row); }, ...aggregatorAccounts, connectGalleryApp, finishGalleryAppConnection, reconnectGalleryApp, storeConnectorOAuthSecret: createOrRotateOAuthSecret, resolveConnectorOAuthGrantSecret: resolveOAuthGrantSecret, startOAuth, startAuthorizationForAgent: async (input: { companyId: string; connectionId: string; agentId: string; runId: string; subjectUserId: string; scopes?: string[]; returnTo?: string; redirectUri: string; }) => { const runContext = await loadBrokerRunContext(input); const connection = await getConnectionRow( input.connectionId, input.companyId, ); if ( !runContext.responsibleUserId || runContext.responsibleUserId !== input.subjectUserId ) { throw new HttpError( 403, "The agent run cannot start authorization for the requested user", { code: "subject_not_permitted", connection: { uid: connection.uid }, subject: { type: "user", userId: input.subjectUserId }, }, ); } return startOAuth(input.companyId, connection.id, { redirectUri: input.redirectUri, actor: { actorType: "agent", actorId: input.agentId }, subjectUserId: input.subjectUserId, scopes: input.scopes, returnTo: input.returnTo, issueId: runContext.issueId ?? undefined, }); }, peekOAuthState, completePaperclipCloudConnectorCallback, completeVercelConnectCallback, completeOAuthCallback, refreshOAuthGrantCredentials, finalizeOAuthAccess, listExamples: async (companyId: string): Promise => { return Promise.all( TOOL_EXAMPLES.map(async (definition) => { const rows = await exampleRows(companyId, definition); return exampleSummary(definition, rows); }), ); }, installExample: async ( companyId: string, exampleId: string, actor?: ActorInfo, ): Promise => { const definition = findExample(exampleId); const blocker = localStdioInstallBlocker(); if (blocker) throw unprocessable(blocker); assertLocalStdioCanBeEnabled("local_stdio", true); await stdioTemplateId(companyId, { templateId: definition.templateId }); const before = await exampleRows(companyId, definition); const application = await upsertExampleApplication( companyId, definition, before.application, ); const connection = await upsertExampleConnection( companyId, definition, application.row.id, before.connection, ); const refresh = await refreshCatalog(connection.row.id, actor); let catalog = refresh.catalog; const safeReadEntryIds = catalog .filter((entry) => entry.riskLevel === "read") .map((entry) => entry.id); if (safeReadEntryIds.length > 0) { const reviewedAt = new Date(); await db .update(toolCatalogEntries) .set({ status: "active", reviewedAt, reviewedByAgentId: actor?.actorType === "agent" ? (actor.actorId ?? null) : null, reviewedByUserId: actor?.actorType === "user" ? (actor.actorId ?? null) : null, quarantinedAt: null, quarantineReason: null, updatedAt: reviewedAt, }) .where( and( eq(toolCatalogEntries.companyId, companyId), inArray(toolCatalogEntries.id, safeReadEntryIds), ), ); catalog = catalog.map((entry) => safeReadEntryIds.includes(entry.id) ? { ...entry, status: "active", reviewedAt, quarantinedAt: null, quarantineReason: null, updatedAt: reviewedAt, } : entry, ); } const profile = await upsertExampleProfile( companyId, definition, before.profile, ); const profileEntries = await syncExampleProfileEntries( companyId, profile.row.id, catalog, ); const profileBinding = await upsertExampleProfileBinding( companyId, profile.row.id, before.profileBinding, actor, ); const after = await exampleRows(companyId, definition); return { example: exampleSummary(definition, after), created: application.created || connection.created || profile.created || !before.profileBinding, application: toApplication(application.row), connection: refresh.connection, profile: toProfile(profile.row), profileEntries, profileBinding, catalog, }; }, smokeExample: async ( companyId: string, exampleId: string, actor?: ActorInfo, ): Promise => { const definition = findExample(exampleId); const rows = await exampleRows(companyId, definition); if (!rows.connection || !rows.profile || !rows.profileBinding) { throw conflict("Install this tool example before running smoke checks"); } const catalog = rows.catalog.length > 0 ? rows.catalog.map(toCatalogEntry) : (await refreshCatalog(rows.connection.id, actor)).catalog; const readEntry = catalog.find( (entry) => entry.riskLevel === "read" && entry.status === "active", ); const deniedEntry = catalog.find( (entry) => entry.riskLevel === "write" || entry.riskLevel === "destructive", ); if (!readEntry || !deniedEntry) { throw unprocessable( "Example smoke requires at least one read tool and one denied write/destructive tool", ); } const smokeActor = await exampleSmokeActor(companyId, actor); const connection = toConnection(rows.connection); const allowCheck = await runSmokeDecisionCheck({ companyId, actor: smokeActor, connection, catalogEntry: readEntry, expectedDecision: "allow", name: "allow_read_tool", }); const denyCheck = await runSmokeDecisionCheck({ companyId, actor: smokeActor, connection, catalogEntry: deniedEntry, expectedDecision: "deny", name: "deny_write_tool", }); const auditCheck: ToolExampleSmokeCheck = { name: "audit_written", ok: Boolean( allowCheck.auditEventId && allowCheck.toolCallEventId && denyCheck.auditEventId && denyCheck.toolCallEventId, ), details: { auditEventIds: [allowCheck.auditEventId, denyCheck.auditEventId], toolCallEventIds: [ allowCheck.toolCallEventId, denyCheck.toolCallEventId, ], }, }; const checks = [allowCheck, denyCheck, auditCheck]; return { exampleId: definition.id, ok: checks.every((check) => check.ok), actor: smokeActor, connection, profile: toProfile(rows.profile), checks, }; }, listApplications: async (companyId: string): Promise => { const rows = await db .select() .from(toolApplications) .where(eq(toolApplications.companyId, companyId)) .orderBy(desc(toolApplications.updatedAt)); return rows.map(toApplication); }, createApplication: async ( companyId: string, input: CreateToolApplication, ): Promise => { await assertOptionalPlugin(input.pluginId); await assertOptionalAgent( companyId, input.ownerAgentId, "Tool application owner agent", ); const [row] = await db .insert(toolApplications) .values({ companyId, applicationKey: input.applicationKey ?? normalizeKey(input.name), name: input.name, description: input.description ?? null, type: input.type, status: input.status ?? "active", pluginId: input.pluginId ?? null, ownerAgentId: input.ownerAgentId ?? null, ownerUserId: input.ownerUserId ?? null, metadata: input.metadata ?? {}, }) .returning(); return toApplication(row); }, getApplication: async ( applicationId: string, companyId?: string, ): Promise => { const where = companyId ? and( eq(toolApplications.id, applicationId), eq(toolApplications.companyId, companyId), ) : eq(toolApplications.id, applicationId); const [row] = await db.select().from(toolApplications).where(where); if (!row) throw notFound("Tool application not found"); return toApplication(row); }, updateApplication: async ( applicationId: string, input: UpdateToolApplication, ): Promise => { const [existing] = await db .select() .from(toolApplications) .where(eq(toolApplications.id, applicationId)); if (!existing) throw notFound("Tool application not found"); await assertOptionalPlugin(input.pluginId); await assertOptionalAgent( existing.companyId, input.ownerAgentId, "Tool application owner agent", ); if (input.name && input.name !== existing.name) { const [duplicate] = await db .select({ id: toolApplications.id }) .from(toolApplications) .where( and( eq(toolApplications.companyId, existing.companyId), eq(toolApplications.name, input.name), ne(toolApplications.id, applicationId), ), ) .limit(1); if (duplicate) { throw conflict("A tool access record with that name already exists", { code: "tool_access_name_conflict", }); } } const [row] = await db .update(toolApplications) .set({ name: input.name ?? existing.name, description: input.description ?? existing.description, status: input.status ?? existing.status, pluginId: input.pluginId ?? existing.pluginId, ownerAgentId: input.ownerAgentId ?? existing.ownerAgentId, ownerUserId: input.ownerUserId ?? existing.ownerUserId, metadata: input.metadata ?? existing.metadata, updatedAt: new Date(), }) .where(eq(toolApplications.id, applicationId)) .returning(); return toApplication(row); }, deleteApplication: async ( applicationId: string, ): Promise => { const [existing] = await db .select() .from(toolApplications) .where(eq(toolApplications.id, applicationId)); if (!existing) throw notFound("Tool application not found"); // Guard: never orphan connections. The caller must remove the connections // or archive the application instead — there is no force-cascade in v1. const linkedConnections = await db .select({ id: toolConnections.id }) .from(toolConnections) .where(eq(toolConnections.applicationId, applicationId)); if (linkedConnections.length > 0) { throw conflict( "This application still has connections. Remove its connections or archive the application instead of deleting it.", { connectionCount: linkedConnections.length }, ); } // The pre-check above gives a friendly 409 in the common case, but it cannot close the // race where a connection is created in the gap before this delete runs. The FK is now // ON DELETE RESTRICT, so such a delete fails closed with a foreign_key_violation instead // of silently cascading the new connection away. Translate that into the same 409 so the // endpoint keeps its contract instead of surfacing a 500. let row: typeof toolApplications.$inferSelect | undefined; try { [row] = await db .delete(toolApplications) .where(eq(toolApplications.id, applicationId)) .returning(); } catch (error) { if (isToolConnectionForeignKeyViolation(error)) { throw conflict( "This application still has connections. Remove its connections or archive the application instead of deleting it.", ); } throw error; } if (!row) throw notFound("Tool application not found"); return toApplication(row); }, // Repository discovery uses credential audiences, not connection-management // visibility. An administrator cannot browse another user's private repos. listProjectRepositories: async (companyId: string, userId: string | null, localTrusted = false) => toolAccessService(db).listGitHubRepositories(companyId, userId, localTrusted), // Return identifiers, never credentials, so each attempted read can recheck its grant. githubReadConnectionIds: async (companyId: string, userId: string | null, localTrusted = false): Promise => { const connections = await db.select().from(toolConnections).where(and(eq(toolConnections.companyId, companyId), eq(toolConnections.enabled, true))).orderBy(desc(toolConnections.updatedAt)); const ids: string[] = []; for (const connection of connections) { if (connection.status !== "active" || asRecord(connection.config).sourceTemplateKey !== "github") continue; try { await gitHubReadGrantAccess(db, companyId, connection.id, userId, localTrusted); ids.push(connection.id); } catch (error) { // Only ineligible grants are skipped; database/service failures must surface. if (!(error instanceof Error && "status" in error && error.status === 403)) throw error; } } return ids; }, githubReadGrantIds: async (companyId: string, connectionId: string, userId: string | null, localTrusted = false): Promise> => { const { allowed, legacyShared } = await gitHubReadGrantAccess(db, companyId, connectionId, userId, localTrusted); return legacyShared ? [null] : allowed.map(grant => grant.id); }, // Server-side reads share the same grant audience and credential lifecycle as discovery. githubReadHeaders: async (companyId: string, connectionId: string, userId: string | null, localTrusted = false, forceRefresh = false, grantId?: string | null): Promise> => { const { connection, allowed, legacyShared } = await gitHubReadGrantAccess(db, companyId, connectionId, userId, localTrusted); const actor: ActorInfo = { actorType: "user", actorId: userId ?? "board" }; if (legacyShared && !grantId) return resolveCredentialHeaders(connection, actor); let grant = grantId ? allowed.find(candidate => candidate.id === grantId) : allowed.length === 1 ? allowed[0] : undefined; if (!grant) throw forbidden("Choose an active GitHub authorization you can use."); if (asRecord(asRecord(connection.config).oauth).connectorProfile === "github.code") { grant = await refreshOAuthGrantCredentials({ companyId, connectionId, grantId: grant.id, actor, forceRefresh }); } const ref = grant.credentialSecretRefs.find(ref => ref.configPath === "oauth.access_token" || /authorization|token|api_key/i.test(ref.configPath)); if (!ref) throw unprocessable("Reconnect GitHub to read repository files."); const secret = await resolveOAuthGrantSecret(connection, grant, ref, actor, undefined); return { Authorization: `Bearer ${secret.value}` }; }, listGitHubRepositories: async ( companyId: string, userId: string | null, localTrusted = false, ) => measureRunPhase("connectors.github.catalog", async () => { const [connections, grants, members, memberships] = await Promise.all([ measureRunPhase("connectors.github.connections", () => db .select() .from(toolConnections) .where( and( eq(toolConnections.companyId, companyId), eq(toolConnections.enabled, true), ), )), measureRunPhase("connectors.github.grants", () => db .select() .from(connectionGrants) .where(eq(connectionGrants.companyId, companyId))), measureRunPhase("connectors.github.grant_members", () => db .select() .from(connectionGrantMembers) .where(eq(connectionGrantMembers.companyId, companyId))), userId ? measureRunPhase("connectors.github.membership", () => db .select() .from(companyMemberships) .where( and( eq(companyMemberships.companyId, companyId), eq(companyMemberships.principalType, "user"), eq(companyMemberships.principalId, userId), eq(companyMemberships.status, "active"), ), )) : Promise.resolve([]), ]); const repositories = new Map< string, import("@paperclipai/shared").ProjectRepository >(); const usableConnections: Array<{ id: string; name: string }> = []; let connectionCount = 0; let failedConnectionCount = 0; for (const connection of connections) { if ( connection.status !== "active" || asRecord(connection.config).sourceTemplateKey !== "github" ) continue; const connectionGrants = grants.filter( (grant) => grant.connectionId === connection.id, ); const availableGrants = connectionGrants.filter( (grant) => !( grant.kind === "organization" && ["per_user", "per_agent"].includes(connection.credentialPolicy) ) && canBrowseProjectRepositoryGrant({ grant, userId, activeMember: localTrusted || memberships.length > 0, audience: members .filter((member) => member.grantId === grant.id) .map((member) => member.subjectId), }), ); // Legacy shared PAT connections predate grants. Never fall back when a // grant exists but is revoked, private, or outside the caller's audience. const legacyShared = connectionGrants.length === 0 && connection.credentialPolicy === "shared" && (localTrusted || (!!userId && memberships.length > 0)); if (!availableGrants.length && !legacyShared) continue; connectionCount += 1; usableConnections.push({ id: connection.id, name: connection.name }); const actor: ActorInfo = { actorType: "user", actorId: userId ?? "board", }; let failed = false; for (const initialGrant of legacyShared ? [null] : availableGrants) { try { let rows: Array<{ id: string; fullName: string; private?: boolean; }>; if ( initialGrant && asRecord(asRecord(connection.config).oauth).connectorProfile === "github.code" ) { const grant = await refreshManagedGitHubGrantAccess( connection, initialGrant, actor, ); rows = grant.providerTenant?.github?.repositories ?? []; } else { const headers = await measureRunPhase("connectors.github.credentials", () => initialGrant ? (async () => { const ref = initialGrant.credentialSecretRefs.find( (ref) => ref.configPath === "oauth.access_token" || /authorization|token|api_key/i.test(ref.configPath), ); if (!ref) throw unprocessable( "Reconnect GitHub to load repositories", ); const secret = await resolveOAuthGrantSecret( connection, initialGrant, ref, actor, undefined, ); return { Authorization: `Bearer ${secret.value}` }; })() : resolveCredentialHeaders(connection, actor)); rows = await measureRunPhase("connectors.github.repositories", () => loadGitHubTokenRepositories(headers)); } for (const row of rows) { mergeProjectRepository(repositories, row, connection.name, connection.id); } } catch { // Credential/provider errors may contain secrets. Only expose an // aggregate failure; successful connections remain usable. failed = true; } } if (failed) failedConnectionCount += 1; } return { repositories: [...repositories.values()].sort((a, b) => a.fullName.localeCompare(b.fullName), ), connections: usableConnections, connectionCount, failedConnectionCount, }; }), listConnections: async ( companyId: string, viewerUserId?: string, ): Promise => { const rows = await db .select() .from(toolConnections) .where(eq(toolConnections.companyId, companyId)) .orderBy(desc(toolConnections.updatedAt)); const connections = rows.map(toConnection); if (connections.length === 0) return connections; const installRows = await db .select() .from(toolConnectionInstalls) .where(eq(toolConnectionInstalls.companyId, companyId)) .orderBy( asc(toolConnectionInstalls.targetType), asc(toolConnectionInstalls.targetId), ); const installsByConnection = new Map(); for (const row of installRows) { const installs = installsByConnection.get(row.connectionId) ?? []; installs.push(toConnectionInstall(row)); installsByConnection.set(row.connectionId, installs); } for (const connection of connections) connection.installs = installsByConnection.get(connection.id) ?? []; // Enrich with "last used" = most recent tool-call event per connection so the // prosumer Apps list can surface a staleness signal without an N+1 fan-out. const lastUsedRows = await db .select({ connectionId: toolCallEvents.connectionId, lastUsedAt: max(toolCallEvents.createdAt), }) .from(toolCallEvents) .where( and( eq(toolCallEvents.companyId, companyId), inArray( toolCallEvents.connectionId, connections.map((connection) => connection.id), ), ), ) .groupBy(toolCallEvents.connectionId); const lastUsedByConnection = new Map( lastUsedRows.map((row) => [row.connectionId, row.lastUsedAt]), ); for (const connection of connections) { connection.lastUsedAt = lastUsedByConnection.get(connection.id) ?? null; } await annotateAiGrantHealth(connections); await annotateGitHubAuthorization(connections, viewerUserId); await annotateSavedOAuthClientSecret(connections, viewerUserId); return connections; }, createConnection: async ( companyId: string, input: CreateToolConnection, actor?: ActorInfo, ): Promise => { let applicationId = input.applicationId; let applicationNamespace = input.applicationName ?? input.name; const transport = input.transport; if (!transport) throw badRequest("Tool connection transport is required"); const config = normalizeGoogleSheetsConnectionConfig( input.config ?? input.transportConfig ?? {}, ); await assertConnectorConnectionSetupEnabled({ config, applicationId, companyId }); // Validate company-scoped references before touching a caller-supplied // network endpoint. Besides failing fast, this keeps cross-company // authorization errors from being masked by DNS or SSRF validation. await assertSecretRefs(companyId, [ ...(input.credentialRefs ?? []), ...(input.credentialSecretRefs ?? []), ]); if (transport === "mcp_remote") await assertRemoteConnectionEndpointsAllowed(config); if (transport === "local_stdio") await stdioTemplateId(companyId, config); assertLocalStdioCanBeEnabled(transport, input.enabled ?? false, config); await assertGoogleSheetsSpreadsheetOwnership(companyId, config); if (applicationId) { const app = await assertApplication(companyId, applicationId); applicationNamespace = app.applicationKey ?? app.name; await assertConnectorConnectionSetupEnabled({ config }, applicationNamespace); if ( (transport === "mcp_remote" && app.type !== "mcp_http") || (transport === "local_stdio" && app.type !== "mcp_stdio") ) { throw unprocessable( "Connection transport must match application type", ); } } else { const [app] = await db .insert(toolApplications) .values({ companyId, applicationKey: normalizeKey(input.applicationName ?? input.name), name: input.applicationName ?? input.name, type: isBrowserUseConnection({ transport, config }) || nativeProviderCatalogFor({ transport, config }) || restConnectorFor({ transport, config }) ? "rest_api" : transport === "mcp_remote" ? "mcp_http" : "mcp_stdio", status: "active", metadata: {}, }) .returning(); applicationId = app.id; } const connectionId = randomUUID(); const binding = actorBinding(actor); const [row] = await db .insert(toolConnections) .values({ id: connectionId, companyId, applicationId, name: input.name, uid: connectionUid(applicationNamespace, input.name, connectionId), connectionKind: input.connectionKind ?? "managed", ownership: input.ownership ?? "customer", transport, authKind: input.authKind ?? "none", credentialPolicy: input.credentialPolicy ?? (input.authKind === "oauth" ? "per_user" : "shared"), status: input.status ?? "draft", enabled: input.enabled ?? false, agentInstructions: input.agentInstructions !== undefined ? input.agentInstructions : defaultConnectionAgentInstructions( typeof config.sourceTemplateKey === "string" ? getConnectableAppDefinition(config.sourceTemplateKey)?.agentInstructions : null, ), config, transportConfig: isGoogleSheetsConnectionConfig(config) ? config : (input.transportConfig ?? config), credentialRefs: input.credentialRefs ?? [], credentialSecretRefs: input.credentialSecretRefs ?? [], createdByAgentId: binding.actorType === "agent" ? binding.actorId : null, createdByUserId: binding.actorType === "user" ? binding.actorId : null, }) .returning(); await ensureDefaultOrganizationGrant(row); await syncCredentialBindings(row); await ensureRuntimeSlot(row); emitConnectionCreated(row, "api"); return toConnection(row); }, getConnection: async ( connectionId: string, companyId?: string, viewerUserId?: string, ): Promise => { const connection = toConnection( await getConnectionRow(connectionId, companyId), ); connection.installs = await listConnectionInstalls( connection.id, connection.companyId, ); await annotateAiGrantHealth([connection]); await annotateGitHubAuthorization([connection], viewerUserId); await annotateSavedOAuthClientSecret([connection], viewerUserId); return connection; }, ...connectionGrantsService, ...toolActivity, listConnectionInstalls, putConnectionInstalls: connectionInstalls.putConnectionInstalls, updateConnection: async ( connectionId: string, input: UpdateToolConnection, ): Promise => { const existing = await getConnectionRow(connectionId); if (existing.connectionPurpose === "ai" && (input.config || input.transportConfig || input.credentialRefs || input.credentialSecretRefs || (input.credentialPolicy && input.credentialPolicy !== existing.credentialPolicy))) throw badRequest("Use AI account reconnect to change credentials. Provider, sign-in method, and ownership cannot be changed."); const config = normalizeGoogleSheetsConnectionConfig( input.config ?? input.transportConfig ?? existing.config, ); if (input.config?.methodConfig !== undefined && typeof config.sourceTemplateKey === "string") { const app = getConnectableAppDefinition(config.sourceTemplateKey); if (app) normalizeConnectionMethodConfig(connectionMethodForConnection(app, { ...existing, config }), asRecord(config.methodConfig)); } if (existing.transport === "mcp_remote") await assertRemoteConnectionEndpointsAllowed(config); if (existing.transport === "local_stdio") await stdioTemplateId(existing.companyId, config); assertLocalStdioCanBeEnabled( existing.transport, input.enabled ?? existing.enabled, config, ); await assertGoogleSheetsSpreadsheetOwnership(existing.companyId, config, { excludeConnectionId: existing.id, }); await assertSecretRefs(existing.companyId, [ ...(input.credentialRefs ?? existing.credentialRefs), ...(input.credentialSecretRefs ?? existing.credentialSecretRefs), ]); const write = (database: Db) => database .update(toolConnections) .set({ name: input.name ?? existing.name, status: input.status ?? existing.status, enabled: input.enabled ?? existing.enabled, ...(input.agentInstructions !== undefined ? { agentInstructions: input.agentInstructions } : {}), config, transportConfig: isGoogleSheetsConnectionConfig(config) ? config : (input.transportConfig ?? config), credentialRefs: input.credentialRefs ?? existing.credentialRefs, credentialSecretRefs: input.credentialSecretRefs ?? existing.credentialSecretRefs, credentialPolicy: input.credentialPolicy ?? existing.credentialPolicy, updatedAt: new Date(), }) .where(eq(toolConnections.id, connectionId)) .returning(); const [row] = existing.transport === "chat_sdk" ? await db.transaction(async tx => { await notifyChatEndpointWork(tx, existing.id); const rows = await write(tx as unknown as Db); // Credential resolution must never observe the binding rebuild's // delete/insert gap after a configuration wake. await syncCredentialBindings(rows[0]!, [], tx); return rows; }) : await write(db); if (existing.transport !== "chat_sdk") await syncCredentialBindings(row); await ensureRuntimeSlot(row); emitConnectionUpdated(row, existing, "api"); return toConnection(row); }, archiveConnection: removeConnection, checkHealth: checkConnectionHealth, refreshCatalog, configureRailwaySsh: async (connectionId: string, companyId: string, input: ConfigureRailwaySsh, actor: ActorInfo): Promise => { const knownHosts = input.action === "enable" ? validateRailwayKnownHosts(input.knownHosts) : ""; const setup = await db.transaction(async (tx) => { const [grant] = await tx.select().from(connectionGrants).where(and(eq(connectionGrants.id, input.grantId), eq(connectionGrants.connectionId, connectionId), eq(connectionGrants.companyId, companyId))).for("update"); const [connection] = await tx.select().from(toolConnections).where(and(eq(toolConnections.id, connectionId), eq(toolConnections.companyId, companyId))).for("update"); if (!connection || !isRailwayConnection(connection) || (connection.status !== "active" && input.action !== "remove")) throw unprocessable("Connect Railway before configuring container access."); if (!grant || (grant.status !== "active" && input.action !== "remove")) throw unprocessable("Select an active Railway authorization."); if (grant.kind === "user" && (actor.actorType !== "user" || actor.actorId !== grant.subjectUserId)) throw forbidden("Only this authorization's owner can configure its SSH key."); const existing = asRecord(connection.config.railwaySsh); if (existing.grantId && existing.grantId !== grant.id) throw conflict("Remove the existing container key before choosing another authorization."); const currentRef = grant.credentialSecretRefs.find((ref) => ref.configPath === RAILWAY_SSH_SECRET_PATH); let next: RailwaySshSetup | null = null; let refs = grant.credentialSecretRefs; if (input.action === "remove") { if (currentRef) await secretService(tx).remove(currentRef.secretId); refs = refs.filter((ref) => ref.configPath !== RAILWAY_SSH_SECRET_PATH); } else if (input.action === "prepare") { if (currentRef && typeof existing.publicKey === "string") return existing as unknown as RailwaySshSetup; const key = await generateRailwaySshKey(); const ref = await createOrRotateOAuthSecret({ companyId, connection, configPath: RAILWAY_SSH_SECRET_PATH, label: "Railway container SSH key", value: key.privateKey, existingRefs: [], ownerUserId: grant.kind === "user" ? grant.subjectUserId ?? undefined : undefined, actor }, { dbClient: tx, secretClient: secretService(tx) }); refs = [...refs.filter((ref) => ref.configPath !== RAILWAY_SSH_SECRET_PATH), ref]; next = { grantId: grant.id, publicKey: key.publicKey, knownHosts: "", enabled: false }; } else { if (!currentRef || typeof existing.publicKey !== "string") throw unprocessable("Generate and register a container key first."); next = { grantId: grant.id, publicKey: existing.publicKey, knownHosts, enabled: true }; } await tx.update(connectionGrants).set({ credentialSecretRefs: refs, updatedAt: now() }).where(and(eq(connectionGrants.id, grant.id), eq(connectionGrants.companyId, companyId))); const config = { ...connection.config, railwaySsh: next }; const [updated] = await tx.update(toolConnections).set({ config, transportConfig: config, updatedAt: now() }).where(and(eq(toolConnections.id, connectionId), eq(toolConnections.companyId, companyId))).returning(); await syncCredentialBindings(updated, [], tx); return next; }); await audit({ companyId, connectionId, action: "tool_connection.railway_ssh_updated", outcome: "success", actor, details: { action: input.action, grantId: input.grantId, enabled: setup?.enabled ?? false } }); return setup; }, listAppsNeedingAttention, sweepConnectionHealth, sweepGitHubConnectionContinuity, ...catalogReader, ...toolActionReviewQueue, ...profileService, ...profileResolver, mintConnectionTokenForAgent, ...runtimeSlots.api, getRuntimeHealth: runtimeHealth, previewMcpJsonImport: async ( input: ImportMcpJson, ): Promise => { let raw: unknown; try { raw = typeof input.mcpJson === "string" ? (JSON.parse(input.mcpJson) as unknown) : input.mcpJson; } catch { throw badRequest("mcp.json must be valid JSON"); } const mcpServers = asRecord(asRecord(raw).mcpServers); const drafts = Object.entries(mcpServers).map(([name, rawServer]) => { const server = asRecord(rawServer); const warnings: string[] = []; if ( typeof server.url === "string" || typeof server.endpoint === "string" ) { const headers = asRecord(server.headers); const credentialFields = Object.keys(headers) .sort() .map((key) => { warnings.push( `Header ${key} will be stored as a Paperclip secret before activation.`, ); return { configPath: `headers.${key}`, label: key, placement: "header" as const, key, prefix: null, required: true, }; }); return { name, transport: "mcp_remote" as const, status: "draft" as const, config: { url: server.url ?? server.endpoint }, credentialRefs: [] as McpConnectionCredentialRef[], credentialFields, warnings, }; } if (typeof server.command === "string") { warnings.push( "Imported stdio commands stay draft-only unless mapped to an approved Paperclip template.", ); return { name, transport: "local_stdio" as const, status: "draft" as const, config: { importedCommand: server.command, importedArgs: Array.isArray(server.args) ? server.args : [], }, credentialRefs: [], credentialFields: [], warnings, }; } warnings.push("Unsupported MCP server entry."); return { name, transport: "mcp_remote" as const, status: "draft" as const, config: {}, credentialRefs: [], credentialFields: [], warnings, }; }); if (drafts.length === 0) throw badRequest("mcp.json must include an mcpServers object"); return { drafts }; }, assertConnectionCompany: async ( connectionId: string, companyId: string, ) => { const connection = await getConnectionRow(connectionId, companyId); return toConnection(connection); }, ensureNoDuplicateNameError: (error: unknown) => { const maybeRecord = typeof error === "object" && error !== null ? (error as Record) : null; const cause = maybeRecord?.cause; const maybeCause = typeof cause === "object" && cause !== null ? (cause as Record) : null; const message = [ error instanceof Error ? error.message : String(error), maybeRecord && typeof maybeRecord.detail === "string" ? maybeRecord.detail : null, maybeCause instanceof Error ? maybeCause.message : null, maybeCause && typeof maybeCause.detail === "string" ? maybeCause.detail : null, ] .filter(Boolean) .join("\n"); const code = maybeRecord && typeof maybeRecord.code === "string" ? maybeRecord.code : maybeCause && typeof maybeCause.code === "string" ? maybeCause.code : null; const constraint = maybeRecord && typeof maybeRecord.constraint === "string" ? maybeRecord.constraint : maybeRecord && typeof maybeRecord.constraint_name === "string" ? maybeRecord.constraint_name : maybeCause && typeof maybeCause.constraint === "string" ? maybeCause.constraint : maybeCause && typeof maybeCause.constraint_name === "string" ? maybeCause.constraint_name : null; if ( code === "23505" || constraint?.includes("tool_applications") || /duplicate key value|unique constraint|tool_applications_company_id_name_unique/i.test( message, ) ) { throw conflict("A tool access record with that name already exists", { code: "tool_access_name_conflict", }); } throw error; }, }; }