feat(apps): add experimental MCP aggregator connectors (#13755)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Its tool gateway applies company access rules and approval controls
to connected apps.
> - MCP aggregators expose many apps through one provider endpoint.
> - Each aggregator needs its own credential, catalog, grants, and
lifecycle in Paperclip.
> - This pull request adds independent Zapier, Arcade, Composio Connect,
and Executor setup with a common Access → Connect layout.
> - A default-off MCP aggregators flag lets operators opt in while we
complete provider acceptance tests.
> - Agents use the normal Paperclip permissions, Test screen, and
gateway after setup.

## Linked Issues or Issue Description

**Subsystem affected**

Apps, connection setup, shared contracts, and the remote MCP gateway.

**Problem or motivation**

Aggregator endpoints need clear provider setup and correct MCP sessions.
Generic setup does not explain each provider's authentication or broad
execution tools. Provider approval must preserve the original execution
instead of replaying a write.

**Proposed solution**

Add four separate connectors behind Settings → Experimental → MCP
aggregators. Start with human and agent access, then connect the
endpoint and read its tools. Enable tools by default. Use the existing
Permissions and Test screens after setup. Keep legacy Composio API-key
and child connections intact.

**Alternatives considered**

A shared connection for all providers would mix credentials and access
rules. Separate provider-specific permission and test screens would
duplicate existing controls. Vercel Connect is outside this change.

**Roadmap alignment**

Extends the existing MCP Tool Gateway & Apps capability and the
Connected Apps roadmap area. This work was requested and reviewed by the
maintainer.

Related work: #11894, #12630, #12632, #12634, and #12906 concern the
legacy Composio broker. #13102 also covers remote MCP pagination. This
change preserves the broker path and adds initialized sessions, response
matching, and provider resume handling alongside pagination.

## What Changed

- Add branded setup and interactive Storybooks for Zapier, Arcade,
Composio Connect, and Executor. Use the existing access controls and
normal action tests. Do not request a connection name or action choices
during setup.
- Add the default-off `enableMcpAggregators` flag to settings, managed
feature metadata, the catalog, and setup guards. Hidden connections keep
running. Legacy Composio connections remain unchanged.
- Reuse the vault, grants, policy, and catalog models. Support OAuth
discovery, bearer tokens, custom headers, and credential-bearing URLs.
Add no database tables or migrations.
- Initialize and retain Streamable HTTP sessions by connection and
effective credentials. Read paginated catalogs and match streaming
responses to request IDs.
- Classify unfamiliar aggregator tools as writes despite upstream
read-only hints; only exact reviewed read capabilities enter the
read-only allowlist. Legacy Composio child behavior is preserved.
- Preserve provider authorization links and execution IDs. Support
Executor approve/resume, decline, and cancel without automatic replay of
uncertain writes.
- Preserve Off and Ask first choices during refresh and reconnect. Allow
new tools and retire removed tools. Keep agent access updates atomic and
preserve an empty agent selection.
- Document connector UX rules, provider branding sources, and live
acceptance results.
- Stabilize the existing Sentry release fixture after its repeated CI
failure by reusing one module mock; production Sentry behavior is
unchanged.

## Verification

- Final head `d11781970`: [CI
run](https://github.com/paperclipai/paperclip/actions/runs/35633534900)
passed, including broad typecheck, test shards, build, and E2E. All 54
checks pass; 2 optional checks are skipped. Greptile is 5/5, Security
Scan passes, and all review threads are resolved.

- Passed 27 focused connector Vitest checks and 18 connector-only
Storybook browser checks before the flag change. All 85 stories rendered
at desktop and narrow widths.
- Passed 5 connector lifecycle/server checks and 7 selected flag checks
after adding the flag. The latter cover settings, managed defaults,
cached catalog visibility, and all four setup routes.
- Review fixes passed 13 risk/handoff/lifecycle checks, dedicated
session-expiration and transport regressions, 13 selected
connector/gateway CI cases, and 10 selected setup/reconnect UI cases. A
real Composio connection-list call also succeeded through the refreshed
UI on `9ab115f71`.
- UI and server TypeScript checks passed. UI build, Storybook build,
token gates, and diff whitespace checks passed during implementation.
- Real browser and real Paperclip agent tests passed for Arcade,
Composio, and Executor. Tested action permissions, denied agent access,
reconnect, disconnect, and isolation. Tested Arcade catalog
additions/removal and Executor provider approve/resume, decline, and
cancel.
- Zapier live acceptance is incomplete. Its dedicated provider server is
configured, but its credential-copy dialog returned an empty clipboard
through browser automation. No live Zapier action is claimed.
- The three isolated Sentry release cases pass after the CI fixture fix.
- Local verification is deliberately narrow at the maintainer's request.
The full local suite, recursive typecheck, and repository-wide build
were not run. CI provides the broader checks.

## Risks

- Shared MCP transport changes affect other remote MCP servers. Protocol
fixtures cover initialized sessions, streaming response matching,
pagination, and isolation.
- Broad execution tools remain broad permissions. The provider governs
actions inside those tools.
- Provider handoff links are retained briefly in memory. After a server
restart, a one-time link may require reopening the provider dashboard.
Paperclip does not replay the original call.
- Zapier remains unproven live. Custom-header imports and self-hosted
endpoints have fixture coverage rather than a separate live account for
every variant.
- Turning the experimental flag off hides setup; it does not revoke
existing credentials or stop existing connections.

## Model Used

OpenAI GPT-6 through Codex, with reasoning, repository tools, shell
execution, and browser automation. The exact runtime model ID and
context-window size are not exposed in this session. A separate
Anthropic-backed Paperclip agent performed live gateway acceptance
tasks.

## 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>
This commit is contained in:
DottaandPaperclip authored and GitHub committed 2026-09-21 12:53:11 -05:00
1 parent 3790ca2f13
commit e8c8ba3c19
74 files changed
+2988 -240

No files matched your search

@@ -1,6 +1,6 @@
import { AiConnectionCredentialStep } from "@/components/ai-connections/AiConnectionCredentialStep";
import { ConnectionChoiceList } from "./ConnectionChoiceList";
import { useCallback, useEffect, useId, useMemo, useRef, useState, type ReactNode } from "react";
import { useCallback, useEffect, useId, useMemo, useRef, useState, type ReactNode, type Ref } from "react";
import { useMutation, useQuery } from "@tanstack/react-query";
import {
ArrowUpRight,
@@ -19,7 +19,6 @@ import {
UsersRound,
} from "lucide-react";
import type {
Agent,
AppDefinition,
ConnectionGrantKind,
ConnectionIntentSetupConnection,
@@ -35,6 +34,7 @@ import type {
} from "@paperclipai/shared";
import {
aiConnectionMetadataSchema,
isRemoteMcpConnectorId,
connectionMethodAcceptsCustomerOAuthClient,
connectionMethodRequiresConfiguration,
connectionMethodSupportsAutomaticOAuth,
@@ -56,7 +56,7 @@ import { ApiError } from "@/api/client";
import { toolsApi } from "@/api/tools";
import { agentsApi } from "@/api/agents";
import { appCopyFor, credentialFieldLabel } from "@/lib/app-gallery-copy";
import { AgentMultiSelect } from "@/components/AgentMultiSelect";
import { AgentMultiSelect, type AgentMultiSelectOption } from "@/components/AgentMultiSelect";
import { InlineBanner } from "@/components/InlineBanner";
import { Button } from "@/components/ui/button";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
@@ -850,6 +850,10 @@ export function ConnectionSetupFlow({
* definition — the generic path stays available either way.
*/
const useMatchedGalleryEntry = (picked: AppDefinition) => {
if (host === "page" && !connectionIntentId && credentialSource === "paperclip_vault" && isRemoteMcpConnectorId(picked.slug)) {
navigate(`/apps/connect?source=${picked.slug}`);
return;
}
if (picked.slug === "zapier") {
setEntry(null);
setGalleryName("");
@@ -2429,7 +2433,9 @@ export function ConnectionSetupFlow({
);
}
function StepHeader({
export function StepHeader({
title,
headingRef,
subtitle,
step,
activeIndex,
@@ -2438,6 +2444,8 @@ function StepHeader({
unverifiedHost,
onCancel,
}: {
title?: string;
headingRef?: Ref<HTMLHeadingElement>;
subtitle: string;
step: Step;
activeIndex: number;
@@ -2449,7 +2457,7 @@ function StepHeader({
* just on the screen where they pasted the address.
*/
unverifiedHost?: string | null;
onCancel: () => void;
onCancel?: () => void;
}) {
return (
<div className="mb-6">
@@ -2459,16 +2467,16 @@ function StepHeader({
<AppLogo name={appIdentity.name} logoUrl={appIdentity.logoUrl} darkLogoUrl={appIdentity.darkLogoUrl} size={44} />
) : null}
<div>
<h1 className="text-2xl font-bold tracking-tight">
{appIdentity ? `Connect ${appIdentity.name}` : "Connect your own MCP server"}
<h1 ref={headingRef} tabIndex={headingRef ? -1 : undefined} className="text-2xl font-bold tracking-tight outline-none">
{title ?? (appIdentity ? `Connect ${appIdentity.name}` : "Connect your own MCP server")}
</h1>
<p className="mt-1 text-sm text-muted-foreground">{subtitle}</p>
{unverifiedHost ? <UnverifiedServerBadge host={unverifiedHost} className="mt-2" /> : null}
</div>
</div>
<Button variant="ghost" size="sm" onClick={onCancel}>
{onCancel && <Button variant="ghost" size="sm" onClick={onCancel}>
Cancel
</Button>
</Button>}
</div>
{step !== "gallery" && (
// A landmark with stable hooks, so the step model can be read without
@@ -3886,8 +3894,18 @@ function MethodConfigField({
* so the reader understands the identity and the reach the secret is about to
* get. Hick's Law: two choices, not a matrix. Both use full-row radio targets.
*/
export function AccessStep({
companyId,
export function AccessStep({ companyId, ...props }: Omit<Parameters<typeof AccessStepContent>[0], "agents" | "agentsLoading"> & { companyId: string }) {
const agentsQuery = useQuery({
queryKey: queryKeys.agents.list(companyId),
queryFn: () => agentsApi.list(companyId),
});
return <AccessStepContent {...props} agents={(agentsQuery.data ?? []).filter((agent) => agent.status !== "terminated")} agentsLoading={agentsQuery.isLoading} />;
}
/** Shared Gmail access presentation; callers supply agents so review stories stay offline. */
export function AccessStepContent({
agents: allAgents,
agentsLoading = false,
authKind,
grantKinds,
grantKind,
@@ -3907,7 +3925,8 @@ export function AccessStep({
onBack,
onContinue,
}: {
companyId: string;
agents: AgentMultiSelectOption[];
agentsLoading?: boolean;
authKind: ToolConnectionAuthKind;
grantKinds?: ConnectionGrantKind[];
grantKind: ConnectionGrantKind;
@@ -3935,11 +3954,6 @@ export function AccessStep({
onBack: () => void;
onContinue: () => void;
}) {
const agentsQuery = useQuery({
queryKey: queryKeys.agents.list(companyId),
queryFn: () => agentsApi.list(companyId),
});
const allAgents: Agent[] = (agentsQuery.data ?? []).filter((a) => a.status !== "terminated");
// "Only agents I choose" / "Just agents I pick" means agents this person may actually edit. When the server
// has not told us, fall back to every live agent rather than an empty list —
// an empty picker would read as "you have no agents".
@@ -4144,7 +4158,7 @@ export function AccessStep({
onChange={(next) => setInstallAgentIds(
grantKind === "agent" && next.size > 1 ? new Set([[...next].at(-1)!]) : next,
)}
loading={agentsQuery.isLoading}
loading={agentsLoading}
emptyMessage="You cannot edit any agents yet."
showSelectionPreview={false}
/>
@@ -0,0 +1,136 @@
import { useEffect, useId, useRef, useState, type ReactNode } from "react";
import { CheckCircle2, ExternalLink, HelpCircle, Loader2, Plus, Trash2 } from "lucide-react";
import { InlineBanner } from "@/components/InlineBanner";
import { ActionsSection } from "@/pages/apps/app-detail/PermissionsPanel";
import { SetupWizardFooter } from "@/components/SetupWizard";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { RemoteMcpManagement } from "./RemoteMcpManagement";
import { AccessStepContent, StepHeader } from "../ConnectionSetupFlow";
import type { RemoteMcpProvider } from "./providers";
import type { RemoteMcpSetupActions, RemoteMcpSetupState } from "./types";
const steps = ["access", "connect"] as const;
const selectClass = "h-9 w-full rounded-md border border-input bg-background px-3 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50";
function FieldHelp({ label, children }: { label: string; children: ReactNode }) {
const [open, setOpen] = useState(false);
return <Tooltip open={open} onOpenChange={setOpen}>
<TooltipTrigger asChild><button type="button" aria-label={`Help with ${label}`} onClick={() => setOpen(!open)} className="rounded-sm text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"><HelpCircle className="size-4" /></button></TooltipTrigger>
<TooltipContent className="max-w-xs">{children}</TooltipContent>
</Tooltip>;
}
/** Controlled presentation shared by provider setup, configuration imports and review stories.
* Authentication, persistence and calls belong to the controller, never these views. */
export function RemoteMcpConnectionSetup({ provider, state: s, actions: a, agents, connectionId }: {
provider: RemoteMcpProvider;
connectionId: string;
state: RemoteMcpSetupState;
actions: RemoteMcpSetupActions;
agents: { id: string; name: string }[];
}) {
const uid = useId();
const heading = useRef<HTMLHeadingElement>(null);
const previousStep = useRef(s.step);
useEffect(() => {
if (previousStep.current !== s.step) heading.current?.focus();
previousStep.current = s.step;
}, [s.step]);
const currentStep = steps.indexOf(s.step as typeof steps[number]);
const busy = s.connectStatus === "connecting";
const change = (patch: Partial<RemoteMcpSetupState>) => a.edit(patch);
const external = (purpose: Parameters<typeof a.openProvider>[0], text: string) => <Button type="button" variant="link" className="h-auto p-0 text-sm text-current underline" onClick={() => a.openProvider(purpose)}>{text}<ExternalLink className="size-3.5" aria-hidden="true" /></Button>;
const boundary = <InlineBanner compact>
Paperclip controls access to the tools listed here. App and action permissions inside these tools are managed in {external("manage", provider.name)}.
</InlineBanner>;
const footer = (children: ReactNode) => <SetupWizardFooter onSaveExit={a.saveExit} disabled={busy}>{children}</SetupWizardFooter>;
const error = s.connectStatus === "invalid_url" ? { title: "Enter a valid MCP URL", body: "Paste the complete server URL, including https:// or http://. A dashboard page is not an MCP endpoint." }
: s.connectStatus === "rejected" ? { title: "Credentials were rejected", body: `Check or replace the credentials from ${provider.name}, then reconnect. Your agent access and tool choices are preserved.` }
: s.connectStatus === "unreachable" ? { title: "Paperclip could not reach this server", body: "Check that the endpoint is running and reachable from Paperclip, then try again. Your draft is still here." }
: null;
return <div className="mx-auto max-w-6xl p-4 text-foreground sm:p-8" data-remote-mcp-provider={provider.id}>
<StepHeader headingRef={heading} appIdentity={{ name: provider.name, logoUrl: null }}
title={s.step === "draft" ? "Continue your setup" : s.setupComplete ? s.step === "access" ? "Who can use this connection" : s.step === "connect" ? `Reconnect ${provider.name}` : provider.name : undefined}
subtitle={currentStep >= 0 && !s.setupComplete ? `Step ${currentStep + 1} of 2` : s.step === "draft" ? `Your ${provider.name} setup is ready to resume.` : s.step === "permissions" ? `Connected${s.identity ? ` as ${s.identity}` : ""} · ${s.tools.length} actions available` : `Manage this ${provider.name} connection.`}
step={currentStep >= 0 && !s.setupComplete ? "access" : "gallery"} activeIndex={currentStep} labels={["Access", "Connect"]} onCancel={busy || s.step === "management" || s.step === "permissions" || s.step === "draft" ? undefined : a.saveExit} />
<main className="space-y-6">
{s.notice && <p role="status" className="text-sm text-muted-foreground">{s.notice}</p>}
{s.step === "access" && <AccessStepContent agents={agents} authKind="oauth" grantKind={s.grantKind} setGrantKind={(grantKind) => { if (grantKind !== "agent") change({ grantKind }); }}
installChoice={s.allAgents ? "all" : "specific"} setInstallChoice={(choice) => change({ allAgents: choice === "all" })}
installAgentIds={new Set(s.agentIds)} setInstallAgentIds={(ids) => change({ agentIds: [...ids] })}
submitLabel={s.setupComplete ? "Done" : "Continue"} onBack={s.setupComplete ? a.finish : a.saveExit} onContinue={s.setupComplete ? a.finish : () => a.navigate("connect")} />}
{s.step === "permissions" && <>
<div className="flex items-center justify-between gap-3"><h2 className="text-sm font-semibold">Permissions</h2><Button variant="outline" onClick={a.finish}>Connection settings</Button></div>
{s.tools.some((entry) => entry.broad) && boundary}
<ActionsSection connectionId={connectionId} appName={provider.name}
readOnly={s.tools.filter((entry) => entry.isReadOnly)} canChange={s.tools.filter((entry) => !entry.isReadOnly)} quarantined={[]}
enabledIds={new Set(s.tools.filter((entry) => s.permissions[entry.id] !== "off").map((entry) => entry.id))}
askFirstIds={new Set(s.tools.filter((entry) => s.permissions[entry.id] === "ask_first").map((entry) => entry.id))}
disabled={!s.connected} refreshPending={s.refreshing} canConfigure
onSetPermission={(id, next) => change({ permissions: { ...s.permissions, [id]: next === "ask" ? "ask_first" : next } })}
onReviewQuarantined={() => {}} onRefreshActions={a.refresh} />
</>}
<div className="mx-auto max-w-2xl space-y-6">
{s.step === "connect" && <>
<div className="space-y-3">
<ol className="list-decimal space-y-2 pl-5 text-sm">{provider.instructions.map((instruction) => <li key={instruction}>{instruction}</li>)}</ol>
{external("setup", `Open ${provider.name} setup guide`)}
</div>
{s.connectStatus === "sign_in" && provider.supportsBrowserAuth ? <>
<div role="status"><InlineBanner title={`Finish signing in to ${provider.name}`}>
Complete sign-in in the provider window, then return here. Paperclip is waiting for confirmation.
</InlineBanner></div>
<p className="text-sm text-muted-foreground">If a window did not open, {external("sign_in", "open sign-in again")}.</p>
{footer(<><Button variant="outline" onClick={a.cancelConnect}>Cancel sign-in</Button><Button disabled>Waiting for sign-in</Button></>)}
</> : <form className="space-y-6" onSubmit={(event) => { event.preventDefault(); a.connect(); }}>
{error && <div role="alert"><InlineBanner tone="danger" title={error.title}>{error.body}</InlineBanner></div>}
{s.connectStatus === "cancelled" && <p role="status" className="text-sm text-muted-foreground">Connection cancelled. Your setup details are preserved; try again when you are ready.</p>}
<fieldset disabled={busy} className="min-w-0 space-y-5">
<div className="space-y-2">
<div className="flex items-center gap-2"><Label htmlFor={`${uid}-url`}>MCP server URL</Label><FieldHelp label="MCP server URL">{provider.urlHelp}</FieldHelp></div>
<Input id={`${uid}-url`} type="password" autoComplete="off" spellCheck={false} placeholder={provider.placeholder} value={s.url} aria-invalid={s.connectStatus === "invalid_url"} aria-describedby={`${uid}-url-help`} onChange={(event) => change({ url: event.target.value })} />
<p id={`${uid}-url-help`} className="text-xs text-muted-foreground">{provider.urlHelp}</p>
</div>
<details open={s.advanced} onToggle={(event) => { if (event.currentTarget.open !== s.advanced) change({ advanced: event.currentTarget.open }); }}>
<summary className="cursor-pointer text-sm font-medium">Advanced authentication</summary>
<div className="space-y-4 pt-4">
<p className="text-sm text-muted-foreground">{provider.authHelp}</p>
<div className="space-y-2"><Label htmlFor={`${uid}-auth`}>Authentication</Label><select id={`${uid}-auth`} className={selectClass} value={s.auth} onChange={(event) => change({ auth: event.target.value as RemoteMcpSetupState["auth"] })}>
{provider.supportsBrowserAuth && <option value="auto">Automatic (sign in if required)</option>}<option value="bearer">Bearer token</option><option value="headers">Custom headers</option><option value="none">No additional authentication</option>
</select></div>
{s.auth === "bearer" && <div className="space-y-2"><div className="flex items-center gap-2"><Label htmlFor={`${uid}-token`}>Bearer token</Label><FieldHelp label="bearer token">Paste the token only, without the word Bearer. It is kept in the connection’s credentials.</FieldHelp></div><Input id={`${uid}-token`} type="password" autoComplete="off" value={s.token} onChange={(event) => change({ token: event.target.value })} /></div>}
{(s.auth === "bearer" || s.auth === "headers") && <div className="space-y-3">
<p className="text-sm font-medium">{s.auth === "bearer" ? "Additional headers" : "Headers"}</p>
{s.headers.map((header, index) => <div key={header.id} className="flex flex-wrap items-end gap-2">
<div className="min-w-0 flex-1 space-y-2"><Label htmlFor={`${uid}-${header.id}-name`}>Header {index + 1} name</Label><Input id={`${uid}-${header.id}-name`} value={header.name} placeholder={provider.id === "arcade" ? "Arcade-User-ID" : "Header name"} onChange={(event) => change({ headers: s.headers.map((h) => h.id === header.id ? { ...h, name: event.target.value } : h) })} /></div>
<div className="min-w-0 flex-1 space-y-2"><Label htmlFor={`${uid}-${header.id}-value`}>Header {index + 1} value</Label><Input id={`${uid}-${header.id}-value`} type="password" autoComplete="off" value={header.value} onChange={(event) => change({ headers: s.headers.map((h) => h.id === header.id ? { ...h, value: event.target.value } : h) })} /></div>
<Button type="button" variant="ghost" size="icon" aria-label={`Remove header ${index + 1}`} onClick={() => change({ headers: s.headers.filter((h) => h.id !== header.id) })}><Trash2 className="size-4" /></Button>
</div>)}
<Button type="button" variant="outline" size="sm" onClick={() => change({ headers: [...s.headers, { id: crypto.randomUUID(), name: "", value: "" }] })}><Plus className="size-4" />Add header</Button>
</div>}
</div>
</details>
</fieldset>
{busy && <p role="status" className="flex items-center gap-2 text-sm"><Loader2 className="size-4 animate-spin motion-reduce:animate-none" />Connecting and discovering tools…</p>}
{footer(<><Button type="button" variant="outline" disabled={busy} onClick={() => s.setupComplete ? a.finish() : a.navigate("access")}>Back</Button><Button type="submit" disabled={busy || !s.url.trim()}>{busy ? "Connecting…" : error || s.connectStatus === "cancelled" ? "Try again" : "Connect"}</Button></>)}
</form>}
</>}
{s.step === "management" && <>
{!s.connected ? <InlineBanner tone="warning" title="Disconnected">Agents cannot use this connection. Reconnect to restore access under the saved permission choices.</InlineBanner> : <div className="space-y-2"><p className="flex items-center gap-2 text-sm"><CheckCircle2 className="size-4" />Connected{s.identity ? ` as ${s.identity}` : ""}</p><p className="text-sm text-muted-foreground">{s.grantKind === "user" ? "Just me" : "Any human in the organization"} · {s.tools.length} tools · {s.allAgents ? "Any agent" : `${s.agentIds.length} agents with access`}</p></div>}
<div className="flex flex-wrap gap-2"><Button onClick={() => a.navigate("access")}>Who can use this connection</Button><Button variant="outline" disabled={!s.connected} onClick={() => a.navigate("permissions")}>Permissions</Button></div>
<p className="text-sm text-muted-foreground">Refresh the catalog after changing tools in {provider.name}. Existing Off and Ask first choices are preserved.</p>
<Button variant="outline" disabled={!s.connected || s.refreshing} onClick={a.refresh}>{s.refreshing ? "Refreshing…" : "Refresh tools"}</Button>
<RemoteMcpManagement providerName={provider.name} connected={s.connected} onReconnect={a.reconnect} onManage={() => a.openProvider("manage")} onDisconnect={a.disconnect} />
</>}
{s.step === "draft" && <><p className="text-sm">Your access choices and setup progress are kept with this connection.</p><div className="flex justify-end"><Button onClick={a.resumeDraft}>Resume setup</Button></div></>}
</div>
</main>
</div>;
}
@@ -0,0 +1,30 @@
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { remoteMcpProviders, type RemoteMcpProviderId } from "./providers";
import { RemoteMcpConnectionSetup } from "./RemoteMcpConnectionSetup";
import type { RemoteMcpSetupActions, RemoteMcpSetupState } from "./types";
function ConnectExample({ providerId }: { providerId: RemoteMcpProviderId }) {
const provider = remoteMcpProviders[providerId];
const [state, setState] = useState<RemoteMcpSetupState>({
step: "access", grantKind: "organization", setupComplete: false, url: provider.defaultUrl,
auth: provider.supportsBrowserAuth ? "auto" : "none", token: "", headers: [], advanced: false, connectStatus: "idle",
connected: false, identity: null, allAgents: true, agentIds: [], permissions: {},
tools: [], notice: null, refreshing: false,
});
const edit: RemoteMcpSetupActions["edit"] = (patch) => setState((value) => ({ ...value, ...patch }));
const explain = () => edit({ notice: "Design example only. Review the interactive states in Storybook → Apps / Connections. No credentials are saved or sent." });
const actions: RemoteMcpSetupActions = { edit, navigate: (step) => edit({ step }), connect: explain, cancelConnect: explain,
openProvider: explain, saveExit: explain, resumeDraft: explain, finish: explain, refresh: explain, reconnect: explain, disconnect: explain,
};
return <RemoteMcpConnectionSetup connectionId="design-example" provider={provider} state={state} actions={actions} agents={[]} />;
}
/** Design-guide specimen; full state matrix is maintained in each provider's Storybook group. */
export function RemoteMcpDesignExample() {
const [providerId, setProviderId] = useState<RemoteMcpProviderId>("zapier");
return <div className="space-y-4">
<div className="flex flex-wrap gap-2">{Object.values(remoteMcpProviders).map((provider) => <Button key={provider.id} size="sm" variant={providerId === provider.id ? "default" : "outline"} onClick={() => setProviderId(provider.id)}>{provider.name}</Button>)}</div>
<ConnectExample key={providerId} providerId={providerId} />
</div>;
}
@@ -0,0 +1,40 @@
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from "@/components/ui/alert-dialog";
/** Shared by the saved connection and its interactive review stories. */
export function RemoteMcpManagement({ providerName, connected = true, canReconnect = true, canDisconnect = true, busy = false, onReconnect, onManage, onDisconnect }: {
providerName: string;
connected?: boolean;
canReconnect?: boolean;
canDisconnect?: boolean;
busy?: boolean;
onReconnect: () => void;
onManage: () => void;
onDisconnect: () => void | Promise<unknown>;
}) {
const [confirming, setConfirming] = useState(false);
return <section className="space-y-4">
<h2 className="text-sm font-semibold">Connection settings</h2>
<div className="flex flex-wrap items-center gap-3">
<Button variant="outline" disabled={!canReconnect || busy} onClick={onReconnect}>Reconnect</Button>
<Button variant="outline" onClick={onManage}>Manage in {providerName}</Button>
{connected && canDisconnect && <AlertDialog open={confirming} onOpenChange={(open) => { if (!busy) setConfirming(open); }}>
<AlertDialogTrigger asChild><Button variant="ghost" className="text-destructive">Disconnect</Button></AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Disconnect {providerName}?</AlertDialogTitle>
<AlertDialogDescription>Delete this connection’s saved credentials and stop further calls through Paperclip. Other connections are unaffected. Actions already sent to the provider may still complete. Connecting again requires a new sign-in or key.</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={busy}>Cancel</AlertDialogCancel>
<AlertDialogAction disabled={busy} onClick={async (event) => {
event.preventDefault();
try { await onDisconnect(); setConfirming(false); } catch { /* The controller displays the failure. */ }
}}>{busy ? "Disconnecting…" : "Disconnect connection"}</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>}
</div>
</section>;
}
@@ -0,0 +1,133 @@
import { useEffect, useRef, useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { REMOTE_MCP_CONNECTOR_METHODS, type ToolConnection } from "@paperclipai/shared";
import { agentsApi } from "@/api/agents";
import { toolsApi } from "@/api/tools";
import { useCompany } from "@/context/CompanyContext";
import { useNavigate } from "@/lib/router";
import { resolveAuthorizationTarget } from "@/lib/authorizationUrl";
import { navigateTopLevel } from "@/lib/browserNavigation";
import { queryKeys } from "@/lib/queryKeys";
import { RemoteMcpConnectionSetup } from "./RemoteMcpConnectionSetup";
import { remoteMcpProviders, type RemoteMcpProviderId } from "./providers";
import type { RemoteMcpSetupActions, RemoteMcpSetupState } from "./types";
function readAccessDraft(key: string): Partial<RemoteMcpSetupState> {
try {
const value = JSON.parse(sessionStorage.getItem(key) ?? "null");
if (!value || !["organization", "user"].includes(value.grantKind) || typeof value.allAgents !== "boolean" || !Array.isArray(value.agentIds)) return {};
return { grantKind: value.grantKind, allAgents: value.allAgents, agentIds: value.agentIds.filter((id: unknown) => typeof id === "string") };
} catch { return {}; }
}
export function RemoteMcpProductionSetup({ providerId, connection }: {
providerId: RemoteMcpProviderId;
connection?: ToolConnection;
}) {
const provider = remoteMcpProviders[providerId];
const { selectedCompanyId } = useCompany();
const navigate = useNavigate();
const queries = useQueryClient();
const accessDraftKey = `paperclip:mcp-access-draft:${selectedCompanyId}:${providerId}`;
const savedConnection = useRef(connection);
const busy = useRef(false);
const authorizationUrl = useRef<string | undefined>(undefined);
const [state, setState] = useState<RemoteMcpSetupState>(() => ({
step: connection ? "connect" : "access", grantKind: connection?.credentialPolicy === "per_user" ? "user" : "organization",
setupComplete: Boolean(connection && connection.status !== "draft"),
url: typeof connection?.config?.url === "string" ? connection.config?.url : provider.defaultUrl,
auth: connection?.config?.mcpAuthMode === "bearer" ? "bearer" : connection?.authKind === "api_key" ? "headers" : provider.supportsBrowserAuth ? "auto" : "none",
token: "", headers: [], advanced: false, connectStatus: "idle", connected: false,
identity: null, allAgents: true, agentIds: [], permissions: {}, tools: [], notice: connection?.authKind === "api_key" ? "Saved credentials are retained when these fields are left blank. Enter a replacement only to change them." : null, refreshing: false,
...(!connection ? readAccessDraft(accessDraftKey) : {}),
}));
const installs = useQuery({ queryKey: queryKeys.tools.connectionInstalls(connection?.id ?? "__new__"), queryFn: () => toolsApi.getConnectionInstalls(connection!.id), enabled: !!connection });
const agents = useQuery({
queryKey: queryKeys.agents.list(selectedCompanyId!), queryFn: () => agentsApi.list(selectedCompanyId!), enabled: !!selectedCompanyId,
});
useEffect(() => {
if (installs.data) setState((s) => ({ ...s, allAgents: installs.data.installs.some((i) => i.targetType === "company"), agentIds: installs.data.installs.filter((i) => i.targetType === "agent").map((i) => i.targetId) }));
}, [installs.data]);
useEffect(() => {
if (connection) return;
try { sessionStorage.setItem(accessDraftKey, JSON.stringify({ grantKind: state.grantKind, allAgents: state.allAgents, agentIds: state.agentIds })); } catch { /* Storage can be disabled. */ }
}, [connection, accessDraftKey, state.grantKind, state.allAgents, state.agentIds]);
const edit = (patch: Partial<RemoteMcpSetupState>) => setState((s) => ({ ...s, ...patch }));
const finish = async (id: string) => {
try { sessionStorage.removeItem(accessDraftKey); } catch { /* Storage can be disabled. */ }
await queries.invalidateQueries({ queryKey: ["tools"] });
navigate(`/apps/${id}/permissions`);
};
const submit = async (saveDraft = false) => {
if (busy.current || !selectedCompanyId || (connection && !installs.data)) return;
try {
const url = new URL(state.url.trim());
if (!["http:", "https:"].includes(url.protocol)) throw new Error();
} catch { edit({ connectStatus: "invalid_url" }); return; }
busy.current = true;
edit({ connectStatus: "connecting", notice: null });
try {
const credentials: Record<string, string> = {};
if (state.auth === "bearer" && state.token.trim()) credentials["credentials.authorization"] = state.token.trim();
if (state.auth === "headers" || state.auth === "bearer") {
for (const header of state.headers) {
if (!header.name.trim() && !header.value) continue;
if (!header.name.trim() || !header.value) throw new Error("Enter both a name and value for each header.");
credentials[`headers.${header.name.trim()}`] = header.value;
}
}
const prior = savedConnection.current;
const result = await toolsApi.connectApp(selectedCompanyId, {
galleryKey: providerId, connectionMethodKey: REMOTE_MCP_CONNECTOR_METHODS[providerId],
name: provider.name, link: state.url.trim(), grantKind: state.grantKind,
authMode: state.auth === "headers" ? "custom_headers" : state.auth,
credentialValues: credentials, saveDraft,
...(prior ? prior.status === "draft" ? { resumeConnectionId: prior.id } : { reconnectConnectionId: prior.id } : {}),
});
savedConnection.current = result.connection;
// Persist the Access step before leaving for OAuth. Reconnect retains its existing installs.
if (!prior || prior.status === "draft") {
await toolsApi.putConnectionInstalls(result.connectionId, state.allAgents
? [{ targetType: "company", targetId: selectedCompanyId }]
: state.agentIds.map((targetId) => ({ targetType: "agent", targetId })));
}
if (saveDraft) { navigate("/apps"); return; }
if (result.auth?.kind === "oauth") {
const oauth = await toolsApi.startOAuth(result.connectionId, { asCurrentUser: state.grantKind === "user" });
const target = resolveAuthorizationTarget(oauth.authorizationUrl);
if (!target.ok) throw new Error(target.message);
authorizationUrl.current = target.url;
edit({ connectStatus: "sign_in", token: "", headers: [] });
navigateTopLevel(target.url);
return;
}
// The server retains existing permissions when reconnecting. Only a fresh setup enables everything.
{
await toolsApi.finishApp(selectedCompanyId, result.connectionId, {
enabledCatalogEntryIds: result.catalog.filter((tool) => tool.status === "active").map((tool) => tool.id),
askFirstCatalogEntryIds: [], access: state.allAgents ? "all_agents" : { agentIds: state.agentIds },
});
}
await finish(result.connectionId);
} catch (error) {
edit({ connectStatus: "idle", notice: error instanceof Error ? error.message : "Could not connect. Please try again." });
} finally { busy.current = false; }
};
const actions: RemoteMcpSetupActions = {
edit, navigate: (step) => edit({ step }), connect: () => { void submit(); },
cancelConnect: () => { if (!busy.current) edit({ connectStatus: "cancelled" }); },
openProvider: (purpose) => {
if (purpose === "sign_in" && authorizationUrl.current) navigateTopLevel(authorizationUrl.current);
else window.open(provider.setupUrl, "_blank", "noopener,noreferrer");
},
saveExit: () => {
if (busy.current) return;
if (state.url.trim()) void submit(true);
else navigate("/apps");
},
resumeDraft: () => edit({ step: "connect" }), finish: () => { if (savedConnection.current) void finish(savedConnection.current.id); },
refresh: () => {}, reconnect: () => edit({ step: "connect" }), disconnect: () => {},
};
if (connection && !installs.data) return <div className="space-y-3 p-8"><p>{installs.isError ? "Could not load saved access. Retry before changing this connection." : "Loading saved access…"}</p>{installs.isError && <button type="button" className="text-primary underline" onClick={() => void installs.refetch()}>Try again</button>}</div>;
return <RemoteMcpConnectionSetup provider={provider} connectionId={savedConnection.current?.id ?? ""} state={state} actions={actions} agents={agents.data ?? []} />;
}
@@ -0,0 +1,59 @@
/** Presentation metadata only. Each provider will have its own catalog entry and connection. */
export type RemoteMcpProviderId = "zapier" | "arcade" | "composio" | "executor";
export interface RemoteMcpProvider {
id: RemoteMcpProviderId;
name: string;
description: string;
instructions: string[];
setupUrl: string;
dashboardUrl: string;
defaultUrl: string;
placeholder: string;
urlHelp: string;
authHelp: string;
supportsBrowserAuth: boolean;
}
export const remoteMcpProviders: Record<RemoteMcpProviderId, RemoteMcpProvider> = {
zapier: {
id: "zapier", name: "Zapier", supportsBrowserAuth: false,
description: "Use the apps and actions on your Zapier MCP server.",
instructions: ["Create an MCP server in Zapier and choose Other as the client.", "Connect your apps and select the actions to expose.", "Open Connect, generate a token, and copy the full server URL."],
setupUrl: "https://docs.zapier.com/mcp/get-started/connect/other",
dashboardUrl: "https://mcp.zapier.com",
defaultUrl: "", placeholder: "Paste the full URL from Zapier",
urlHelp: "The full server URL can contain a secret token. Paste it exactly as Zapier provides it.",
authHelp: "For a separate token, use https://mcp.zapier.com/api/v1/connect and choose Bearer token.",
},
arcade: {
id: "arcade", name: "Arcade", supportsBrowserAuth: true,
description: "Use the tools exposed by your Arcade gateway.",
instructions: ["Create a gateway in Arcade and select its tools.", "Choose its User Source and copy the gateway URL.", "Paste the URL here, then sign in when prompted."],
setupUrl: "https://docs.arcade.dev/en/operate/governance/mcp-gateways",
dashboardUrl: "https://app.arcade.dev",
defaultUrl: "", placeholder: "https://api.arcade.dev/mcp/your-gateway",
urlHelp: "Copy the MCP gateway URL from Arcade. App authorization may be required the first time you use a tool.",
authHelp: "For Arcade Headers, use an API key as the bearer token and add the Arcade-User-ID header for the acting user.",
},
composio: {
id: "composio", name: "Composio", supportsBrowserAuth: true,
description: "Discover and use your apps through Composio Connect.",
instructions: ["Connect to Composio and sign in with your account.", "Connect underlying apps in Composio when prompted."],
setupUrl: "https://docs.composio.dev/docs/composio-connect",
dashboardUrl: "https://dashboard.composio.dev",
defaultUrl: "https://connect.composio.dev/mcp", placeholder: "https://connect.composio.dev/mcp",
urlHelp: "Composio Connect is prefilled. For an externally configured session, replace this with its MCP URL and add its supplied headers under Advanced authentication.",
authHelp: "Session URLs and headers come from your external Composio setup. Direct-tools sessions expose individual actions. Existing project API-key connections are managed separately.",
},
executor: {
id: "executor", name: "Executor", supportsBrowserAuth: true,
description: "Run tools through your Executor workspace.",
instructions: ["Connect your apps and configure action policies in Executor.", "Copy the MCP URL from your workspace’s connection instructions.", "Paste it here and sign in when prompted."],
setupUrl: "https://executor.sh/docs/mcp-proxy",
dashboardUrl: "https://executor.sh",
defaultUrl: "", placeholder: "Paste your Executor workspace MCP URL",
urlHelp: "Use the hosted workspace URL or a self-hosted HTTP endpoint reachable from Paperclip. The server’s endpoint policy also applies.",
authHelp: "Keep any options in the copied URL. If using an API key, use a user key; workspace and organization keys cannot open an MCP session.",
},
};
@@ -0,0 +1,42 @@
import type { ToolCatalogEntry } from "@paperclipai/shared";
export type ToolPermission = "allowed" | "ask_first" | "off";
export type SetupStep = "access" | "connect" | "permissions" | "management" | "draft";
export type ConnectStatus = "idle" | "connecting" | "sign_in" | "returned" | "cancelled" | "invalid_url" | "rejected" | "unreachable";
export interface RemoteMcpTool extends ToolCatalogEntry {
broad?: boolean;
}
export interface RemoteMcpSetupState {
step: SetupStep;
grantKind: "user" | "organization";
setupComplete: boolean;
url: string;
auth: "auto" | "bearer" | "headers" | "none";
token: string;
headers: { id: string; name: string; value: string }[];
advanced: boolean;
connectStatus: ConnectStatus;
connected: boolean;
identity: string | null;
allAgents: boolean;
agentIds: string[];
permissions: Record<string, ToolPermission>;
tools: RemoteMcpTool[];
notice: string | null;
refreshing: boolean;
}
export interface RemoteMcpSetupActions {
edit: (patch: Partial<RemoteMcpSetupState>) => void;
navigate: (step: "access" | "connect" | "permissions") => void;
connect: () => void;
cancelConnect: () => void;
openProvider: (purpose: "setup" | "manage" | "sign_in") => void;
saveExit: () => void;
resumeDraft: () => void;
finish: () => void;
refresh: () => void;
reconnect: () => void;
disconnect: () => void;
}