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} + + : }