Files
PaperClipAI/ui/src/pages/apps/AppNotConnected.tsx
T
DottaandPaperclip b82661b561 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 <noreply@paperclip.ing>
2026-09-21 13:57:35 -05:00

343 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useMemo } from "react";
import { useQuery } from "@tanstack/react-query";
import type { ToolConnection } from "@paperclipai/shared";
import { isConnectableAppSlug } from "@paperclipai/shared";
import { Navigate, useNavigate, useParams } from "@/lib/router";
import { useCompany } from "@/context/CompanyContext";
import { useBreadcrumbs } from "@/context/BreadcrumbContext";
import { queryKeys } from "@/lib/queryKeys";
import { toolsApi } from "@/api/tools";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { AppLogo } from "./AppLogo";
import {
appApplicationSourceSlug,
appDefinitionDarkLogoUrl,
appDefinitionLogoUrl,
appDefinitionName,
appDefinitionSlug,
type AppGalleryDisplayEntry,
} from "./app-definition-display";
import { connectionAddress } from "./AppDetail";
import { ReviewPanel } from "./app-detail/ReviewPanel";
import { appApplicationTabHref, appTabHref, appTabLabel, isAppTabKey, type AppTabKey } from "./app-tabs";
export function AppNotConnected() {
const { applicationId = "", tab } = useParams<{ applicationId: string; tab?: string }>();
const navigate = useNavigate();
const { selectedCompanyId } = useCompany();
const { setBreadcrumbs } = useBreadcrumbs();
const activeTab: AppTabKey | null = isAppTabKey(tab) ? tab : null;
const applicationsQuery = useQuery({
queryKey: queryKeys.tools.applications(selectedCompanyId ?? "__none__"),
queryFn: () => toolsApi.listApplications(selectedCompanyId!),
enabled: !!selectedCompanyId && !!activeTab,
});
const connectionsQuery = useQuery({
queryKey: queryKeys.tools.connections(selectedCompanyId ?? "__none__"),
queryFn: () => toolsApi.listConnections(selectedCompanyId!),
enabled: !!selectedCompanyId && !!activeTab,
});
const galleryQuery = useQuery({
queryKey: queryKeys.apps.gallery(selectedCompanyId ?? "__none__"),
queryFn: () => toolsApi.listGallery(selectedCompanyId!),
enabled: !!selectedCompanyId && !!activeTab,
});
const application = useMemo(
() => (applicationsQuery.data?.applications ?? []).find((app) => app.id === applicationId),
[applicationsQuery.data, applicationId],
);
const appSourceSlug = appApplicationSourceSlug(application);
const relatedApplicationIds = useMemo(() => {
if (!application) return new Set<string>();
if (!appSourceSlug) return new Set([application.id]);
return new Set(
(applicationsQuery.data?.applications ?? [])
.filter((candidate) => appApplicationSourceSlug(candidate) === appSourceSlug)
.map((candidate) => candidate.id),
);
}, [application, applicationsQuery.data, appSourceSlug]);
const appConnections = useMemo(
() => (connectionsQuery.data?.connections ?? []).filter((c) => relatedApplicationIds.has(c.applicationId)),
[connectionsQuery.data, relatedApplicationIds],
);
const activeConnections = useMemo(
() => appConnections.filter((c) => c.status !== "archived" && c.status !== "draft"),
[appConnections],
);
const activeConnection = activeConnections[0] ?? null;
const previousConnection = useMemo(() => latestArchivedConnection(appConnections), [appConnections]);
const grantsQuery = useQuery({
queryKey: queryKeys.tools.connectionGrants(previousConnection?.id ?? "__none__"),
queryFn: () => toolsApi.listConnectionGrants(previousConnection!.id),
enabled: !!previousConnection && !!activeTab,
});
const appName = application?.name ?? "App";
useEffect(() => {
if (!activeTab) return;
setBreadcrumbs([
{ label: "Connectors", href: "/apps" },
{ label: appName, href: appApplicationTabHref(applicationId, "permissions") },
{ label: appTabLabel(activeTab) },
]);
return () => setBreadcrumbs([]);
}, [setBreadcrumbs, appName, applicationId, activeTab]);
if (tab === "activity") {
return <Navigate to="/activity?action=tool_" replace />;
}
if (tab === "setup" || tab === "test") {
return <Navigate to={appApplicationTabHref(applicationId, "permissions")} replace />;
}
if (!selectedCompanyId) {
return <div className="p-6 text-sm text-muted-foreground">Select an organization to manage apps.</div>;
}
if (!applicationId || !activeTab) {
return <Navigate to={applicationId ? appApplicationTabHref(applicationId, "permissions") : "/apps"} replace />;
}
if (applicationsQuery.isLoading || connectionsQuery.isLoading) {
return (
<div className="max-w-3xl space-y-3">
<Skeleton className="h-10 w-64" />
<Skeleton className="h-40 w-full" />
</div>
);
}
if (!application) {
return (
<div className="max-w-3xl space-y-3 p-6 text-sm text-muted-foreground">
<p>This app doesn’t exist anymore.</p>
<Button variant="outline" size="sm" onClick={() => navigate("/apps")}>Back to connectors</Button>
</div>
);
}
if (activeConnection) {
return <Navigate to={appTabHref(activeConnection.id, activeTab)} replace />;
}
const gallery = (galleryQuery.data?.apps ?? []) as AppGalleryDisplayEntry[];
const logoEntry = (appSourceSlug
? gallery.find((entry) => appDefinitionSlug(entry) === appSourceSlug)
: undefined) ?? gallery.find(
(entry) => appDefinitionName(entry).toLowerCase() === application.name.toLowerCase(),
);
const logoUrl = appDefinitionLogoUrl(logoEntry);
const darkLogoUrl = appDefinitionDarkLogoUrl(logoEntry);
const previousAddress = previousConnection ? connectionAddress(previousConnection) : null;
const retainedPersonalGrant = previousConnection?.credentialPolicy === "per_user"
? grantsQuery.data?.grants.find((grant) => (
grant.kind === "user" && grant.subjectUserId === previousConnection.createdByUserId
))
?? grantsQuery.data?.grants.find((grant) => grant.kind === "user" && grant.status === "active")
?? grantsQuery.data?.grants.find((grant) => grant.kind === "user")
?? null
: null;
const retainedPersonalUserId = previousConnection?.credentialPolicy === "per_user"
? previousConnection.createdByUserId ?? retainedPersonalGrant?.subjectUserId ?? null
: null;
const canReconnect = !previousConnection
|| (previousConnection.credentialPolicy === "per_user"
? Boolean(
retainedPersonalUserId
&& retainedPersonalUserId === grantsQuery.data?.currentUserId
&& grantsQuery.data?.capabilities.canConnectAsCurrentUser,
)
: grantsQuery.data?.capabilities.canConfigure === true);
const reconnectUnavailableMessage = grantsQuery.isLoading
? "Checking who can reconnect this identity…"
: grantsQuery.isError
? "We couldn't verify who can reconnect this identity. Reload the page to try again."
: previousConnection?.credentialPolicy === "per_user"
&& retainedPersonalUserId !== grantsQuery.data?.currentUserId
? "The person this connection belongs to must reconnect it."
: "You don't have permission to reconnect this identity.";
const connectHref = newConnectionHref({
applicationId,
appName: application.name,
previousAddress,
previousConnection,
sourceSlug: isConnectableAppSlug(appSourceSlug) ? appSourceSlug : null,
});
return (
<div className="max-w-3xl space-y-6 pb-12">
<ApplicationHeader
applicationName={application.name}
description={application.description}
logoUrl={logoUrl}
darkLogoUrl={darkLogoUrl}
connectedCount={activeConnections.length}
/>
<ConnectionCallout
applicationName={application.name}
previousConnection={previousConnection}
canReconnect={canReconnect}
reconnectUnavailableMessage={reconnectUnavailableMessage}
onConnect={() => navigate(connectHref)}
/>
{activeTab === "review" && (
previousConnection ? (
<ReviewPanel connectionId={previousConnection.id} />
) : (
<EmptyTab
title="Nothing is waiting for your OK right now."
body="Review requests will appear here after this app is connected."
/>
)
)}
{activeTab === "permissions" && (
<PermissionsTab previousConnection={previousConnection} />
)}
</div>
);
}
function ApplicationHeader({
applicationName,
description,
logoUrl,
darkLogoUrl,
connectedCount,
}: {
applicationName: string;
description: string | null;
logoUrl: string | undefined;
darkLogoUrl: string | undefined;
connectedCount: number;
}) {
return (
<header className="flex flex-wrap items-center gap-4">
<AppLogo name={applicationName} logoUrl={logoUrl} darkLogoUrl={darkLogoUrl} size={48} />
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<h1 className="truncate text-2xl font-bold tracking-tight">{applicationName}</h1>
<span className="inline-flex items-center rounded-full border border-border bg-background px-2 py-0.5 text-xs font-medium text-muted-foreground">
{connectedCount > 0 ? `${connectedCount} connected` : "Not connected"}
</span>
</div>
{description && (
<p className="mt-1 text-sm text-muted-foreground">{description}</p>
)}
</div>
</header>
);
}
function ConnectionCallout({
applicationName,
previousConnection,
canReconnect,
reconnectUnavailableMessage,
onConnect,
}: {
applicationName: string;
previousConnection: ToolConnection | null;
canReconnect: boolean;
reconnectUnavailableMessage: string;
onConnect: () => void;
}) {
return (
<section className="flex flex-wrap items-center justify-between gap-3 rounded-lg border border-border px-4 py-3">
<div>
<h2 className="text-sm font-semibold text-foreground">
{previousConnection ? "Needs attention" : "Not connected"}
</h2>
<p className="mt-0.5 text-sm text-muted-foreground">
{previousConnection
? previousConnection.authKind === "oauth"
? `Sign in to ${applicationName} again to restore access.`
: `Add a working ${applicationName} key to restore access.`
: `Connect ${applicationName} so agents can use it.`}
</p>
{previousConnection && !canReconnect ? (
<p className="mt-1 text-sm text-muted-foreground">{reconnectUnavailableMessage}</p>
) : null}
</div>
{!previousConnection || canReconnect ? (
<Button onClick={onConnect}>{previousConnection ? "Reconnect" : "Connect"}</Button>
) : null}
</section>
);
}
function PermissionsTab({ previousConnection }: { previousConnection: ToolConnection | null }) {
return (
<section>
<h2 className="text-sm font-bold text-foreground">Permissions paused</h2>
<p className="mt-1 text-sm text-muted-foreground">
Reconnect this app to edit who can use it and which actions need a human first.
</p>
{previousConnection && (
<p className="mt-3 text-xs text-muted-foreground">
Previous setup is retained for reconnect, but access controls stay read-only until the app is online.
</p>
)}
</section>
);
}
function EmptyTab({ title, body }: { title: string; body: string }) {
return (
<section>
<h2 className="text-sm font-bold text-foreground">{title}</h2>
<p className="mt-1 text-sm text-muted-foreground">{body}</p>
</section>
);
}
function latestArchivedConnection(connections: ToolConnection[]): ToolConnection | null {
const archived = connections.filter((c) => c.status === "archived");
if (archived.length === 0) return null;
return archived.reduce((latest, connection) => {
const latestTime = new Date(latest.updatedAt ?? latest.createdAt ?? 0).getTime();
const connectionTime = new Date(connection.updatedAt ?? connection.createdAt ?? 0).getTime();
return connectionTime > latestTime ? connection : latest;
});
}
function newConnectionHref({
applicationId,
appName,
previousAddress,
previousConnection,
sourceSlug,
}: {
applicationId: string;
appName: string;
previousAddress: string | null;
previousConnection: ToolConnection | null;
sourceSlug: string | null;
}): string {
const params = new URLSearchParams({ applicationId, name: appName, new: "1" });
if (previousConnection) {
params.set("reconnect", previousConnection.id);
params.set("identity",
previousConnection.credentialPolicy === "per_user"
? "user"
: previousConnection.credentialPolicy === "per_agent"
? "agent"
: "organization");
}
if (sourceSlug) params.set("source", sourceSlug);
else params.set("byo", "1");
const storedLink = [
previousConnection?.config?.url,
previousConnection?.config?.endpoint,
previousConnection?.config?.remoteUrl,
previousConnection?.transportConfig.url,
previousConnection?.transportConfig.endpoint,
previousConnection?.transportConfig.remoteUrl,
previousAddress,
].find((value): value is string => typeof value === "string" && /^https?:\/\//i.test(value));
if (storedLink) params.set("link", storedLink);
const path = previousConnection?.credentialSource === "vercel_connect"
? "/apps/vercel-connect"
: "/apps/connect";
return `${path}?${params.toString()}`;
}