fix(apps): configure MCP aggregators from inline task cards (#13879)

## Thinking Path

> - Paperclip lets people manage AI agents and their work.
> - Agents request connections through cards in task threads.
> - MCP aggregators need provider-specific URLs and authentication.
> - The task dialog used the generic setup form and omitted these
fields.
> - This pull request uses the same provider setup controller in tasks
and Apps.
> - Users can configure a connection without leaving the task.

## Linked Issues or Issue Description

Related: #13755, #13855.

**What happened?**

An inline Executor request opened a very wide dialog with an empty
credential step. Connect failed because the MCP URL was missing. The
other aggregator cards also bypassed their provider setup.

**Expected behavior**

Each card shows its provider instructions, URL field, and authentication
options in a bounded dialog. Completing setup grants access only to the
requesting agent.

**Steps to reproduce**

1. Enable experimental MCP aggregators.
2. Have an agent request Zapier, Arcade, Composio, or Executor from a
task.
3. Open the card and continue past Access.

## What Changed

- Route page and task setup through the same provider controller.
- Bound the task dialog width and preserve the requesting agent's access
scope.
- Support existing accounts, saved drafts, URL/token setup, and
task-bound OAuth.
- Keep a sign-in link available when the browser cannot open a popup.
Verify completion through the existing durable callback path.
- Add inline Access, configuration, and narrow Storybooks for all four
providers.
- Document the shared setup requirement and correct Executor's URL
instructions.

## Verification

- Focused Vitest selection: 24 passed. Covers all four inline forms,
requester access, existing accounts, saved drafts, OAuth retry, callback
validation, popup cleanup, and generic reconnect endpoint preservation.
- UI typecheck, UI build, design-token gates, and Storybook build
passed.
- Live local browser: new Executor, Arcade, and Composio connections
completed provider consent from task cards. Each appeared Connected with
the requester selected.
- Real Test calls returned Executor output `4`, an Arcade public GitHub
star count, and Composio tool-discovery results. An ungranted agent was
denied access. Real Paperclip process-agent runs discovered each
provider catalog with only the requester’s connection installed.
- Deployed implementation commit `5d722e89c` to the isolated staging
tenant. The original failing Executor card now completes, discovers
seven actions, limits access to its requesting agent, and resumes that
agent. Its continuation completed real Executor calls and the provider
resume flow, then returned an upstream Airtable authorization link. A
real staging Test call returned `4` in 1.5 seconds. Later PR commits add
regression coverage and popup-unmount cleanup.
- Zapier fresh-token browser test remains pending a provider clipboard
handoff. Its URL and token flow passes focused tests.
- Latest-head CI (`d14f4c73c`): 53 checks passed; optional Storybook
deployment and visual regression jobs skipped. Greptile 5/5, both review
threads resolved. Canceled runners and unrelated chat timeouts passed
the single retry on unchanged code.
- The full local test suite was not run, as requested by the maintainer.
CI runs the repository gates.

## Risks

- OAuth popup behavior differs by browser. The explicit sign-in link and
durable server completion checks provide recovery.
- Saved task drafts store only a connection ID in browser storage.
Credentials remain in the existing server vault.
- No database or server protocol changes.

## Model Used

OpenAI GPT-6 (`gpt-6-astra`) through Codex, with reasoning, code
execution, and browser tools. The exact context-window size is 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
#` 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-23 09:19:18 -05:00
1 parent 60c7c9cd1a
commit b41ccf097f
14 files changed
+282 -74

No files matched your search

+8
View File
@@ -13,6 +13,14 @@ classification, credential boundaries, and production validation requirements
are specified below so contributors can implement a connector without access
to an internal issue tracker.
Inline task connection cards must use the same provider setup controller and
fields as Apps: URL guidance, authentication options, validation, and recovery.
Keep dialogs bounded to the form width on wide screens and scrollable on narrow
screens. A task request locks agent access to its requester and returns to the
card after completion; reusing an account must preserve its existing access.
OAuth popups need a normal sign-in link fallback, and callback messages must be
verified against durable server state before accepting the request.
For chat and email setup, account linking, and ongoing configuration, also follow
[Chat connector UX](./CHAT-CONNECTOR-UX.md). It covers step navigation, footer
layout, credential instructions, provider handoffs, identity linking, optional
@@ -44,7 +44,7 @@ vi.mock("./ConnectionSetupFlow", () => ({
requestedAgentId?: string;
existingConnections?: ToolConnection[];
onUseExisting?: (id: string) => Promise<void>;
onComplete?: (completion: { connectionId: string }) => void;
onComplete?: (completion: { connectionId: string } | { resolvedByCallback: true }) => void;
onPhaseChange?: (phase: "needs_retry") => void;
onCancel?: () => void;
}) => (
@@ -66,6 +66,7 @@ vi.mock("./ConnectionSetupFlow", () => ({
>
Connect new
</button>
<button onClick={() => props.onComplete?.({ resolvedByCallback: true })}>Simulate OAuth callback</button>
<button onClick={() => props.onPhaseChange?.("needs_retry")}>
Simulate retry
</button>
@@ -218,6 +219,24 @@ describe("ConnectionIntentInteractionBody states and audience", () => {
});
describe("ConnectionIntentInteractionBody dialog behavior", () => {
it("verifies inline OAuth callback hints against durable server acceptance", async () => {
const options = { requestedAgentId: pendingConnectionIntentInteraction.payload.requestingAgentId, existingConnections: [], interaction: pendingConnectionIntentInteraction };
setupOptionsMock.mockResolvedValue(options);
renderBody();
await act(() => button("Connect / Use existing")?.click());
await flush();
const readsBeforeMessage = setupOptionsMock.mock.calls.length;
await act(() => button("Simulate OAuth callback")?.click());
await flush();
expect(setupOptionsMock.mock.calls.length).toBeGreaterThan(readsBeforeMessage);
expect(document.querySelector('[role="dialog"]')).not.toBeNull();
expect(completeMock).not.toHaveBeenCalled();
setupOptionsMock.mockResolvedValue({ ...options, interaction: connectedConnectionIntentInteraction });
await act(() => button("Simulate OAuth callback")?.click());
await waitForAssertion(() => expect(document.querySelector('[role="dialog"]')).toBeNull());
expect(completeMock).not.toHaveBeenCalled();
});
it("shows loading, then passes existing choices and the locked requesting agent to the shared flow", async () => {
let resolveSetup!: (value: unknown) => void;
setupOptionsMock.mockReturnValue(
@@ -383,7 +383,8 @@ export function ConnectionIntentInteractionBody({
</Button>
</DialogTrigger>
<DialogContent
className="!max-w-(--pct-90) max-h-(--sz-85vh) w-full overflow-y-auto sm:max-w-5xl"
className="max-h-(--sz-85vh) overflow-y-auto sm:max-w-3xl"
showCloseButton={false}
onCloseAutoFocus={(event) => {
event.preventDefault();
focusTargetRef.current?.focus();
@@ -1,3 +1,5 @@
import { RemoteMcpProductionSetup } from "./remote-mcp/RemoteMcpProductionSetup";
import { useMcpAggregatorsEnabled } from "@/hooks/useMcpAggregatorsEnabled";
import { AiConnectionCredentialStep } from "@/components/ai-connections/AiConnectionCredentialStep";
import { ConnectionChoiceList } from "./ConnectionChoiceList";
import { useCallback, useEffect, useId, useMemo, useRef, useState, type ReactNode, type Ref } from "react";
@@ -35,6 +37,7 @@ import type {
import {
aiConnectionMetadataSchema,
isRemoteMcpConnectorId,
isRemoteMcpConnectorMethod,
connectionMethodAcceptsCustomerOAuthClient,
connectionMethodRequiresConfiguration,
connectionMethodSupportsAutomaticOAuth,
@@ -529,7 +532,36 @@ export interface ConnectionSetupFlowProps {
* callbacks; provider fields, validation, OAuth, access, and finishing remain
* here so a provider can never drift between entry points.
*/
export function ConnectionSetupFlow({
export function ConnectionSetupFlow(props: ConnectionSetupFlowProps = {}) {
const [searchParams] = useSearchParams();
const params = useParams<{ appKey?: string }>();
const { selectedCompanyId } = useCompany();
const aggregators = useMcpAggregatorsEnabled();
const interactionId = props.interactionId || searchParams.get("intent") || undefined;
const source = props.serviceSlug || searchParams.get("source") || params.appKey || searchParams.get("appKey");
const draftId = useMemo(() => {
if (interactionId && isRemoteMcpConnectorId(source)) {
try { return sessionStorage.getItem(`paperclip:mcp-intent-draft:${selectedCompanyId}:${interactionId}`); } catch { /* Storage may be disabled. */ }
}
return null;
}, [interactionId, selectedCompanyId, source]);
const existingId = props.configuredConnection?.id || searchParams.get("resume") || searchParams.get("reconnect") || draftId;
const lookup = Boolean(existingId && (!source || isRemoteMcpConnectorId(source)));
const existing = useQuery({ queryKey: ["tools", "connection", existingId], queryFn: () => toolsApi.getConnection(existingId!), enabled: lookup });
const provider = source || existing.data?.config?.sourceTemplateKey;
const method = searchParams.get("method") || existing.data?.config?.connectionMethodKey;
if (lookup && existing.isPending) return <p className="p-6 text-sm text-muted-foreground">Loading connection…</p>;
if (lookup && existing.isError) return <div role="alert" className="space-y-3 p-6"><p>Could not load this connection. Your saved access and credentials have not changed.</p><Button variant="outline" onClick={() => void existing.refetch()}>Try again</Button></div>;
if (!props.byoOnly && (props.credentialSource ?? "paperclip_vault") === "paperclip_vault"
&& isRemoteMcpConnectorId(provider) && (!method || isRemoteMcpConnectorMethod(provider, method))) {
if (!aggregators.loaded) return <p className="p-6 text-sm text-muted-foreground">Loading connection settings…</p>;
if (!aggregators.enabled) return <p role="status" className="p-6 text-sm text-muted-foreground">Enable MCP aggregators in Settings → Experimental to set up this connection.</p>;
return <RemoteMcpProductionSetup key={`${interactionId || "page"}:${provider}`} {...props} interactionId={interactionId} providerId={provider} connection={existing.data} />;
}
return <StandardConnectionSetupFlow {...props} />;
}
function StandardConnectionSetupFlow({
byoOnly = false,
credentialSource = "paperclip_vault",
host = "page",
@@ -25,7 +25,10 @@ function FieldHelp({ label, children }: { label: string; children: ReactNode })
/** 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, fixedGrantKind }: {
export function RemoteMcpConnectionSetup({ provider, state: s, actions: a, agents, connectionId, fixedGrantKind, lockedAgentId, host = "page", authorizationUrl }: {
host?: "page" | "dialog";
lockedAgentId?: string;
authorizationUrl?: string;
provider: RemoteMcpProvider;
connectionId: string;
fixedGrantKind?: RemoteMcpSetupState["grantKind"];
@@ -55,7 +58,7 @@ export function RemoteMcpConnectionSetup({ provider, state: s, actions: a, agent
: 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}>
return <div className={host === "dialog" ? "min-w-0 text-foreground" : "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.`}
@@ -63,7 +66,7 @@ export function RemoteMcpConnectionSetup({ provider, state: s, actions: a, agent
<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" grantKinds={fixedGrantKind ? [fixedGrantKind] : undefined} grantKind={s.grantKind} setGrantKind={(grantKind) => { if (grantKind !== "agent") change({ grantKind }); }}
{s.step === "access" && <AccessStepContent agents={agents} lockedAgentId={lockedAgentId} authKind="oauth" grantKinds={fixedGrantKind ? [fixedGrantKind] : undefined} 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")} />}
@@ -88,7 +91,7 @@ export function RemoteMcpConnectionSetup({ provider, state: s, actions: a, agent
<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>
<p className="text-sm text-muted-foreground">If a window did not open, {authorizationUrl ? <a className="text-current underline" href={authorizationUrl} onClick={() => a.openProvider("sign_in")} target="_blank" rel="noopener noreferrer">open sign-in again</a> : 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>}
@@ -1,6 +1,9 @@
import { useEffect, useRef, useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { REMOTE_MCP_CONNECTOR_METHODS, type ToolConnection } from "@paperclipai/shared";
import { Button } from "@/components/ui/button";
import { ConnectionChoiceList } from "../ConnectionChoiceList";
import { readConnectionIntentOAuthOutcome, type ConnectionSetupFlowProps } from "../ConnectionSetupFlow";
import { agentsApi } from "@/api/agents";
import { toolsApi } from "@/api/tools";
import { useCompany } from "@/context/CompanyContext";
@@ -20,45 +23,71 @@ function readAccessDraft(key: string): Partial<RemoteMcpSetupState> {
} catch { return {}; }
}
export function RemoteMcpProductionSetup({ providerId, connection }: {
providerId: RemoteMcpProviderId;
connection?: ToolConnection;
}) {
export function RemoteMcpProductionSetup({ providerId, connection, host = "page", interactionId,
requestedAgentId, existingConnections = [], forceNewConnection, onUseExisting, onComplete, onCancel, onPhaseChange,
}: ConnectionSetupFlowProps & { providerId: RemoteMcpProviderId; connection?: ToolConnection }) {
const provider = remoteMcpProviders[providerId];
const { selectedCompanyId } = useCompany();
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const oauthOutcome = connection ? searchParams.get("oauth") : null;
const queries = useQueryClient();
const accessDraftKey = `paperclip:mcp-access-draft:${selectedCompanyId}:${providerId}`;
const accessDraftKey = `paperclip:mcp-access-draft:${selectedCompanyId}:${interactionId || providerId}`;
const intentDraftKey = `paperclip:mcp-intent-draft:${selectedCompanyId}:${interactionId}`;
const popup = useRef<Window | null>(null);
useEffect(() => () => {
popup.current?.close();
popup.current = null;
}, []);
const [showChoices, setShowChoices] = useState(!connection && !forceNewConnection && existingConnections.length > 0 && Boolean(onUseExisting));
const [choicePending, setChoicePending] = useState<string | null>(null);
const [choiceError, setChoiceError] = useState<string | null>(null);
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",
step: connection ? "connect" : "access", grantKind: connection ? connection.credentialPolicy === "per_user" ? "user" : "organization" : requestedAgentId ? "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: oauthOutcome === "denied" ? "cancelled" : oauthOutcome === "failed" ? "oauth_failed" : "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) : {}),
...(requestedAgentId ? { allAgents: false, agentIds: [requestedAgentId] } : {}),
}));
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]);
if (installs.data && !requestedAgentId) 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, requestedAgentId]);
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]);
useEffect(() => {
if (host !== "dialog" || !interactionId) return;
const receive = (event: MessageEvent) => {
const outcome = readConnectionIntentOAuthOutcome(event, window.location.origin, interactionId);
if (!outcome) return;
if (outcome === "connected") {
// The host verifies durable acceptance; a postMessage is never proof.
onComplete?.({ resolvedByCallback: true });
} else {
setState((s) => ({ ...s, connectStatus: outcome === "declined" ? "cancelled" : "oauth_failed" }));
onPhaseChange?.("needs_retry");
}
};
window.addEventListener("message", receive);
return () => window.removeEventListener("message", receive);
}, [host, interactionId, onComplete, onPhaseChange]);
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`);
if (onComplete) onComplete({ connectionId: id });
else navigate(`/apps/${id}/permissions`);
};
const submit = async (saveDraft = false) => {
if (busy.current || !selectedCompanyId || (connection && !installs.data)) return;
@@ -66,6 +95,11 @@ export function RemoteMcpProductionSetup({ providerId, connection }: {
const url = new URL(state.url.trim());
if (!["http:", "https:"].includes(url.protocol)) throw new Error();
} catch { edit({ connectStatus: "invalid_url" }); return; }
// Reserve the window while handling the click so popup blockers do not
// discard the later OAuth response. URL/token-only providers never need it.
if (!saveDraft && host === "dialog" && state.auth === "auto" && (!popup.current || popup.current.closed)) {
popup.current = window.open("about:blank", "paperclip-connection-oauth", "popup,width=720,height=760,resizable=yes,scrollbars=yes");
}
busy.current = true;
edit({ connectStatus: "connecting", notice: null });
try {
@@ -87,49 +121,74 @@ export function RemoteMcpProductionSetup({ providerId, connection }: {
...(prior ? prior.status === "draft" ? { resumeConnectionId: prior.id } : { reconnectConnectionId: prior.id } : {}),
});
savedConnection.current = result.connection;
if (interactionId) {
try { sessionStorage.setItem(intentDraftKey, result.connectionId); } catch { /* Storage may be disabled. */ }
}
// Persist the Access step before leaving for OAuth. Reconnect retains its existing installs.
if (!prior || prior.status === "draft") {
if (!requestedAgentId && (!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 (saveDraft) { if (onCancel) onCancel(); else navigate("/apps"); return; }
if (result.auth?.kind === "oauth") {
const oauth = await toolsApi.startOAuth(result.connectionId, { asCurrentUser: result.connection.credentialPolicy === "per_user" });
onPhaseChange?.("authorizing");
const oauth = await toolsApi.startOAuth(result.connectionId, { asCurrentUser: result.connection.credentialPolicy === "per_user", ...(interactionId ? { interactionId } : {}) });
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);
if (host === "dialog") {
if (popup.current && !popup.current.closed) {
popup.current.location.assign(target.url);
popup.current.focus();
}
} else navigateTopLevel(target.url);
return;
}
popup.current?.close();
popup.current = null;
// 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 },
askFirstCatalogEntryIds: [], access: requestedAgentId ? { agentIds: [requestedAgentId] } : state.allAgents ? "all_agents" : { agentIds: state.agentIds },
...(requestedAgentId ? { preserveExistingAccess: true } : {}),
});
}
await finish(result.connectionId);
} catch (error) {
popup.current?.close();
popup.current = null;
onPhaseChange?.("needs_retry");
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" }); },
cancelConnect: () => { if (!busy.current) { popup.current?.close(); popup.current = null; edit({ connectStatus: "cancelled" }); onPhaseChange?.("needs_retry"); } },
openProvider: (purpose) => {
if (purpose === "sign_in" && authorizationUrl.current) navigateTopLevel(authorizationUrl.current);
if (purpose === "sign_in" && authorizationUrl.current) { if (host === "dialog") { popup.current = null; edit({ connectStatus: "sign_in" }); } else navigateTopLevel(authorizationUrl.current); }
else window.open(provider.setupUrl, "_blank", "noopener,noreferrer");
},
saveExit: () => {
if (busy.current) return;
if (state.url.trim()) void submit(true);
else if (onCancel) onCancel();
else navigate("/apps");
},
resumeDraft: () => edit({ step: "connect" }), finish: () => { if (savedConnection.current) void finish(savedConnection.current.id); },
refresh: () => {}, reconnect: () => edit({ step: "connect" }), disconnect: () => {},
};
if (showChoices && onUseExisting) return <div className="space-y-5">
<div><h1 className="text-xl font-bold">Connect {provider.name}</h1><p className="mt-2 text-sm text-muted-foreground">Use an existing connection or connect a new account. Existing access stays unchanged.</p></div>
<ConnectionChoiceList choices={existingConnections.map((c) => ({ id: c.id, name: c.name, description: "Ready to use" }))} pendingId={choicePending} onSelect={(id) => {
setChoicePending(id); setChoiceError(null);
void onUseExisting(id).catch((error) => { setChoiceError(error instanceof Error ? error.message : "Could not use this connection."); setChoicePending(null); });
}} />
{choiceError && <p role="alert" className="text-sm text-destructive">{choiceError}</p>}
<div className="flex items-center justify-between gap-3"><Button variant="ghost" disabled={Boolean(choicePending)} onClick={onCancel}>Cancel</Button><Button disabled={Boolean(choicePending)} onClick={() => setShowChoices(false)}>Connect new</Button></div>
</div>;
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 ?? ""} fixedGrantKind={savedConnection.current ? savedConnection.current.credentialPolicy === "per_user" ? "user" : "organization" : undefined} state={state} actions={actions} agents={agents.data ?? []} />;
return <RemoteMcpConnectionSetup host={host} lockedAgentId={requestedAgentId} authorizationUrl={host === "dialog" ? authorizationUrl.current : undefined} provider={provider} connectionId={savedConnection.current?.id ?? ""} fixedGrantKind={savedConnection.current ? savedConnection.current.credentialPolicy === "per_user" ? "user" : "organization" : undefined} state={state} actions={actions} agents={agents.data ?? []} />;
}
@@ -49,7 +49,7 @@ export const remoteMcpProviders: Record<RemoteMcpProviderId, RemoteMcpProvider>
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."],
instructions: ["Connect your apps and configure action policies in Executor.", "Open Integrations and copy the URL under “Connect an agent”.", "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",
+109
View File
@@ -322,6 +322,113 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
return root;
}
it.each(["zapier", "arcade", "composio", "executor"])("inline aggregator %s collects the endpoint and completes only for the requester", async (provider) => {
experimentalMock.mockResolvedValue({ enableMcpAggregators: true });
const onComplete = vi.fn();
const popup = vi.spyOn(window, "open").mockReturnValue(null);
const connection = { id: "conn-inline", status: "draft", credentialPolicy: "per_user", authKind: "api_key" };
connectAppMock.mockResolvedValue({ connectionId: connection.id, connection, catalog: [{ id: "tool-1", status: "active" }] });
await render(undefined, false, <ConnectionSetupFlow host="dialog" serviceSlug={provider} requestedAgentId="agent-1" interactionId="intent-inline" onComplete={onComplete} />);
expect(container.textContent).toContain("This task grants access only to Ada");
expect(radioContaining("Any agent")).toBeUndefined();
await passAccessStep();
expect(container.textContent).toContain("MCP server URL");
expect(container.textContent).not.toContain("Add your key");
const url = container.querySelector<HTMLInputElement>('input[type="password"]')!;
expect(url.value).toBe(provider === "composio" ? "https://connect.composio.dev/mcp" : "");
expect(buttonByText("Connect")?.disabled).toBe(provider !== "composio");
await act(async () => setInputValue(url, "not a url"));
await act(async () => buttonByText("Connect")!.click());
expect(container.textContent).toContain("Enter a valid MCP URL");
expect(connectAppMock).not.toHaveBeenCalled();
await act(async () => {
setInputValue(url, "https://provider.example/mcp");
const auth = container.querySelector<HTMLSelectElement>("select")!;
auth.value = "bearer";
auth.dispatchEvent(new Event("change", { bubbles: true }));
});
const token = container.querySelector<HTMLInputElement>('input[id$="-token"]')!;
await act(async () => setInputValue(token, "fixture-token"));
await act(async () => buttonByText("Try again")!.click());
await vi.waitFor(() => expect(onComplete).toHaveBeenCalledWith({ connectionId: connection.id }));
expect(connectAppMock).toHaveBeenCalledWith("company-1", expect.objectContaining({ galleryKey: provider, link: "https://provider.example/mcp", grantKind: "user", authMode: "bearer", credentialValues: { "credentials.authorization": "fixture-token" } }));
expect(finishAppMock).toHaveBeenCalledWith("company-1", connection.id, { enabledCatalogEntryIds: ["tool-1"], askFirstCatalogEntryIds: [], access: { agentIds: ["agent-1"] }, preserveExistingAccess: true });
expect(putConnectionInstallsMock).not.toHaveBeenCalled();
expect(mockNavigate).not.toHaveBeenCalled();
expect(popup).not.toHaveBeenCalled();
});
it.each(["arcade", "composio", "executor"])("inline aggregator %s binds OAuth to the task and retries the same draft", async (provider) => {
experimentalMock.mockResolvedValue({ enableMcpAggregators: true });
const onComplete = vi.fn();
const onPhaseChange = vi.fn();
const popup = { closed: false, location: { assign: vi.fn() }, focus: vi.fn(), close: vi.fn() };
vi.spyOn(window, "open").mockReturnValue(popup as unknown as Window);
const connection = { id: "conn-inline-oauth", status: "draft", credentialPolicy: "per_user", authKind: "oauth" };
connectAppMock.mockResolvedValue({ connectionId: connection.id, connection, catalog: [], auth: { kind: "oauth" } });
const root = await render(undefined, false, <ConnectionSetupFlow host="dialog" serviceSlug={provider} requestedAgentId="agent-1" interactionId="intent-inline" onComplete={onComplete} onPhaseChange={onPhaseChange} />);
await passAccessStep();
await act(async () => setInputValue(container.querySelector<HTMLInputElement>('input[type="password"]')!, "https://provider.example/mcp"));
await act(async () => buttonByText("Connect")!.click());
await vi.waitFor(() => expect(startOAuthMock).toHaveBeenCalledWith(connection.id, { asCurrentUser: true, interactionId: "intent-inline" }));
expect(popup.location.assign).toHaveBeenCalled();
popup.closed = true;
expect(container.textContent).toContain("Paperclip is waiting for confirmation");
expect(container.querySelector('a[target="_blank"]')?.getAttribute("href")).toBe("https://mcp.notion.com/authorize?state=resumed");
expect(navigateTopLevelMock).not.toHaveBeenCalled();
expect(putConnectionInstallsMock).not.toHaveBeenCalled();
expect(finishAppMock).not.toHaveBeenCalled();
const message = (origin: string, interactionId: string, outcome: string) => window.dispatchEvent(new MessageEvent("message", { origin, data: { type: "paperclip.connection-intent.oauth", interactionId, outcome } }));
await act(async () => { message("https://untrusted.example", "intent-inline", "connected"); message(window.location.origin, "other-intent", "connected"); });
expect(onComplete).not.toHaveBeenCalled();
await act(async () => message(window.location.origin, "intent-inline", "failed"));
expect(container.textContent).toContain("Authorization did not complete");
await act(async () => buttonByText("Try again")!.click());
await vi.waitFor(() => expect(startOAuthMock).toHaveBeenCalledTimes(2));
expect(connectAppMock).toHaveBeenLastCalledWith("company-1", expect.objectContaining({ resumeConnectionId: connection.id }));
await act(async () => message(window.location.origin, "intent-inline", "connected"));
expect(onComplete).toHaveBeenCalledWith({ resolvedByCallback: true });
await act(async () => root.unmount());
mountedRoot = null;
expect(popup.close).toHaveBeenCalled();
});
it("inline aggregator saves and resumes a draft without storing credentials in browser storage", async () => {
experimentalMock.mockResolvedValue({ enableMcpAggregators: true });
const onCancel = vi.fn();
const connection = { id: "conn-inline-draft", status: "draft", credentialPolicy: "per_user", authKind: "none", config: { url: "https://provider.example/mcp", sourceTemplateKey: "zapier", connectionMethodKey: "generated-url" } };
connectAppMock.mockResolvedValue({ connectionId: connection.id, connection, catalog: [] });
getConnectionMock.mockResolvedValue(connection);
const content = <ConnectionSetupFlow host="dialog" serviceSlug="zapier" requestedAgentId="agent-1" interactionId="intent-inline" onCancel={onCancel} />;
const root = await render(undefined, false, content);
await passAccessStep();
await act(async () => setInputValue(container.querySelector<HTMLInputElement>('input[type="password"]')!, "https://provider.example/mcp?token=fixture-secret"));
await act(async () => buttonByText("Save & exit")!.click());
await vi.waitFor(() => expect(onCancel).toHaveBeenCalled());
expect(connectAppMock).toHaveBeenCalledWith("company-1", expect.objectContaining({ saveDraft: true }));
expect(sessionStorage.getItem("paperclip:mcp-intent-draft:company-1:intent-inline")).toBe(connection.id);
expect(JSON.stringify(sessionStorage)).not.toContain("fixture-secret");
expect(finishAppMock).not.toHaveBeenCalled();
expect(putConnectionInstallsMock).not.toHaveBeenCalled();
await act(async () => root.unmount());
mountedRoot = null;
await render(undefined, false, content);
await vi.waitFor(() => expect(container.textContent).toContain("MCP server URL"));
expect(getConnectionMock).toHaveBeenCalledWith(connection.id);
expect(container.textContent).toContain("Step 2 of 2");
});
it("inline aggregator reuses an eligible account without changing its access", async () => {
experimentalMock.mockResolvedValue({ enableMcpAggregators: true });
const onUseExisting = vi.fn().mockResolvedValue(undefined);
await render(undefined, false, <ConnectionSetupFlow host="dialog" serviceSlug="composio" requestedAgentId="agent-1" interactionId="intent-inline" existingConnections={[{ id: "existing", applicationId: "app", name: "Existing Composio", status: "active", enabled: true }]} onUseExisting={onUseExisting} />);
await act(async () => buttonContaining("Existing Composio")!.click());
expect(onUseExisting).toHaveBeenCalledWith("existing");
expect(connectAppMock).not.toHaveBeenCalled();
expect(putConnectionInstallsMock).not.toHaveBeenCalled();
expect(finishAppMock).not.toHaveBeenCalled();
});
it.each(["zapier", "arcade", "composio", "executor"])("blocks direct %s setup while MCP aggregators are off", async (provider) => {
mockSearch.value = `source=${provider}`;
await render();
@@ -402,6 +509,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
config: source === "config-url" ? { url: endpoint } : {},
transportConfig: source === "transport-url" ? { url: endpoint } : source === "transport-serverUrl" ? { serverUrl: endpoint } : {},
}] });
getConnectionMock.mockImplementation(async () => (await listConnectionsMock()).connections[0]);
await render(undefined, false, <ConnectionSetupFlow host="dialog" configuredConnection={choice} requestedAgentId="agent-1" />);
const input = container.querySelector<HTMLInputElement>('input[aria-label="MCP server URL"]');
expect(input?.value).toBe(endpoint);
@@ -421,6 +529,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
listApplicationsMock.mockResolvedValue({ applications: [{ id: choice.applicationId, name: "Archive", applicationKey: "archive", type: "mcp_http" }] });
listConnectionsMock.mockResolvedValue({ connections: [{ ...choice, companyId: "company-1", transport: "mcp_remote", authKind: "none", credentialPolicy: "shared", credentialSource: "paperclip_vault", config: { url: "https://archive.example.test/mcp" } }] });
getConnectionMock.mockImplementation(async () => (await listConnectionsMock()).connections[0]);
await render(client, false, <ConnectionSetupFlow host="dialog" configuredConnection={choice} requestedAgentId="agent-1" />);
const input = container.querySelector<HTMLInputElement>('input[aria-label="MCP server URL"]');
expect(input?.value).toBe("https://archive.example.test/mcp");
+4 -47
View File
@@ -1,55 +1,12 @@
import { ConnectionSetupFlow } from "@/features/connections/ConnectionSetupFlow";
import { useQuery } from "@tanstack/react-query";
import { toolsApi } from "@/api/tools";
import { RemoteMcpProductionSetup } from "@/features/connections/remote-mcp/RemoteMcpProductionSetup";
import { useMcpAggregatorsEnabled } from "@/hooks/useMcpAggregatorsEnabled";
import { isRemoteMcpConnectorId, isRemoteMcpConnectorMethod } from "@paperclipai/shared";
import { useParams } from "@/lib/router";
import { useSearchParams } from "@/lib/router";
import type { ToolConnectionCredentialSource } from "@paperclipai/shared";
export {
AccessStep,
OAuthConnectStateScreen,
type OAuthConnectPhase,
} from "@/features/connections/ConnectionSetupFlow";
export { AccessStep, OAuthConnectStateScreen, type OAuthConnectPhase } from "@/features/connections/ConnectionSetupFlow";
/** Full-page host for the shared connection setup implementation. */
export function AppsConnect({
byoOnly = false,
credentialSource = "paperclip_vault",
}: {
/** Full-page host for the same setup used by inline connection requests. */
export function AppsConnect({ byoOnly = false, credentialSource = "paperclip_vault" }: {
byoOnly?: boolean;
credentialSource?: ToolConnectionCredentialSource;
} = {}) {
const [searchParams] = useSearchParams();
const aggregators = useMcpAggregatorsEnabled();
const interactionId = searchParams.get("intent")?.trim() || undefined;
const params = useParams<{ appKey?: string }>();
const existingId = searchParams.get("resume") || searchParams.get("reconnect");
const explicitSource = searchParams.get("source") || params.appKey || searchParams.get("appKey");
// Ordinary connectors own their existing recovery/loading path. Only load
// here when selecting an aggregator controller, or resolving an unknown source.
const lookupExisting = !!existingId && (!explicitSource || isRemoteMcpConnectorId(explicitSource));
const existing = useQuery({ queryKey: ["tools", "connection", existingId], queryFn: () => toolsApi.getConnection(existingId!), enabled: lookupExisting });
const source = explicitSource || existing.data?.config?.sourceTemplateKey;
const method = searchParams.get("method") || existing.data?.config?.connectionMethodKey;
if (lookupExisting && existing.isPending) return <p className="p-8 text-sm text-muted-foreground">Loading connection…</p>;
if (lookupExisting && existing.isError) return <div role="alert" className="space-y-3 p-8"><p>Could not load this connection. Your saved access and credentials have not changed.</p><button type="button" className="text-primary underline" onClick={() => void existing.refetch()}>Try again</button></div>;
if (isRemoteMcpConnectorId(source) && (!method || isRemoteMcpConnectorMethod(source, method))) {
if (!aggregators.loaded) return <p className="p-8 text-sm text-muted-foreground">Loading connection settings…</p>;
if (!aggregators.enabled) return <p role="status" className="p-8 text-sm text-muted-foreground">Enable MCP aggregators in Settings → Experimental to set up this connection.</p>;
}
if (!byoOnly && !interactionId && credentialSource === "paperclip_vault" && isRemoteMcpConnectorId(source)
&& (!method || isRemoteMcpConnectorMethod(source, method))) {
return <RemoteMcpProductionSetup key={existingId || source} providerId={source} connection={existing.data} />;
}
return (
<ConnectionSetupFlow
byoOnly={byoOnly}
credentialSource={credentialSource}
host="page"
interactionId={interactionId}
/>
);
return <ConnectionSetupFlow byoOnly={byoOnly} credentialSource={credentialSource} host="page" />;
}
@@ -1,3 +1,4 @@
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
import { useEffect, useMemo, useRef, useState } from "react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { ToolConnectionAccessSummary, ToolConnectionTestAgent } from "@paperclipai/shared";
@@ -10,7 +11,7 @@ import { exampleUrl, fixtureTools, initialReviewState, newFixtureTool, reviewAge
/** Provider responses are simulated in memory. The saved connection uses the real
* Permissions action list and its real Test dialog, with scoped API fixtures. */
export function RemoteMcpConnectionReview({ provider, scenario = "journey" }: { provider: RemoteMcpProviderId; scenario?: ReviewScenario }) {
export function RemoteMcpConnectionReview({ provider, scenario = "journey", inline = false }: { provider: RemoteMcpProviderId; scenario?: ReviewScenario; inline?: boolean }) {
const connectionId = `review-${provider}`;
const client = useMemo(() => new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: Infinity } } }), [provider, scenario]);
const [s, setState] = useState(() => initialReviewState(provider, scenario));
@@ -120,7 +121,10 @@ export function RemoteMcpConnectionReview({ provider, scenario = "journey" }: {
if (anchor?.href.includes("provider.example.invalid")) { event.preventDefault(); event.stopPropagation(); setExternal("Provider authorization simulated. No external request was made."); }
}}>
<div className="border-b border-border bg-muted px-4 py-3 text-sm" role="note"><strong>Design review · {remoteMcpProviders[provider].name}</strong><span className="text-muted-foreground"> — Example accounts and tools. No real sign-in, calls, or credential storage. Use fake values only.</span></div>
<RemoteMcpConnectionSetup connectionId={connectionId} provider={remoteMcpProviders[provider]} state={s} actions={actions} agents={reviewAgents} />
{inline ? <Dialog defaultOpen><DialogContent className="max-h-(--sz-85vh) overflow-y-auto sm:max-w-3xl" showCloseButton={false} aria-describedby={undefined}>
<DialogTitle className="sr-only">Connect {remoteMcpProviders[provider].name}</DialogTitle>
<RemoteMcpConnectionSetup host="dialog" lockedAgentId="researcher" connectionId={connectionId} provider={remoteMcpProviders[provider]} state={{ ...s, allAgents: false, agentIds: ["researcher"] }} actions={actions} agents={reviewAgents} />
</DialogContent></Dialog> : <RemoteMcpConnectionSetup connectionId={connectionId} provider={remoteMcpProviders[provider]} state={s} actions={actions} agents={reviewAgents} />}
<aside aria-label="Storybook simulation" className="mx-auto max-w-6xl space-y-4 border-t border-border p-4 sm:p-8">
<p className="text-xs font-semibold text-muted-foreground">STORYBOOK SIMULATION</p>
<div className="flex flex-wrap items-end gap-4 text-sm">
@@ -41,3 +41,7 @@ export const ProviderHandoffAfterSetup: Story = { args: { scenario: "provider_pe
const dialog = await within(document.body).findByRole("dialog");
await userEvent.click(within(dialog).getByRole("button", { name: "Run" }));
} };
export const InlineCardAccess: Story = { args: { scenario: "initial", inline: true } };
export const InlineCardConfiguration: Story = { args: { scenario: "connect", inline: true } };
export const NarrowInlineCard: Story = { args: { scenario: "connect", inline: true }, globals: { viewport: { value: "mobile", isRotated: false } } };
@@ -42,3 +42,7 @@ export const ProviderHandoffAfterSetup: Story = { args: { scenario: "provider_pe
const dialog = await within(document.body).findByRole("dialog");
await userEvent.click(within(dialog).getByRole("button", { name: "Run" }));
} };
export const InlineCardAccess: Story = { args: { scenario: "initial", inline: true } };
export const InlineCardConfiguration: Story = { args: { scenario: "connect", inline: true } };
export const NarrowInlineCard: Story = { args: { scenario: "connect", inline: true }, globals: { viewport: { value: "mobile", isRotated: false } } };
@@ -41,3 +41,7 @@ export const ProviderHandoffAfterSetup: Story = { args: { scenario: "provider_pe
const dialog = await within(document.body).findByRole("dialog");
await userEvent.click(within(dialog).getByRole("button", { name: "Run" }));
} };
export const InlineCardAccess: Story = { args: { scenario: "initial", inline: true } };
export const InlineCardConfiguration: Story = { args: { scenario: "connect", inline: true } };
export const NarrowInlineCard: Story = { args: { scenario: "connect", inline: true }, globals: { viewport: { value: "mobile", isRotated: false } } };
@@ -30,3 +30,7 @@ export const NarrowSetup: Story = { args: { scenario: "initial" }, globals: { vi
export const NarrowToolManagement: Story = { args: { scenario: "permissions" }, globals: { viewport: { value: "mobile", isRotated: false } } };
export const NarrowConnectionDetails: Story = { args: { scenario: "connect" }, globals: { viewport: { value: "mobile", isRotated: false } } };
export const InlineCardAccess: Story = { args: { scenario: "initial", inline: true } };
export const InlineCardConfiguration: Story = { args: { scenario: "connect", inline: true } };
export const NarrowInlineCard: Story = { args: { scenario: "connect", inline: true }, globals: { viewport: { value: "mobile", isRotated: false } } };