feat(mcp): start assistant setup from Connections

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-10-05 16:18:27 -05:00
1 parent e75317fd2e
commit baeb1ffe2e
15 files changed
+373 -231

No files matched your search

@@ -0,0 +1,21 @@
# Assistant connection entry and Butter walkthrough
Start inside the user’s Paperclip organization. Connections must explain how to
connect an outside assistant to this instance as the current person, with no
agent selection.
1. Add Assistant Connection (MCP) to the existing Connectors catalog.
2. Keep setup inside the app shell. Show the organization, experimental enable
path, canonical server URL, and exact client setup/authentication instructions.
3. Explain that the assistant’s sign-in action opens browser consent. Show only
real authorized connections, scoped to the current person and organization.
4. Cover disabled, enabled, failure, connection refresh, and revocation states
with focused tests and production-page stories. Preserve OAuth enforcement.
5. Deploy the revised preview only to Butter staging. Walk from its dashboard
through Connections, OpenCode configuration, browser consent, and useful work.
6. Capture the actual screens and rebuild the portable grid/keyboard gallery.
Clearly label any incomplete step; never substitute a mock result for live proof.
Acceptance: a user can discover the feature, understand which identity connects,
reach consent using the displayed instructions, and return to inspect/revoke the
connection. Enabling the experiment or opening setup alone grants no access.
+29
View File
@@ -38,6 +38,35 @@ token again; normal API endpoints do not accept these OAuth tokens. Public
installation requires a reachable HTTPS deployment. Private self-hosted
instances require a directly reachable endpoint; no managed relay is included.
## Start from Connections
Inside your organization, open **Connectors → Assistant Connection (MCP) → Set up**.
This connects an outside assistant to Paperclip using your human account; no agent
is selected or impersonated. The entry is discoverable while disabled, but its
setup instructions require the experimental setting. Follow **Open Experimental
settings**, enable **Assistant connections (MCP)**, then return to setup.
Choose Codex, Claude Code, OpenCode, or Other. The page supplies the canonical
instance URL and the client’s setup and sign-in commands. Run the sign-in action
in that client to open Paperclip’s browser consent page. A consent URL is generated
for that OAuth attempt; there is no reusable consent link to copy from Paperclip.
For OpenCode, merge the displayed `mcp.paperclip` entry into your project’s
`opencode.json`, run `opencode mcp auth paperclip` there, approve the organization,
then start `opencode web`. Restart an already-running OpenCode after authentication.
Never add upstream model keys to the MCP URL or config; the assistant’s model
connection is configured separately.
Return to the same Connections entry to see your connected assistants, their
read/write access, and revoke access. The list refreshes after consent and only
shows your grants for the selected organization. The legacy
`/assistant-connections` URL remains available for account-wide management.
`GET /api/mcp/setup` returns the live experimental gate and canonical endpoint to
authenticated browser/Cloud users, including while disabled. It cannot grant
access, accepts no destination URL, never reflects forwarded hosts, and is not
available to agent keys, MCP bearer tokens, or implicit local authority.
## Connect an assistant directly
After the instance is enabled and reachable, an existing team member can connect
+5
View File
@@ -21,6 +21,11 @@ export interface McpConnectionRequest {
setupUrl: string | null;
}
export interface McpConnectionSetup {
enabled: boolean;
serverUrl: string;
}
export interface McpConnection {
id: string;
companyId: string;
+25
View File
@@ -168,14 +168,39 @@ describe.skipIf(!support.supported)("public MCP OAuth and tool boundary", () =>
await expect(oauth.token({ grant_type: "refresh_token", client_id: f.client.client_id, resource: config.resource, refresh_token: f.tokens.refresh_token })).rejects.toMatchObject({ status: 503 });
await expect(execute(f.tokens.access_token, "paperclip_list_agents", { companyId: f.company.id })).rejects.toMatchObject({ status: 503 });
expect(dispatch).not.toHaveBeenCalled();
const setup = await request(app).get("/api/mcp/setup").set("X-Forwarded-Host", "attacker.example");
expect(setup.status).toBe(200);
expect(setup.headers["cache-control"]).toBe("no-store");
expect(setup.body).toEqual({ enabled: false, serverUrl: config.resource });
expect((await request(app).get("/api/mcp/connections")).status).toBe(200);
expect((await request(app).post("/mcp/oauth/revoke").send({ token: revocable.tokens.access_token, client_id: revocable.client.client_id })).status).toBe(200);
} finally { await settings.updateExperimental({ enablePublicMcp: true }); }
expect((await request(app).get("/api/mcp/setup")).body).toEqual({ enabled: true, serverUrl: config.resource });
expect((await request(app).get("/.well-known/oauth-authorization-server")).status).toBe(200);
await expect(oauth.authenticate(f.tokens.access_token)).resolves.toMatchObject({ grant: { userId: f.actor.userId } });
await expect(oauth.authenticate(revocable.tokens.access_token)).rejects.toThrow();
});
it("limits assistant setup metadata to signed-in humans", async () => {
const app = express();
let actor: Request["actor"] = { type: "none" };
app.use((req, _res, next) => { req.actor = actor; next(); });
app.use("/api", publicMcpManagementRoutes(oauth));
for (const candidate of [
{ type: "none" },
{ type: "agent", agentId: randomUUID(), companyId: randomUUID() },
{ type: "board", source: "local_implicit", userId: "board" },
{ type: "board", source: "mcp_oauth", userId: randomUUID() },
] as Request["actor"][]) {
actor = candidate;
expect((await request(app).get("/api/mcp/setup")).status).toBe(401);
}
for (const source of ["session", "cloud_tenant"] as const) {
actor = { type: "board", source, userId: randomUUID() };
expect((await request(app).get("/api/mcp/setup")).body).toEqual({ enabled: true, serverUrl: config.resource });
}
});
it("rotates refresh tokens and persists revocation on replay", async () => {
const f = await fixture();
const refresh = { grant_type: "refresh_token", client_id: f.client.client_id, resource: config.resource, refresh_token: f.tokens.refresh_token };
+4 -1
View File
@@ -51,7 +51,7 @@ export function publicMcpIngressRoutes(oauth: PublicMcpOAuth, execute: ReturnTyp
});
router.get(["/.well-known/oauth-protected-resource", "/.well-known/oauth-protected-resource" + PUBLIC_MCP_PATH], (_req, res) => res.json({
resource, authorization_servers: [origin], scopes_supported: PUBLIC_MCP_SCOPES, bearer_methods_supported: ["header"],
resource_name: "Paperclip team",
resource_name: "Paperclip",
}));
router.get("/.well-known/oauth-authorization-server", (_req, res) => res.json({
issuer: origin, authorization_endpoint: prefix + "/authorize", token_endpoint: prefix + "/token",
@@ -183,6 +183,9 @@ export function publicMcpManagementRoutes(oauth: PublicMcpOAuth) {
if (!parsed.success) throw new McpOAuthError("invalid_request", "Choose a company and the requested access.");
res.json(await oauth.consent(String(req.params.id), req.actor, parsed.data));
});
router.get("/mcp/setup", realUser, async (_req, res) => res.json({
enabled: await oauth.isEnabled(), serverUrl: oauth.config.resource,
}));
router.get("/mcp/connections", realUser, async (req, res) => res.json(await oauth.listConnections(req.actor.userId!)));
router.delete("/mcp/connections/:id", realUser, sameOrigin, async (req, res) => {
if (!z.uuid().safeParse(req.params.id).success) throw new McpOAuthError("invalid_request", "Invalid connection ID.");
+2
View File
@@ -97,6 +97,7 @@ import { PluginPage } from "./pages/PluginPage";
import { NewAgent } from "./pages/NewAgent";
import { AuthPage } from "./pages/Auth";
import { BoardClaimPage } from "./pages/BoardClaim";
import { AssistantConnection } from "./pages/apps/AssistantConnection";
import { McpConnectPage, AssistantConnectionsPage } from "./pages/McpConnect";
import { CliAuthPage } from "./pages/CliAuth";
import { InviteLandingPage } from "./pages/InviteLanding";
@@ -196,6 +197,7 @@ function boardRoutes(streamlinedUiEnabled: boolean) {
<Route path="tools" element={<LegacyToolsRedirect />} />
<Route path="tools/:tab" element={<LegacyToolsRedirect />} />
<Route path="apps" element={<Browse />} />
<Route path="apps/assistant-connection" element={<AssistantConnection />} />
<Route path="apps/browse" element={<Navigate to="/apps" replace />} />
<Route path="apps/connections" element={<Navigate to="/apps" replace />} />
<Route path="apps/byo" element={<AppsConnect byoOnly />} />
+8
View File
@@ -0,0 +1,8 @@
import type { McpConnection, McpConnectionSetup } from "@paperclipai/shared";
import { api } from "./client";
export const publicMcpApi = {
setup: () => api.get<McpConnectionSetup>("/mcp/setup"),
connections: () => api.get<McpConnection[]>("/mcp/connections"),
revoke: (id: string) => api.delete(`/mcp/connections/${encodeURIComponent(id)}`),
};
+1
View File
@@ -69,6 +69,7 @@ function getCompanyRouteSegment(pathname: string, companyPrefix: string | undefi
const RESERVED_APP_SUBPATHS = new Set([
"browse",
"connections",
"assistant-connection",
"connect",
"chat",
"vercel-connect",
@@ -1,4 +1,4 @@
import { useEffect, useState } from "react";
import { useEffect, useState, type ReactNode } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { AlertTriangle, FlaskConical, Lock, Play } from "lucide-react";
import type {
@@ -18,6 +18,7 @@ import { ToggleSwitch } from "@/components/ui/toggle-switch";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { Link } from "@/lib/router";
type WorktreeRunExecutionDisplayState =
| { kind: "off" }
@@ -81,7 +82,7 @@ function ExperimentalToggleCard({
}: {
title: string;
description: string;
footnote?: string;
footnote?: ReactNode;
checked: boolean;
onCheckedChange: (checked: boolean) => void;
disabled: boolean;
@@ -308,7 +309,7 @@ export function InstanceExperimentalSettings() {
<ExperimentalToggleCard
title="Assistant connections (MCP)"
description="Connect Codex, Claude, and other assistants to your Paperclip organization. People sign in, select an organization once, and approve access to review work, delegate tasks, and add feedback."
footnote="Requires an authenticated instance with a configured public URL. Takes effect immediately. Turning this off blocks assistant calls and event delivery; work already delegated continues."
footnote={<>Requires an authenticated instance with a configured public URL. Takes effect immediately. Turning this off blocks assistant calls and event delivery; work already delegated continues.{experimentalQuery.data?.enablePublicMcp && <> <Link className="underline" to="/apps/assistant-connection">Set up an assistant connection</Link></>}</>}
checked={experimentalQuery.data?.enablePublicMcp === true}
onCheckedChange={(checked) => toggleMutation.mutate({ enablePublicMcp: checked })}
disabled={toggleMutation.isPending}
@@ -0,0 +1,74 @@
// @vitest-environment jsdom
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { AssistantConnection } from "./AssistantConnection";
const mocks = vi.hoisted(() => ({ setup: vi.fn(), connections: vi.fn(), revoke: vi.fn(), breadcrumbs: vi.fn() }));
vi.mock("@/api/publicMcp", () => ({ publicMcpApi: mocks }));
vi.mock("@/context/CompanyContext", () => ({ useCompany: () => ({ selectedCompanyId: "butter", selectedCompany: { id: "butter", name: "Butter", logoUrl: null } }) }));
vi.mock("@/context/BreadcrumbContext", () => ({ useBreadcrumbs: () => ({ setBreadcrumbs: mocks.breadcrumbs }) }));
vi.mock("@/lib/router", () => ({ Link: ({ children, to, ...props }: { children: React.ReactNode; to: string }) => <a href={to} {...props}>{children}</a> }));
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
const grant = { id: "grant", companyId: "butter", companyName: "Butter", clientName: "OpenCode", scopes: ["paperclip:read", "paperclip:write"], createdAt: "2026-10-05T00:00:00Z", revokedAt: null };
let root: Root, container: HTMLDivElement, client: QueryClient;
async function flush() { await act(async () => { await new Promise(resolve => setTimeout(resolve, 20)); }); }
async function render() {
await act(async () => root.render(<QueryClientProvider client={client}><AssistantConnection initialAssistant="opencode" /></QueryClientProvider>));
await flush();
}
beforeEach(() => {
mocks.setup.mockResolvedValue({ enabled: true, serverUrl: "https://canonical.example/mcp/paperclip" });
mocks.connections.mockResolvedValue([]);
mocks.revoke.mockResolvedValue(undefined);
client = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } });
container = document.createElement("div"); document.body.append(container); root = createRoot(container);
});
afterEach(async () => { await act(async () => root.unmount()); client.clear(); container.remove(); vi.clearAllMocks(); });
describe("assistant setup from Connections", () => {
it("uses the canonical URL and explains how OpenCode opens consent without granting access", async () => {
await render();
const config = Array.from(container.querySelectorAll("pre")).map(p => p.textContent!).find(p => p.startsWith("{"))!;
expect(JSON.parse(config).mcp.paperclip).toEqual({ type: "remote", url: "https://canonical.example/mcp/paperclip", enabled: true, oauth: { scope: "paperclip:read paperclip:write offline_access" } });
expect(container.textContent).toContain("opencode mcp auth paperclip");
expect(container.textContent).toContain("No assistants connected to Butter yet");
expect(container.querySelector('[role="combobox"]')).toBeNull();
expect(mocks.revoke).not.toHaveBeenCalled();
});
it("shows the enable path while disabled, with no usable setup instructions", async () => {
mocks.setup.mockResolvedValue({ enabled: false, serverUrl: "https://canonical.example/mcp/paperclip" });
await render();
expect(container.querySelector('a[href="/company/settings/instance/experimental"]')).not.toBeNull();
expect(container.textContent).not.toContain("opencode mcp auth");
expect(container.textContent).not.toContain("https://canonical.example");
});
it("only shows this organization’s grants and revokes the selected grant", async () => {
mocks.connections.mockResolvedValue([grant, { ...grant, id: "other", companyId: "elsewhere", clientName: "Other secret client" }]);
await render();
expect(container.textContent).not.toContain("Other secret client");
expect(container.textContent).toContain("Connected as you · Read and write");
mocks.connections.mockResolvedValue([{ ...grant, revokedAt: "2026-10-05T00:00:00Z" }]);
await act(async () => container.querySelector<HTMLButtonElement>('[aria-label="Revoke OpenCode connection"]')!.click());
await flush();
expect(mocks.revoke.mock.calls[0]?.[0]).toBe("grant");
expect(container.textContent).toContain("Revoked");
expect(container.querySelector('[aria-label="Copy first prompt"]')).toBeNull();
});
it("shows a real grant when the list refreshes after assistant sign-in", async () => {
await render();
mocks.connections.mockResolvedValue([grant]);
await act(async () => { await client.invalidateQueries({ queryKey: ["mcp-connections"] }); });
await flush();
expect(container.textContent).toContain("Connected as you · Read and write");
expect(container.querySelector('[aria-label="Copy first prompt"]')).not.toBeNull();
});
it("shows a recoverable error instead of pretending setup succeeded", async () => {
mocks.setup.mockRejectedValue(new Error("offline"));
await render();
expect(container.querySelector('[role="alert"]')?.textContent).toContain("Couldn’t load assistant setup");
mocks.setup.mockResolvedValue({ enabled: true, serverUrl: "https://canonical.example/mcp/paperclip" });
await act(async () => Array.from(container.querySelectorAll('button')).find(b => b.textContent === "Try again")!.click());
await flush();
expect(container.textContent).toContain("opencode mcp auth paperclip");
});
});
+138
View File
@@ -0,0 +1,138 @@
import { useEffect, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Check, Copy, ExternalLink, Paperclip } from "lucide-react";
import { publicMcpApi } from "@/api/publicMcp";
import { ApiError } from "@/api/client";
import { useCompany } from "@/context/CompanyContext";
import { useBreadcrumbs } from "@/context/BreadcrumbContext";
import { Link } from "@/lib/router";
import { CompanyPatternIcon } from "@/components/CompanyPatternIcon";
import { Button } from "@/components/ui/button";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
export const ASSISTANT_CONNECTION_PATH = "/apps/assistant-connection";
const connectionsKey = ["mcp-connections"];
type Assistant = "codex" | "claude" | "opencode" | "other";
const assistants: Record<Assistant, string> = { codex: "Codex", claude: "Claude Code", opencode: "OpenCode", other: "Other" };
function useConnections(poll = false) {
const { selectedCompanyId } = useCompany();
const query = useQuery({
queryKey: connectionsKey, queryFn: publicMcpApi.connections, retry: false,
enabled: Boolean(selectedCompanyId), refetchInterval: poll ? 5000 : false,
});
return { ...query, rows: (query.data ?? []).filter(row => row.companyId === selectedCompanyId) };
}
/** Inbound assistant access belongs beside the existing outbound connectors. */
export function AssistantConnectionCard({ onNavigate }: { onNavigate: (href: string) => void }) {
const { rows } = useConnections();
const active = rows.filter(row => !row.revokedAt);
return <div role="listitem" data-app-slug="assistant-connection" data-connected={active.length ? "true" : "false"} className="overflow-hidden rounded-xl border border-border">
<div className="flex flex-wrap items-center gap-3 px-4 py-4">
<Paperclip className="size-9 shrink-0 p-1 text-foreground" aria-hidden="true" />
<div className="min-w-0 flex-1">
<h2 className="text-sm font-semibold text-foreground">Assistant Connection (MCP)</h2>
<p className="mt-0.5 text-xs text-muted-foreground">Use your Paperclip organization from Codex, Claude, OpenCode, or another assistant.</p>
</div>
<Button type="button" size="sm" variant="outline" onClick={() => onNavigate(ASSISTANT_CONNECTION_PATH)} aria-label={`${active.length ? "Manage" : "Set up"} Assistant Connection (MCP)`}>{active.length ? "Manage" : "Set up"}</Button>
</div>
{active.length > 0 && <div className="flex flex-wrap items-center gap-2 border-t border-border px-4 py-3 text-sm">
<Check className="size-4 text-muted-foreground" aria-hidden="true" />
<span>{active.map(row => row.clientName).join(", ")}</span>
<span className="text-xs text-muted-foreground">Connected as you</span>
</div>}
</div>;
}
/** Only copy user-visible, non-secret setup values. Never execute them here. */
function CopyValue({ value, label }: { value: string; label: string }) {
const [copied, setCopied] = useState(false);
const [error, setError] = useState(false);
useEffect(() => { setCopied(false); setError(false); }, [value]);
return <div className="space-y-2">
<div className="flex items-start gap-3 rounded-md border border-border bg-muted/30 p-3">
<pre className="min-w-0 flex-1 overflow-x-auto whitespace-pre-wrap break-words font-mono text-xs leading-relaxed">{value}</pre>
<Button type="button" variant="ghost" size="icon" className="size-8 shrink-0" aria-label={`Copy ${label}`} onClick={async () => {
try { await navigator.clipboard.writeText(value); setCopied(true); setError(false); }
catch { setError(true); }
}}>{copied ? <Check className="size-4" /> : <Copy className="size-4" />}</Button>
</div>
<p role="status" className="text-xs text-muted-foreground">{error ? "Couldn’t copy. Select and copy the text above." : copied ? "Copied" : null}</p>
</div>;
}
function setupError(error: Error): string {
if (error instanceof ApiError && error.status === 401) return "Sign in to connect an assistant. This requires a Paperclip instance with authenticated user accounts.";
if (error instanceof ApiError && error.status === 404) return "Assistant connections need an authenticated instance with a public HTTPS URL. Ask your instance administrator to configure the public URL, then try again.";
return "Couldn’t load assistant setup. Try again in a moment.";
}
export function AssistantConnection({ initialAssistant = "codex" }: { initialAssistant?: Assistant } = {}) {
const { selectedCompany, selectedCompanyId } = useCompany();
const { setBreadcrumbs } = useBreadcrumbs();
const client = useQueryClient();
const [assistant, setAssistant] = useState<Assistant>(initialAssistant);
const setup = useQuery({ queryKey: ["mcp-setup"], queryFn: publicMcpApi.setup, retry: false, refetchOnWindowFocus: "always", refetchOnMount: "always" });
const connections = useConnections(setup.data?.enabled === true);
const revoke = useMutation({ mutationFn: publicMcpApi.revoke, onSuccess: () => client.invalidateQueries({ queryKey: connectionsKey }) });
useEffect(() => {
setBreadcrumbs([{ label: "Connectors", href: "/apps" }, { label: "Assistant Connection (MCP)" }]);
return () => setBreadcrumbs([]);
}, [setBreadcrumbs]);
if (!selectedCompanyId || !selectedCompany) return <p className="text-sm text-muted-foreground">Select an organization to connect an assistant.</p>;
const serverUrl = setup.data?.serverUrl ?? "";
// A canonical server origin is supplied by Paperclip, never inferred from a proxy or a browser query parameter.
const quotedUrl = `'${serverUrl.replaceAll("'", "'\"'\"'")}'`;
const config = JSON.stringify({ mcp: { paperclip: { type: "remote", url: serverUrl, enabled: true, oauth: { scope: "paperclip:read paperclip:write offline_access" } } } }, null, 2);
const active = connections.rows.filter(row => !row.revokedAt);
return <div className="max-w-3xl space-y-6 pb-8">
<header className="space-y-4">
<div className="flex items-center gap-3"><Paperclip className="size-7 shrink-0" /><h1 className="text-xl font-semibold">Assistant Connection (MCP)</h1></div>
<div className="flex items-center gap-3">
<CompanyPatternIcon companyName={selectedCompany.name} logoUrl={selectedCompany.logoUrl} className="size-12 shrink-0 rounded-lg" />
<div className="space-y-1"><p className="font-medium">{selectedCompany.name}</p><p className="text-sm text-muted-foreground">Connect your assistant to Paperclip. Review work, create tasks, and follow up using your account’s access to this organization.</p></div>
</div>
</header>
{setup.isPending && <p className="text-sm text-muted-foreground">Loading assistant setup…</p>}
{setup.error && <div role="alert" className="space-y-3"><p className="text-sm text-destructive">{setupError(setup.error)}</p><Button variant="outline" onClick={() => void setup.refetch()}>Try again</Button></div>}
{setup.data && !setup.data.enabled && <section className="space-y-3 rounded-lg border border-border bg-muted/30 p-4">
<h2 className="text-sm font-semibold">Enable assistant connections</h2>
<p className="text-sm text-muted-foreground">An instance administrator must turn on Assistant connections (MCP) in Experimental settings. Then return here to connect your assistant.</p>
<Button variant="outline" asChild><Link to="/company/settings/instance/experimental">Open Experimental settings</Link></Button>
</section>}
{setup.data?.enabled && <>
<section className="space-y-3">
<h2 className="text-sm font-semibold">Choose your assistant</h2>
<Tabs value={assistant} onValueChange={value => setAssistant(value as Assistant)}><TabsList className="flex h-auto flex-wrap justify-start">{Object.entries(assistants).map(([value, name]) => <TabsTrigger key={value} value={value}>{name}</TabsTrigger>)}</TabsList></Tabs>
</section>
<section className="space-y-3" aria-label={`Set up ${assistants[assistant]}`}>
<h2 className="text-sm font-semibold">1. Add Paperclip to {assistants[assistant] === "Other" ? "your assistant" : assistants[assistant]}</h2>
{assistant === "opencode" ? <><p className="text-sm text-muted-foreground">Add this entry to your project’s opencode.json. Keep any other settings you already use.</p><CopyValue value={config} label="OpenCode configuration" /></>
: assistant === "other" ? <><p className="text-sm text-muted-foreground">Add a remote MCP server using this URL. Choose OAuth / browser sign-in when your assistant asks how to connect.</p><CopyValue value={serverUrl} label="MCP server URL" /></>
: <><p className="text-sm text-muted-foreground">Run this in a terminal on the computer where you use {assistants[assistant]}.</p><CopyValue value={assistant === "codex" ? `codex mcp add paperclip --url ${quotedUrl}` : `claude mcp add --transport http paperclip ${quotedUrl}`} label="setup command" /></>}
<h2 className="text-sm font-semibold">2. Sign in and approve access</h2>
{assistant === "opencode" && <><p className="text-sm text-muted-foreground">Run this from the same project directory. OpenCode opens Paperclip’s sign-in and consent page in your browser.</p><CopyValue value="opencode mcp auth paperclip" label="sign-in command" /></>}
{assistant === "codex" && <><p className="text-sm text-muted-foreground">Run this command to open Paperclip’s sign-in and consent page in your browser.</p><CopyValue value="codex mcp login paperclip --scopes paperclip:read,paperclip:write,offline_access" label="sign-in command" /></>}
{assistant === "claude" && <p className="text-sm text-muted-foreground">Open Claude Code, run /mcp, select paperclip, and choose Authenticate. Claude opens Paperclip’s sign-in and consent page in your browser.</p>}
{assistant === "other" && <p className="text-sm text-muted-foreground">Use your assistant’s Connect or Authenticate action. It opens Paperclip’s sign-in and consent page in your browser.</p>}
<p className="text-sm">Choose <strong>{selectedCompany.name}</strong>, review read and write access, then click <strong>Connect organization</strong>. Return here after approval; your connection appears below automatically.</p>
<p className="text-xs text-muted-foreground">Creating tasks and adding comments may start agent work using the organization’s configured execution budget.</p>
{assistant === "opencode" && <><h2 className="text-sm font-semibold">3. Open your assistant</h2><p className="text-sm text-muted-foreground">After sign-in completes, start the browser app from that project directory. If it was already running, restart it to pick up the connection.</p><CopyValue value="opencode web" label="OpenCode launch command" /></>}
</section>
<details className="text-sm"><summary className="cursor-pointer text-muted-foreground">MCP server URL</summary><div className="pt-3"><CopyValue value={serverUrl} label="MCP server URL" /></div></details>
</>}
<section className="space-y-3" aria-labelledby="connected-assistants">
<h2 id="connected-assistants" className="text-sm font-semibold">Your connected assistants</h2>
{connections.isPending && <p className="text-sm text-muted-foreground">Loading connections…</p>}
{(connections.error || revoke.error) && <p role="alert" className="text-sm text-destructive">{revoke.error ? "Couldn’t revoke this connection. Try again." : "Couldn’t load your connections. Try again."} <button type="button" className="underline" onClick={() => void connections.refetch()}>Refresh</button></p>}
{connections.isSuccess && connections.rows.length === 0 && <p className="text-sm text-muted-foreground">No assistants connected to {selectedCompany.name} yet.</p>}
<div className="divide-y divide-border">{connections.rows.map(row => <div key={row.id} className="flex flex-wrap items-center justify-between gap-3 py-3">
<div className="space-y-1"><p className="text-sm font-medium">{row.clientName}</p><p className="text-xs text-muted-foreground">{row.revokedAt ? "Revoked" : `Connected as you · ${row.scopes.includes("paperclip:write") ? "Read and write" : "Read only"}`}</p></div>
{!row.revokedAt && <Button variant="outline" size="sm" disabled={revoke.isPending} onClick={() => revoke.mutate(row.id)} aria-label={`Revoke ${row.clientName} connection`}>Revoke</Button>}
</div>)}</div>
{active.length > 0 && <><p className="text-xs text-muted-foreground">Revoking stops future assistant calls. Work already delegated continues.</p>{setup.data?.enabled && <div className="space-y-2 pt-3"><h3 className="text-sm font-semibold">Try it in your assistant</h3><CopyValue value={`Show me the agents and open tasks in my ${selectedCompany.name} Paperclip organization.`} label="first prompt" /></div>}</>}
</section>
<footer className="flex items-center justify-between gap-3 border-t border-border pt-4"><Button variant="ghost" asChild><Link to="/apps">Back to Connections</Link></Button><a className="inline-flex items-center gap-1 text-xs text-muted-foreground underline" href={assistant === "opencode" ? "https://opencode.ai/docs/mcp-servers/" : assistant === "claude" ? "https://code.claude.com/docs/en/mcp" : "https://developers.openai.com/codex/mcp"} target="_blank" rel="noreferrer">Setup documentation <ExternalLink className="size-3" /></a></footer>
</div>;
}
+15 -2
View File
@@ -8,6 +8,8 @@ import { Browse } from "./Browse";
import { getAppStoreDefinition } from "@paperclipai/shared";
import { queryKeys } from "@/lib/queryKeys";
vi.mock("@/api/publicMcp", () => ({ publicMcpApi: { connections: async () => [] } }));
const listGalleryMock = vi.hoisted(() => vi.fn());
const listApplicationsMock = vi.hoisted(() => vi.fn());
const listConnectionsMock = vi.hoisted(() => vi.fn());
@@ -194,6 +196,15 @@ describe("Connectors landing page", () => {
return client;
}
it("offers assistant setup from Connections without choosing an agent", async () => {
await renderBrowse();
const button = container.querySelector<HTMLButtonElement>('[aria-label="Set up Assistant Connection (MCP)"]');
expect(button).not.toBeNull();
await act(() => button!.click());
expect(navigateMock).toHaveBeenCalledWith("/apps/assistant-connection");
expect(chatSetupMock).not.toHaveBeenCalled();
});
it("shows retirement guidance before paused state for an obsolete Composio account", async () => {
listApplicationsMock.mockResolvedValue({ applications: [application({ id: "old-app", name: "Composio", metadata: { sourceTemplateKey: "composio" } })] });
listConnectionsMock.mockResolvedValue({ connections: [connection({ applicationId: "old-app", enabled: false, healthStatus: "error", transport: "rest_api", config: { sourceTemplateKey: "composio", connectionMethodKey: "api-key" } })] });
@@ -466,6 +477,7 @@ describe("Connectors landing page", () => {
),
).map((row) => row.dataset.appSlug),
).toEqual([
"assistant-connection",
"agentmail",
"discord",
"github-code-review-bot",
@@ -546,8 +558,9 @@ describe("Connectors landing page", () => {
'[aria-label="Connector list"] > [data-app-slug]',
),
);
expect(rows[0]?.dataset.appSlug).toBe("notion");
const notion = rows[0]!;
const providers = rows.filter(row => row.dataset.appSlug !== "assistant-connection");
expect(providers[0]?.dataset.appSlug).toBe("notion");
const notion = providers[0]!;
expect(notion.textContent).toContain("devinfoley@gmail.com");
expect(notion.textContent).toContain("ops@example.com");
expect(notion.textContent).toContain("Connected by");
+4 -1
View File
@@ -3,6 +3,7 @@ import {
isRetiredComposioConnection,
RETIRED_COMPOSIO_MESSAGE,
} from "@paperclipai/shared";
import { AssistantConnectionCard } from "./AssistantConnection";
import { ManagedAiConnectionRow } from "@/components/ai-connections/ManagedAiConnectionDetails";
import { useEffect, useMemo, useState, type ReactNode } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
@@ -642,6 +643,7 @@ export function Browse({ renderAccountDetails = (connection) => connection.conne
),
);
}, [rows, trimmed]);
const showAssistantConnection = !trimmed || "assistant connection (mcp) paperclip codex claude opencode".includes(trimmed);
const showCustomConnector =
!trimmed || "connect your own tool custom mcp server".includes(trimmed);
@@ -663,7 +665,7 @@ export function Browse({ renderAccountDetails = (connection) => connection.conne
applicationsQuery.isError ||
connectionsQuery.isError ||
chatEndpointsQuery.isError;
const nothingMatches = visibleRows.length === 0 && !showCustomConnector;
const nothingMatches = visibleRows.length === 0 && !showCustomConnector && !showAssistantConnection;
return (
<div className="max-w-5xl space-y-5 pb-12">
@@ -720,6 +722,7 @@ export function Browse({ renderAccountDetails = (connection) => connection.conne
</p>
) : (
<div className="space-y-3" role="list" aria-label="Connector list">
{showAssistantConnection && <AssistantConnectionCard onNavigate={navigate} />}
{visibleRows.map((row) => (
<ConnectorCard
renderAccountDetails={renderAccountDetails}
+4 -1
View File
@@ -21,6 +21,7 @@ export interface PublicMcpFixture {
pending?: boolean;
mutationError?: boolean;
empty?: boolean;
connections?: McpConnection[];
revoked?: boolean;
enabled?: boolean;
managed?: boolean;
@@ -30,19 +31,21 @@ export interface PublicMcpFixture {
export function installPublicMcpFixture(fixture: PublicMcpFixture = {}) {
consentSubmission.mockClear();
const original = window.fetch;
let rows = fixture.empty ? [] : connections.map(row => ({ ...row, revokedAt: fixture.revoked ? "2020-01-02T00:00:00Z" : null }));
let rows = fixture.empty ? [] : (fixture.connections ?? connections).map(row => ({ ...row, revokedAt: fixture.revoked ? "2020-01-02T00:00:00Z" : null }));
let settings: InstanceExperimentalSettingsWithManaged = { ...instanceExperimentalSettingsSchema.parse({}), enablePublicMcp: Boolean(fixture.enabled), managedKeys: fixture.managed ? { enablePublicMcp: { managed: true, managedBy: "paperclip-cloud" } } : {} };
const error = (message: string, status = 503) => Response.json({ error: message }, { status });
window.fetch = async (input, init) => {
const url = new URL(typeof input === "string" ? input : input instanceof URL ? input.href : input.url, location.origin);
const path = url.pathname;
const method = (init?.method ?? (input instanceof Request ? input.method : "GET")).toUpperCase();
if (path === "/api/companies/company-storybook/chat-endpoints" && method === "GET") return Response.json([]);
const target = path.startsWith("/api/mcp/") || path === "/api/instance/settings/experimental";
if (!target) return original(input, init);
if (method === "GET") {
if (fixture.loading) return new Promise<Response>(() => {});
if (fixture.unavailable) return error("Assistant connections are unavailable. Check the experimental setting or reconnect.", 403);
if (path === "/api/instance/settings/experimental") return Response.json(settings);
if (path === "/api/mcp/setup") return Response.json({ enabled: settings.enablePublicMcp, serverUrl: "https://paperclip.example/mcp/paperclip" });
if (path === "/api/mcp/connections") return Response.json(rows);
if (path === `/api/mcp/requests/${request.id}`) return Response.json({ ...request, ...fixture.request });
} else {
@@ -1,236 +1,52 @@
import { useState } from "react";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, userEvent, within } from "storybook/test";
import { Layout } from "@/components/Layout";
import { PluginLauncherProvider } from "@/plugins/launchers";
import { Link, Route, Routes } from "@/lib/router";
import { Browse } from "@/pages/apps/Browse";
import { AssistantConnection } from "@/pages/apps/AssistantConnection";
import { InstanceExperimentalSettings } from "@/pages/InstanceExperimentalSettings";
import { installPublicMcpFixture } from "../fixtures/publicMcp";
interface JourneyStep {
title: string;
place: string;
why: string;
action: string;
outcome: string;
question: string;
story?: string;
peer?: boolean;
scene?: string[][];
function ConnectionJourney({ assistant = "opencode" }: { assistant?: "codex" | "claude" | "opencode" | "other" }) {
return <PluginLauncherProvider><Routes>
<Route path="/:companyPrefix" element={<Layout />}>
<Route path="apps" element={<Browse />} />
<Route path="apps/assistant-connection" element={<AssistantConnection initialAssistant={assistant} />} />
<Route path="company/settings/instance/experimental" element={<InstanceExperimentalSettings />} />
<Route path="*" element={<Link className="text-sm underline" to="/apps">Return to Connections</Link>} />
</Route>
</Routes></PluginLauncherProvider>;
}
const steps: JourneyStep[] = [
{
"title": "Meet Alex and Acme Research",
"place": "Start of the story",
"why": "Alex leads Acme Research and works in Codex or Claude. Alex wants a researcher to compare three competitors, keep working after the chat closes, and leave a report the team can find later.",
"action": "Use Next step to tell the story in order. Use the numbered steps to jump to a screen during discussion.",
"outcome": "The assistant is Alex’s interface. Paperclip holds the team, task, progress, permissions, and result.",
"question": "Can a user explain what remains in Paperclip when the conversation ends?",
"scene": [
[
"Alex’s goal",
"“Ask our researcher to compare three competitors and produce a sourced report.”"
],
[
"The journey",
"Enable access → connect the right organization → delegate → return for the result → manage access."
],
[
"What you can try here",
"Product screens use isolated fixtures. Example conversations explain the workflow; they do not call an LLM or create real work. Each step starts with fresh sample state."
]
]
},
{
"title": "Enable assistant access",
"place": "Paperclip · Settings → Experimental",
"why": "Before Alex can connect, an instance administrator enables Assistant connections (MCP). The experiment starts off.",
"action": "Find Assistant connections (MCP) and turn it on. Other settings on this page are existing Paperclip experiments.",
"outcome": "The instance can accept assistant connections. This does not connect an account, create an organization, or start an agent. Paid execution still needs its own configuration.",
"question": "Is it clear that enabling access and granting a particular assistant access are separate decisions?",
"story": "assistant-connections-experimental-setting--disabled-by-default"
},
{
"title": "Arrive from Codex or Claude",
"place": "Assistant → browser → Paperclip",
"why": "Alex configures the Paperclip MCP connection in the assistant and starts browser sign-in. Hosted customers choose their organization in Cloud before reviewing access to that same organization.",
"action": "For the hosted sign-in and organization-creation screens, open the Cloud walkthrough. For this walkthrough, continue as Alex returning from Cloud.",
"outcome": "Alex arrives with Acme Research fixed on the connection request. A self-hosted connection reaches the instance directly and can choose an organization there.",
"question": "Does the handoff make sense: Cloud selects the organization; Paperclip reviews its permissions?",
"peer": true,
"scene": [
[
"Hosted route",
"Assistant → Cloud sign-in → choose or create organization → Paperclip consent → assistant."
],
[
"Self-hosted route",
"Assistant → reachable Paperclip instance → sign-in, organization choice, and consent → assistant."
],
[
"Availability",
"These PRs supply the connection flow. Public directory listings require separate deployment and submission. This walkthrough does not assume a published listing."
]
]
},
{
"title": "Review organization permissions",
"place": "Paperclip · Acme Research consent",
"why": "Alex is connecting as a person, not becoming the researcher. The selected organization and Alex’s granted permissions bound every subsequent tool call.",
"action": "Acme Research is already selected from Cloud. Review the organization icon and name. Write access starts enabled for delegation and feedback; uncheck it for read-only access, then click Connect organization. Preview redirects stay in this frame; then use Next step.",
"outcome": "Read access permits summaries and results. Write access also permits task creation and comments, which may wake or queue an agent. The organization stays fixed for this request. To connect a different organization, start a new connection from the assistant.",
"question": "Would Alex understand that “delegate to researcher” does not let the assistant impersonate that agent?",
"story": "assistant-connections-consent--hosted-organization"
},
{
"title": "Review the connected organization",
"place": "Back in Codex or Claude · example conversation",
"why": "Alex checks which organization is connected and which agents are available before assigning work.",
"action": "Read the sample conversation aloud. The assistant should identify the organization and check agent availability instead of guessing from the chat.",
"outcome": "Alex knows which organization will receive the task. A paused or unavailable researcher needs attention in Paperclip before execution can proceed.",
"question": "Are the connected organization and human identity visible enough to prevent a task going to the wrong organization?",
"scene": [
[
"Alex",
"“Which Paperclip organization am I connected to, and who can do competitor research?”"
],
[
"Assistant · illustrative response",
"“You’re connected to Acme Research as Alex. I’ll check the agents and their availability before assigning the research.”"
],
[
"What supports this",
"The connection and agent-list tools supply the identity and available agents. This panel is an example, not a captured model response."
]
]
},
{
"title": "Delegate a durable task",
"place": "Codex or Claude → Paperclip · example conversation",
"why": "Alex asks the team to do work. The assistant creates a task assigned to an available Paperclip agent; Paperclip controls when it runs.",
"action": "Read the request and response. Notice that the response gives a durable task reference and describes the scheduling effect.",
"outcome": "One task exists in Paperclip and is submitted to its scheduler. A task reference is not evidence that execution has started or finished. Retries must reuse the request’s idempotency key.",
"question": "Does the response distinguish “task created” from “research completed”?",
"scene": [
[
"Alex",
"“Have our researcher compare Acme’s three main competitors. Include sources and save the report on the task.”"
],
[
"Assistant · illustrative response",
"“Created ACM-42, Competitor comparison, assigned to Researcher. This submits work to Paperclip’s scheduler and may start paid execution under the team’s settings. Keep ACM-42 to check progress later.”"
],
[
"In Paperclip",
"The task is attributed to Alex. The researcher runs with its configured credentials, execution capacity, budget, and approvals. The assistant’s connection does not provide those."
]
]
},
{
"title": "Return for results and give feedback",
"place": "A later assistant conversation · example",
"why": "Alex closes the original chat and returns later. The task and documents remain in Paperclip.",
"action": "Read the follow-up, then the feedback. Events-capable clients can subscribe to updates explicitly; other clients can check the task when asked.",
"outcome": "The assistant reads current progress and the saved deliverable. A comment records Alex’s feedback and can wake or queue more work. Pending approvals link to Paperclip for a decision.",
"question": "Can Alex recover the result without remembering the original conversation?",
"scene": [
[
"Alex · later",
"“What happened with ACM-42? Read the completed report and summarize the evidence.”"
],
[
"Assistant · illustrative response after reading a completed task",
"“ACM-42 is complete. I found the saved competitor report and can summarize it with its source links.”"
],
[
"Alex · feedback",
"“Add a comment asking Researcher to include pricing changes.”"
],
[
"Assistant · illustrative response",
"“Added your feedback to ACM-42 as Alex. That comment may wake or queue the researcher.”"
],
[
"Optional follow-up",
"In an Events-capable client, Alex can explicitly ask to watch the task. Connecting alone does not subscribe, and event support depends on the client."
]
]
},
{
"title": "Manage or revoke the connection",
"place": "Paperclip · Assistant connections",
"why": "Alex can see which clients have access, which organization each connection reaches, and whether it can write.",
"action": "Compare Codex’s Acme Research access with Claude’s read-only Design Partners connection. Click Revoke connection on Codex.",
"outcome": "The Codex connection becomes revoked. Subsequent requests with it are denied. Work already delegated remains in Paperclip; revocation does not cancel the task.",
"question": "Is the scope and consequence of revoking access understandable?",
"story": "assistant-connections-manage-connections--connected"
}
];
function GuidedJourney() {
const [index, setIndex] = useState(0);
const [previewVersion, setPreviewVersion] = useState(0);
const step = steps[index]!;
// Relative URLs work on both dev servers and published static Storybooks.
const storyUrl = step.story ? `./iframe.html?id=${step.story}&viewMode=story&globals=theme:dark` : undefined;
const isLocal = ["localhost", "127.0.0.1"].includes(window.location.hostname);
const companionUrl = isLocal ? `${window.location.protocol}//${window.location.hostname}:6107/?path=/story/assistant-connections-start-here--guided-walkthrough` : undefined;
return (
<main className="mx-auto flex max-w-screen-2xl flex-col gap-6 p-6 text-sm" aria-label="Paperclip assistant connection walkthrough">
<header className="flex flex-col gap-2">
<p className="text-xs text-muted-foreground">STORYBOOK WALKTHROUGH · PAPERCLIP</p>
<h1 className="text-xl font-bold">Acme Research in Alex’s assistant</h1>
<p className="text-muted-foreground">A guided story you can present, pause, and try. Screen actions affect only sample data; Next step advances the explanation.</p>
</header>
<nav className="flex flex-wrap gap-2" aria-label="Journey steps">
{steps.map((item, i) => <button key={item.title} type="button" aria-current={i === index ? "step" : undefined} className={`rounded-md border border-border px-3 py-2 text-left text-xs hover:bg-accent ${i === index ? "bg-accent font-semibold" : ""}`} onClick={() => setIndex(i)}>{i + 1}. {item.title}</button>)}
</nav>
<nav aria-label="Walkthrough controls" className="flex items-center justify-between gap-4 border-t border-border bg-background py-4">
<button type="button" className="rounded-md border border-border px-4 py-2 text-sm hover:bg-accent disabled:opacity-50" disabled={index === 0} onClick={() => setIndex(i => i - 1)}>Back</button>
<span className="text-xs text-muted-foreground">{index + 1} / {steps.length} · {index === steps.length - 1 ? "End of this walkthrough" : `Next: ${steps[index + 1]!.title}`}</span>
<button type="button" className="rounded-md bg-primary px-4 py-2 text-sm text-primary-foreground" onClick={() => setIndex(i => i === steps.length - 1 ? 0 : i + 1)}>{index === steps.length - 1 ? "Restart walkthrough" : "Next step"}</button>
</nav>
<div className="grid gap-6 sm:grid-cols-3">
<section className="flex flex-col gap-4" aria-label="Presenter notes" aria-live="polite">
<div className="flex flex-col gap-1"><p className="text-xs text-muted-foreground">Step {index + 1} of {steps.length} · {step.place}</p><h2 className="text-lg font-semibold">{step.title}</h2></div>
<p>{step.why}</p>
<div className="flex flex-col gap-1"><h3><strong>Try / narrate</strong></h3><p>{step.action}</p></div>
<div className="flex flex-col gap-1"><h3><strong>What happens next</strong></h3><p>{step.outcome}</p></div>
<div className="flex flex-col gap-1"><h3><strong>Talk about this</strong></h3><p className="text-muted-foreground">{step.question}</p></div>
{step.peer && <div className="flex flex-col gap-1">
{companionUrl && <a className="text-primary underline underline-offset-4" href={companionUrl} target="_blank" rel="noreferrer">Open Cloud walkthrough ↗</a>}
<p className="text-xs text-muted-foreground">Cloud Storybook → Assistant connections → Start here → Guided walkthrough. {companionUrl ? "Local companion on port 6107." : "Open the companion repository’s Storybook separately."}</p>
</div>}
</section>
<section className="min-w-0 sm:col-span-2" aria-label="Current scene">
{storyUrl ? <>
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-border px-4 py-3 text-xs text-muted-foreground"><span>REAL PRODUCT SCREEN · MOCKED SERVICES</span><button className="text-primary underline underline-offset-4" type="button" onClick={() => setPreviewVersion(v => v + 1)}>Reset this screen</button></div>
<iframe key={`${index}:${previewVersion}`} className="h-screen max-h-screen w-full border-0" src={storyUrl} title={`${step.title} — interactive product preview`} />
</> : <div className="flex flex-col gap-6 rounded-lg border border-border bg-card p-6">
<p className="text-xs text-muted-foreground">ILLUSTRATIVE SCENARIO · NO LIVE ASSISTANT OR EXECUTION</p>
{step.scene?.map(([label, text]) => <div key={label} className="flex flex-col gap-1"><h3><strong>{label}</strong></h3><p>{text}</p></div>)}
</div>}
</section>
</div>
</main>
);
}
const meta = {
title: "Assistant connections/Start here",
component: GuidedJourney,
parameters: { layout: "fullscreen", shell: "none" },
} satisfies Meta<typeof GuidedJourney>;
component: ConnectionJourney,
parameters: {
layout: "fullscreen", initialEntries: ["/PAP/apps"], fixture: { enabled: false, empty: true },
docs: { description: { component: "Begin inside Paperclip. Open Assistant Connection (MCP), follow its Experimental settings link if needed, then choose your assistant. These are production pages in the real app shell; API responses are fixtures. Follow the displayed commands in a live instance to launch browser sign-in and consent. Storybook does not run a terminal, grant access, or simulate a successful OpenCode conversation. Consent states have their own stories. The Connected checkpoint represents returning after a real grant, not clicking a mock connect button." } },
},
beforeEach: ({ parameters }) => installPublicMcpFixture(parameters.fixture),
} satisfies Meta<typeof ConnectionJourney>;
export default meta;
type Story = StoryObj<typeof meta>;
export const GuidedWalkthrough: Story = {};
/** Separate from the presentation so opening it never auto-advances the story. */
export const OpenCodeSetup: Story = { parameters: { initialEntries: ["/PAP/apps/assistant-connection"], fixture: { enabled: true, empty: true } } };
export const CodexSetup: Story = { ...OpenCodeSetup, args: { assistant: "codex" } };
export const ClaudeSetup: Story = { ...OpenCodeSetup, args: { assistant: "claude" } };
export const Connected: Story = { parameters: { initialEntries: ["/PAP/apps/assistant-connection"], fixture: { enabled: true, empty: false, connections: [
{ id: "opencode", companyId: "company-storybook", companyName: "Paperclip", clientName: "OpenCode", scopes: ["paperclip:read", "paperclip:write", "offline_access"], createdAt: "2026-10-05T12:00:00Z", revokedAt: null },
{ id: "other-org", companyId: "another-company", companyName: "Other organization", clientName: "Private other client", scopes: ["paperclip:read"], createdAt: "2026-10-05T12:00:00Z", revokedAt: null },
] } } };
export const Unavailable: Story = { parameters: { initialEntries: ["/PAP/apps/assistant-connection"], fixture: { unavailable: true } } };
export const Loading: Story = { parameters: { initialEntries: ["/PAP/apps/assistant-connection"], fixture: { loading: true } } };
export const NavigationCheck: Story = { play: async ({ canvasElement }) => {
const c = within(canvasElement);
await expect(c.getByRole("button", { name: "Back" })).toBeDisabled();
await userEvent.click(c.getByRole("button", { name: "Next step" }));
await expect(c.getByRole("heading", { name: steps[1]!.title })).toBeVisible();
await userEvent.click(c.getByRole("button", { name: `${steps.length}. ${steps[steps.length - 1]!.title}` }));
await expect(c.getByRole("button", { name: "Restart walkthrough" })).toBeVisible();
await userEvent.click(c.getByRole("button", { name: "Back" }));
await expect(c.getByRole("heading", { name: steps[steps.length - 2]!.title })).toBeVisible();
await userEvent.click(c.getByRole("button", { name: "Next step" }));
await userEvent.click(c.getByRole("button", { name: "Restart walkthrough" }));
await expect(c.getByRole("heading", { name: steps[0]!.title })).toBeVisible();
await userEvent.click(await c.findByRole("button", { name: "Set up Assistant Connection (MCP)" }));
await userEvent.click(await c.findByRole("link", { name: "Open Experimental settings" }));
await userEvent.click(await c.findByRole("switch", { name: "Toggle assistant connections experimental setting" }));
await userEvent.click(await c.findByRole("link", { name: "Set up an assistant connection" }));
await c.findByRole("heading", { name: "1. Add Paperclip to OpenCode" });
await expect(c.getByText("opencode mcp auth paperclip")).toBeVisible();
await expect(c.getByText(/No assistants connected/)).toBeVisible();
} };