From baeb1ffe2ed4b8bc27cb6925e07ed7a663b88295 Mon Sep 17 00:00:00 2001 From: Dotta Date: Mon, 5 Oct 2026 16:18:27 -0500 Subject: [PATCH] feat(mcp): start assistant setup from Connections Co-Authored-By: Paperclip --- .../2026-10-05-assistant-connection-entry.md | 21 ++ doc/public-mcp.md | 29 ++ packages/shared/src/public-mcp.ts | 5 + server/src/__tests__/public-mcp.test.ts | 25 ++ server/src/routes/public-mcp.ts | 5 +- ui/src/App.tsx | 2 + ui/src/api/publicMcp.ts | 8 + ui/src/components/Layout.tsx | 1 + ui/src/pages/InstanceExperimentalSettings.tsx | 7 +- .../pages/apps/AssistantConnection.test.tsx | 74 +++++ ui/src/pages/apps/AssistantConnection.tsx | 138 +++++++++ ui/src/pages/apps/Browse.test.tsx | 17 +- ui/src/pages/apps/Browse.tsx | 5 +- ui/storybook/fixtures/publicMcp.ts | 5 +- .../stories/public-mcp-journey.stories.tsx | 262 +++--------------- 15 files changed, 373 insertions(+), 231 deletions(-) create mode 100644 doc/plans/2026-10-05-assistant-connection-entry.md create mode 100644 ui/src/api/publicMcp.ts create mode 100644 ui/src/pages/apps/AssistantConnection.test.tsx create mode 100644 ui/src/pages/apps/AssistantConnection.tsx diff --git a/doc/plans/2026-10-05-assistant-connection-entry.md b/doc/plans/2026-10-05-assistant-connection-entry.md new file mode 100644 index 00000000000..120a0c6356d --- /dev/null +++ b/doc/plans/2026-10-05-assistant-connection-entry.md @@ -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. diff --git a/doc/public-mcp.md b/doc/public-mcp.md index 03d23a32cf7..de241d2b9e8 100644 --- a/doc/public-mcp.md +++ b/doc/public-mcp.md @@ -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 diff --git a/packages/shared/src/public-mcp.ts b/packages/shared/src/public-mcp.ts index 2ceb89f1237..20b8c6a123e 100644 --- a/packages/shared/src/public-mcp.ts +++ b/packages/shared/src/public-mcp.ts @@ -21,6 +21,11 @@ export interface McpConnectionRequest { setupUrl: string | null; } +export interface McpConnectionSetup { + enabled: boolean; + serverUrl: string; +} + export interface McpConnection { id: string; companyId: string; diff --git a/server/src/__tests__/public-mcp.test.ts b/server/src/__tests__/public-mcp.test.ts index ddcebad9a16..50bc09ec034 100644 --- a/server/src/__tests__/public-mcp.test.ts +++ b/server/src/__tests__/public-mcp.test.ts @@ -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 }; diff --git a/server/src/routes/public-mcp.ts b/server/src/routes/public-mcp.ts index 8f8a6dde015..2adebaec054 100644 --- a/server/src/routes/public-mcp.ts +++ b/server/src/routes/public-mcp.ts @@ -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."); diff --git a/ui/src/App.tsx b/ui/src/App.tsx index d4be98cf9c2..d9e6a7fbe5f 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -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) { } /> } /> } /> + } /> } /> } /> } /> diff --git a/ui/src/api/publicMcp.ts b/ui/src/api/publicMcp.ts new file mode 100644 index 00000000000..ee4386cb2bc --- /dev/null +++ b/ui/src/api/publicMcp.ts @@ -0,0 +1,8 @@ +import type { McpConnection, McpConnectionSetup } from "@paperclipai/shared"; +import { api } from "./client"; + +export const publicMcpApi = { + setup: () => api.get("/mcp/setup"), + connections: () => api.get("/mcp/connections"), + revoke: (id: string) => api.delete(`/mcp/connections/${encodeURIComponent(id)}`), +}; diff --git a/ui/src/components/Layout.tsx b/ui/src/components/Layout.tsx index 06507acbe87..895b949a1ae 100644 --- a/ui/src/components/Layout.tsx +++ b/ui/src/components/Layout.tsx @@ -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", diff --git a/ui/src/pages/InstanceExperimentalSettings.tsx b/ui/src/pages/InstanceExperimentalSettings.tsx index a4da463f14f..a44559baa31 100644 --- a/ui/src/pages/InstanceExperimentalSettings.tsx +++ b/ui/src/pages/InstanceExperimentalSettings.tsx @@ -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() { 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 && <> Set up an assistant connection}} checked={experimentalQuery.data?.enablePublicMcp === true} onCheckedChange={(checked) => toggleMutation.mutate({ enablePublicMcp: checked })} disabled={toggleMutation.isPending} diff --git a/ui/src/pages/apps/AssistantConnection.test.tsx b/ui/src/pages/apps/AssistantConnection.test.tsx new file mode 100644 index 00000000000..221cc4dc6ee --- /dev/null +++ b/ui/src/pages/apps/AssistantConnection.test.tsx @@ -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 }) => {children} })); +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()); + 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('[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"); + }); +}); diff --git a/ui/src/pages/apps/AssistantConnection.tsx b/ui/src/pages/apps/AssistantConnection.tsx new file mode 100644 index 00000000000..cc065e850a5 --- /dev/null +++ b/ui/src/pages/apps/AssistantConnection.tsx @@ -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 = { 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
+
+
+ {active.length > 0 &&
+
} +
; +} + +/** 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
+
+
{value}
+ +
+

{error ? "Couldn’t copy. Select and copy the text above." : copied ? "Copied" : null}

+
; +} + +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(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

Select an organization to connect an assistant.

; + 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
+
+

Assistant Connection (MCP)

+
+ +

{selectedCompany.name}

Connect your assistant to Paperclip. Review work, create tasks, and follow up using your account’s access to this organization.

+
+
+ {setup.isPending &&

Loading assistant setup…

} + {setup.error &&

{setupError(setup.error)}

} + {setup.data && !setup.data.enabled &&
+

Enable assistant connections

+

An instance administrator must turn on Assistant connections (MCP) in Experimental settings. Then return here to connect your assistant.

+ +
} + {setup.data?.enabled && <> +
+

Choose your assistant

+ setAssistant(value as Assistant)}>{Object.entries(assistants).map(([value, name]) => {name})} +
+
+

1. Add Paperclip to {assistants[assistant] === "Other" ? "your assistant" : assistants[assistant]}

+ {assistant === "opencode" ? <>

Add this entry to your project’s opencode.json. Keep any other settings you already use.

+ : assistant === "other" ? <>

Add a remote MCP server using this URL. Choose OAuth / browser sign-in when your assistant asks how to connect.

+ : <>

Run this in a terminal on the computer where you use {assistants[assistant]}.

} +

2. Sign in and approve access

+ {assistant === "opencode" && <>

Run this from the same project directory. OpenCode opens Paperclip’s sign-in and consent page in your browser.

} + {assistant === "codex" && <>

Run this command to open Paperclip’s sign-in and consent page in your browser.

} + {assistant === "claude" &&

Open Claude Code, run /mcp, select paperclip, and choose Authenticate. Claude opens Paperclip’s sign-in and consent page in your browser.

} + {assistant === "other" &&

Use your assistant’s Connect or Authenticate action. It opens Paperclip’s sign-in and consent page in your browser.

} +

Choose {selectedCompany.name}, review read and write access, then click Connect organization. Return here after approval; your connection appears below automatically.

+

Creating tasks and adding comments may start agent work using the organization’s configured execution budget.

+ {assistant === "opencode" && <>

3. Open your assistant

After sign-in completes, start the browser app from that project directory. If it was already running, restart it to pick up the connection.

} +
+
MCP server URL
+ } +
+

Your connected assistants

+ {connections.isPending &&

Loading connections…

} + {(connections.error || revoke.error) &&

{revoke.error ? "Couldn’t revoke this connection. Try again." : "Couldn’t load your connections. Try again."}

} + {connections.isSuccess && connections.rows.length === 0 &&

No assistants connected to {selectedCompany.name} yet.

} +
{connections.rows.map(row =>
+

{row.clientName}

{row.revokedAt ? "Revoked" : `Connected as you · ${row.scopes.includes("paperclip:write") ? "Read and write" : "Read only"}`}

+ {!row.revokedAt && } +
)}
+ {active.length > 0 && <>

Revoking stops future assistant calls. Work already delegated continues.

{setup.data?.enabled &&

Try it in your assistant

}} +
+ +
; +} diff --git a/ui/src/pages/apps/Browse.test.tsx b/ui/src/pages/apps/Browse.test.tsx index 35ab90ed2ff..7cc74886001 100644 --- a/ui/src/pages/apps/Browse.test.tsx +++ b/ui/src/pages/apps/Browse.test.tsx @@ -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('[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"); diff --git a/ui/src/pages/apps/Browse.tsx b/ui/src/pages/apps/Browse.tsx index c1177433249..a7e17cc2cae 100644 --- a/ui/src/pages/apps/Browse.tsx +++ b/ui/src/pages/apps/Browse.tsx @@ -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 (
@@ -720,6 +722,7 @@ export function Browse({ renderAccountDetails = (connection) => connection.conne

) : (
+ {showAssistantConnection && } {visibleRows.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(() => {}); 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 { diff --git a/ui/storybook/stories/public-mcp-journey.stories.tsx b/ui/storybook/stories/public-mcp-journey.stories.tsx index fc128dbfc8c..349e4dd9a6b 100644 --- a/ui/storybook/stories/public-mcp-journey.stories.tsx +++ b/ui/storybook/stories/public-mcp-journey.stories.tsx @@ -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 + }> + } /> + } /> + } /> + Return to Connections} /> + + ; } -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 ( -
-
-

STORYBOOK WALKTHROUGH · PAPERCLIP

-

Acme Research in Alex’s assistant

-

A guided story you can present, pause, and try. Screen actions affect only sample data; Next step advances the explanation.

-
- - -
-
-

Step {index + 1} of {steps.length} · {step.place}

{step.title}

-

{step.why}

-

Try / narrate

{step.action}

-

What happens next

{step.outcome}

-

Talk about this

{step.question}

- {step.peer &&
- {companionUrl && Open Cloud walkthrough ↗} -

Cloud Storybook → Assistant connections → Start here → Guided walkthrough. {companionUrl ? "Local companion on port 6107." : "Open the companion repository’s Storybook separately."}

-
} -
-
- {storyUrl ? <> -
REAL PRODUCT SCREEN · MOCKED SERVICES
-