import { useEffect, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Globe, Paperclip } from "lucide-react"; import { Link, useParams } from "@/lib/router"; import type { McpConnection, McpConnectionRequest, McpDotPairingPreview } from "@paperclipai/shared"; import { deriveInitials, Identity } from "@/components/Identity"; import { assistantConnectionDisplayName } from "./apps/connection-owner"; import { QueryErrorState, useQueryView } from "@/components/QueryView"; import { Button } from "@/components/ui/button"; import { Card } from "@/components/ui/card"; import { Checkbox } from "@/components/ui/checkbox"; import { Input } from "@/components/ui/input"; import { CompanyPatternIcon } from "@/components/CompanyPatternIcon"; import { useResilientFetch } from "@/hooks/useResilientFetch"; import { api } from "../api/client"; import { describeError } from "../api/errors"; const PAIRING_CODE_PATTERN = /^[A-Za-z0-9_-]{32}$/; // Bundle known-origin icons so opening consent never contacts a client-selected site. // Client-supplied names never select branding. function ClientOrigin({ origin }: { origin: string }) { const [failed, setFailed] = useState(false); let favicon: string | undefined; try { const url = new URL(origin); if (url.origin === "https://claude.ai") favicon = "/brands/claude-color.svg"; else if (["https://chatgpt.com", "https://chat.openai.com", "https://openai.com"].includes(url.origin)) favicon = "/brands/codex-color.svg"; } catch { /* An unavailable origin keeps the neutral site icon. */ } return
{favicon && !failed ? setFailed(true)} /> :
; } export function McpConnectPage({ agentPairingOnly = false }: { agentPairingOnly?: boolean } = {}) { const { id = "" } = useParams(); return ; } export function McpDevicePage({ initialCode }: { initialCode?: string } = {}) { const [code, setCode] = useState(initialCode ?? new URLSearchParams(window.location.search).get("user_code") ?? ""); const [submitted, setSubmitted] = useState(code); if (submitted) return setSubmitted("")} />; return

Connect your assistant to Paperclip

{ event.preventDefault(); setSubmitted(code.trim()); }}> setCode(event.target.value)} required maxLength={12} />
; } function McpConnectRequest({ id, device = false, agentPairingOnly = false, onEditCode }: { id: string; device?: boolean; agentPairingOnly?: boolean; onEditCode?: () => void }) { const requestPrefix = agentPairingOnly ? "/dot-mcp/requests" : "/mcp/requests"; const [companyId, setCompanyId] = useState(""); const [pairingCode, setPairingCode] = useState(""); const [writeEnabled, setWriteEnabled] = useState(true); const [deviceResult, setDeviceResult] = useState<"approved" | "denied" | null>(null); const request = useQuery({ queryKey: [device ? "mcp-device" : agentPairingOnly ? "dot-mcp-request" : "mcp-request", id], queryFn: () => api.get(device ? `/mcp/device?user_code=${encodeURIComponent(id)}` : `${requestPrefix}/${encodeURIComponent(id)}`) }); const requestView = useQueryView(request); const data = request.data; const trimmedPairingCode = pairingCode.trim(); const previewEnabled = Boolean(data?.agentConnection) && !device && PAIRING_CODE_PATTERN.test(trimmedPairingCode); // A read-only preview behind a POST. The code is part of the key so an older // preview is dropped the moment the code changes and a late response for an // old code is ignored; it stays in this component's memory, never in the // shared query cache. const preview = useResilientFetch({ key: ["dot-pairing-preview", requestPrefix, id, trimmedPairingCode], enabled: previewEnabled, fetcher: (signal) => api.post(`${requestPrefix}/${encodeURIComponent(id)}/dot-pairing/preview`, { pairingCode: trimmedPairingCode }, { signal }), }); const pairingPreview = previewEnabled && (preview.view.kind === "ready" || preview.view.kind === "stale") ? preview.data ?? null : null; const previewPending = previewEnabled && (preview.view.kind === "loading" || preview.view.kind === "reconnecting"); const selectedCompanyId = data?.requestedCompanyId ?? (companyId || data?.companies[0]?.id || ""); // Pin the default once loaded so a refetch cannot silently switch organizations. useEffect(() => { if (!companyId && !data?.requestedCompanyId && data?.companies[0]) setCompanyId(data.companies[0].id); }, [companyId, data]); const clientName = data?.clientName.trim(); const assistantName = clientName && !/^(assistant|mcp client)$/i.test(clientName) ? clientName : "your assistant"; const clientOrigin = data?.clientOrigin || data?.redirectOrigin; const company = data?.companies.find((item) => item.id === selectedCompanyId); const canApproveWrites = Boolean(!data?.agentConnection && company?.canWrite && writeEnabled); const allowWrites = Boolean(data?.requestedWrite && canApproveWrites); const allowConfiguration = Boolean(data?.requestedConfigure && canApproveWrites); const consent = useMutation({ mutationFn: (decision: "approve" | "deny") => api.post<{ redirectUrl?: string; status?: "approved" | "denied" }>(device ? "/mcp/device/consent" : `/mcp/requests/${encodeURIComponent(id)}/consent`, { decision, companyId: selectedCompanyId || undefined, allowWrites: decision === "approve" && allowWrites, allowConfiguration: decision === "approve" && allowConfiguration, ...(device ? { userCode: id } : {}) }), onSuccess: ({ redirectUrl, status }) => { if (device && status) setDeviceResult(status); else if (redirectUrl) window.location.assign(redirectUrl); }, }); const pairDot = useMutation({ mutationFn: () => api.post<{ redirectUrl: string }>(`${requestPrefix}/${encodeURIComponent(id)}/dot-pairing`, { pairingCode: pairingCode.trim() }), onSuccess: ({ redirectUrl }) => { setPairingCode(""); window.location.assign(redirectUrl); }, }); if (deviceResult) return

{deviceResult === "approved" ? "Access approved" : "Connection declined"}

{deviceResult === "approved" ? "Return to your assistant. It will finish connecting automatically." : "No access was granted. You can start a new connection from your assistant."}

; const returnPath = device ? `/mcp-device?user_code=${encodeURIComponent(id)}` : `${agentPairingOnly ? "/dot-connect" : "/mcp-connect"}/${id}`; return

Connect {assistantName} to Paperclip

{clientOrigin && } {!device && data?.redirectOrigin && data.redirectOrigin !== clientOrigin && } {device &&

Confirm this matches the code shown by your assistant: {id.toUpperCase()}

} {(requestView.kind === "loading" || requestView.kind === "reconnecting") &&

Loading connection request…

} {requestView.kind === "error" && } {requestView.kind === "error" &&

Start a new connection from your assistant.

} {device && requestView.kind === "error" && } {data?.agentConnection && !device &&
{ event.preventDefault(); pairDot.mutate(); }}>

Enter the one-use code from Paperclip’s agent setup to review this connection’s access. Keep the code out of Bot chat. Access is granted only when you approve the named agent below.

setPairingCode(event.target.value)} required maxLength={32} /> {previewPending &&

Checking pairing code…

} {pairingPreview?.providerKind === "grok_bot" &&

Allow this connection to work as {pairingPreview.agent.name} in {pairingPreview.company.name}.

} {pairingPreview &&
Company
{pairingPreview.company.name} ({pairingPreview.company.id})
Agent
{pairingPreview.agent.name} ({pairingPreview.agent.id})
Permissions
{pairingPreview.permissions}
{pairingPreview.authorizingPerson &&
Authorized by
{pairingPreview.authorizingPerson}
}
Access duration
{pairingPreview.accessDuration}
} {previewEnabled && preview.view.kind === "error" && } {pairDot.error &&

{describeError(pairDot.error, { action: "connect Dot" }).body}

}
} {data && <> {data.requiresSignIn ? : <> {data.requestedCompanyId ?
{company && }

Organization

{company ?

{company.name}

:

The selected organization is no longer available to this account. Cancel and reconnect from your assistant to choose an organization you can access.

}
:
Organization {data.companies.map((item) => )} {!data.companies.length &&

This account has no available organizations. Ask an organization owner to add you, then reconnect from your assistant.

}
}

{data.agentConnection ? "Connect Dot as a Paperclip agent. Pair it with an agent after connecting; assigned work uses that agent’s permissions." : "Read all of your Paperclip data"}

{(data.requestedWrite || data.requestedConfigure) && !data.agentConnection && } {company && !company.canWrite &&

Your role in this organization is read-only.

} {consent.error &&

{describeError(consent.error).body}

}
} }
; } export function AssistantConnectionsPage() { const client = useQueryClient(); const connections = useQuery({ queryKey: ["mcp-connections"], queryFn: () => api.get("/mcp/connections") }); const connectionsView = useQueryView(connections); const active = connections.data?.filter(connection => !connection.revokedAt); const revoke = useMutation({ mutationFn: (id: string) => api.delete(`/mcp/connections/${id}`), onSuccess: (_, id) => { client.setQueryData(["mcp-connections"], rows => rows?.filter(row => row.id !== id)); return client.invalidateQueries({ queryKey: ["mcp-connections"] }); } }); return

Assistant connections

Revoking a connection stops its future tool calls. Work already delegated continues under your organization’s normal controls.

{(connectionsView.kind === "loading" || connectionsView.kind === "reconnecting") &&

Loading connections…

} {connectionsView.kind === "error" && } {revoke.error &&

{describeError(revoke.error, { action: "revoke the connection" }).body}

} {active?.length === 0 &&

No assistant connections.

} {active?.map((connection) =>

Organization: {connection.companyName}

{connection.agent &&

Works as {connection.agent.name}{connection.user ? ` · Authorized by ${connection.user.name}` : ""}

}

{connection.scopes.includes("paperclip:agent") ? "Agent connection" : connection.scopes.includes("paperclip:write") ? "Read and edit work" : "Read only"}

{connection.scopes.includes("paperclip:configure") &&

Configure agents, projects and skills

}
)} Back to Paperclip
; }