mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-21 08:22:20 +02:00
feat(mcp): start assistant setup from Connections
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
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.
|
||||
@@ -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
|
||||
|
||||
@@ -21,6 +21,11 @@ export interface McpConnectionRequest {
|
||||
setupUrl: string | null;
|
||||
}
|
||||
|
||||
export interface McpConnectionSetup {
|
||||
enabled: boolean;
|
||||
serverUrl: string;
|
||||
}
|
||||
|
||||
export interface McpConnection {
|
||||
id: string;
|
||||
companyId: string;
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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.");
|
||||
|
||||
@@ -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 />} />
|
||||
|
||||
@@ -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)}`),
|
||||
};
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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();
|
||||
} };
|
||||
Reference in new issue
Block a user