From b41ccf097f26a2d75e31200301c447e17daa31eb Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Wed, 23 Sep 2026 07:19:18 -0700 Subject: [PATCH] fix(apps): configure MCP aggregators from inline task cards (#13879) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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 --- doc/connections/CONNECTOR-PLAYBOOK.md | 8 ++ .../ConnectionIntentInteractionBody.test.tsx | 21 +++- .../ConnectionIntentInteractionBody.tsx | 3 +- .../connections/ConnectionSetupFlow.tsx | 34 +++++- .../remote-mcp/RemoteMcpConnectionSetup.tsx | 11 +- .../remote-mcp/RemoteMcpProductionSetup.tsx | 93 ++++++++++++--- .../connections/remote-mcp/providers.ts | 2 +- ui/src/pages/apps/AppsConnect.test.tsx | 109 ++++++++++++++++++ ui/src/pages/apps/AppsConnect.tsx | 51 +------- .../prototypes/RemoteMcpConnectionReview.tsx | 8 +- .../stories/ArcadeConnection.stories.tsx | 4 + .../stories/ComposioConnection.stories.tsx | 4 + .../stories/ExecutorConnection.stories.tsx | 4 + .../stories/ZapierConnection.stories.tsx | 4 + 14 files changed, 282 insertions(+), 74 deletions(-) diff --git a/doc/connections/CONNECTOR-PLAYBOOK.md b/doc/connections/CONNECTOR-PLAYBOOK.md index 99f4021340..5efd4cca05 100644 --- a/doc/connections/CONNECTOR-PLAYBOOK.md +++ b/doc/connections/CONNECTOR-PLAYBOOK.md @@ -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 diff --git a/ui/src/features/connections/ConnectionIntentInteractionBody.test.tsx b/ui/src/features/connections/ConnectionIntentInteractionBody.test.tsx index b97d789f0f..2687086f9c 100644 --- a/ui/src/features/connections/ConnectionIntentInteractionBody.test.tsx +++ b/ui/src/features/connections/ConnectionIntentInteractionBody.test.tsx @@ -44,7 +44,7 @@ vi.mock("./ConnectionSetupFlow", () => ({ requestedAgentId?: string; existingConnections?: ToolConnection[]; onUseExisting?: (id: string) => Promise; - 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 + @@ -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( diff --git a/ui/src/features/connections/ConnectionIntentInteractionBody.tsx b/ui/src/features/connections/ConnectionIntentInteractionBody.tsx index 7e344c73f7..86ab271129 100644 --- a/ui/src/features/connections/ConnectionIntentInteractionBody.tsx +++ b/ui/src/features/connections/ConnectionIntentInteractionBody.tsx @@ -383,7 +383,8 @@ export function ConnectionIntentInteractionBody({ { event.preventDefault(); focusTargetRef.current?.focus(); diff --git a/ui/src/features/connections/ConnectionSetupFlow.tsx b/ui/src/features/connections/ConnectionSetupFlow.tsx index c36b891a0f..c456e12a3b 100644 --- a/ui/src/features/connections/ConnectionSetupFlow.tsx +++ b/ui/src/features/connections/ConnectionSetupFlow.tsx @@ -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

Loading connection…

; + if (lookup && existing.isError) return

Could not load this connection. Your saved access and credentials have not changed.

; + if (!props.byoOnly && (props.credentialSource ?? "paperclip_vault") === "paperclip_vault" + && isRemoteMcpConnectorId(provider) && (!method || isRemoteMcpConnectorMethod(provider, method))) { + if (!aggregators.loaded) return

Loading connection settings…

; + if (!aggregators.enabled) return

Enable MCP aggregators in Settings → Experimental to set up this connection.

; + return ; + } + return ; +} + +function StandardConnectionSetupFlow({ byoOnly = false, credentialSource = "paperclip_vault", host = "page", diff --git a/ui/src/features/connections/remote-mcp/RemoteMcpConnectionSetup.tsx b/ui/src/features/connections/remote-mcp/RemoteMcpConnectionSetup.tsx index 4abd08da38..0b465f8afb 100644 --- a/ui/src/features/connections/remote-mcp/RemoteMcpConnectionSetup.tsx +++ b/ui/src/features/connections/remote-mcp/RemoteMcpConnectionSetup.tsx @@ -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
+ return
= 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
{s.notice &&

{s.notice}

} - {s.step === "access" && { if (grantKind !== "agent") change({ grantKind }); }} + {s.step === "access" && { 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
Complete sign-in in the provider window, then return here. Paperclip is waiting for confirmation.
-

If a window did not open, {external("sign_in", "open sign-in again")}.

+

If a window did not open, {authorizationUrl ? a.openProvider("sign_in")} target="_blank" rel="noopener noreferrer">open sign-in again : external("sign_in", "open sign-in again")}.

{footer(<>)} :
{ event.preventDefault(); a.connect(); }}> {error &&
{error.body}
} diff --git a/ui/src/features/connections/remote-mcp/RemoteMcpProductionSetup.tsx b/ui/src/features/connections/remote-mcp/RemoteMcpProductionSetup.tsx index 02cbc3ff3a..992f515bfa 100644 --- a/ui/src/features/connections/remote-mcp/RemoteMcpProductionSetup.tsx +++ b/ui/src/features/connections/remote-mcp/RemoteMcpProductionSetup.tsx @@ -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 { } 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(null); + useEffect(() => () => { + popup.current?.close(); + popup.current = null; + }, []); + const [showChoices, setShowChoices] = useState(!connection && !forceNewConnection && existingConnections.length > 0 && Boolean(onUseExisting)); + const [choicePending, setChoicePending] = useState(null); + const [choiceError, setChoiceError] = useState(null); const savedConnection = useRef(connection); const busy = useRef(false); const authorizationUrl = useRef(undefined); const [state, setState] = useState(() => ({ - 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) => 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
+

Connect {provider.name}

Use an existing connection or connect a new account. Existing access stays unchanged.

+ ({ 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 &&

{choiceError}

} +
+
; if (connection && !installs.data) return

{installs.isError ? "Could not load saved access. Retry before changing this connection." : "Loading saved access…"}

{installs.isError && }
; - return ; + return ; } diff --git a/ui/src/features/connections/remote-mcp/providers.ts b/ui/src/features/connections/remote-mcp/providers.ts index 300750ab96..283c55c373 100644 --- a/ui/src/features/connections/remote-mcp/providers.ts +++ b/ui/src/features/connections/remote-mcp/providers.ts @@ -49,7 +49,7 @@ export const remoteMcpProviders: Record 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", diff --git a/ui/src/pages/apps/AppsConnect.test.tsx b/ui/src/pages/apps/AppsConnect.test.tsx index 93e93966cc..31867dfb01 100644 --- a/ui/src/pages/apps/AppsConnect.test.tsx +++ b/ui/src/pages/apps/AppsConnect.test.tsx @@ -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, ); + 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('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("select")!; + auth.value = "bearer"; + auth.dispatchEvent(new Event("change", { bubbles: true })); + }); + const token = container.querySelector('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, ); + await passAccessStep(); + await act(async () => setInputValue(container.querySelector('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 = ; + const root = await render(undefined, false, content); + await passAccessStep(); + await act(async () => setInputValue(container.querySelector('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, ); + 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, ); const input = container.querySelector('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, ); const input = container.querySelector('input[aria-label="MCP server URL"]'); expect(input?.value).toBe("https://archive.example.test/mcp"); diff --git a/ui/src/pages/apps/AppsConnect.tsx b/ui/src/pages/apps/AppsConnect.tsx index 89df16d331..ee409f495b 100644 --- a/ui/src/pages/apps/AppsConnect.tsx +++ b/ui/src/pages/apps/AppsConnect.tsx @@ -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

Loading connection…

; - if (lookupExisting && existing.isError) return

Could not load this connection. Your saved access and credentials have not changed.

; - if (isRemoteMcpConnectorId(source) && (!method || isRemoteMcpConnectorMethod(source, method))) { - if (!aggregators.loaded) return

Loading connection settings…

; - if (!aggregators.enabled) return

Enable MCP aggregators in Settings → Experimental to set up this connection.

; - } - if (!byoOnly && !interactionId && credentialSource === "paperclip_vault" && isRemoteMcpConnectorId(source) - && (!method || isRemoteMcpConnectorMethod(source, method))) { - return ; - } - return ( - - ); + return ; } diff --git a/ui/storybook/prototypes/RemoteMcpConnectionReview.tsx b/ui/storybook/prototypes/RemoteMcpConnectionReview.tsx index 7e7fd705be..1616a975d5 100644 --- a/ui/storybook/prototypes/RemoteMcpConnectionReview.tsx +++ b/ui/storybook/prototypes/RemoteMcpConnectionReview.tsx @@ -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."); } }}>
Design review · {remoteMcpProviders[provider].name} — Example accounts and tools. No real sign-in, calls, or credential storage. Use fake values only.
- + {inline ? + Connect {remoteMcpProviders[provider].name} + + : }