From b82661b561531800542cecfbbf51fc465bcb865c Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Mon, 21 Sep 2026 13:57:35 -0500 Subject: [PATCH] refactor(connections): retire the legacy Composio broker (#13758) ## Thinking Path > - Paperclip manages agents and their access to external tools. > - Connectors expose these tools through a governed MCP gateway. > - PR #13755 added a direct Composio MCP connection behind the experimental MCP aggregators flag. > - The old project API-key broker still created toolkit child connections and showed a separate Services tab. > - Keeping both paths leaves obsolete setup and session code in the product. > - This change removes the broker and preserves direct MCP setup, credentials, permissions, and execution. > - Saved legacy records fail closed and remain available for explicit removal. ## Linked Issues or Issue Description Related: #13755. This retirement supersedes the legacy-path fixes proposed in #12630, #12632, #12634, and #12906. It does not close those PRs. **What existing behavior does this improve?** Composio connector setup, management, and runtime dispatch. **Current behavior** Composio offers both direct MCP and a project API-key broker. The broker mints sessions and creates one child connection per toolkit. **Proposed behavior** Offer only direct MCP. Remove the toolkit Services UI, REST routes, API client, and session broker. Block saved legacy parent and child records from discovery, execution, health checks, reconnect, and OAuth. Preserve their records and credentials until the operator removes each connection. **Reason and benefit** The direct MCP connector becomes the single supported Composio workflow. Provider accounts remain managed in Composio. ## What Changed - Remove the API-key catalog method and its generated-source definition. - Delete Composio broker clients, session creation, account synchronization, child lifecycle, and toolkit routes. - Remove the Services tab, service rows, child provenance, and cascade-removal controls. Keep Vercel provenance intact. - Retain a shared retirement guard for stored legacy records. Show Retired status and replacement/removal guidance in the connection list and details; hide obsolete runtime controls. - Preserve the experimental MCP aggregators flag and direct MCP infrastructure. - Replace broker fixtures with retirement tests and extend direct Composio catalog/reconnect coverage. ## Verification - Focused shared, server, and UI tests passed with one worker. Server retirement tests use a name filter; no full local test suite was run, as requested. - Server and UI TypeScript checks passed. - Token gates and UI build passed. - Real browser: opened the saved Composio connection, refreshed all 11 tools, and ran the provider's read-only GitHub account-list operation through the standard Test dialog as an agent. The provider returned success using the existing OAuth credentials. - See `doc/connections/COMPOSIO-BROKER-RETIREMENT.md` for scope and live evidence. - Storybook build passed. A fresh real agent used `COMPOSIO_SEARCH_TOOLS` and `COMPOSIO_MULTI_EXECUTE_TOOL` to return the actual Paperclip DeepWiki hierarchy: one success, zero errors. Gateway audit records confirm both calls succeeded. - Browser retirement check: a credential-free legacy fixture showed the guidance, opened the direct MCP replacement flow, and was removed through the standard confirmation. - Focused regressions for the experimental settings copy and exact OpenAPI route coverage passed. All latest-head CI checks passed (54 successful, two intentionally skipped); Greptile scored 5/5 with no unresolved review threads. The PR has no merge conflicts. ## Risks This intentionally breaks the old Composio project API-key and child-connection workflow. Existing legacy records cannot run, even if their stored status is active. Operators must create a new direct MCP connection and choose access rules; credentials and grants are not migrated. Remove each old record separately to delete its credentials. No schema migration or data deletion runs automatically. Direct MCP connections keep their existing grants and secrets. ## Model Used OpenAI GPT-6 via Codex, with reasoning, code execution, and browser tools. The exact runtime variant and context-window size are not exposed in this session. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip --- doc/connections/COMPOSIO-BROKER-RETIREMENT.md | 25 + doc/connections/REMOTE-MCP-LIVE-ACCEPTANCE.md | 4 +- .../shared/src/app-definitions-url.test.ts | 3 +- .../shared/src/app-definitions/composio.json | 33 - packages/shared/src/feature-catalog.ts | 2 +- packages/shared/src/index.ts | 2 + .../shared/src/remote-mcp-connectors.test.ts | 4 +- packages/shared/src/retired-composio.ts | 11 + scripts/ingest-app-definitions.mjs | 26 +- .../__tests__/generic-mcp-connection.test.ts | 170 ----- .../__tests__/remote-mcp-connectors.test.ts | 7 +- .../src/__tests__/tool-access-service.test.ts | 161 +---- server/src/__tests__/tool-gateway.test.ts | 142 +---- server/src/routes/openapi.ts | 28 - server/src/routes/tool-access.ts | 55 -- .../src/services/composio-session-manager.ts | 263 -------- server/src/services/composio.test.ts | 198 ------ server/src/services/composio.ts | 216 ------- server/src/services/index.ts | 1 - server/src/services/tool-access.ts | 602 +----------------- server/src/services/tool-gateway.ts | 59 +- ui/src/api/tools.ts | 28 +- .../AppConnectionSidebar.production.tsx | 5 +- ui/src/components/AppConnectionSidebar.tsx | 15 +- .../connections/remote-mcp/providers.ts | 2 +- ui/src/lib/queryKeys.ts | 2 - ui/src/pages/DesignGuide.tsx | 98 --- .../InstanceExperimentalSettings.test.tsx | 2 +- ui/src/pages/InstanceExperimentalSettings.tsx | 2 +- ui/src/pages/apps/AppDetail.test.tsx | 20 +- ui/src/pages/apps/AppDetail.tsx | 26 +- ui/src/pages/apps/AppNotConnected.tsx | 3 - ui/src/pages/apps/AppsConnect.test.tsx | 11 +- ui/src/pages/apps/Browse.test.tsx | 22 +- ui/src/pages/apps/Browse.tsx | 23 +- ui/src/pages/apps/ComposioProvenanceChip.tsx | 73 --- .../pages/apps/ConnectionProvenanceChip.tsx | 33 + ui/src/pages/apps/Connections.test.tsx | 54 +- ui/src/pages/apps/Connections.tsx | 25 +- .../app-detail/AdvancedPanel.render.test.tsx | 15 - .../pages/apps/app-detail/AdvancedPanel.tsx | 9 +- .../app-detail/ServicesPanel.render.test.tsx | 244 ------- .../pages/apps/app-detail/ServicesPanel.tsx | 435 ------------- ui/src/pages/apps/app-tabs.ts | 24 +- ui/src/pages/apps/composio-services.test.ts | 237 ------- ui/src/pages/apps/composio-services.ts | 225 ------- .../stories/agentmail-onboarding.stories.tsx | 2 +- .../stories/imessage-photon.stories.tsx | 2 +- 48 files changed, 258 insertions(+), 3391 deletions(-) create mode 100644 doc/connections/COMPOSIO-BROKER-RETIREMENT.md create mode 100644 packages/shared/src/retired-composio.ts delete mode 100644 server/src/services/composio-session-manager.ts delete mode 100644 server/src/services/composio.test.ts delete mode 100644 server/src/services/composio.ts delete mode 100644 ui/src/pages/apps/ComposioProvenanceChip.tsx create mode 100644 ui/src/pages/apps/ConnectionProvenanceChip.tsx delete mode 100644 ui/src/pages/apps/app-detail/ServicesPanel.render.test.tsx delete mode 100644 ui/src/pages/apps/app-detail/ServicesPanel.tsx delete mode 100644 ui/src/pages/apps/composio-services.test.ts delete mode 100644 ui/src/pages/apps/composio-services.ts diff --git a/doc/connections/COMPOSIO-BROKER-RETIREMENT.md b/doc/connections/COMPOSIO-BROKER-RETIREMENT.md new file mode 100644 index 00000000000..63499390d1c --- /dev/null +++ b/doc/connections/COMPOSIO-BROKER-RETIREMENT.md @@ -0,0 +1,25 @@ +# Composio broker retirement + +September 21, 2026 + +Composio now has one Paperclip catalog method: direct MCP. Enable **Settings → Experimental → MCP aggregators**, then connect with Composio Connect or an externally configured MCP session URL and headers. The existing Access → Connect setup, OAuth, permissions, per-action Test, and gateway execution are unchanged. Connect underlying apps in Composio; Paperclip does not create per-app child connections. + +The project API-key method, toolkit-management API routes, Services tab, connected-account synchronization, and session-minting broker have been removed. There is no automatic credential or grant migration. + +Previously saved broker and child records are retained for inspection and explicit removal. They appear as Retired with replacement guidance, are absent from gateway discovery, and cannot execute or refresh tools, reconnect, or start OAuth. Add a new Composio MCP connection and choose its access rules. Remove each obsolete record using the normal removal action to delete its credentials and revoke its grants. Removing a legacy parent does not automatically remove other saved records. + +No database migration or new tables are required. Direct MCP connections are identified separately from the old REST API parent and `provider: composio` child markers, so their existing vault secrets, grants, catalogs, and permissions remain in place. + +## Verification + +Focused fixtures cover removal of the API-key catalog method and toolkit routes, retirement of stored parent/child records without upstream calls, explicit credential cleanup, rejection of previously discovered child tools, and direct MCP catalog refresh/reconnect with preserved Off/Ask choices and company isolation. Browser acceptance is recorded below after testing the existing live Composio Connect account. + +### Live browser regression + +Used the existing isolated connector lab with fresh development data, rebuilt UI, and server on the cleanup branch. Opened Connectors → saved Composio → Permissions. Refresh actions returned the same 11 tools with access intact. The ordinary Test dialog ran `COMPOSIO_MANAGE_CONNECTIONS` with GitHub's `list` operation as the test agent; it returned `successful: true`, no provider error, and the account list in 2.0 seconds. No new sign-in or credentials were needed. This exercises the retained OAuth grant and direct MCP gateway, not a mocked provider. + +Functional result: catalog refresh and real gateway Test passed. UX result: the existing Permissions/Test flow remained usable and no broker Services tab appeared. This focused regression does not repeat every provider's original acceptance matrix or claim new account authorization. + +A fresh real-agent follow-up also passed: `COMPOSIO_SEARCH_TOOLS` discovered `DEEPWIKI_MCP_READ_WIKI_STRUCTURE`, then `COMPOSIO_MULTI_EXECUTE_TOOL` returned the Paperclip documentation hierarchy (13 top-level sections, 62 subsections; one success, zero errors). The gateway audit independently records both invocations as succeeded on the new run. No provider accounts were modified. Server/UI typechecks, token gates, UI build, Storybook build, and the focused tests passed; the full local suite was intentionally not run. + +The retired-record UI was also exercised with a disposable, credential-free legacy fixture in the isolated lab. The list showed Retired rather than Paused; details displayed replacement/removal guidance without obsolete runtime controls. Add Composio MCP connection opened the normal Access → Connect flow with the direct MCP URL and authentication fields. The fixture was removed through the normal confirmation UI afterward. diff --git a/doc/connections/REMOTE-MCP-LIVE-ACCEPTANCE.md b/doc/connections/REMOTE-MCP-LIVE-ACCEPTANCE.md index cf201584c72..44d8d873caf 100644 --- a/doc/connections/REMOTE-MCP-LIVE-ACCEPTANCE.md +++ b/doc/connections/REMOTE-MCP-LIVE-ACCEPTANCE.md @@ -6,7 +6,7 @@ Test environment: an isolated development worktree with a fresh database and org ## Experimental rollout -Setup is behind **Settings → Experimental → MCP aggregators** (`enableMcpAggregators`). It defaults off on self-hosted and managed instances. When off, all four fresh catalog entries are hidden and direct setup, reconnect setup, and OAuth-start requests are rejected server-side. Existing connections keep running and remain available for management. Legacy Composio API-key/child connections are unchanged. An in-progress OAuth callback may complete; the flag does not revoke already issued credentials or grants. +Setup is behind **Settings → Experimental → MCP aggregators** (`enableMcpAggregators`). It defaults off on self-hosted and managed instances. When off, all four fresh catalog entries are hidden and direct setup, reconnect setup, and OAuth-start requests are rejected server-side. Existing connections keep running and remain available for management. The legacy Composio API-key broker and child connections have since been retired; see [Composio broker retirement](COMPOSIO-BROKER-RETIREMENT.md). An in-progress OAuth callback may complete; the flag does not revoke already issued credentials or grants. Focused regression tests cover flag defaults, persistence, managed metadata, cached catalog visibility, all four direct setup routes, and server-side rejection before network/credential writes. @@ -66,7 +66,7 @@ Storybook links: `apps-connections-zapier--complete-setup-journey`, `apps-connec PR review regressions: expired MCP sessions now trigger one safe discovery handshake; action calls fail visibly and wait for an explicit retry. Provider handoff detection recognizes protocol/provider envelopes rather than arbitrary app-data statuses. Buffered transports preserve response-ID matching, size limits, and distinct error codes. Dedicated regression checks and selected existing connector/gateway cases pass. Ordinary connector resume/reconnect continues through its existing controller. -Aggregator action risk is conservative: an unfamiliar or renamed tool defaults to write risk even if the provider advertises it as read-only. Only an explicit reviewed allowlist receives read risk; annotations can still escalate it. This affects risk labels and risk-based governance, not the approved default-enabled behavior or saved Off/Ask first choices. Legacy Composio child connections retain their existing classification path. +Aggregator action risk is conservative: an unfamiliar or renamed tool defaults to write risk even if the provider advertises it as read-only. Only an explicit reviewed allowlist receives read risk; annotations can still escalate it. This affects risk labels and risk-based governance, not the approved default-enabled behavior or saved Off/Ask first choices. - Paste Zapier's generated Full URL into the already-open local Zapier setup field (not chat), then complete its browser Test, agent, governance, refresh, and lifecycle acceptance. Do not rotate the displayed credential unnecessarily. - Optional Composio GitHub account authorization awaits the user's GitHub verification. The no-auth DeepWiki app path is proven; GitHub app execution is not claimed. diff --git a/packages/shared/src/app-definitions-url.test.ts b/packages/shared/src/app-definitions-url.test.ts index f5bcea47688..6e6954be430 100644 --- a/packages/shared/src/app-definitions-url.test.ts +++ b/packages/shared/src/app-definitions-url.test.ts @@ -33,11 +33,10 @@ describe("tool app gallery URL matching", () => { expect(getAppDefinitionForUrl("https://drivemcp.googleapis.com/mcp/v1")?.slug).toBe("google-drive"); }); - it("lists Composio Connect alongside the legacy API-key method", () => { + it("offers only direct MCP for Composio", () => { const composio = CONNECTABLE_APP_DEFINITIONS.find((app) => app.slug === "composio"); expect(composio?.methods).toEqual([ expect.objectContaining({ key: "mcp", transport: "mcp_remote", ownershipModes: ["dcr", "customer"] }), - expect.objectContaining({ key: "api-key", transport: "rest_api", auth: "api_key" }), ]); }); diff --git a/packages/shared/src/app-definitions/composio.json b/packages/shared/src/app-definitions/composio.json index b5c880da38c..19bc349f24e 100644 --- a/packages/shared/src/app-definitions/composio.json +++ b/packages/shared/src/app-definitions/composio.json @@ -33,39 +33,6 @@ "guidanceMd": "Sign in to Composio Connect, or paste an externally configured MCP session URL and headers.", "riskTier": "S3", "label": "Composio Connect" - }, - { - "key": "api-key", - "transport": "rest_api", - "auth": "api_key", - "ownershipModes": [ - "customer" - ], - "whenToUse": "Use a project API key from the Composio project that owns the toolkits and connected accounts.", - "defaults": { - "serviceHost": "backend.composio.dev" - }, - "guidanceMd": "Create a scoped project API key in Composio. It needs read access to toolkits and auth configs; later service-connection phases also need connected-account and session access.", - "riskTier": "S3", - "credentialFields": [ - { - "key": "apiKey", - "label": "Composio project API key", - "type": "password", - "required": true, - "placeholder": "Paste the Composio API key", - "secret": true - } - ], - "keyPlacement": { - "location": "header", - "name": "x-api-key" - }, - "consoleLinks": { - "keys": "https://app.composio.dev/", - "settings": "https://app.composio.dev/", - "docs": "https://docs.composio.dev/reference/authenticating-to-composio/project-api-key-permissions" - } } ], "docsUrl": "https://docs.composio.dev/docs/composio-connect" diff --git a/packages/shared/src/feature-catalog.ts b/packages/shared/src/feature-catalog.ts index 00e4945e9f7..fcbd2242e63 100644 --- a/packages/shared/src/feature-catalog.ts +++ b/packages/shared/src/feature-catalog.ts @@ -119,7 +119,7 @@ export const INSTANCE_FEATURE_CATALOG: Record { expect(connectToolAppSchema.safeParse({ galleryKey: provider, connectionMethodKey: methodKey, link: "https://example.com/mcp", authMode: "bearer", credentialValues: { "credentials.authorization": "test-secret", "headers.X-User-ID": "test-user" } }).success).toBe(true); }); } - it("keeps the legacy Composio API-key broker distinct", () => { + it("removes the legacy Composio API-key method", () => { expect(isRemoteMcpConnectorMethod("composio", "api-key")).toBe(false); - expect(getConnectableAppDefinition("composio")?.methods.find((method) => method.key === "api-key")?.transport).toBe("rest_api"); + expect(getConnectableAppDefinition("composio")?.methods.find((method) => method.key === "api-key")).toBeUndefined(); expect(connectToolAppSchema.safeParse({ galleryKey: "composio", connectionMethodKey: "api-key", authMode: "bearer" }).success).toBe(false); }); it("allows removing all agent access without changing the tool choices", () => { diff --git a/packages/shared/src/retired-composio.ts b/packages/shared/src/retired-composio.ts new file mode 100644 index 00000000000..ae048fd6b72 --- /dev/null +++ b/packages/shared/src/retired-composio.ts @@ -0,0 +1,11 @@ +/** Persisted broker records must never fall through to generic MCP dispatch. */ +export function isRetiredComposioConnection(connection: { + transport: string; + config?: Record | null; +}): boolean { + return (connection.config?.sourceTemplateKey === "composio" && connection.transport === "rest_api") + || connection.config?.provider === "composio"; +} + +export const RETIRED_COMPOSIO_MESSAGE = + "This legacy Composio connection is no longer supported. Add a new Composio MCP connection from Connectors, then remove this connection. Existing credentials and permissions are not migrated."; diff --git a/scripts/ingest-app-definitions.mjs b/scripts/ingest-app-definitions.mjs index cc8ca176be4..71c839db1d0 100644 --- a/scripts/ingest-app-definitions.mjs +++ b/scripts/ingest-app-definitions.mjs @@ -707,31 +707,7 @@ const apps = [ "productivity", "composio.dev", ["https://backend.composio.dev/*", "https://connect.composio.dev/*", "https://mcp.composio.dev/*", "https://*.composio.dev/*"], - [method("mcp", "mcp_remote", "none", { serverUrl: "https://connect.composio.dev/mcp" }, "S3", "Sign in to Composio Connect, or paste an externally configured MCP session URL and headers.", { label: "Composio Connect", ownershipModes: ["dcr", "customer"] }), method( - "api-key", - "rest_api", - "api_key", - { serviceHost: "backend.composio.dev" }, - "S3", - "Create a scoped project API key in Composio. It needs read access to toolkits and auth configs; later service-connection phases also need connected-account and session access.", - { - whenToUse: - "Use a project API key from the Composio project that owns the toolkits and connected accounts.", - credentialFields: [ - field( - "apiKey", - "Composio project API key", - "Paste the Composio API key", - ), - ], - keyPlacement: { location: "header", name: "x-api-key" }, - consoleLinks: { - keys: "https://app.composio.dev/", - settings: "https://app.composio.dev/", - docs: "https://docs.composio.dev/reference/authenticating-to-composio/project-api-key-permissions", - }, - }, - )], + [method("mcp", "mcp_remote", "none", { serverUrl: "https://connect.composio.dev/mcp" }, "S3", "Sign in to Composio Connect, or paste an externally configured MCP session URL and headers.", { label: "Composio Connect", ownershipModes: ["dcr", "customer"] })], { featured: true, docsUrl: "https://docs.composio.dev/docs/composio-connect" }, ], [ diff --git a/server/src/__tests__/generic-mcp-connection.test.ts b/server/src/__tests__/generic-mcp-connection.test.ts index 77b7c165801..0da43880f97 100644 --- a/server/src/__tests__/generic-mcp-connection.test.ts +++ b/server/src/__tests__/generic-mcp-connection.test.ts @@ -40,8 +40,6 @@ import { } from "./helpers/embedded-postgres.js"; import { toolAccessService } from "../services/tool-access.js"; import { instanceSettingsService } from "../services/instance-settings.js"; -import { ComposioApiError, type ComposioClient } from "../services/composio.js"; -import { createComposioSessionManager } from "../services/composio-session-manager.js"; import { toolAccessPolicyService } from "../services/tool-access-policy.js"; import { toolAccessRoutes } from "../routes/tool-access.js"; import { errorHandler } from "../middleware/index.js"; @@ -643,176 +641,8 @@ describeEmbeddedPostgres("generic remote MCP connections", () => { expect(connection!.credentialSecretRefs.map((ref) => ref.configPath)).toEqual(["credentials.authorization"]); }); - it("stores and validates a Composio API key without returning plaintext", async () => { - const company = await createCompany(db); - const validatedKeys: string[] = []; - const service = toolAccessService(db, { - composioClientFactory: (apiKey) => ({ - validateApiKey: async () => { validatedKeys.push(apiKey); }, - }) as unknown as ComposioClient, - }); - const result = await service.connectGalleryApp(company.id, { - galleryKey: "composio", - connectionMethodKey: "api-key", - credentialValues: { "credentials.apiKey": "ak_composio_fixture" }, - }); - expect(validatedKeys).toEqual(["ak_composio_fixture"]); - expect(result.catalog).toEqual([]); - expect(result.actions).toEqual({ readOnly: [], canMakeChanges: [] }); - expect(result.connection).toMatchObject({ - transport: "rest_api", - authKind: "api_key", - healthStatus: "ok", - }); - expect(result.connection.healthMessage).toContain("returned its toolkits"); - - const [connection] = await db.select().from(toolConnections).where(eq(toolConnections.id, result.connectionId)); - expect(connection!.credentialSecretRefs.map((ref) => ref.configPath)).toEqual(["credentials.apiKey"]); - expect(connection!.credentialRefs).toEqual([ - expect.objectContaining({ placement: "header", key: "x-api-key", prefix: null }), - ]); - expect(JSON.stringify({ result, connection })).not.toContain("ak_composio_fixture"); - }); - - it("rejects an invalid Composio key and removes the draft and secret", async () => { - const company = await createCompany(db); - const service = toolAccessService(db, { - composioClientFactory: () => ({ - validateApiKey: async () => { throw new ComposioApiError("Composio rejected the API key.", 401); }, - }) as unknown as ComposioClient, - }); - - await expect(service.connectGalleryApp(company.id, { - galleryKey: "composio", - connectionMethodKey: "api-key", - credentialValues: { "credentials.apiKey": "bad_composio_fixture" }, - })).rejects.toMatchObject({ - status: 422, - details: { code: "composio_api_key_rejected" }, - }); - - await expect(db.select().from(toolConnections)).resolves.toHaveLength(0); - await expect(db.select().from(toolApplications)).resolves.toHaveLength(0); - await expect(db.select().from(companySecrets)).resolves.toHaveLength(0); - }); - - it("creates, refreshes, and disconnects a Composio toolkit child", async () => { - const company = await createCompany(db); - const connectRequests: unknown[] = []; - const sessionRequests: unknown[] = []; - const deletedAccounts: string[] = []; - const client = { - validateApiKey: async () => undefined, - listToolkits: async () => ({ items: [{ slug: "github", name: "GitHub", meta: { tools_count: 1 } }] }), - listAuthConfigs: async () => ({ - items: [{ - id: "auth-github", - auth_scheme: "OAUTH2", - is_composio_managed: true, - status: "ACTIVE", - toolkit: { slug: "github" }, - }], - }), - createConnectLink: async (input: unknown) => { - connectRequests.push(input); - return { link_token: "link-token", redirect_url: "https://connect.composio.test/github", expires_at: "2026-08-21T20:00:00Z" }; - }, - listConnectedAccounts: async () => ({ - items: [{ - id: "account-github", - user_id: `paperclip:${company.id}`, - status: "ACTIVE", - toolkit: { slug: "github" }, - auth_config: { id: "auth-github", auth_scheme: "OAUTH2", is_composio_managed: true }, - }], - }), - deleteConnectedAccount: async (accountId: string) => { deletedAccounts.push(accountId); }, - createSession: async (userId: string, options: unknown) => { - sessionRequests.push({ userId, options }); - return { - session_id: "session-github", - mcp: { url: "https://mcp.composio.test/github", headers: { Authorization: "Bearer session-secret" } }, - }; - }, - } as unknown as ComposioClient; - const service = toolAccessService(db, { - composioClientFactory: () => client, - remoteHttpRequest: async (_url, init) => { - expect(new Headers(init.headers).get("authorization")).toBe("Bearer session-secret"); - return jsonResponse({ - jsonrpc: "2.0", - id: "paperclip-catalog-refresh", - result: { tools: [{ name: "GITHUB_LIST_REPOS", description: "List repositories", annotations: { readOnlyHint: true } }] }, - }); - }, - }); - const connected = await service.connectGalleryApp(company.id, { - galleryKey: "composio", - connectionMethodKey: "api-key", - credentialValues: { "credentials.apiKey": "ak_composio_fixture" }, - }); - - const listed = await service.listComposioServices(connected.connectionId); - expect(listed.services).toEqual([ - expect.objectContaining({ - status: "connected", - connectedAccountId: "account-github", - childConnectionId: expect.any(String), - }), - ]); - const childId = listed.services[0]!.childConnectionId!; - const [child] = await db.select().from(toolConnections).where(eq(toolConnections.id, childId)); - expect(child).toMatchObject({ - companyId: company.id, - applicationId: connected.application.id, - transport: "mcp_remote", - status: "active", - enabled: true, - config: { - provider: "composio", - parentConnectionId: connected.connectionId, - toolkitSlug: "github", - connectedAccountId: "account-github", - }, - }); - await expect(db.select().from(toolCatalogEntries).where(eq(toolCatalogEntries.connectionId, childId))).resolves.toEqual([ - expect.objectContaining({ toolName: "GITHUB_LIST_REPOS", status: "active" }), - ]); - expect(sessionRequests).toEqual([ - expect.objectContaining({ userId: `paperclip:${company.id}`, options: expect.objectContaining({ toolkits: ["github"], mcp: true }) }), - ]); - const sessionManager = createComposioSessionManager(db, { composioClientFactory: () => client }); - const [readScope, writeScope] = await Promise.all([ - sessionManager.ensureSession(childId, { tools: ["GITHUB_LIST_REPOS"] }), - sessionManager.ensureSession(childId, { tools: ["GITHUB_CREATE_ISSUE"] }), - ]); - expect(readScope.scopeKey).not.toBe(writeScope.scopeKey); - expect(sessionRequests.slice(1)).toEqual([ - expect.objectContaining({ options: expect.objectContaining({ tools: { github: { enable: ["GITHUB_LIST_REPOS"] } } }) }), - expect.objectContaining({ options: expect.objectContaining({ tools: { github: { enable: ["GITHUB_CREATE_ISSUE"] } } }) }), - ]); - - await expect(service.startComposioServiceConnect(connected.connectionId, "github", {})).resolves.toMatchObject({ - toolkitSlug: "github", - authConfigId: "auth-github", - redirect_url: "https://connect.composio.test/github", - }); - expect(connectRequests).toEqual([ - expect.objectContaining({ authConfigId: "auth-github", userId: `paperclip:${company.id}` }), - ]); - await expect(service.pollComposioService(connected.connectionId, "github")).resolves.toMatchObject({ - child: { id: childId }, - }); - await expect(service.disconnectComposioService(connected.connectionId, "github")).resolves.toMatchObject({ - disconnectedAccountIds: ["account-github"], - removedChildIds: [childId], - }); - expect(deletedAccounts).toEqual(["account-github"]); - const [archivedChild] = await db.select().from(toolConnections).where(eq(toolConnections.id, childId)); - expect(archivedChild).toMatchObject({ status: "archived", enabled: false, credentialSecretRefs: [] }); - }); it("stores custom header values as secrets and shows only header names", async () => { installMcpOAuthFixture({ diff --git a/server/src/__tests__/remote-mcp-connectors.test.ts b/server/src/__tests__/remote-mcp-connectors.test.ts index 48b7bb72702..abc62ab1d53 100644 --- a/server/src/__tests__/remote-mcp-connectors.test.ts +++ b/server/src/__tests__/remote-mcp-connectors.test.ts @@ -75,9 +75,12 @@ describe("remote connector lifecycle", () => { await instanceSettingsService(db).updateExperimental({ enableMcpAggregators: true }); } }); - it("vaults generated URLs, paginates discovery, preserves Off/Ask during refresh and reconnect, and isolates companies", async () => { + it.each([ + ["zapier", "generated-url", "https://mcp.zapier.com/api/v1/connect?token=fixture-secret"], + ["composio", "mcp", "https://mcp.composio.dev/session/fixture?token=fixture-secret"], + ])("%s vaults session URLs, paginates discovery, preserves Off/Ask during refresh and reconnect, and isolates companies", async (galleryKey, connectionMethodKey, link) => { const org = await company(); const other = await company(); const remote = remoteFixture(); - const input = { galleryKey: "zapier", connectionMethodKey: "generated-url", link: "https://mcp.zapier.com/api/v1/connect?token=fixture-secret", authMode: "none" as const }; + const input = { galleryKey, connectionMethodKey, link, authMode: "none" as const }; const connected = await remote.service.connectGalleryApp(org.id, input, actor); expect(connected.catalog).toHaveLength(3); expect(JSON.stringify(connected)).not.toContain("fixture-secret"); diff --git a/server/src/__tests__/tool-access-service.test.ts b/server/src/__tests__/tool-access-service.test.ts index 6b8356dd066..0c748429f4b 100644 --- a/server/src/__tests__/tool-access-service.test.ts +++ b/server/src/__tests__/tool-access-service.test.ts @@ -85,7 +85,6 @@ import { } from "../services/tool-gateway.js"; import { toolAccessRoutes } from "../routes/tool-access.js"; import { errorHandler } from "../middleware/index.js"; -import type { ComposioClient } from "../services/composio.js"; import type { VercelConnectClient } from "../services/vercel-connect.js"; import { invalidatePaperclipCloudConnectorCapabilities, type PaperclipCloudConnector } from "../services/paperclip-cloud-connector.js"; @@ -290,45 +289,6 @@ async function createComposioParentAndChild( return { parent: parent!, child: child! }; } -function fakeComposioClient(accountStatus: () => string): ComposioClient { - return { - validateApiKey: vi.fn(async () => undefined), - listToolkits: vi.fn(async () => ({ - items: [{ slug: "github", name: "GitHub" }], - })), - listAuthConfigs: vi.fn(async () => ({ items: [] })), - createConnectLink: vi.fn(async () => ({ - link_token: "link", - redirect_url: "https://composio.test/link", - expires_at: new Date().toISOString(), - })), - listConnectedAccounts: vi.fn(async () => ({ - items: [ - { - id: "account-github", - user_id: "paperclip:test", - status: accountStatus(), - toolkit: { slug: "github" }, - auth_config: { - id: "auth-github", - auth_scheme: "OAUTH2", - is_composio_managed: true, - }, - }, - ], - })), - deleteConnectedAccount: vi.fn(async () => undefined), - createSession: vi.fn(async () => ({ - session_id: "session", - mcp: { url: "https://composio.test/mcp" }, - })), - resumeSession: vi.fn(async () => ({ - session_id: "session", - mcp: { url: "https://composio.test/mcp" }, - })), - }; -} - // Build a Response-like object that mirrors what `fetch` returns for an MCP // Streamable HTTP JSON response: `text()`, `json()`, and a `content-type` // header. Production now reads the body via `text()` + content-type so it can @@ -5281,99 +5241,44 @@ describeEmbeddedPostgres("tool access service", () => { ); }); - it("degrades a Composio child when its connected account becomes inactive", async () => { - const company = await createCompany(db); - const { child } = await createComposioParentAndChild(db, company.id); - const client = fakeComposioClient(() => "INACTIVE"); - const service = createTestToolAccessService(db, { - composioClientFactory: () => client, - }); - await expect(service.checkHealth(child.id)).rejects.toMatchObject({ - status: 502, - details: { - code: "composio_connected_account_inactive", - connection: expect.objectContaining({ - id: child.id, - healthStatus: "degraded", - }), - }, - }); - await expect(service.getConnection(child.id)).resolves.toMatchObject({ - healthStatus: "degraded", - healthMessage: expect.stringContaining("INACTIVE"), - }); - expect(client.listConnectedAccounts).toHaveBeenCalledWith( - expect.objectContaining({ - toolkitSlugs: ["github"], - }), - ); + + + it("retires saved Composio broker and child records without touching credentials until removal", async () => { + const company = await createCompany(db); + const { parent, child } = await createComposioParentAndChild(db, company.id); + const remoteHttpRequest = vi.fn(); + const service = createTestToolAccessService(db, { remoteHttpRequest }); + for (const connection of [parent, child]) { + await expect(service.getConnection(connection.id)).resolves.toMatchObject({ + enabled: false, healthStatus: "error", healthMessage: expect.stringContaining("Add a new Composio MCP connection"), + }); + await expect(service.checkHealth(connection.id)).rejects.toMatchObject({ status: 422, details: { code: "composio_broker_retired" } }); + await expect(service.refreshCatalog(connection.id)).rejects.toMatchObject({ status: 422, details: { code: "composio_broker_retired" } }); + await expect(service.reconnectGalleryApp(connection.id, company.id, { credentialValues: {} })).rejects.toMatchObject({ status: 422 }); + await expect(service.connectGalleryApp(company.id, { galleryKey: "composio", connectionMethodKey: "mcp", reconnectConnectionId: connection.id })).rejects.toMatchObject({ status: 422, details: { code: "composio_broker_retired" } }); + } + expect(remoteHttpRequest).not.toHaveBeenCalled(); + const [savedParent] = await db.select().from(toolConnections).where(eq(toolConnections.id, parent.id)); + expect(savedParent.credentialSecretRefs).toEqual(parent.credentialSecretRefs); + // Each obsolete record can still be explicitly removed with ordinary cleanup. + for (const connection of [child, parent]) { + await expect(service.archiveConnection(connection.id, company.id)).resolves.toMatchObject({ connection: { status: "archived", enabled: false, credentialSecretRefs: [] } }); + } }); - it("cascades Composio parent pause, restores active children, and keeps inactive children disabled", async () => { + it("rejects old Composio API-key setup and removes toolkit-management routes", async () => { const company = await createCompany(db); - const { parent, child } = await createComposioParentAndChild( - db, - company.id, - ); - let accountStatus = "ACTIVE"; - const service = createTestToolAccessService(db, { - composioClientFactory: () => fakeComposioClient(() => accountStatus), - }); - - await service.updateConnection(parent.id, { enabled: false }); - await expect(service.getConnection(child.id)).resolves.toMatchObject({ - enabled: false, - config: expect.objectContaining({ disabledByComposioParent: true }), - }); - - accountStatus = "INACTIVE"; - await service.updateConnection(parent.id, { enabled: true }); - await expect(service.getConnection(child.id)).resolves.toMatchObject({ - enabled: false, - healthStatus: "degraded", - healthMessage: expect.stringContaining("INACTIVE"), - }); - - accountStatus = "ACTIVE"; - await service.updateConnection(parent.id, { enabled: true }); - const restored = await service.getConnection(child.id); - expect(restored).toMatchObject({ - enabled: true, - healthStatus: "unchecked", - healthMessage: null, - }); - expect(restored.config).not.toHaveProperty("disabledByComposioParent"); - }); - - it("requires child-removal confirmation before deleting a Composio parent", async () => { - const company = await createCompany(db); - const { parent, child } = await createComposioParentAndChild( - db, - company.id, - ); const service = createTestToolAccessService(db); - - await expect( - service.archiveConnection(parent.id, company.id), - ).rejects.toMatchObject({ - status: 409, - details: { - code: "composio_child_removal_confirmation_required", - childConnectionCount: 1, - }, - }); - await expect( - service.archiveConnection(parent.id, company.id, undefined, { - confirmComposioChildren: true, - }), - ).resolves.toMatchObject({ - connection: expect.objectContaining({ status: "archived" }), - }); - await expect(service.getConnection(child.id)).resolves.toMatchObject({ - status: "archived", - enabled: false, - }); + await expect(service.connectGalleryApp(company.id, { + galleryKey: "composio", connectionMethodKey: "api-key", credentialValues: { "credentials.apiKey": "obsolete" }, + })).rejects.toMatchObject({ status: 422 }); + const app = createRouteApp(db, boardSessionActor(company.id, "admin")); + const id = randomUUID(); + await request(app).get(`/api/tool-connections/${id}/services`).expect(404); + await request(app).post(`/api/tool-connections/${id}/services/github/connect`).send({}).expect(404); + await request(app).get(`/api/tool-connections/${id}/services/github/status`).expect(404); + await request(app).delete(`/api/tool-connections/${id}/services/github`).expect(404); }); it("returns server-derived create capabilities for a non-manager member", async () => { diff --git a/server/src/__tests__/tool-gateway.test.ts b/server/src/__tests__/tool-gateway.test.ts index 92ac2656752..6c0ec669080 100644 --- a/server/src/__tests__/tool-gateway.test.ts +++ b/server/src/__tests__/tool-gateway.test.ts @@ -53,7 +53,6 @@ import { summarizeToolValue, } from "../services/tool-content-guards.js"; import { createToolGatewayService, ToolGatewayHttpError } from "../services/tool-gateway.js"; -import type { ComposioClient } from "../services/composio.js"; import { secretService } from "../services/secrets.js"; import { createKvDemoHttpServer, type KvDemoHttpServer } from "../../../packages/kv-demo-mcp-server/src/http.js"; import { @@ -1464,141 +1463,24 @@ describeEmbeddedPostgres("tool gateway acceptance", () => { expect(otherTools.map((tool) => tool.catalogEntryId)).not.toContain(remoteTool.catalogEntry.id); }); - it("invokes an installed Composio child through a tool-scoped session and re-mints once on 401", async () => { + + it("hides retired Composio child tools and denies a previously discovered tool without upstream traffic", async () => { const company = await createCompany(db); const agent = await createAgent(db, company.id); const { run } = await createIssueAndRun(db, company.id, agent.id); - const remoteTool = await createRemoteMcpTool(db, company.id, { - applicationKey: "composio", - connectionName: "GitHub (via Composio)", - toolName: "GITHUB_LIST_REPOS", - title: "List repositories", - riskLevel: "read", - }); - const apiKey = await secretService(db).create(company.id, { - name: "Composio API key", - key: `tool_app.${randomUUID()}.composio_api_key`, - provider: "local_encrypted", - value: "ak_composio_gateway_fixture", - }); - const [parent] = await db.insert(toolConnections).values({ - companyId: company.id, - applicationId: remoteTool.application.id, - name: "Composio", - uid: `composio/${randomUUID()}`, - transport: "rest_api", - authKind: "api_key", - status: "active", - enabled: true, - config: { sourceTemplateKey: "composio" }, - transportConfig: { sourceTemplateKey: "composio" }, - credentialRefs: [{ - name: "credentials.apiKey", - secretId: apiKey.id, - version: "latest", - placement: "header", - key: "x-api-key", - prefix: null, - }], - credentialSecretRefs: [{ - secretId: apiKey.id, - versionSelector: "latest", - configPath: "credentials.apiKey", - required: true, - label: "Composio API key", - }], - }).returning(); - await db.insert(companySecretBindings).values({ - companyId: company.id, - secretId: apiKey.id, - targetType: "tool_connection", - targetId: parent!.id, - configPath: "credentials.apiKey", - }); - const childConfig = { - provider: "composio", - parentConnectionId: parent!.id, - toolkitSlug: "github", - connectedAccountId: "ca_github_fixture", - }; - await db.update(toolConnections).set({ config: childConfig, transportConfig: childConfig }) - .where(eq(toolConnections.id, remoteTool.connection.id)); - await db.insert(toolConnectionInstalls).values({ - companyId: company.id, - connectionId: remoteTool.connection.id, - targetType: "agent", - targetId: agent.id, - }); + const remote = await createRemoteMcpTool(db, company.id, { applicationKey: "composio", connectionName: "Old toolkit", toolName: "read", title: "Read" }); const profile = await allowToolsForAgent(db, company.id, agent.id, []); - await db.insert(toolProfileEntries).values({ - companyId: company.id, - profileId: profile.id, - selectorType: "catalog_entry", - effect: "include", - catalogEntryId: remoteTool.catalogEntry.id, - }); - - const sessionRequests: Array<{ apiKey: string; userId: string; options: unknown }> = []; - let upstreamCalls = 0; - const gateway = createTestToolGatewayService(db, { - composioClientFactory: (resolvedApiKey) => ({ - createSession: async (userId, sessionOptions) => { - sessionRequests.push({ apiKey: resolvedApiKey, userId, options: sessionOptions }); - const suffix = sessionRequests.length; - return { - session_id: `composio-session-${suffix}`, - mcp: { - url: `https://mcp.composio.test/session-${suffix}`, - headers: { Authorization: `Bearer composio-session-token-${suffix}` }, - }, - }; - }, - }) as unknown as ComposioClient, - remoteHttpRequest: async (url, init) => { - upstreamCalls += 1; - expect(url).toBe(`https://mcp.composio.test/session-${upstreamCalls}`); - expect(new Headers(init.headers).get("authorization")).toBe(`Bearer composio-session-token-${upstreamCalls}`); - if (upstreamCalls === 1) return new Response("unauthorized", { status: 401 }); - return new Response(JSON.stringify({ - jsonrpc: "2.0", - id: JSON.parse(String(init.body)).id, - result: { content: [{ type: "text", text: "repo-a" }], structuredContent: { repositories: ["repo-a"] } }, - }), { status: 200, headers: { "content-type": "application/json" } }); - }, - }); + await db.insert(toolProfileEntries).values({ companyId: company.id, profileId: profile.id, selectorType: "catalog_entry", effect: "include", catalogEntryId: remote.catalogEntry.id }); + const remoteHttpRequest = vi.fn(); + const gateway = createTestToolGatewayService(db, { remoteHttpRequest }); const session = await gateway.createSession({ companyId: company.id, agentId: agent.id, runId: run.id }); - const tool = (await gateway.listToolsForSession(session.token)).find((candidate) => candidate.connectionId === remoteTool.connection.id); + const listed = await gateway.listToolsForSession(session.token); + const tool = listed.find((entry) => entry.connectionId === remote.connection.id)!; expect(tool).toBeDefined(); - await expect(gateway.executeTool({ sessionToken: session.token, tool: tool!.name, parameters: {} })).resolves.toMatchObject({ - status: "completed", - result: { content: "repo-a", data: { structuredContent: { repositories: ["repo-a"] } } }, - }); - await db.update(connectionGrants).set({ updatedAt: new Date(Date.now() + 1_000) }) - .where(eq(connectionGrants.connectionId, remoteTool.connection.id)); - await expect(gateway.executeTool({ sessionToken: session.token, tool: tool!.name, parameters: {} })).resolves.toMatchObject({ - status: "completed", - result: { content: "repo-a" }, - }); - expect(sessionRequests).toEqual([ - expect.objectContaining({ - apiKey: "ak_composio_gateway_fixture", - userId: `paperclip:${company.id}`, - options: expect.objectContaining({ - mcp: true, - toolkits: ["github"], - tools: { github: { enable: ["GITHUB_LIST_REPOS"] } }, - }), - }), - expect.objectContaining({ options: expect.objectContaining({ tools: { github: { enable: ["GITHUB_LIST_REPOS"] } } }) }), - expect.objectContaining({ options: expect.objectContaining({ tools: { github: { enable: ["GITHUB_LIST_REPOS"] } } }) }), - ]); - const [persistedChild] = await db.select().from(toolConnections).where(eq(toolConnections.id, remoteTool.connection.id)); - expect(JSON.stringify(persistedChild!.config)).not.toContain("session-"); - expect(JSON.stringify(persistedChild!.transportConfig)).not.toContain("mcp.composio.test"); - expect(persistedChild!.credentialSecretRefs.map((ref) => ref.configPath)).toEqual(expect.arrayContaining([ - expect.stringMatching(/^composio\.session\.[a-f0-9]+\.url$/), - expect.stringMatching(/^composio\.session\.[a-f0-9]+\.header\.[a-f0-9]+$/), - ])); + await db.update(toolConnections).set({ config: { provider: "composio", parentConnectionId: randomUUID(), toolkitSlug: "github", url: "https://legacy.example/mcp" } }).where(eq(toolConnections.id, remote.connection.id)); + expect((await gateway.listToolsForSession(session.token)).some((entry) => entry.connectionId === remote.connection.id)).toBe(false); + await expect(gateway.executeTool({ sessionToken: session.token, tool: tool.name, parameters: {} })).rejects.toThrow(); + expect(remoteHttpRequest).not.toHaveBeenCalled(); }); it("lists and executes connected local stdio MCP catalog tools through the gateway", async () => { diff --git a/server/src/routes/openapi.ts b/server/src/routes/openapi.ts index e1fb0fcf180..ba7332c92ae 100644 --- a/server/src/routes/openapi.ts +++ b/server/src/routes/openapi.ts @@ -10351,13 +10351,6 @@ registerCurrentRoute({ summary: "Replace the member audience of a tool connection grant", }); -registerCurrentRoute({ - method: "get", - path: "/api/tool-connections/{connectionId}/services", - tags: ["tool-access"], - summary: "List the broker services behind a tool connection", -}); - registerCurrentRoute({ method: "post", path: "/api/tool-connections/{connectionId}/railway/ssh", @@ -10375,27 +10368,6 @@ registerCurrentRoute({ }, }); -registerCurrentRoute({ - method: "post", - path: "/api/tool-connections/{connectionId}/services/{toolkitSlug}/connect", - tags: ["tool-access"], - summary: "Start a broker service connection for a toolkit", -}); - -registerCurrentRoute({ - method: "get", - path: "/api/tool-connections/{connectionId}/services/{toolkitSlug}/status", - tags: ["tool-access"], - summary: "Poll the connection status of a broker service", -}); - -registerCurrentRoute({ - method: "delete", - path: "/api/tool-connections/{connectionId}/services/{toolkitSlug}", - tags: ["tool-access"], - summary: "Disconnect a broker service from a tool connection", -}); - registerCurrentRoute({ method: "get", path: "/api/tool-connections/{connectionId}/installs", diff --git a/server/src/routes/tool-access.ts b/server/src/routes/tool-access.ts index e4a0c1b0a96..17018e2daf5 100644 --- a/server/src/routes/tool-access.ts +++ b/server/src/routes/tool-access.ts @@ -58,7 +58,6 @@ import { badRequest, forbidden, HttpError, notFound, unprocessable } from "../er import { accessService, logActivity, toolAccessPolicyService, toolAccessService, vercelConnectIntegrationStatus } from "../services/index.js"; import { ToolGatewayHttpError, type ToolGatewayService } from "../services/tool-gateway.js"; import { RailwayError } from "../services/railway.js"; -import type { ComposioClient } from "../services/composio.js"; import type { VercelConnectClient } from "../services/vercel-connect.js"; import { appWithPaperclipCloudConnectorAvailability, @@ -240,7 +239,6 @@ export function toolAccessRoutes( /** Test-only seams forwarded to the tool access service. */ remoteHttpEndpointLookup?: NonNullable[1]>["remoteHttpEndpointLookup"]; remoteHttpRequest?: NonNullable[1]>["remoteHttpRequest"]; - composioClientFactory?: (apiKey: string) => ComposioClient; vercelConnectClient?: VercelConnectClient | null; paperclipCloudConnector?: PaperclipCloudConnector | null; connectionIntentHeartbeat?: Pick; @@ -1677,13 +1675,6 @@ function connectorEnrollmentPrincipal(req: Request): string { res.json(connection); }); - router.get("/tool-connections/:connectionId/services", async (req, res) => { - const connection = await getAccessibleResource(req, res, svc.getConnection(req.params.connectionId as string), "Tool connection not found"); - if (!connection) return; - await assertToolConnectionConfigureAccess(req, connection); - res.json(await svc.listComposioServices(connection.id, getActorInfo(req))); - }); - router.post("/tool-connections/:connectionId/railway/ssh", validate(configureRailwaySshSchema), async (req, res) => { assertBoard(req); const connection = await getAccessibleResource(req, res, svc.getConnection(req.params.connectionId as string), "Tool connection not found"); @@ -1697,51 +1688,6 @@ function connectorEnrollmentPrincipal(req: Request): string { res.json(setup); }); - router.post("/tool-connections/:connectionId/services/:toolkitSlug/connect", async (req, res) => { - const connection = await getAccessibleResource(req, res, svc.getConnection(req.params.connectionId as string), "Tool connection not found"); - if (!connection) return; - await assertToolConnectionConfigureAccess(req, connection); - const body = req.body && typeof req.body === "object" ? req.body as Record : {}; - const result = await svc.startComposioServiceConnect(connection.id, req.params.toolkitSlug as string, { - ...(typeof body.authConfigId === "string" ? { authConfigId: body.authConfigId } : {}), - ...(typeof body.callbackUrl === "string" ? { callbackUrl: body.callbackUrl } : {}), - }); - await logActivity(db, { - companyId: connection.companyId, - actorType: "user", - actorId: req.actor.userId ?? "board", - action: "composio.service_connect_started", - entityType: "tool_connection", - entityId: connection.id, - details: { toolkitSlug: req.params.toolkitSlug, authConfigId: result.authConfigId }, - }); - res.status(201).json(result); - }); - - router.get("/tool-connections/:connectionId/services/:toolkitSlug/status", async (req, res) => { - const connection = await getAccessibleResource(req, res, svc.getConnection(req.params.connectionId as string), "Tool connection not found"); - if (!connection) return; - await assertToolConnectionConfigureAccess(req, connection); - res.json(await svc.pollComposioService(connection.id, req.params.toolkitSlug as string, getActorInfo(req))); - }); - - router.delete("/tool-connections/:connectionId/services/:toolkitSlug", async (req, res) => { - const connection = await getAccessibleResource(req, res, svc.getConnection(req.params.connectionId as string), "Tool connection not found"); - if (!connection) return; - await assertToolConnectionConfigureAccess(req, connection); - const result = await svc.disconnectComposioService(connection.id, req.params.toolkitSlug as string, getActorInfo(req)); - await logActivity(db, { - companyId: connection.companyId, - actorType: "user", - actorId: req.actor.userId ?? "board", - action: "composio.service_disconnected", - entityType: "tool_connection", - entityId: connection.id, - details: { toolkitSlug: req.params.toolkitSlug, removedChildCount: result.removedChildIds.length }, - }); - res.json(result); - }); - router.get("/tool-connections/:connectionId/grants", async (req, res) => { assertBoard(req); const connection = await getAccessibleResource(req, res, svc.getConnection(req.params.connectionId as string), "Tool connection not found"); @@ -2155,7 +2101,6 @@ function connectorEnrollmentPrincipal(req: Request): string { existing.id, existing.companyId, getActorInfo(req), - { confirmComposioChildren: req.query.confirmComposioChildren === "true" }, ); const applicationAfter = await svc.getApplication(existing.applicationId); // The receipt is counts and outcomes only. Removal is a revocation boundary diff --git a/server/src/services/composio-session-manager.ts b/server/src/services/composio-session-manager.ts deleted file mode 100644 index 3dc6a1f6628..00000000000 --- a/server/src/services/composio-session-manager.ts +++ /dev/null @@ -1,263 +0,0 @@ -import { createHash, randomUUID } from "node:crypto"; -import { and, eq } from "drizzle-orm"; -import type { Db } from "@paperclipai/db"; -import { companySecretBindings, toolConnections } from "@paperclipai/db"; -import type { ToolCredentialSecretRef } from "@paperclipai/shared"; -import { unprocessable } from "../errors.js"; -import { createComposioClient, type ComposioClient } from "./composio.js"; -import { secretService } from "./secrets.js"; - -const DEFAULT_SESSION_TTL_MS = 50 * 60 * 1000; -const sessionQueues = new Map>(); - -type ComposioChildConfig = { - parentConnectionId: string; - toolkitSlug: string; - connectedAccountId?: string; -}; - -type SessionRef = { name: string; configPath: string; secretId: string }; -type CachedSession = { - sessionId: string; - scopeKey: string; - fingerprint: string; - createdAt: string; - urlRef: SessionRef; - headerRefs: SessionRef[]; -}; - -export type ComposioSessionCredentials = { - sessionId: string; - scopeKey: string; - url: string; - headers: Record; -}; - -export type ComposioSessionManagerOptions = { - composioClientFactory?: (apiKey: string) => ComposioClient; - now?: () => Date; - sessionTtlMs?: number; -}; - -function record(value: unknown): Record { - return value && typeof value === "object" && !Array.isArray(value) - ? value as Record - : {}; -} - -export function composioChildConfig(connection: typeof toolConnections.$inferSelect): ComposioChildConfig | null { - const config = record(connection.config); - if (config.provider !== "composio") return null; - if (typeof config.parentConnectionId !== "string" || typeof config.toolkitSlug !== "string") return null; - return { - parentConnectionId: config.parentConnectionId, - toolkitSlug: config.toolkitSlug, - ...(typeof config.connectedAccountId === "string" ? { connectedAccountId: config.connectedAccountId } : {}), - }; -} - -function normalizedTools(tools: string[] | undefined): string[] { - return [...new Set((tools ?? []).map((tool) => tool.trim()).filter(Boolean))].sort(); -} - -function scopeKeyFor(toolkitSlug: string, tools: string[]): string { - return createHash("sha256").update(JSON.stringify({ toolkitSlug, tools })).digest("hex").slice(0, 24); -} - -function cacheFrom(connection: typeof toolConnections.$inferSelect): Record { - return record(record(connection.transportConfig).composioSessions) as Record; -} - -export function createComposioSessionManager(db: Db, options: ComposioSessionManagerOptions = {}) { - const secrets = secretService(db); - const now = options.now ?? (() => new Date()); - const ttlMs = Math.max(1, options.sessionTtlMs ?? DEFAULT_SESSION_TTL_MS); - - async function connectionRow(connectionId: string) { - const [connection] = await db.select().from(toolConnections).where(eq(toolConnections.id, connectionId)).limit(1); - if (!connection) throw unprocessable("The Composio toolkit connection no longer exists.", { code: "composio_child_missing" }); - return connection; - } - - async function parentApiKey(child: typeof toolConnections.$inferSelect, config: ComposioChildConfig) { - const [parent] = await db.select().from(toolConnections).where(and( - eq(toolConnections.id, config.parentConnectionId), - eq(toolConnections.companyId, child.companyId), - )).limit(1); - if (!parent) throw unprocessable("The parent Composio connection is missing.", { code: "composio_parent_missing" }); - const headerRef = parent.credentialRefs.find((ref) => ref.placement === "header" && ref.key.toLowerCase() === "x-api-key"); - const secretRef = parent.credentialSecretRefs.find((ref) => ref.configPath === "credentials.apiKey") - ?? (headerRef - ? { - secretId: headerRef.secretId, - versionSelector: headerRef.version ?? "latest", - configPath: headerRef.name.startsWith("credentials.") ? headerRef.name : `credentials.${headerRef.name}`, - } - : undefined); - if (!secretRef) throw unprocessable("The parent Composio API key is missing.", { code: "composio_api_key_missing" }); - const apiKey = await secrets.resolveSecretValue(parent.companyId, secretRef.secretId, secretRef.versionSelector ?? "latest", { - consumerType: "tool_connection", - consumerId: parent.id, - configPath: secretRef.configPath, - actorType: "system", - }); - const revision = createHash("sha256").update(JSON.stringify({ - parentId: parent.id, - updatedAt: parent.updatedAt.toISOString(), - secretId: secretRef.secretId, - versionSelector: secretRef.versionSelector ?? "latest", - })).digest("hex"); - return { apiKey, revision }; - } - - async function resolveRef(child: typeof toolConnections.$inferSelect, ref: SessionRef) { - return secrets.resolveSecretValue(child.companyId, ref.secretId, "latest", { - consumerType: "tool_connection", - consumerId: child.id, - configPath: ref.configPath, - actorType: "system", - }); - } - - async function resolveCached(child: typeof toolConnections.$inferSelect, cached: CachedSession): Promise { - const url = await resolveRef(child, cached.urlRef); - const headers = Object.fromEntries(await Promise.all(cached.headerRefs.map(async (ref) => [ref.name, await resolveRef(child, ref)]))); - return { sessionId: cached.sessionId, scopeKey: cached.scopeKey, url, headers }; - } - - async function createOrRotateRef(input: { - child: typeof toolConnections.$inferSelect; - cached?: SessionRef; - name: string; - configPath: string; - value: string; - }): Promise { - if (input.cached) { - await secrets.rotate(input.cached.secretId, { value: input.value }); - return { ...input.cached, name: input.name }; - } - const secret = await secrets.create(input.child.companyId, { - name: `${input.child.name} Composio session ${input.name} ${randomUUID().slice(0, 8)}`, - key: `tool_app.${randomUUID()}.${input.configPath.replace(/[^a-z0-9_:-]+/gi, "_")}`, - provider: "local_encrypted", - value: input.value, - description: `Hosted Composio MCP session credential for ${input.child.name}.`, - }); - return { name: input.name, configPath: input.configPath, secretId: secret.id }; - } - - async function mint( - connectionId: string, - tools: string[], - force: boolean, - scopeRevision?: string, - ): Promise { - const child = await connectionRow(connectionId); - const config = composioChildConfig(child); - if (!config) throw unprocessable("This connection is not a Composio toolkit child.", { code: "not_composio_child" }); - const { apiKey, revision } = await parentApiKey(child, config); - const scopeKey = scopeKeyFor(config.toolkitSlug, tools); - const fingerprint = createHash("sha256").update(JSON.stringify({ - revision, - toolkitSlug: config.toolkitSlug, - connectedAccountId: config.connectedAccountId ?? null, - tools, - scopeRevision: scopeRevision ?? null, - })).digest("hex"); - const cache = cacheFrom(child); - const cached = cache[scopeKey]; - if (!force && cached?.fingerprint === fingerprint && Date.parse(cached.createdAt) + ttlMs > now().getTime()) { - return resolveCached(child, cached); - } - - const client = options.composioClientFactory?.(apiKey) ?? createComposioClient({ apiKey }); - const session = await client.createSession(`paperclip:${child.companyId}`, { - mcp: true, - toolkits: [config.toolkitSlug], - ...(tools.length > 0 ? { tools: { [config.toolkitSlug]: { enable: tools } } } : {}), - ...(config.connectedAccountId ? { connectedAccounts: { [config.toolkitSlug]: [config.connectedAccountId] } } : {}), - }); - if (!session.mcp?.url) throw unprocessable("Composio did not return a hosted MCP URL.", { code: "composio_session_invalid" }); - - const prefix = `composio.session.${scopeKey}`; - const urlRef = await createOrRotateRef({ - child, - cached: cached?.urlRef, - name: "url", - configPath: `${prefix}.url`, - value: session.mcp.url, - }); - const priorHeaders = new Map((cached?.headerRefs ?? []).map((ref) => [ref.name.toLowerCase(), ref])); - const headerRefs: SessionRef[] = []; - for (const [name, value] of Object.entries(session.mcp.headers ?? {})) { - headerRefs.push(await createOrRotateRef({ - child, - cached: priorHeaders.get(name.toLowerCase()), - name, - configPath: `${prefix}.header.${createHash("sha256").update(name.toLowerCase()).digest("hex").slice(0, 16)}`, - value, - })); - } - const nextCached: CachedSession = { - sessionId: session.session_id, - scopeKey, - fingerprint, - createdAt: now().toISOString(), - urlRef, - headerRefs, - }; - const nextCache = { ...cache, [scopeKey]: nextCached }; - const refsByPath = new Map(child.credentialSecretRefs.map((ref) => [ref.configPath, ref])); - for (const ref of [urlRef, ...headerRefs]) { - refsByPath.set(ref.configPath, { - secretId: ref.secretId, - versionSelector: "latest", - configPath: ref.configPath, - required: true, - label: ref.name === "url" ? "Composio MCP session URL" : `Composio MCP ${ref.name} header`, - keyScope: scopeKey, - } satisfies ToolCredentialSecretRef); - } - const credentialSecretRefs = [...refsByPath.values()]; - const updated = await db.transaction(async (tx) => { - const [row] = await tx.update(toolConnections).set({ - transportConfig: { ...record(child.transportConfig), composioSessions: nextCache }, - credentialSecretRefs, - updatedAt: now(), - }).where(eq(toolConnections.id, child.id)).returning(); - await tx.delete(companySecretBindings).where(and( - eq(companySecretBindings.companyId, child.companyId), - eq(companySecretBindings.targetType, "tool_connection"), - eq(companySecretBindings.targetId, child.id), - )); - if (credentialSecretRefs.length > 0) { - await tx.insert(companySecretBindings).values(credentialSecretRefs.map((ref) => ({ - companyId: child.companyId, - secretId: ref.secretId, - targetType: "tool_connection" as const, - targetId: child.id, - configPath: ref.configPath, - projectionClass: ref.projectionClass ?? "unclassified", - projectionAllowlistKey: ref.projectionAllowlistKey ?? null, - }))); - } - return row; - }); - return resolveCached(updated, nextCached); - } - - return { - ensureSession(connectionId: string, input: { tools?: string[]; force?: boolean; scopeRevision?: string } = {}) { - const tools = normalizedTools(input.tools); - const previous = sessionQueues.get(connectionId) ?? Promise.resolve(); - const pending = previous.catch(() => undefined).then( - () => mint(connectionId, tools, input.force === true, input.scopeRevision), - ); - const queued = pending.then(() => undefined, () => undefined); - sessionQueues.set(connectionId, queued); - return pending.finally(() => { - if (sessionQueues.get(connectionId) === queued) sessionQueues.delete(connectionId); - }); - }, - }; -} diff --git a/server/src/services/composio.test.ts b/server/src/services/composio.test.ts deleted file mode 100644 index fad99fef6eb..00000000000 --- a/server/src/services/composio.test.ts +++ /dev/null @@ -1,198 +0,0 @@ -import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; -import { afterEach, describe, expect, it } from "vitest"; -import { ComposioApiError, createComposioClient } from "./composio.js"; - -type Fixture = { - baseUrl: string; - requests: Array<{ method: string; url: string; apiKey: string | undefined }>; - close(): Promise; -}; - -async function startFixture( - handle: (request: IncomingMessage, response: ServerResponse) => void, -): Promise { - const requests: Fixture["requests"] = []; - const server = createServer((request, response) => { - requests.push({ - method: request.method ?? "GET", - url: request.url ?? "/", - apiKey: Array.isArray(request.headers["x-api-key"]) - ? request.headers["x-api-key"]?.[0] - : request.headers["x-api-key"], - }); - handle(request, response); - }); - await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); - const address = server.address(); - if (!address || typeof address === "string") throw new Error("Fixture did not bind a TCP port"); - return { - baseUrl: `http://127.0.0.1:${address.port}/api/v3.1`, - requests, - close: () => new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve())), - }; -} - -const fixtures: Fixture[] = []; - -afterEach(async () => { - await Promise.all(fixtures.splice(0).map((fixture) => fixture.close())); -}); - -describe("Composio REST client", () => { - it("serializes every supported REST operation", async () => { - const requests: Array<{ url: string; method: string; body: unknown }> = []; - const fetchMock = (async (input: string | URL | Request, init?: RequestInit) => { - requests.push({ - url: String(input), - method: init?.method ?? "GET", - body: init?.body ? JSON.parse(String(init.body)) : undefined, - }); - return new Response(JSON.stringify({ items: [], session_id: "session-1", mcp: { url: "https://mcp.test" } }), { - status: 200, - headers: { "content-type": "application/json" }, - }); - }) as typeof fetch; - const client = createComposioClient({ - apiKey: "ak_fixture", - baseUrl: "https://composio.test/api/v3.1", - fetch: fetchMock, - }); - - await client.listAuthConfigs({ - cursor: "auth-page", - limit: 25, - toolkitSlugs: ["github", "slack"], - showDisabled: true, - }); - await client.createConnectLink({ - authConfigId: "ac_1", - userId: "user-1", - alias: "primary", - callbackUrl: "https://paperclip.test/callback", - }); - await client.listConnectedAccounts({ - cursor: "account-page", - limit: 10, - toolkitSlugs: ["github", "slack"], - statuses: ["ACTIVE", "EXPIRED"], - userIds: ["user-1", "user-2"], - authConfigIds: ["ac_1", "ac_2"], - }); - await client.deleteConnectedAccount("ca/with spaces"); - await client.createSession("user-1", { - mcp: true, - toolkits: ["github"], - tools: { github: { enable: ["GITHUB_LIST_REPOS"] } }, - authConfigs: { github: "ac_1" }, - connectedAccounts: { github: ["ca_1"] }, - }); - await client.resumeSession("session/with spaces", { mcp: true }); - - expect(requests).toEqual([ - { - url: "https://composio.test/api/v3.1/auth_configs?cursor=auth-page&limit=25&show_disabled=true&toolkit_slug=github%2Cslack", - method: "GET", - body: undefined, - }, - { - url: "https://composio.test/api/v3.1/connected_accounts/link", - method: "POST", - body: { - auth_config_id: "ac_1", - user_id: "user-1", - alias: "primary", - callback_url: "https://paperclip.test/callback", - }, - }, - { - url: "https://composio.test/api/v3.1/connected_accounts?cursor=account-page&limit=10&toolkit_slugs=github&toolkit_slugs=slack&statuses=ACTIVE&statuses=EXPIRED&user_ids=user-1&user_ids=user-2&auth_config_ids=ac_1&auth_config_ids=ac_2", - method: "GET", - body: undefined, - }, - { - url: "https://composio.test/api/v3.1/connected_accounts/ca%2Fwith%20spaces", - method: "DELETE", - body: undefined, - }, - { - url: "https://composio.test/api/v3.1/tool_router/session", - method: "POST", - body: { - user_id: "user-1", - mcp: true, - toolkits: { enabled: ["github"] }, - tools: { github: { enable: ["GITHUB_LIST_REPOS"] } }, - auth_configs: { github: "ac_1" }, - connected_accounts: { github: ["ca_1"] }, - }, - }, - { - url: "https://composio.test/api/v3.1/tool_router/session/session%2Fwith%20spaces/attach", - method: "POST", - body: {}, - }, - ]); - }); - - it("validates an API key with the cheap toolkit-list call", async () => { - const fixture = await startFixture((_request, response) => { - response.writeHead(200, { "content-type": "application/json" }); - response.end(JSON.stringify({ items: [] })); - }); - fixtures.push(fixture); - - const client = createComposioClient({ apiKey: "ak_fixture", baseUrl: fixture.baseUrl }); - await expect(client.validateApiKey()).resolves.toBeUndefined(); - - expect(fixture.requests).toEqual([{ - method: "GET", - url: "/api/v3.1/toolkits?limit=1", - apiKey: "ak_fixture", - }]); - }); - - it("rejects an invalid key without reflecting the provider response body", async () => { - const fixture = await startFixture((_request, response) => { - response.writeHead(401, { "content-type": "application/json" }); - response.end(JSON.stringify({ message: "provider-controlled secret detail" })); - }); - fixtures.push(fixture); - - const client = createComposioClient({ apiKey: "bad_fixture", baseUrl: fixture.baseUrl }); - const error = await client.validateApiKey().catch((caught) => caught); - - expect(error).toBeInstanceOf(ComposioApiError); - expect(error).toMatchObject({ status: 401, message: "Composio rejected the API key." }); - expect(String(error)).not.toContain("provider-controlled"); - }); - - it("lists typed toolkits from the project behind the key", async () => { - const fixture = await startFixture((_request, response) => { - response.writeHead(200, { "content-type": "application/json" }); - response.end(JSON.stringify({ - items: [{ - slug: "github", - name: "GitHub", - auth_schemes: ["oauth2"], - meta: { tools_count: 42, logo: "https://example.test/github.png" }, - }], - next_cursor: "next-page", - })); - }); - fixtures.push(fixture); - - const client = createComposioClient({ apiKey: "ak_fixture", baseUrl: fixture.baseUrl }); - const result = await client.listToolkits({ limit: 10, cursor: "page-1" }); - - expect(result).toEqual({ - items: [{ - slug: "github", - name: "GitHub", - auth_schemes: ["oauth2"], - meta: { tools_count: 42, logo: "https://example.test/github.png" }, - }], - next_cursor: "next-page", - }); - expect(fixture.requests[0]?.url).toBe("/api/v3.1/toolkits?cursor=page-1&limit=10"); - }); -}); diff --git a/server/src/services/composio.ts b/server/src/services/composio.ts deleted file mode 100644 index d21be24cf2f..00000000000 --- a/server/src/services/composio.ts +++ /dev/null @@ -1,216 +0,0 @@ -const DEFAULT_COMPOSIO_API_BASE_URL = "https://backend.composio.dev/api/v3.1"; - -export class ComposioApiError extends Error { - constructor(message: string, readonly status: number) { - super(message); - this.name = "ComposioApiError"; - } -} - -export type ComposioPage = { - items: T[]; - next_cursor?: string | null; - total_pages?: number; - current_page?: number; - total_items?: number; -}; - -export type ComposioToolkit = { - slug: string; - name: string; - type?: string; - auth_schemes?: string[]; - composio_managed_auth_schemes?: string[]; - no_auth?: boolean; - auth_guide_url?: string | null; - meta?: { - description?: string | null; - logo?: string | null; - app_url?: string | null; - tools_count?: number; - triggers_count?: number; - version?: string; - [key: string]: unknown; - }; - [key: string]: unknown; -}; - -export type ComposioAuthConfig = { - id: string; - uuid?: string; - name?: string; - auth_scheme: string; - is_composio_managed: boolean; - status?: string; - toolkit: { slug: string; logo?: string | null; auth_guide_url?: string | null; auth_hint_url?: string | null }; - [key: string]: unknown; -}; - -export type ComposioConnectLink = { - link_token: string; - redirect_url: string; - expires_at: string; - connected_account_id?: string | null; - [key: string]: unknown; -}; - -export type ComposioConnectedAccount = { - id: string; - user_id: string; - status: string; - status_reason?: string | null; - is_disabled?: boolean; - toolkit: { slug: string }; - auth_config: { id: string; auth_scheme: string; is_composio_managed: boolean; is_disabled?: boolean }; - [key: string]: unknown; -}; - -export type ComposioSession = { - session_id: string; - mcp: { type?: string; url: string; headers?: Record }; - config?: Record; - config_version?: number; - warnings?: Array<{ code: string; message: string }>; - [key: string]: unknown; -}; - -export type ComposioListOptions = { cursor?: string; limit?: number }; -export type ComposioSessionOptions = { - mcp: true; - toolkits?: string[]; - tools?: Record; - authConfigs?: Record; - connectedAccounts?: Record; -}; - -export interface ComposioClient { - validateApiKey(): Promise; - listToolkits(options?: ComposioListOptions): Promise>; - listAuthConfigs(options?: ComposioListOptions & { toolkitSlugs?: string[]; showDisabled?: boolean }): Promise>; - createConnectLink(input: { authConfigId: string; userId: string; alias?: string; callbackUrl?: string }): Promise; - listConnectedAccounts(options?: ComposioListOptions & { toolkitSlugs?: string[]; statuses?: string[]; userIds?: string[]; authConfigIds?: string[] }): Promise>; - deleteConnectedAccount(connectedAccountId: string): Promise; - createSession(userId: string, options: ComposioSessionOptions): Promise; - resumeSession(sessionId: string, options: { mcp: true }): Promise; -} - -type ComposioClientOptions = { apiKey: string; baseUrl?: string; fetch?: typeof globalThis.fetch }; - -function appendQuery(url: URL, key: string, value: string | number | boolean | undefined) { - if (value !== undefined) url.searchParams.append(key, String(value)); -} - -function appendQueryList(url: URL, key: string, values: string[] | undefined) { - for (const value of values ?? []) url.searchParams.append(key, value); -} - -function errorMessage(status: number): string { - if (status === 401 || status === 403) return "Composio rejected the API key."; - if (status === 429) return "Composio rate-limited the request. Try again shortly."; - return `Composio request failed with HTTP ${status}.`; -} - -export function createComposioClient(options: ComposioClientOptions): ComposioClient { - const apiKey = options.apiKey.trim(); - if (!apiKey) throw new Error("A Composio API key is required."); - const baseUrl = (options.baseUrl ?? DEFAULT_COMPOSIO_API_BASE_URL).replace(/\/+$/, ""); - const fetchImpl = options.fetch ?? globalThis.fetch; - - async function request(path: string, init: RequestInit = {}, query?: (url: URL) => void): Promise { - const url = new URL(`${baseUrl}/${path.replace(/^\/+/, "")}`); - query?.(url); - let response: Response; - try { - response = await fetchImpl(url, { - ...init, - headers: { - accept: "application/json", - "x-api-key": apiKey, - ...(init.body ? { "content-type": "application/json" } : {}), - ...init.headers, - }, - }); - } catch { - throw new ComposioApiError("Could not reach Composio.", 0); - } - if (!response.ok) throw new ComposioApiError(errorMessage(response.status), response.status); - try { - return await response.json() as T; - } catch { - throw new ComposioApiError("Composio returned an invalid response.", response.status); - } - } - - return { - async validateApiKey() { - await this.listToolkits({ limit: 1 }); - }, - listToolkits(options = {}) { - return request>("toolkits", {}, (url) => { - appendQuery(url, "cursor", options.cursor); - appendQuery(url, "limit", options.limit); - }); - }, - listAuthConfigs(options = {}) { - return request>("auth_configs", {}, (url) => { - appendQuery(url, "cursor", options.cursor); - appendQuery(url, "limit", options.limit); - appendQuery(url, "show_disabled", options.showDisabled); - appendQuery(url, "toolkit_slug", options.toolkitSlugs?.join(",")); - }); - }, - createConnectLink(input) { - return request("connected_accounts/link", { - method: "POST", - body: JSON.stringify({ - auth_config_id: input.authConfigId, - user_id: input.userId, - ...(input.alias ? { alias: input.alias } : {}), - ...(input.callbackUrl ? { callback_url: input.callbackUrl } : {}), - }), - }); - }, - listConnectedAccounts(options = {}) { - return request>("connected_accounts", {}, (url) => { - appendQuery(url, "cursor", options.cursor); - appendQuery(url, "limit", options.limit); - appendQueryList(url, "toolkit_slugs", options.toolkitSlugs); - appendQueryList(url, "statuses", options.statuses); - appendQueryList(url, "user_ids", options.userIds); - appendQueryList(url, "auth_config_ids", options.authConfigIds); - }); - }, - async deleteConnectedAccount(connectedAccountId) { - const url = new URL(`${baseUrl}/connected_accounts/${encodeURIComponent(connectedAccountId)}`); - let response: Response; - try { - response = await fetchImpl(url, { - method: "DELETE", - headers: { accept: "application/json", "x-api-key": apiKey }, - }); - } catch { - throw new ComposioApiError("Could not reach Composio.", 0); - } - if (!response.ok) throw new ComposioApiError(errorMessage(response.status), response.status); - }, - createSession(userId, options) { - return request("tool_router/session", { - method: "POST", - body: JSON.stringify({ - user_id: userId, - mcp: options.mcp, - ...(options.toolkits ? { toolkits: { enabled: options.toolkits } } : {}), - ...(options.tools ? { tools: options.tools } : {}), - ...(options.authConfigs ? { auth_configs: options.authConfigs } : {}), - ...(options.connectedAccounts ? { connected_accounts: options.connectedAccounts } : {}), - }), - }); - }, - resumeSession(sessionId, _options) { - return request(`tool_router/session/${encodeURIComponent(sessionId)}/attach`, { - method: "POST", - body: JSON.stringify({}), - }); - }, - }; -} diff --git a/server/src/services/index.ts b/server/src/services/index.ts index 15ea31a7ad3..a007837a394 100644 --- a/server/src/services/index.ts +++ b/server/src/services/index.ts @@ -98,7 +98,6 @@ export { VercelConnectClientError, type VercelConnectClient, } from "./vercel-connect.js"; -export { createComposioClient, ComposioApiError, type ComposioClient } from "./composio.js"; export { smokeLabService } from "./smoke-lab.js"; export { backfillLegacyToolOAuthTokens } from "./tool-oauth-legacy-backfill.js"; export { toolAccessPolicyService } from "./tool-access-policy.js"; diff --git a/server/src/services/tool-access.ts b/server/src/services/tool-access.ts index bf07307ce0e..c01dec73ac9 100644 --- a/server/src/services/tool-access.ts +++ b/server/src/services/tool-access.ts @@ -232,15 +232,7 @@ import { ToolRuntimeSupervisorError, } from "./tool-runtime-supervisor.js"; import { listConnectionLifecycleEvents } from "./tool-connection-activity.js"; -import { - ComposioApiError, - createComposioClient, - type ComposioClient, -} from "./composio.js"; -import { - composioChildConfig, - createComposioSessionManager, -} from "./composio-session-manager.js"; +import { isRetiredComposioConnection, RETIRED_COMPOSIO_MESSAGE } from "@paperclipai/shared"; import { appWithPaperclipCloudConnectorAvailability, paperclipCloudConnectorCapabilitiesFromEnv, @@ -648,8 +640,6 @@ type ToolAccessServiceOptions = { remoteHttpEndpointLookup?: RemoteHttpEndpointLookup; /** Test seam for protocol fixtures. Production uses the DNS-pinned transport. */ remoteHttpRequest?: (url: string, init: RequestInit) => Promise; - /** Test seam for Composio without live vendor traffic. */ - composioClientFactory?: (apiKey: string) => ComposioClient; /** Test seam for the centrally registered Gmail OAuth broker. */ paperclipCloudConnector?: PaperclipCloudConnector | null; /** @deprecated Use paperclipCloudConnector. */ @@ -922,7 +912,6 @@ const APPROVED_STDIO_TEMPLATES: Record< }; const GOOGLE_SHEETS_GALLERY_KEY = "google-sheets"; -const COMPOSIO_GALLERY_KEY = "composio"; const GOOGLE_SHEETS_TEMPLATE_ID = "paperclip.google-sheets"; const GOOGLE_SHEETS_ALLOWED_SPREADSHEET_IDS_ENV = "GOOGLE_SHEETS_ALLOWED_SPREADSHEET_IDS"; @@ -1590,6 +1579,7 @@ function assertClass3ToolCredentialRefAllowed(ref: { function toConnection(row: typeof toolConnections.$inferSelect): ToolConnection { connectionPurposeTransportSchema.parse(row); + const retired = isRetiredComposioConnection(row); return { id: row.id, companyId: row.companyId, @@ -1605,13 +1595,13 @@ function toConnection(row: typeof toolConnections.$inferSelect): ToolConnection externalCredential: row.externalCredential ?? null, credentialPolicy: row.credentialPolicy, status: row.status, - enabled: row.enabled, + enabled: row.enabled && !retired, config: row.config ?? {}, transportConfig: row.transportConfig ?? {}, credentialRefs: row.credentialRefs ?? [], credentialSecretRefs: row.credentialSecretRefs ?? [], - healthStatus: row.healthStatus, - healthMessage: row.healthMessage, + healthStatus: retired ? "error" : row.healthStatus, + healthMessage: retired ? RETIRED_COMPOSIO_MESSAGE : row.healthMessage, healthCheckedAt: row.healthCheckedAt, lastHealthAt: row.lastHealthAt, lastCatalogRefreshAt: row.lastCatalogRefreshAt, @@ -2324,7 +2314,6 @@ function normalizedProviderToolName(toolName: string): string { // Aggregators can add arbitrarily powerful tools without changing their MCP // endpoint. Only these reviewed, exact capabilities are read-only. Unknown or // renamed actions remain enabled by the usual access rules, but have write risk. -// Legacy Composio child connections do not use these gallery template keys. const AGGREGATOR_READ_TOOLS = new Map>([ ["executor", new Set(["skills"])], ["composio", new Set(["COMPOSIO_SEARCH_TOOLS", "COMPOSIO_GET_TOOL_SCHEMAS", "COMPOSIO_SEARCH_SKILLS", "COMPOSIO_USE_SKILL"])], @@ -2814,7 +2803,7 @@ function healthFailureHttpStatus(failure: { }): number { if (failure.status === "missing_secret") return 422; if (failure.code === "user_authorization_required") return 422; - if (failure.code === "composio_api_key_rejected") return 422; + if (failure.code === "composio_broker_retired") return 422; if (failure.code === "tool_connection_transport_unsupported") return 422; if (failure.code.endsWith("_endpoint_rejected")) return 422; return 502; @@ -2832,16 +2821,6 @@ function sanitizeHttpFailure(error: unknown): { message: string; code: string; } { - if (error instanceof ComposioApiError) { - return { - status: "error", - message: error.message, - code: - error.status === 401 || error.status === 403 - ? "composio_api_key_rejected" - : "composio_request_failed", - }; - } if (error instanceof HttpError) { const code = asRecord(error.details).code; if (code === "user_authorization_required") { @@ -2850,8 +2829,8 @@ function sanitizeHttpFailure(error: unknown): { 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 }; + if (code === "composio_broker_retired") { + return { status: "error", message: error.message, code }; } if (typeof code === "string" && code.startsWith("remote_http_")) { return { status: "error", message: error.message, code }; @@ -3020,10 +2999,6 @@ export function toolAccessService( badRequest(message, { code }), ).toString(); } - const composioSessions = createComposioSessionManager(db, { - composioClientFactory: options.composioClientFactory, - now: options.now, - }); const policySvc = toolAccessPolicyService(db); const now = options.now ?? (() => new Date()); const configuredCloudConnector = @@ -6087,36 +6062,12 @@ export function toolAccessService( connectionId: string, companyId?: string, actor?: ActorInfo, - removalOptions: { confirmComposioChildren?: boolean } = {}, ): Promise { const connection = await getConnectionRow(connectionId, companyId); forgetMcpHttpSessions(connection.id); const now = new Date(); const binding = actorBinding(actor); - if (isComposioConnection(connection)) { - const children = (await existingComposioChildren(connection)).filter( - (child) => child.status !== "archived", - ); - if ( - children.length > 0 && - removalOptions.confirmComposioChildren !== true - ) { - throw conflict( - "Deleting this Composio connection also removes its connected services. Confirm child removal to continue.", - { - code: "composio_child_removal_confirmation_required", - childConnectionCount: children.length, - }, - ); - } - for (const child of children) { - await removeConnection(child.id, child.companyId, actor, { - confirmComposioChildren: true, - }); - } - } - // Grants are read before they are revoked: a retried removal must still see // the credential refs of a grant an earlier pass already marked revoked. const grantRows = await db @@ -6774,17 +6725,12 @@ export function toolAccessService( credentialHeaders?: Record, actor?: ActorInfo, ): Promise { - const composioChild = composioChildConfig(connection); - const composioSession = composioChild - ? await composioSessions.ensureSession(connection.id) - : null; - let headers = composioSession?.headers ?? - credentialHeaders ?? { - ...projectedConnectionHeaders(connection), - ...(await resolveCredentialHeaders(connection, actor)), - }; - const endpoint = - composioSession?.url ?? (await resolvedRemoteEndpoint(connection, actor)); + assertSupportedConnection(connection); + let headers = credentialHeaders ?? { + ...projectedConnectionHeaders(connection), + ...(await resolveCredentialHeaders(connection, actor)), + }; + const endpoint = await resolvedRemoteEndpoint(connection, actor); // Pinned to the address the guard approved: `config.url` is operator-supplied, // so a second DNS resolution here would reopen the rebinding window that // PAP-17098 closed for the OAuth endpoints. @@ -6858,23 +6804,6 @@ export function toolAccessService( ), ); } - if (response.status === 401 && composioChild) { - const refreshed = await composioSessions.ensureSession(connection.id, { - force: true, - }); - listRequestId = "paperclip-catalog-refresh-retry"; - sessionHeaders = refreshed.headers; - response = await requestRemoteHttpEndpoint(new URL(refreshed.url), { - method: "POST", - headers: mcpHttpRequestHeaders(refreshed.headers), - body: JSON.stringify({ - jsonrpc: "2.0", - id: "paperclip-catalog-refresh-retry", - method: "tools/list", - params: {}, - }), - }); - } if ( response.status === 401 && connection.credentialSource === "vercel_connect" @@ -7045,432 +6974,6 @@ export function toolAccessService( })); } - function isComposioConnection( - connection: typeof toolConnections.$inferSelect, - ): boolean { - return ( - asRecord(connection.config).sourceTemplateKey === COMPOSIO_GALLERY_KEY && connection.transport === "rest_api" - ); - } - - async function composioClientForParent( - parent: typeof toolConnections.$inferSelect, - ) { - if (!isComposioConnection(parent) || parent.transport !== "rest_api") { - throw unprocessable( - "This connection is not a parent Composio connection.", - { code: "not_composio_parent" }, - ); - } - const headers = await resolveCredentialHeaders(parent); - const apiKey = Object.entries(headers).find( - ([name]) => name.toLowerCase() === "x-api-key", - )?.[1]; - if (!apiKey) - throw unprocessable("The Composio API key secret is missing.", { - code: "secret_missing", - }); - return ( - options.composioClientFactory?.(apiKey) ?? - createComposioClient({ apiKey }) - ); - } - - async function existingComposioChildren( - parent: typeof toolConnections.$inferSelect, - ) { - const rows = await db - .select() - .from(toolConnections) - .where( - and( - eq(toolConnections.companyId, parent.companyId), - eq(toolConnections.applicationId, parent.applicationId), - ), - ); - return rows.filter( - (row) => composioChildConfig(row)?.parentConnectionId === parent.id, - ); - } - - async function assertComposioConnectedAccountActive( - child: typeof toolConnections.$inferSelect, - ) { - const childConfig = composioChildConfig(child); - if (!childConfig) return; - const parent = await getConnectionRow( - childConfig.parentConnectionId, - child.companyId, - ); - const client = await composioClientForParent(parent); - const accounts = await client.listConnectedAccounts({ - toolkitSlugs: [childConfig.toolkitSlug], - userIds: [`paperclip:${child.companyId}`], - limit: 100, - }); - const account = childConfig.connectedAccountId - ? accounts.items.find( - (candidate) => candidate.id === childConfig.connectedAccountId, - ) - : accounts.items.find( - (candidate) => candidate.toolkit.slug === childConfig.toolkitSlug, - ); - if (account?.status.toUpperCase() === "ACTIVE") return; - const status = account?.status.trim().toUpperCase() || "MISSING"; - throw unprocessable( - `Composio reports the ${childConfig.toolkitSlug} connected account as ${status}. Reconnect it in Composio.`, - { - code: "composio_connected_account_inactive", - connectedAccountStatus: status, - }, - ); - } - - async function disableComposioChildren( - parent: typeof toolConnections.$inferSelect, - ) { - const children = await existingComposioChildren(parent); - for (const child of children) { - if (child.status === "archived") continue; - const config = asRecord(child.config); - await db - .update(toolConnections) - .set({ - enabled: false, - config: child.enabled - ? { ...config, disabledByComposioParent: true } - : config, - updatedAt: now(), - }) - .where(eq(toolConnections.id, child.id)); - } - } - - async function restoreComposioChildren( - parent: typeof toolConnections.$inferSelect, - ) { - const children = await existingComposioChildren(parent); - const restorable = children.filter( - (child) => - child.status !== "archived" && - asRecord(child.config).disabledByComposioParent === true, - ); - if (restorable.length === 0) return; - - let accounts: Awaited< - ReturnType - >["items"] = []; - try { - const client = await composioClientForParent(parent); - accounts = ( - await client.listConnectedAccounts({ - userIds: [`paperclip:${parent.companyId}`], - limit: 1000, - }) - ).items; - } catch { - // Fail closed while Composio is unavailable. A later resume or reconnect - // can retry without exposing a child whose account state is unknown. - return; - } - - for (const child of restorable) { - const childConfig = composioChildConfig(child)!; - const account = childConfig.connectedAccountId - ? accounts.find( - (candidate) => candidate.id === childConfig.connectedAccountId, - ) - : accounts.find( - (candidate) => candidate.toolkit.slug === childConfig.toolkitSlug, - ); - const config = { ...asRecord(child.config) }; - delete config.disabledByComposioParent; - const active = account?.status.toUpperCase() === "ACTIVE"; - await db - .update(toolConnections) - .set({ - enabled: active, - config: active - ? config - : { ...config, disabledByComposioParent: true }, - healthStatus: active ? "unchecked" : "degraded", - healthMessage: active - ? null - : `Composio reports the ${childConfig.toolkitSlug} connected account as ${account?.status.toUpperCase() ?? "MISSING"}. Reconnect it in Composio.`, - updatedAt: now(), - }) - .where(eq(toolConnections.id, child.id)); - } - } - - async function syncComposioChild( - parent: typeof toolConnections.$inferSelect, - account: { id: string; status: string; toolkit: { slug: string } }, - toolkitName: string, - actor?: ActorInfo, - ) { - if (account.status.toUpperCase() !== "ACTIVE") return null; - const children = await existingComposioChildren(parent); - const existing = children.find((candidate) => { - const config = composioChildConfig(candidate); - return ( - config?.toolkitSlug === account.toolkit.slug && - candidate.status !== "archived" - ); - }); - if (existing) { - const config = composioChildConfig(existing)!; - if (config.connectedAccountId !== account.id) { - const nextConfig = { - ...existing.config, - connectedAccountId: account.id, - }; - const [updated] = await db - .update(toolConnections) - .set({ - config: nextConfig, - transportConfig: { - ...existing.transportConfig, - connectedAccountId: account.id, - composioSessions: {}, - }, - updatedAt: now(), - }) - .where(eq(toolConnections.id, existing.id)) - .returning(); - return updated; - } - return existing; - } - const connectionId = randomUUID(); - const binding = actorBinding(actor); - const config = { - provider: "composio", - parentConnectionId: parent.id, - toolkitSlug: account.toolkit.slug, - connectedAccountId: account.id, - }; - const [created] = await db - .insert(toolConnections) - .values({ - id: connectionId, - companyId: parent.companyId, - applicationId: parent.applicationId, - name: `${toolkitName} (via Composio)`, - uid: connectionUid( - `composio:${parent.id}`, - account.toolkit.slug, - connectionId, - ), - connectionKind: "managed", - ownership: parent.ownership, - transport: "mcp_remote", - authKind: "none", - credentialPolicy: "shared", - status: "active", - enabled: true, - config, - transportConfig: { ...config, composioSessions: {} }, - credentialRefs: [], - credentialSecretRefs: [], - createdByAgentId: - binding.actorType === "agent" ? binding.actorId : null, - createdByUserId: binding.actorType === "user" ? binding.actorId : null, - }) - .returning(); - if (!created) - throw new Error("Failed to create Composio toolkit connection"); - await ensureDefaultOrganizationGrant(created); - await syncCredentialBindings(created); - await ensureRuntimeSlot(created); - await audit({ - companyId: created.companyId, - connectionId: created.id, - action: "composio.child_created", - outcome: "success", - actor, - details: { - parentConnectionId: parent.id, - toolkitSlug: account.toolkit.slug, - }, - }); - return created; - } - - async function syncComposioToolkit( - parent: typeof toolConnections.$inferSelect, - toolkitSlug: string, - actor?: ActorInfo, - ) { - const client = await composioClientForParent(parent); - const userId = `paperclip:${parent.companyId}`; - const [toolkits, accounts] = await Promise.all([ - client.listToolkits({ limit: 1000 }), - client.listConnectedAccounts({ - toolkitSlugs: [toolkitSlug], - userIds: [userId], - limit: 100, - }), - ]); - const toolkit = toolkits.items.find((item) => item.slug === toolkitSlug); - if (!toolkit) throw notFound("Composio toolkit not found"); - const account = - accounts.items.find( - (item) => - item.toolkit.slug === toolkitSlug && - item.status.toUpperCase() === "ACTIVE", - ) ?? - accounts.items.find((item) => item.toolkit.slug === toolkitSlug) ?? - null; - const child = account - ? await syncComposioChild(parent, account, toolkit.name, actor) - : null; - if (child) - await refreshCatalog(child.id, actor, { enableAllByDefault: true }); - return { - toolkit, - account, - child: child ? toConnection(await getConnectionRow(child.id)) : null, - }; - } - - async function validateComposioConnection( - connection: typeof toolConnections.$inferSelect, - ) { - const client = await composioClientForParent(connection); - await client.validateApiKey(); - } - - async function listComposioServices( - parentConnectionId: string, - actor?: ActorInfo, - ) { - const parent = await getConnectionRow(parentConnectionId); - const client = await composioClientForParent(parent); - const userId = `paperclip:${parent.companyId}`; - const [toolkits, accounts] = await Promise.all([ - client.listToolkits({ limit: 1000 }), - client.listConnectedAccounts({ userIds: [userId], limit: 1000 }), - ]); - const children = await existingComposioChildren(parent); - const childByToolkit = new Map( - children - .filter((child) => child.status !== "archived") - .map((child) => [composioChildConfig(child)?.toolkitSlug, child]), - ); - const services = []; - for (const toolkit of toolkits.items) { - const toolkitAccounts = accounts.items.filter( - (account) => account.toolkit.slug === toolkit.slug, - ); - const account = - toolkitAccounts.find( - (candidate) => candidate.status.toUpperCase() === "ACTIVE", - ) ?? - toolkitAccounts[0] ?? - null; - let child = childByToolkit.get(toolkit.slug) ?? null; - if (account?.status.toUpperCase() === "ACTIVE" && !child) { - child = await syncComposioChild(parent, account, toolkit.name, actor); - if (child) - await refreshCatalog(child.id, actor, { enableAllByDefault: true }); - } - services.push({ - toolkit, - status: - account?.status.toUpperCase() === "ACTIVE" - ? "connected" - : account - ? "pending" - : "not_connected", - connectedAccountId: account?.id ?? null, - connectedAccountStatus: account?.status ?? null, - childConnectionId: child?.id ?? null, - }); - } - return { parentConnectionId: parent.id, userId, services }; - } - - async function startComposioServiceConnect( - parentConnectionId: string, - toolkitSlug: string, - input: { authConfigId?: string; callbackUrl?: string }, - ) { - const parent = await getConnectionRow(parentConnectionId); - const client = await composioClientForParent(parent); - let authConfigId = input.authConfigId?.trim(); - if (!authConfigId) { - const configs = await client.listAuthConfigs({ - toolkitSlugs: [toolkitSlug], - showDisabled: false, - limit: 100, - }); - authConfigId = configs.items.find( - (config) => - config.toolkit.slug === toolkitSlug && config.status !== "DISABLED", - )?.id; - } - if (!authConfigId) - throw unprocessable( - "This Composio toolkit has no enabled auth configuration.", - { code: "composio_auth_config_missing" }, - ); - const link = await client.createConnectLink({ - authConfigId, - userId: `paperclip:${parent.companyId}`, - alias: `paperclip-${parent.companyId}-${toolkitSlug}`, - ...(input.callbackUrl ? { callbackUrl: input.callbackUrl } : {}), - }); - return { toolkitSlug, authConfigId, ...link }; - } - - async function disconnectComposioService( - parentConnectionId: string, - toolkitSlug: string, - actor?: ActorInfo, - ) { - const parent = await getConnectionRow(parentConnectionId); - const client = await composioClientForParent(parent); - const accounts = await client.listConnectedAccounts({ - toolkitSlugs: [toolkitSlug], - userIds: [`paperclip:${parent.companyId}`], - limit: 100, - }); - for (const account of accounts.items.filter( - (candidate) => candidate.toolkit.slug === toolkitSlug, - )) { - await client.deleteConnectedAccount(account.id); - } - const children = await existingComposioChildren(parent); - const removedChildIds: string[] = []; - for (const child of children) { - if ( - composioChildConfig(child)?.toolkitSlug !== toolkitSlug || - child.status === "archived" - ) - continue; - await removeConnection(child.id, child.companyId, actor); - removedChildIds.push(child.id); - } - await audit({ - companyId: parent.companyId, - connectionId: parent.id, - action: "composio.service_disconnected", - outcome: "success", - actor, - details: { - toolkitSlug, - connectedAccountCount: accounts.items.length, - removedChildCount: removedChildIds.length, - }, - }); - return { - toolkitSlug, - disconnectedAccountIds: accounts.items.map((account) => account.id), - removedChildIds, - }; - } - function isAgentMailConnection(connection: typeof toolConnections.$inferSelect) { return connection.transport === "rest_api" && connection.config.provider === "agentmail"; } @@ -7504,11 +7007,18 @@ export function toolAccessService( await agentmailApi(key).whoami(); } + function assertSupportedConnection(connection: typeof toolConnections.$inferSelect) { + if (isRetiredComposioConnection(connection)) { + throw unprocessable(RETIRED_COMPOSIO_MESSAGE, { code: "composio_broker_retired" }); + } + } + async function discoverTools( connection: typeof toolConnections.$inferSelect, credentialHeaders?: Record, actor?: ActorInfo, ): Promise { + assertSupportedConnection(connection); if (connection.connectionPurpose === "ai") throw unprocessable("AI connections provide runtime authentication, not tool actions"); if (isAgentMailConnection(connection)) { await validateAgentMailConnection(connection); @@ -7516,10 +7026,6 @@ export function toolAccessService( } if (connection.transport === "mcp_remote") return remoteTools(connection, credentialHeaders, actor); - if (isComposioConnection(connection)) { - await validateComposioConnection(connection); - return []; - } if (connection.transport !== "local_stdio") { throw unsupportedToolConnectionTransport(); } @@ -7623,6 +7129,7 @@ export function toolAccessService( const connection = await getConnectionRow(connectionId); if (connection.connectionPurpose === "ai") return { connection: toConnection(connection), runtimeSlot: null }; try { + assertSupportedConnection(connection); const config = asRecord(connection.config); const oauth = asRecord(config.oauth); if ( @@ -7665,7 +7172,6 @@ export function toolAccessService( } else if (isAgentMailConnection(connection)) { await validateAgentMailConnection(connection); } else if (connection.transport === "mcp_remote") { - await assertComposioConnectedAccountActive(connection); const canProbeWithoutAuthorization = options.allowUnauthenticatedProbe === true && connection.status === "draft" && @@ -7682,8 +7188,6 @@ export function toolAccessService( ? {} : undefined; await remoteTools(connection, credentialHeaders, actor); - } else if (isComposioConnection(connection)) { - await validateComposioConnection(connection); } else if (connection.transport === "local_stdio") { await resolveCredentialHeaders(connection); await stdioTemplateId(connection.companyId, connection.config); @@ -7698,11 +7202,9 @@ export function toolAccessService( ? "GitHub account, installation, and repository access are available." : isAgentMailConnection(connection) ? "AgentMail API key is connected." - : isComposioConnection(connection) - ? "Composio accepted the API key and returned its toolkits." - : connection.transport === "local_stdio" - ? "Approved stdio template is ready." - : "Remote MCP server responded to tools/list.", + : connection.transport === "local_stdio" + ? "Approved stdio template is ready." + : "Remote MCP server responded to tools/list.", ); const runtimeSlot = await ensureRuntimeSlot(updated); await audit({ @@ -12312,6 +11814,7 @@ export function toolAccessService( ), ); 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", @@ -13497,23 +13000,6 @@ export function toolAccessService( // later fails: another retry may already be using the committed grant. personalPublicSetupEstablished = true; } - if (galleryEntry?.slug === COMPOSIO_GALLERY_KEY && transport === "rest_api") { - const [application] = await db - .select() - .from(toolApplications) - .where(eq(toolApplications.id, applicationRow.id)); - return { - connectionId: health.connection.id, - application: toApplication(application), - connection: health.connection, - catalog: [], - actions: { readOnly: [], canMakeChanges: [] }, - suggestedDefaults: recommendedDefaultsForApp( - galleryEntry, - method?.key, - ), - }; - } const restoreDraftDefaults = Boolean(revivedConnectionPrevious); const refreshOptions = { enableAllByDefault: restoreDraftDefaults && !remoteMcpConnector, @@ -14344,6 +13830,7 @@ export function toolAccessService( actor?: ActorInfo, ): Promise { const connection = await getConnectionRow(connectionId, companyId); + assertSupportedConnection(connection); await assertMcpAggregatorSetupEnabled(connection.config.sourceTemplateKey, connection.config.connectionMethodKey); if (connection.status === "archived") throw conflict("Archived app connections cannot be reconnected"); @@ -14498,13 +13985,6 @@ export function toolAccessService( personalIdentity ? credentialSecretRefs : [], ); const health = await checkConnectionHealth(updated.id, actor); - if ( - isComposioConnection(updated) && - updated.enabled && - updated.status === "active" - ) { - await restoreComposioChildren(updated); - } const refresh = await refreshCatalog(updated.id, actor, { enableAllByDefault: true, }); @@ -14526,6 +14006,7 @@ export function toolAccessService( }, ): Promise { let connection = await getConnectionRow(connectionId, companyId); + assertSupportedConnection(connection); await assertMcpAggregatorSetupEnabled(connection.config.sourceTemplateKey, connection.config.connectionMethodKey); if (connection.status === "archived") throw conflict("Archived app connections cannot start sign in"); @@ -17732,21 +17213,6 @@ export function toolAccessService( return connections; }, - listComposioServices, - - startComposioServiceConnect, - - pollComposioService: async ( - parentConnectionId: string, - toolkitSlug: string, - actor?: ActorInfo, - ) => { - const parent = await getConnectionRow(parentConnectionId); - return syncComposioToolkit(parent, toolkitSlug, actor); - }, - - disconnectComposioService, - createConnection: async ( companyId: string, input: CreateToolConnection, @@ -17830,14 +17296,6 @@ export function toolAccessService( await ensureDefaultOrganizationGrant(row); await syncCredentialBindings(row); await ensureRuntimeSlot(row); - if ( - isComposioConnection(row) && - (input.enabled !== undefined || input.status !== undefined) - ) { - if (!row.enabled || row.status !== "active") - await disableComposioChildren(row); - else await restoreComposioChildren(row); - } return toConnection(row); }, @@ -18728,10 +18186,6 @@ export function toolAccessService( .returning(); await syncCredentialBindings(row); await ensureRuntimeSlot(row); - if (isComposioConnection(row)) { - if (row.enabled) await restoreComposioChildren(row); - else await disableComposioChildren(row); - } return toConnection(row); }, diff --git a/server/src/services/tool-gateway.ts b/server/src/services/tool-gateway.ts index cdb44da236b..7b762f2457a 100644 --- a/server/src/services/tool-gateway.ts +++ b/server/src/services/tool-gateway.ts @@ -122,11 +122,7 @@ import { type ToolRuntimeSlotView, } from "./tool-runtime-supervisor.js"; import { recordToolRuntimeAuditWriteFailure } from "./tool-runtime-metrics.js"; -import { - composioChildConfig, - createComposioSessionManager, -} from "./composio-session-manager.js"; -import type { ComposioClient } from "./composio.js"; +import { isRetiredComposioConnection, RETIRED_COMPOSIO_MESSAGE } from "@paperclipai/shared"; import { createPaperclipCloudConnector, isPaperclipCloudConnectorStrategy, @@ -1021,8 +1017,6 @@ export function createToolGatewayService( onToolActionSettled?: (actionRequestId: string) => Promise; /** Test seam for deterministic remote MCP protocol fixtures. */ remoteHttpRequest?: (url: string, init: RequestInit) => Promise; - /** Test seam for Composio session creation without vendor traffic. */ - composioClientFactory?: (apiKey: string) => ComposioClient; /** Test seam for refreshing personal Gmail grants. */ paperclipCloudConnector?: PaperclipCloudConnector | null; /** @deprecated Use paperclipCloudConnector. */ @@ -1119,10 +1113,6 @@ export function createToolGatewayService( options.vercelConnectClient === undefined ? createVercelConnectClient() : options.vercelConnectClient; - const composioSessions = createComposioSessionManager(db, { - composioClientFactory: options.composioClientFactory, - now: options.now ? () => new Date(options.now!()) : undefined, - }); const protocolLimits = mcpGatewayProtocolLimits( options.mcpGatewayProtocolLimits, ); @@ -1249,6 +1239,7 @@ export function createToolGatewayService( const eligibleRows = rows.filter( ({ catalogEntry, connection, application }) => + !isRetiredComposioConnection(connection) && !(isRailwayEndpoint(connection.config.url) && (isRailwayToolBlocked(catalogEntry.toolName) || (normalizeRailwayToolName(catalogEntry.toolName).startsWith(RAILWAY_TOOL_PREFIX) && connection.config.railwayApiStatus !== "available"))) && ((connection.transport === "mcp_remote" && application.type === "mcp_http") || @@ -4693,6 +4684,9 @@ export function createToolGatewayService( "tool_not_found", ); } + if (isRetiredComposioConnection(connection)) { + throw new ToolGatewayHttpError(422, RETIRED_COMPOSIO_MESSAGE, "composio_broker_retired"); + } if (!connection.enabled || connection.status !== "active") { throw new ToolGatewayHttpError( 403, @@ -4783,6 +4777,9 @@ export function createToolGatewayService( "tool_not_found", ); } + if (isRetiredComposioConnection(connection)) { + throw new ToolGatewayHttpError(422, RETIRED_COMPOSIO_MESSAGE, "composio_broker_retired"); + } if (!connection.enabled || connection.status !== "active") { throw new ToolGatewayHttpError( 403, @@ -5785,21 +5782,11 @@ export function createToolGatewayService( ms = railwayCommandBudgetMs(parameters); } const grant = await resolveConnectionGrant(session, connection); - const composioScopeRevision = `${grant.id}:${grant.status}:${grant.updatedAt.toISOString()}`; - const composioChild = composioChildConfig(connection); - let composioSession = composioChild - ? await composioSessions.ensureSession(connection.id, { - tools: [entry.toolName], - scopeRevision: composioScopeRevision, - }) - : null; - let endpoint = - composioSession?.url ?? - (await resolvedRemoteEndpoint(session, connection, grant)); + const endpoint = await resolvedRemoteEndpoint(session, connection, grant); // Method-defined headers are trusted catalog configuration. Treat them as // managed headers so callers cannot override the scope that was reviewed // during tools/list. Credentials remain authoritative on collisions. - let credentialHeaders = composioSession?.headers ?? { + let credentialHeaders = { ...projectedConnectionHeaders(connection), ...(await resolveCredentialHeaders(session, connection, grant)), }; @@ -5817,9 +5804,7 @@ export function createToolGatewayService( request: { protocol: "MCP JSON-RPC 2.0", httpMethod: "POST", - endpoint: composioChild - ? `${new URL(endpoint).origin}/[composio-session]` - : auditSafeEndpoint(endpoint), + endpoint: auditSafeEndpoint(endpoint), mcpMethod: "tools/call", requestId, upstreamToolName: entry.toolName, @@ -5905,28 +5890,6 @@ export function createToolGatewayService( }), }; let response = await dispatchRemote(endpoint, requestInit); - if (response.status === 401 && composioChild) { - composioSession = await composioSessions.ensureSession(connection.id, { - tools: [entry.toolName], - scopeRevision: composioScopeRevision, - force: true, - }); - endpoint = composioSession.url; - credentialHeaders = composioSession.headers; - builtHeaders = buildRemoteHeaders({ - session, - connection, - credentialHeaders, - callerHeaders, - }); - headers = builtHeaders.headers; - headerSummary = builtHeaders.summary; - const retryInit = { - ...requestInit, - headers: mcpHttpRequestHeaders(headers), - }; - response = await dispatchRemote(endpoint, retryInit); - } const oauth = asRecord(asRecord(connection.config)?.oauth); if ( response.status === 401 && diff --git a/ui/src/api/tools.ts b/ui/src/api/tools.ts index c6050a1c7e6..2e8a419cbde 100644 --- a/ui/src/api/tools.ts +++ b/ui/src/api/tools.ts @@ -1,9 +1,3 @@ -import type { - ComposioConnectLinkResponse, - ComposioDisconnectResponse, - ComposioServiceStatusResponse, - ComposioServicesResponse, -} from "@/pages/apps/composio-services"; import type { ToolApplication, ConfigureRailwaySsh, @@ -411,9 +405,9 @@ export const toolsApi = { api.post(`/tool-connections/${connectionId}/railway/ssh`, input), // Removal is a credential-revoking teardown (PAP-17119), so the response // carries the cleanup receipt alongside the archived connection. - archiveConnection: (connectionId: string, options: { confirmComposioChildren?: boolean } = {}) => + archiveConnection: (connectionId: string) => api.delete( - `/tool-connections/${connectionId}${options.confirmComposioChildren ? "?confirmComposioChildren=true" : ""}`, + `/tool-connections/${connectionId}`, ), checkConnectionHealth: (connectionId: string) => api.post(`/tool-connections/${connectionId}/health-check`, {}), @@ -449,24 +443,6 @@ export const toolsApi = { api.get( `/tool-connections/${connectionId}/test-calls/${actionRequestId}`, ), - // --- Composio services (PAP-17865) --- - // A Composio connection brokers many toolkits; these four read and change the - // per-toolkit state the Services tab renders. - listComposioServices: (connectionId: string) => - api.get(`/tool-connections/${connectionId}/services`), - startComposioServiceConnect: (connectionId: string, toolkitSlug: string) => - api.post( - `/tool-connections/${connectionId}/services/${encodeURIComponent(toolkitSlug)}/connect`, - {}, - ), - getComposioServiceStatus: (connectionId: string, toolkitSlug: string) => - api.get( - `/tool-connections/${connectionId}/services/${encodeURIComponent(toolkitSlug)}/status`, - ), - disconnectComposioService: (connectionId: string, toolkitSlug: string) => - api.delete( - `/tool-connections/${connectionId}/services/${encodeURIComponent(toolkitSlug)}`, - ), importMcpJson: (companyId: string, body: { mcpJson: unknown }) => api.post(`/companies/${companyId}/tools/mcp/import-json`, body), listStdioTemplates: (companyId: string) => diff --git a/ui/src/components/AppConnectionSidebar.production.tsx b/ui/src/components/AppConnectionSidebar.production.tsx index 8c2cb02e663..8eb84c326da 100644 --- a/ui/src/components/AppConnectionSidebar.production.tsx +++ b/ui/src/components/AppConnectionSidebar.production.tsx @@ -9,7 +9,6 @@ import { useSidebar } from "@/context/SidebarContext"; import { queryKeys } from "@/lib/queryKeys"; import { APP_TABS, - CONNECTED_ONLY_APP_TABS, appApplicationTabHref, appTabHref, type AppTabKey, @@ -101,9 +100,7 @@ export function AppDetailSidebar(props: AppDetailSidebarProps) {