diff --git a/doc/connections/CONNECTOR-PLAYBOOK.md b/doc/connections/CONNECTOR-PLAYBOOK.md index 5124927b72..ca77a2600b 100644 --- a/doc/connections/CONNECTOR-PLAYBOOK.md +++ b/doc/connections/CONNECTOR-PLAYBOOK.md @@ -146,6 +146,14 @@ These axes produce combinations such as: transport is a REST API. Most current API-key catalog entries authenticate a remote MCP server. +Anthropic accounts use the `runtime_auth` AI connection methods. Its obsolete +`api-key` REST tool method is no longer offered. Existing unsupported REST tool +connections fail health and catalog checks with HTTP 422 and +`tool_connection_transport_unsupported`; they never use local stdio templates +or report a successful MCP probe. Add the provider through its supported account +flow, then remove the obsolete connection. This does not transfer credentials +or grants automatically. + For `mcp_remote`, header credentials and secret-bearing generated URLs have the complete generic runtime path. The schema also names `query`, `body_json`, and `env` key placements for specialized transports, but accepting a value in the diff --git a/packages/shared/src/app-definitions.test.ts b/packages/shared/src/app-definitions.test.ts index b387e5286a..37cbc57045 100644 --- a/packages/shared/src/app-definitions.test.ts +++ b/packages/shared/src/app-definitions.test.ts @@ -238,6 +238,13 @@ const GOOGLE_WORKSPACE_PROFILE_EXPECTATIONS = [ writeTools: readonly string[]; }>; describe("AppDefinition catalog", () => { + it("offers Anthropic runtime authentication without the unsupported REST tool method", () => { + const anthropic = APP_DEFINITIONS.find((app) => app.slug === "anthropic")!; + expect(anthropic.methods.map((method) => method.key)).toEqual(["ai-subscription", "ai-api_key"]); + expect(anthropic.methods.every((method) => method.purpose === "ai" && method.transport === "runtime_auth")).toBe(true); + expect(getAvailableConnectionMethod(anthropic, "api-key")).toBeNull(); + }); + it("validates all Wave 1 definitions", () => expect(() => appDefinitionsSchema.parse(APP_DEFINITIONS)).not.toThrow()); it("contains every established provider plus the reviewed self-serve catalog", () => { diff --git a/packages/shared/src/app-definitions/anthropic.json b/packages/shared/src/app-definitions/anthropic.json index 65452bf305..bd16bb3444 100644 --- a/packages/shared/src/app-definitions/anthropic.json +++ b/packages/shared/src/app-definitions/anthropic.json @@ -70,34 +70,6 @@ "location": "env", "name": "ANTHROPIC_API_KEY" } - }, - { - "key": "api-key", - "transport": "rest_api", - "auth": "api_key", - "ownershipModes": [ - "customer" - ], - "whenToUse": "Use credentials from your provider account.", - "defaults": { - "serviceHost": "api.anthropic.com" - }, - "guidanceMd": "Create a key in the Anthropic Console and rotate it if it has been exposed.", - "riskTier": "S3", - "credentialFields": [ - { - "key": "apiKey", - "label": "API key", - "type": "password", - "required": true, - "placeholder": "sk-ant-api03-...", - "secret": true - } - ], - "keyPlacement": { - "location": "header", - "name": "x-api-key" - } } ] } diff --git a/scripts/ingest-app-definitions.mjs b/scripts/ingest-app-definitions.mjs index fb34cc8d6d..6abdf4af55 100644 --- a/scripts/ingest-app-definitions.mjs +++ b/scripts/ingest-app-definitions.mjs @@ -1490,7 +1490,9 @@ for (const [slug, name, subscription, envKey] of [["anthropic", "Claude", true, let app=apps.find(a=>a.slug===slug); if(!app){app={schemaVersion:1,slug,name,description:`Connect ${name} accounts for your agents.`,categories:["ai"],branding:brandingFor(slug),urlPatterns:[{"openai":"https://api.openai.com/*","openrouter":"https://openrouter.ai/api/*","xai":"https://api.x.ai/*"}[slug]],methods:[]};apps.push(app);} const methods=(subscription?["subscription","api_key"]:["api_key"]).map(authMethod=>({key:`ai-${authMethod}`,label:authMethod==="subscription"?`${name} subscription`:`${name} API key`,purpose:"ai",transport:"runtime_auth",auth:authMethod==="subscription"?"oauth":"api_key",ai:{provider:slug,method:authMethod},grantKinds:["user","organization"],ownershipModes:["customer"],whenToUse:"Authenticate an agent with this account.",guidanceMd:"Use your personal account or an explicitly shared company account.",riskTier:"S3",...(authMethod==="api_key"?{credentialFields:[field("apiKey","API key","Enter API key")],keyPlacement:{location:"env",name:envKey}}:{})})); - app.methods.unshift(...methods); + // Legacy REST entries have no tool execution adapter. Only offer the supported + // AI account flow; saved REST connections remain removable through Connections. + app.methods = [...methods, ...app.methods.filter(method => method.transport !== "rest_api")]; } const validateApp = (app) => { if ( diff --git a/server/src/__tests__/connection-intents-service.test.ts b/server/src/__tests__/connection-intents-service.test.ts index d3cb60829a..8b1a5d14e5 100644 --- a/server/src/__tests__/connection-intents-service.test.ts +++ b/server/src/__tests__/connection-intents-service.test.ts @@ -793,7 +793,7 @@ describeEmbeddedPostgres("connectionIntentService", () => { await expect(service.search(claims, "notion")) .rejects.toThrow("no longer active"); }); - it("keeps runtime authentication requests distinct from the same provider's tool requests", async () => { + it("keeps runtime authentication separate from obsolete Anthropic tool requests", async () => { const companyId = claims.company_id; const agentId = randomUUID(); const issueId = randomUUID(); @@ -808,16 +808,38 @@ describeEmbeddedPostgres("connectionIntentService", () => { await db.insert(aiConnectionDefaults).values({ companyId, userId: claims.responsible_user_id!, provider: "anthropic", method: "api_key", grantId: grant!.id }); const aiClaims = { ...claims, sub: agentId, run_id: aiRunId }; const service = connectionIntentService(db); - const toolRequest = await service.request(aiClaims, "anthropic"); + await expect(service.request(aiClaims, "anthropic")).rejects.toMatchObject({ + status: 422, + message: "Connection service anthropic is not available", + }); + // Preserve an intent created before the obsolete REST method was removed. + // It must neither alias the AI request nor accept an AI account as tools. + const toolRequest = await issueThreadInteractionService(db).createConnectionIntent( + { id: issueId, companyId }, + { + payload: { + version: 1, + serviceSlug: "anthropic", + serviceName: "Anthropic", + serviceLogoUrl: null, + requestingAgentId: agentId, + requestingAgentName: "AI Agent", + phase: "requested", + }, + sourceRunId: aiRunId, + addresseeUserId: claims.responsible_user_id!, + idempotencyKey: `connection-intent:${aiRunId}:${claims.responsible_user_id}:anthropic`, + }, + ); const aiRequest = await service.request(aiClaims, "anthropic", { purpose: "ai" }); expect(aiRequest.state).toBe("needs_user_action"); - expect(aiRequest.interactionId).not.toBe(toolRequest.interactionId); + expect(aiRequest.interactionId).not.toBe(toolRequest.id); expect((await service.setupOptions(aiRequest.interactionId!)).aiConnection).toEqual(binding); - expect((await service.setupOptions(toolRequest.interactionId!)).existingConnections).toEqual([]); - await expect(service.complete(toolRequest.interactionId!, connection!.id, claims.responsible_user_id!)).rejects.toThrow("cannot satisfy"); + expect((await service.setupOptions(toolRequest.id)).existingConnections).toEqual([]); + await expect(service.complete(toolRequest.id, connection!.id, claims.responsible_user_id!)).rejects.toThrow("cannot satisfy"); await expect(service.complete(aiRequest.interactionId!, connection!.id, claims.responsible_user_id!)).resolves.toMatchObject({ status: "accepted" }); expect((await service.request(aiClaims, "anthropic", { purpose: "ai" })).state).toBe("ready"); - expect((await service.request(aiClaims, "anthropic")).state).toBe("needs_user_action"); + await expect(service.request(aiClaims, "anthropic")).rejects.toMatchObject({ status: 422 }); expect((await service.search(aiClaims, "openrouter")).results.some(result => result.service === "openrouter")).toBe(false); }); diff --git a/server/src/__tests__/tool-access-service.test.ts b/server/src/__tests__/tool-access-service.test.ts index 2b961cb3d1..48b694029f 100644 --- a/server/src/__tests__/tool-access-service.test.ts +++ b/server/src/__tests__/tool-access-service.test.ts @@ -2614,6 +2614,80 @@ describeEmbeddedPostgres("tool access service", () => { expect(health.connection.healthStatus).toBe("ok"); }); + it.each( + [ + { sourceTemplateKey: "anthropic", connectionMethodKey: "api-key" }, + { + sourceTemplateKey: "unsupported-rest-fixture", + templateId: "paperclip.echo-calculator-time", + }, + ].flatMap((config) => + (["checkHealth", "refreshCatalog"] as const).map((operation) => ({ config, operation })), + ), + )("rejects unsupported REST tool connections without stdio validation: %j", async ({ config, operation }) => { + const company = await createCompany(db); + const service = createTestToolAccessService(db); + const application = await service.createApplication(company.id, { + name: "REST regression fixture", + type: "rest_api", + }); + const connection = await service.createConnection(company.id, { + applicationId: application.id, + name: "REST regression fixture", + transport: "rest_api", + config, + enabled: true, + status: "active", + }); + const fetchMock = vi.spyOn(globalThis, "fetch"); + const message = "This connection has no supported tool integration. Add a supported account or MCP connection from Connectors."; + + await expect(service[operation](connection.id)).rejects.toMatchObject({ + status: 422, + message, + details: { code: "tool_connection_transport_unsupported" }, + }); + const [saved] = await db.select().from(toolConnections) + .where(eq(toolConnections.id, connection.id)); + expect(saved).toMatchObject({ healthStatus: "error", healthMessage: message }); + expect(fetchMock).not.toHaveBeenCalled(); + expect(await service.listRuntimeSlots(company.id)).toEqual([]); + expect(await db.select().from(toolCatalogEntries) + .where(eq(toolCatalogEntries.connectionId, connection.id))).toEqual([]); + const audit = await db.select().from(toolAccessAuditEvents) + .where(eq(toolAccessAuditEvents.connectionId, connection.id)); + expect(audit).toEqual(expect.arrayContaining([ + expect.objectContaining({ + action: operation === "checkHealth" ? "tool_connection.health_check" : "tool_connection.catalog_refresh", + outcome: "failure", + reasonCode: "tool_connection_transport_unsupported", + }), + ])); + // Removing a method from the catalog must not strand its saved connections. + expect(await service.archiveConnection(connection.id)).toMatchObject({ + connection: { status: "archived" }, + }); + }); + + it("rejects the obsolete Anthropic REST setup before storing credentials", async () => { + const company = await createCompany(db); + const service = createTestToolAccessService(db); + + await expect(service.connectGalleryApp(company.id, { + galleryKey: "anthropic", + connectionMethodKey: "api-key", + credentialValues: { "credentials.apiKey": "rest-regression-secret" }, + }, { actorType: "user", actorId: "board" })).rejects.toMatchObject({ + status: 422, + message: "This app does not have an available connection method", + }); + + expect(await db.select().from(toolConnections) + .where(eq(toolConnections.companyId, company.id))).toEqual([]); + expect(await db.select().from(companySecrets) + .where(eq(companySecrets.companyId, company.id))).toEqual([]); + }); + it("registers an approved local stdio template and exposes its runtime slot", async () => { const company = await createCompany(db); const service = createTestToolAccessService(db); diff --git a/server/src/services/tool-access.ts b/server/src/services/tool-access.ts index fe09182a12..0192c64e6b 100644 --- a/server/src/services/tool-access.ts +++ b/server/src/services/tool-access.ts @@ -2776,10 +2776,18 @@ function healthFailureHttpStatus(failure: { }): number { if (failure.status === "missing_secret") return 422; if (failure.code === "composio_api_key_rejected") return 422; + if (failure.code === "tool_connection_transport_unsupported") return 422; if (failure.code.endsWith("_endpoint_rejected")) return 422; return 502; } +function unsupportedToolConnectionTransport() { + return unprocessable( + "This connection has no supported tool integration. Add a supported account or MCP connection from Connectors.", + { code: "tool_connection_transport_unsupported" }, + ); +} + function sanitizeHttpFailure(error: unknown): { status: ToolConnectionHealthStatus; message: string; @@ -2797,6 +2805,9 @@ function sanitizeHttpFailure(error: unknown): { } if (error instanceof HttpError) { const code = asRecord(error.details).code; + if (code === "tool_connection_transport_unsupported") { + return { status: "error", message: error.message, code }; + } if (code === "composio_connected_account_inactive") { return { status: "degraded", message: error.message, code }; } @@ -7428,6 +7439,9 @@ export function toolAccessService( await validateComposioConnection(connection); return []; } + if (connection.transport !== "local_stdio") { + throw unsupportedToolConnectionTransport(); + } await resolveCredentialHeaders(connection); return localTools(connection); } @@ -7579,9 +7593,11 @@ export function toolAccessService( await remoteTools(connection, credentialHeaders, actor); } else if (isComposioConnection(connection)) { await validateComposioConnection(connection); - } else { + } else if (connection.transport === "local_stdio") { await resolveCredentialHeaders(connection); await stdioTemplateId(connection.companyId, connection.config); + } else { + throw unsupportedToolConnectionTransport(); } const updated = await updateConnectionHealth( connection, diff --git a/ui/src/pages/apps/AppDetail.test.tsx b/ui/src/pages/apps/AppDetail.test.tsx index c44df98cb9..8926bdff70 100644 --- a/ui/src/pages/apps/AppDetail.test.tsx +++ b/ui/src/pages/apps/AppDetail.test.tsx @@ -5,6 +5,7 @@ import type { ReactNode } from "react"; import { createRoot } from "react-dom/client"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { getAppStoreDefinition } from "@paperclipai/shared"; import { AppDetail } from "./AppDetail"; import { APP_TABS } from "./app-tabs"; @@ -1172,6 +1173,30 @@ describe("AppDetail", () => { expect(container.textContent).toContain("Which agents can use this connection?"); }); + it.each(["permissions", "review"])("offers a supported replacement for an obsolete Anthropic connection on %s", async (tab) => { + mockParams.tab = tab; + listApplicationsMock.mockResolvedValue({ applications: [] }); + listGalleryMock.mockResolvedValue({ apps: [getAppStoreDefinition("anthropic")!] }); + getConnectionMock.mockResolvedValue(connection({ + name: "Anthropic", + transport: "rest_api", + authKind: "api_key", + config: { sourceTemplateKey: "anthropic", connectionMethodKey: "api-key" }, + healthStatus: "error", + healthMessage: "This connection has no supported tool integration.", + })); + + await renderAppDetail(); + + expect(container.querySelector('input[type="password"]')).toBeNull(); + expect(findButton("Check & reconnect")).toBeUndefined(); + expect(findButton("Reconnect")).toBeUndefined(); + expect(container.textContent).toContain("Connection no longer supported"); + expect(container.textContent).toContain("then remove this connection"); + expect(container.querySelector('a[href="/apps/connect?source=anthropic"]')?.textContent) + .toBe("Add supported connection"); + }); + it("offers retry for a transient GitHub error without asking for another login", async () => { mockParams.tab = "permissions"; getConnectionMock.mockResolvedValue(connection({ diff --git a/ui/src/pages/apps/AppsConnect.test.tsx b/ui/src/pages/apps/AppsConnect.test.tsx index d096e55a26..2818f1d0df 100644 --- a/ui/src/pages/apps/AppsConnect.test.tsx +++ b/ui/src/pages/apps/AppsConnect.test.tsx @@ -6,13 +6,18 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { CONNECTABLE_APP_DEFINITIONS, GOOGLE_WORKSPACE_CONNECTOR_PROFILES, getAppStoreDefinition } from "@paperclipai/shared"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { ApiError } from "@/api/client"; +import { aiConnectionsApi } from "@/api/ai-connections"; import { queryKeys } from "@/lib/queryKeys"; import { ConnectionSetupFlow } from "@/features/connections/ConnectionSetupFlow"; import { AppsConnect } from "./AppsConnect"; const listGalleryMock = vi.hoisted(() => vi.fn()); const experimentalMock = vi.hoisted(() => vi.fn()); -vi.mock("@/api/instanceSettings", () => ({ instanceSettingsApi: { getExperimental: experimentalMock } })); +vi.mock("@/api/instanceSettings", () => ({ instanceSettingsApi: { + getExperimental: experimentalMock, + get: async () => ({ defaultEnvironmentId: "local-env" }), + getGeneral: async () => ({}), +} })); const listApplicationsMock = vi.hoisted(() => vi.fn()); const listConnectionsMock = vi.hoisted(() => vi.fn()); const getConnectionMock = vi.hoisted(() => vi.fn()); @@ -438,24 +443,43 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => { // credential is entered. // ------------------------------------------------------------------------- - it("keeps the existing Anthropic tool method reachable alongside AI authentication", async () => { + it("offers supported Anthropic AI authentication without the obsolete REST tool method", async () => { + const createAiAccount = vi.spyOn(aiConnectionsApi, "create").mockResolvedValue({ + connectionId: "anthropic-ai-account", grantId: "anthropic-ai-grant", + }); mockParams.appKey = "anthropic"; listGalleryMock.mockResolvedValue({ apps: [getAppStoreDefinition("anthropic")] }); - await render(); + const client = new QueryClient({ defaultOptions: { queries: { + retry: false, + staleTime: Infinity, + } } }); + client.setQueryData(queryKeys.environments.list("company-1"), [ + { id: "local-env", name: "Local", driver: "local", status: "active", config: {} }, + ]); + client.setQueryData(queryKeys.environments.capabilities("company-1"), {}); + client.setQueryData(queryKeys.instance.settings, { defaultEnvironmentId: "local-env" }); + client.setQueryData(queryKeys.instance.generalSettings, {}); + client.setQueryData(queryKeys.health, { deploymentMode: "authenticated", localAiLoginSupported: false }); + await render(client); await passAccessStep(); - expect(container.textContent).toContain("How do you want to connect?"); - expect(radioContaining("Claude subscription")).toBeTruthy(); - expect(radioContaining("Claude API key")).toBeTruthy(); - await act(async () => radioContaining("Use an API key")!.click()); + expect(container.textContent).toContain("Connect account"); + expect(container.textContent).toContain("Connection name"); + expect(container.textContent).not.toContain("How do you want to connect?"); + expect(radioContaining("Use an API key")).toBeUndefined(); + expect(container.querySelector('[role="alert"]')).toBeNull(); + await act(async () => buttonContaining("Use API key instead")!.click()); + await act(async () => buttonContaining("Claude")!.click()); await flushReact(); const key = container.querySelector('input[type="password"]'); expect(key).toBeTruthy(); - await act(async () => setInputValue(key!, "fixture-anthropic-tool-key")); + await act(async () => setInputValue(key!, "fixture-anthropic-ai-key")); await act(async () => buttonByText("Connect")!.click()); await flushReact(); - expect(connectAppMock).toHaveBeenCalledWith("company-1", expect.objectContaining({ - galleryKey: "anthropic", connectionMethodKey: "api-key", + expect(createAiAccount).toHaveBeenCalledWith("company-1", expect.objectContaining({ + provider: "anthropic", method: "api_key", apiKey: "fixture-anthropic-ai-key", })); + expect(mockNavigate).toHaveBeenCalledWith("/apps/anthropic-ai-account/permissions"); + expect(connectAppMock).not.toHaveBeenCalled(); expect(container.textContent).not.toContain("Connect for tool access instead"); }); diff --git a/ui/src/pages/apps/app-detail/AdvancedPanel.render.test.tsx b/ui/src/pages/apps/app-detail/AdvancedPanel.render.test.tsx index 1253193821..1db1cdd0d7 100644 --- a/ui/src/pages/apps/app-detail/AdvancedPanel.render.test.tsx +++ b/ui/src/pages/apps/app-detail/AdvancedPanel.render.test.tsx @@ -3,6 +3,7 @@ import { flushSync } from "react-dom"; import { createRoot } from "react-dom/client"; import { afterEach, describe, expect, it, vi } from "vitest"; +import { getAppStoreDefinition } from "@paperclipai/shared"; import { DangerZone } from "./AdvancedPanel"; // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -50,6 +51,54 @@ function expandDangerZone(node: HTMLDivElement) { * operator commits. */ describe("DangerZone", () => { + it("keeps removal available without reconnecting an obsolete Anthropic method", () => { + container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + const onRemove = vi.fn(); + act(() => root.render( + , + )); + expandDangerZone(container); + const button = (label: string) => Array.from(container!.querySelectorAll("button")) + .find((candidate) => candidate.textContent?.trim() === label); + expect(button("Reconnect")).toBeUndefined(); + act(() => button("Remove app")!.dispatchEvent(new MouseEvent("click", { bubbles: true }))); + act(() => button("Yes, remove it")!.dispatchEvent(new MouseEvent("click", { bubbles: true }))); + expect(onRemove).toHaveBeenCalledOnce(); + act(() => root.unmount()); + }); + it("keeps dangerous actions folded by default", () => { const node = renderDangerZone(); diff --git a/ui/src/pages/apps/app-detail/AdvancedPanel.tsx b/ui/src/pages/apps/app-detail/AdvancedPanel.tsx index ba952948a0..57fecd7c32 100644 --- a/ui/src/pages/apps/app-detail/AdvancedPanel.tsx +++ b/ui/src/pages/apps/app-detail/AdvancedPanel.tsx @@ -22,6 +22,7 @@ import { redactUrlSecrets } from "@/lib/redact-url-secrets"; import { navigateTopLevel } from "@/lib/browserNavigation"; import { prepareOAuthNavigation, savePendingCloudHandoff } from "@/lib/oauthHandoff"; import { cn } from "@/lib/utils"; +import { Link } from "@/lib/router"; import type { AppDetailSectionProps } from "./types"; import { RevokeGrantDialog } from "./IdentitiesSection"; @@ -88,6 +89,15 @@ export function AdvancedPanel({ ); } +function connectionMethodUnavailable(connection: ToolConnection, galleryEntry: AppDefinition | null): boolean { + const methodKey = connection.config?.connectionMethodKey; + return typeof methodKey === "string" + && methodKey.length > 0 + && !!galleryEntry + && Array.isArray(galleryEntry.methods) + && !getAvailableConnectionMethod(galleryEntry, methodKey); +} + function KeySection({ connection, galleryEntry, @@ -200,15 +210,18 @@ export function ReconnectCard({ }); const oauth = connection.authKind === "oauth"; const managedByVercel = connection.credentialSource === "vercel_connect"; + const methodUnavailable = connectionMethodUnavailable(connection, galleryEntry); return (

- {oauth ? "Reconnect required" : "This app needs reconnecting"} + {methodUnavailable ? "Connection no longer supported" : oauth ? "Reconnect required" : "This app needs reconnecting"}

- {connection.healthMessage?.trim() || (oauth + {methodUnavailable + ? "Add a supported connection from Connectors, then remove this connection." + : connection.healthMessage?.trim() || (oauth ? "Authorization expired or was revoked. Sign in again to restore access." : "The key stopped working. Paste a new one to get it back online.")}

@@ -218,6 +231,12 @@ export function ReconnectCard({

{reconnectUnavailableMessage ?? "You don't have permission to reconnect this identity."}

+ ) : methodUnavailable ? ( + ) : onReconnect ? ( ) : managedByVercel && !oauth ? ( @@ -449,6 +468,7 @@ export function DangerZone({ const paused = connection ? connection.enabled === false || connection.status === "disabled" : false; + const methodUnavailable = connection ? connectionMethodUnavailable(connection, galleryEntry) : false; return ( ) : null} - {connection && connection.authKind !== "oauth" ? ( + {connection && !methodUnavailable && connection.authKind !== "oauth" ? (
) : null} - {connection?.authKind === "oauth" && (onReconnectIdentity || !canReplaceCredential) ? ( + {connection?.authKind === "oauth" && !methodUnavailable && (onReconnectIdentity || !canReplaceCredential) ? (

Reconnect