diff --git a/doc/public-mcp.md b/doc/public-mcp.md index b3017b500c6..87a66486427 100644 --- a/doc/public-mcp.md +++ b/doc/public-mcp.md @@ -88,7 +88,9 @@ or refetching does not undo a write-access opt-out. Nothing is authorized until 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 +shows your active grants for the selected organization. Each connection shows your +profile avatar and defaults to a name such as “Dotta’s Codex connection.” Revoked +connections disappear from the list; their audit records remain. The legacy `/assistant-connections` URL remains available for account-wide management. `GET /api/mcp/setup` returns the live experimental gate, canonical endpoint, diff --git a/packages/shared/src/mcp-setup.ts b/packages/shared/src/mcp-setup.ts index 06428fa8d17..a774458f00a 100644 --- a/packages/shared/src/mcp-setup.ts +++ b/packages/shared/src/mcp-setup.ts @@ -10,7 +10,7 @@ export function mcpSetupUrl(serverUrl: string, companyId?: string) { return url.toString(); } export function mcpInvitation(serverUrl: string, company?: { id: string; name: string }) { - return `Connect to my ${company ? `${company.name} ` : ""}Paperclip organization using the instructions at ${mcpSetupUrl(serverUrl, company?.id)}. Start authorization, give me the approval link, and verify the connection after I approve. This connects as me, not as a Paperclip agent.`; + return `Connect to my ${company ? `${company.name} ` : ""}Paperclip organization using the instructions at ${mcpSetupUrl(serverUrl, company?.id)}. Start authorization, give me the approval link, and verify the connection after I approve.`; } export function mcpSetupSteps(serverUrl: string, assistant: AssistantClient) { const quoted = `'${serverUrl.replaceAll("'", "'\"'\"'")}'`; diff --git a/packages/shared/src/public-mcp.ts b/packages/shared/src/public-mcp.ts index 537e28f0915..b1ee2612a32 100644 --- a/packages/shared/src/public-mcp.ts +++ b/packages/shared/src/public-mcp.ts @@ -36,6 +36,8 @@ export interface McpConnection { companyId: string; clientName: string; companyName: string; + /** The signed-in person who authorized this connection; absent on older servers. */ + user?: { name: string; image: string | null } | null; scopes: string[]; createdAt: string; revokedAt: string | null; diff --git a/server/src/__tests__/public-mcp.test.ts b/server/src/__tests__/public-mcp.test.ts index 517e5f40875..5156f08d249 100644 --- a/server/src/__tests__/public-mcp.test.ts +++ b/server/src/__tests__/public-mcp.test.ts @@ -120,6 +120,19 @@ describe.skipIf(!support.supported)("public MCP OAuth and tool boundary", () => return { actor, company: company!, membership: membership!, client, tokens, exchange }; } + it("returns only the authorizing person’s profile with their connection list", async () => { + const f = await fixture(); + const other = await fixture(); + await db.update(authUsers).set({ name: "Dotta", image: "https://avatars.example/dotta.png" }).where(eq(authUsers.id, f.actor.userId!)); + const rows = await oauth.listConnections(f.actor.userId!); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ companyId: f.company.id, user: { name: "Dotta", image: "https://avatars.example/dotta.png" } }); + expect(JSON.stringify(rows)).not.toContain(other.actor.userId!); + expect(JSON.stringify(rows)).not.toContain("@example.com"); + await oauth.revokeConnection(rows[0]!.id, f.actor.userId!); + expect((await oauth.listConnections(f.actor.userId!))[0]?.revokedAt).not.toBeNull(); + }); + async function deviceFixture() { const f = await fixture(); const client = await oauth.register({ client_name: "Device client", redirect_uris: [], grant_types: [DEVICE_GRANT, "refresh_token"], response_types: [] }, randomUUID()); diff --git a/server/src/services/public-mcp/oauth.ts b/server/src/services/public-mcp/oauth.ts index abfeec9b0db..21b2f977e3d 100644 --- a/server/src/services/public-mcp/oauth.ts +++ b/server/src/services/public-mcp/oauth.ts @@ -4,7 +4,7 @@ import { and, eq, gt, inArray, isNull, lt, lte, notExists, sql } from "drizzle-o import { z } from "zod"; import type { Request } from "express"; import { - type Db, activityLog, companies, companyLogos, mcpOauthClients, mcpOauthGrants, mcpOauthRequests, mcpOauthTokens, mcpOauthDeviceRequests, mcpOauthMetadataAdmissions, + type Db, activityLog, authUsers, companies, companyLogos, mcpOauthClients, mcpOauthGrants, mcpOauthRequests, mcpOauthTokens, mcpOauthDeviceRequests, mcpOauthMetadataAdmissions, } from "@paperclipai/db"; import { PUBLIC_MCP_PATH, PUBLIC_MCP_SCOPES, type McpConnectionRequest } from "@paperclipai/shared"; import { boardAuthService } from "../board-auth.js"; @@ -459,12 +459,14 @@ export function createPublicMcpOAuth(db: Db, config: PublicMcpConfig, options: { }); }, async listConnections(userId: string) { - const rows = await db.select({ grant: mcpOauthGrants, clientName: mcpOauthClients.name, companyName: companies.name }) + const rows = await db.select({ grant: mcpOauthGrants, clientName: mcpOauthClients.name, companyName: companies.name, userName: authUsers.name, userImage: authUsers.image }) .from(mcpOauthGrants).innerJoin(mcpOauthClients, eq(mcpOauthGrants.clientId, mcpOauthClients.id)) .innerJoin(companies, eq(companies.id, mcpOauthGrants.companyId)) + .leftJoin(authUsers, eq(authUsers.id, mcpOauthGrants.userId)) .where(eq(mcpOauthGrants.userId, userId)); - return rows.map(({ grant, clientName, companyName }) => ({ - id: grant.id, companyId: grant.companyId, clientName, companyName, scopes: grant.scopes, + return rows.map(({ grant, clientName, companyName, userName, userImage }) => ({ + id: grant.id, companyId: grant.companyId, clientName, companyName, + user: userName === null ? null : { name: userName, image: userImage }, scopes: grant.scopes, createdAt: grant.createdAt.toISOString(), revokedAt: grant.revokedAt?.toISOString() ?? null, })); }, diff --git a/ui/src/pages/McpConnect.tsx b/ui/src/pages/McpConnect.tsx index aafa85c4df1..e052559736f 100644 --- a/ui/src/pages/McpConnect.tsx +++ b/ui/src/pages/McpConnect.tsx @@ -1,8 +1,10 @@ import { useEffect, useState } from "react"; -import { useMutation, useQuery } from "@tanstack/react-query"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Globe, Paperclip } from "lucide-react"; import { Link, useParams } from "@/lib/router"; import type { McpConnection, McpConnectionRequest } from "@paperclipai/shared"; +import { deriveInitials, Identity } from "@/components/Identity"; +import { assistantConnectionDisplayName } from "./apps/connection-owner"; import { Button } from "@/components/ui/button"; import { Card } from "@/components/ui/card"; import { Checkbox } from "@/components/ui/checkbox"; @@ -116,20 +118,25 @@ function McpConnectRequest({ id, device = false, onEditCode }: { id: string; dev } export function AssistantConnectionsPage() { + const client = useQueryClient(); const connections = useQuery({ queryKey: ["mcp-connections"], queryFn: () => api.get("/mcp/connections"), retry: false }); - const revoke = useMutation({ mutationFn: (id: string) => api.delete(`/mcp/connections/${id}`), onSuccess: () => { void connections.refetch(); } }); + const active = connections.data?.filter(connection => !connection.revokedAt); + const revoke = useMutation({ mutationFn: (id: string) => api.delete(`/mcp/connections/${id}`), onSuccess: (_, id) => { + client.setQueryData(["mcp-connections"], rows => rows?.filter(row => row.id !== id)); + return client.invalidateQueries({ queryKey: ["mcp-connections"] }); + } }); return

Assistant connections

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

{connections.isPending &&

Loading connections…

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

{(connections.error ?? revoke.error)?.message}

} - {connections.data?.length === 0 &&

No assistant connections.

} - {connections.data?.map((connection) => -

{connection.clientName}

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

No assistant connections.

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

Organization: {connection.companyName}

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

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

Configure agents, projects and skills

} - {connection.revokedAt ?

Revoked

: } +
)} Back to Paperclip
; diff --git a/ui/src/pages/apps/AssistantConnection.test.tsx b/ui/src/pages/apps/AssistantConnection.test.tsx index 57460336df5..618eeef4f8e 100644 --- a/ui/src/pages/apps/AssistantConnection.test.tsx +++ b/ui/src/pages/apps/AssistantConnection.test.tsx @@ -12,7 +12,7 @@ vi.mock("@/context/CompanyContext", () => ({ useCompany: () => ({ selectedCompan 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 }; +const grant = { id: "grant", companyId: "butter", companyName: "Butter", clientName: "OpenCode", user: { name: "Dotta", image: "/avatar.jpg" }, 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(element: React.ReactNode = ) { @@ -72,13 +72,63 @@ describe("assistant setup from Connections", () => { await render(); expect(container.textContent).not.toContain("Other secret client"); expect(container.textContent).toContain("Connected as you · Read and write"); + expect(container.textContent).toContain("Dotta’s OpenCode connection"); + expect(container.querySelector('[data-slot="avatar"]')).not.toBeNull(); mocks.connections.mockResolvedValue([{ ...grant, revokedAt: "2026-10-05T00:00:00Z" }]); - await act(async () => container.querySelector('[aria-label="Revoke OpenCode connection"]')!.click()); + await act(async () => container.querySelector('[aria-label="Revoke Dotta’s OpenCode connection"]')!.click()); await flush(); expect(mocks.revoke.mock.calls[0]?.[0]).toBe("grant"); - expect(container.textContent).toContain("Revoked"); + expect(container.textContent).not.toContain("Revoked"); + expect(container.textContent).not.toContain("Dotta’s OpenCode connection"); + expect(container.querySelector('[data-slot="avatar"]')).toBeNull(); + expect(container.textContent).toContain("No assistants connected to Butter yet"); expect(container.querySelector('[aria-label="Copy first prompt"]')).toBeNull(); }); + it("hides retained revoked grants in both the page and catalog card", async () => { + mocks.connections.mockResolvedValue([{ ...grant, revokedAt: "2026-10-05T00:00:00Z" }]); + await render(); + expect(container.textContent).toContain("No assistants connected to Butter yet"); + expect(container.textContent).not.toContain("Dotta’s OpenCode connection"); + await render(); + expect(container.querySelector('[aria-label="Set up Assistant Connection (MCP)"]')).not.toBeNull(); + expect(container.textContent).not.toContain("Dotta’s OpenCode connection"); + }); + it.each(["https://assistant.example", "my_assistant", "Custom Assistant"])("preserves a custom client’s name: %s", async (clientName) => { + mocks.connections.mockResolvedValue([{ ...grant, clientName }]); + await render(); + expect(container.textContent).toContain(`Dotta’s ${clientName} connection`); + expect(container.querySelector('button[aria-label^="Revoke Dotta’s"]')?.getAttribute("aria-label")).toBe(`Revoke Dotta’s ${clientName} connection`); + }); + it("uses the person’s initials when they have no profile image", async () => { + mocks.connections.mockResolvedValue([{ ...grant, user: { name: "Dotta", image: null } }]); + await render(); + expect(container.querySelector('[data-slot="avatar-fallback"]')?.textContent).toBe("DO"); + expect(container.textContent).toContain("Dotta’s OpenCode connection"); + }); + it("keeps an older server’s connection usable without profile metadata", async () => { + mocks.connections.mockResolvedValue([{ ...grant, user: undefined }]); + await render(); + expect(container.textContent).toContain("OpenCode connection"); + expect(container.querySelector('[aria-label="Revoke OpenCode connection"]')).not.toBeNull(); + }); + it("retains the connection when revocation fails", async () => { + mocks.connections.mockResolvedValue([grant]); + mocks.revoke.mockRejectedValueOnce(new Error("offline")); + await render(); + await act(async () => container.querySelector('[aria-label="Revoke Dotta’s OpenCode connection"]')!.click()); + await flush(); + expect(container.textContent).toContain("Couldn’t revoke this connection"); + expect(container.textContent).toContain("Dotta’s OpenCode connection"); + }); + it("hides a successfully revoked connection even if the follow-up refresh fails", async () => { + mocks.connections.mockResolvedValue([grant]); + await render(); + mocks.connections.mockRejectedValue(new Error("offline")); + await act(async () => container.querySelector('[aria-label="Revoke Dotta’s OpenCode connection"]')!.click()); + await flush(); + expect(mocks.revoke).toHaveBeenCalledWith("grant", expect.anything()); + expect(container.textContent).not.toContain("Dotta’s OpenCode connection"); + }); it("shows a real grant when the list refreshes after assistant sign-in", async () => { await render(); mocks.connections.mockResolvedValue([grant]); diff --git a/ui/src/pages/apps/AssistantConnection.tsx b/ui/src/pages/apps/AssistantConnection.tsx index 8d80d0c7cc0..fb37efd2a7d 100644 --- a/ui/src/pages/apps/AssistantConnection.tsx +++ b/ui/src/pages/apps/AssistantConnection.tsx @@ -1,5 +1,5 @@ import { useEffect, useState } from "react"; -import { assistantClientNames, mcpAuthorizationHandoffInstructions, mcpInvitation, mcpSetupSteps, type AssistantClient } from "@paperclipai/shared"; +import { assistantClientNames, mcpAuthorizationHandoffInstructions, mcpInvitation, mcpSetupSteps, type AssistantClient, type McpConnection } from "@paperclipai/shared"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Check, Copy, ExternalLink, Globe, Paperclip, Plug, Terminal } from "lucide-react"; import { publicMcpApi } from "@/api/publicMcp"; @@ -8,6 +8,8 @@ import { useCompany } from "@/context/CompanyContext"; import { useBreadcrumbs } from "@/context/BreadcrumbContext"; import { Link } from "@/lib/router"; import { copyTextToClipboard } from "@/lib/clipboard"; +import { deriveInitials, Identity } from "@/components/Identity"; +import { assistantConnectionDisplayName } from "./connection-owner"; import { AgentSetupPrompt } from "@/components/AgentSetupPrompt"; import { OpenCodeLogoIcon } from "@/components/OpenCodeLogoIcon"; import { CompanyPatternIcon } from "@/components/CompanyPatternIcon"; @@ -32,13 +34,13 @@ export function useAssistantConnections(poll = false) { queryKey: connectionsKey, queryFn: publicMcpApi.connections, retry: false, enabled: Boolean(selectedCompanyId), refetchInterval: poll ? 5000 : false, }); - return { ...query, rows: (query.data ?? []).filter(row => row.companyId === selectedCompanyId) }; + return { ...query, rows: (query.data ?? []).filter(row => row.companyId === selectedCompanyId && !row.revokedAt) }; } /** Inbound assistant access belongs beside the existing outbound connectors. */ export function AssistantConnectionCard({ onNavigate }: { onNavigate: (href: string) => void }) { const connections = useAssistantConnections(); - const active = connections.rows.filter(row => !row.revokedAt); + const active = connections.rows; const action = !connections.isSuccess ? "Open" : active.length ? "Manage" : "Set up"; return
0) : undefined} className="overflow-hidden rounded-xl border border-border">
@@ -55,9 +57,7 @@ export function AssistantConnectionCard({ onNavigate }: { onNavigate: (href: str
} {connections.isSuccess && active.length > 0 &&
-
}
; } @@ -92,7 +92,10 @@ export function AssistantConnection({ initialAssistant = "codex" }: { initialAss const [assistant, setAssistant] = useState(initialAssistant); const setup = useQuery({ queryKey: ["mcp-setup"], queryFn: publicMcpApi.setup, retry: false, refetchOnWindowFocus: "always", refetchOnMount: "always" }); const connections = useAssistantConnections(setup.data?.enabled === true); - const revoke = useMutation({ mutationFn: publicMcpApi.revoke, onSuccess: () => client.invalidateQueries({ queryKey: connectionsKey }) }); + const revoke = useMutation({ mutationFn: publicMcpApi.revoke, onSuccess: (_, id) => { + client.setQueryData(connectionsKey, rows => rows?.filter(row => row.id !== id)); + return client.invalidateQueries({ queryKey: connectionsKey }); + } }); useEffect(() => { setBreadcrumbs([{ label: "Connectors", href: "/apps" }, { label: "Assistant Connection (MCP)" }]); return () => setBreadcrumbs([]); @@ -140,8 +143,8 @@ export function AssistantConnection({ initialAssistant = "codex" }: { initialAss {(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.scopes.includes("paperclip:configure") ? " · Configure agents, projects and skills" : ""}`}

- {!row.revokedAt && } +

{`Connected as you · ${row.scopes.includes("paperclip:write") ? "Read and write" : "Read only"}${row.scopes.includes("paperclip:configure") ? " · Configure agents, projects and skills" : ""}`}

+
)}
diff --git a/ui/src/pages/apps/connection-owner.tsx b/ui/src/pages/apps/connection-owner.tsx index 3a723e5de2d..473ae6b5d6d 100644 --- a/ui/src/pages/apps/connection-owner.tsx +++ b/ui/src/pages/apps/connection-owner.tsx @@ -1,4 +1,4 @@ -import type { ToolConnection } from "@paperclipai/shared"; +import type { McpConnection, ToolConnection } from "@paperclipai/shared"; import { humanizeConnectionDisplayName } from "@paperclipai/shared"; import { Identity } from "@/components/Identity"; import type { CompanyUserProfile } from "@/lib/company-members"; @@ -52,6 +52,13 @@ export function connectionDisplayNameForOwner( return `${possessive(ownerGivenName(owner.label))} ${applicationName}`; } +/** Use the same owner naming as outbound connections, without changing OAuth client identity. */ +export function assistantConnectionDisplayName(connection: McpConnection): string { + const clientName = connection.clientName.trim(); + const owner = connection.user ? `${possessive(ownerGivenName(connection.user.name))} ` : ""; + return `${owner}${clientName} connection`; +} + export function ConnectionOwnerIdentity({ owner }: { owner: ConnectionOwnerProfile | null }) { if (!owner) return Unknown; return ( diff --git a/ui/storybook/fixtures/publicMcp.ts b/ui/storybook/fixtures/publicMcp.ts index bca39fb3e61..bbddcb5c0ef 100644 --- a/ui/storybook/fixtures/publicMcp.ts +++ b/ui/storybook/fixtures/publicMcp.ts @@ -1,6 +1,9 @@ import { instanceExperimentalSettingsSchema, type InstanceExperimentalSettingsWithManaged, type McpConnection, type McpConnectionRequest } from "@paperclipai/shared"; import { fn } from "storybook/test"; +// Local portrait fixture keeps avatar previews independent of third-party image hosts. +export const connectedUser = { name: "Dotta", image: `data:image/svg+xml,${encodeURIComponent('')}` }; + export const consentSubmission = fn(); export const request: McpConnectionRequest = { id: "storybook-request", clientName: "Codex", redirectOrigin: "https://chatgpt.com", @@ -11,8 +14,8 @@ export const request: McpConnectionRequest = { ], setupUrl: null, }; const connections: McpConnection[] = [ - { id: "codex", companyId: request.companies[0].id, companyName: "Acme Research", clientName: "Codex", scopes: ["paperclip:read", "paperclip:write"], createdAt: "2020-01-01T00:00:00Z", revokedAt: null }, - { id: "claude", companyId: request.companies[1].id, companyName: "Design Partners", clientName: "Claude", scopes: ["paperclip:read"], createdAt: "2020-01-01T00:00:00Z", revokedAt: null }, + { id: "codex", companyId: request.companies[0].id, companyName: "Acme Research", clientName: "Codex", user: connectedUser, scopes: ["paperclip:read", "paperclip:write"], createdAt: "2020-01-01T00:00:00Z", revokedAt: null }, + { id: "claude", companyId: request.companies[1].id, companyName: "Design Partners", clientName: "Claude", user: connectedUser, scopes: ["paperclip:read"], createdAt: "2020-01-01T00:00:00Z", revokedAt: null }, ]; export interface PublicMcpFixture { request?: Partial; @@ -33,7 +36,7 @@ export interface PublicMcpFixture { export function installPublicMcpFixture(fixture: PublicMcpFixture = {}) { consentSubmission.mockClear(); const original = window.fetch; - let rows = fixture.empty ? [] : (fixture.connections ?? 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" : row.revokedAt })); 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) => { diff --git a/ui/storybook/stories/public-mcp-connections.stories.tsx b/ui/storybook/stories/public-mcp-connections.stories.tsx index 04a0f3eba46..fd6623e7599 100644 --- a/ui/storybook/stories/public-mcp-connections.stories.tsx +++ b/ui/storybook/stories/public-mcp-connections.stories.tsx @@ -1,5 +1,5 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; -import { expect, userEvent, within } from "storybook/test"; +import { expect, userEvent, waitFor, within } from "storybook/test"; import { AssistantConnectionsPage } from "@/pages/McpConnect"; import { installPublicMcpFixture } from "../fixtures/publicMcp"; const meta = { @@ -13,11 +13,14 @@ export const Connected: Story = {}; export const Empty: Story = { parameters: { fixture: { empty: true } } }; export const Loading: Story = { parameters: { fixture: { loading: true } } }; export const Unavailable: Story = { parameters: { fixture: { unavailable: true } } }; -export const AlreadyRevoked: Story = { parameters: { fixture: { revoked: true } } }; +export const AlreadyRevoked: Story = { parameters: { fixture: { revoked: true } }, play: async ({ canvasElement }) => { + await expect(await within(canvasElement).findByText("No assistant connections.")).toBeVisible(); +} }; export const Revoke: Story = { play: async ({ canvasElement }) => { const c = within(canvasElement); await userEvent.click((await c.findAllByRole("button", { name: "Revoke connection" }))[0]); - await c.findByText("Revoked"); + await waitFor(() => expect(c.queryByText("Dotta’s Codex connection")).not.toBeInTheDocument()); + await c.findByText("Dotta’s Claude connection"); await expect(c.getAllByRole("button", { name: "Revoke connection" })).toHaveLength(1); } }; export const RevokeFailed: Story = { parameters: { fixture: { mutationError: true } }, play: async ({ canvasElement }) => { diff --git a/ui/storybook/stories/public-mcp-journey.stories.tsx b/ui/storybook/stories/public-mcp-journey.stories.tsx index 61de6adb9ac..a4c43830ffb 100644 --- a/ui/storybook/stories/public-mcp-journey.stories.tsx +++ b/ui/storybook/stories/public-mcp-journey.stories.tsx @@ -7,7 +7,7 @@ import { Browse } from "@/pages/apps/Browse"; import { AssistantConnection } from "@/pages/apps/AssistantConnection"; import { InstanceExperimentalSettings } from "@/pages/InstanceExperimentalSettings"; import { PublicMcpPresenter } from "./public-mcp-presenter"; -import { installPublicMcpFixture } from "../fixtures/publicMcp"; +import { connectedUser, installPublicMcpFixture } from "../fixtures/publicMcp"; function ConnectionJourney({ assistant = "opencode" }: { assistant?: "codex" | "claude" | "opencode" | "other" }) { return @@ -39,7 +39,7 @@ export const OpenCodeSetup: Story = { parameters: { initialEntries: ["/PAP/apps/ 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: "opencode", companyId: "company-storybook", companyName: "Paperclip", clientName: "OpenCode", user: connectedUser, 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 } } }; @@ -65,3 +65,10 @@ export const InvitationCopied: Story = { ...OpenCodeSetup, play: async ({ canvas await expect(await navigator.clipboard.readText()).toContain("/mcp/setup?company="); } }; export const ManualSetup: Story = { ...OpenCodeSetup, play: async ({ canvasElement }) => { await userEvent.click(await within(canvasElement).findByText("Set up manually")); } }; + +export const RevokeConnection: Story = { ...Connected, play: async ({ canvasElement }) => { + const c = within(canvasElement); + await userEvent.click(await c.findByRole("button", { name: "Revoke Dotta’s OpenCode connection" })); + await expect(await c.findByText("No assistants connected to Paperclip Storybook yet.")).toBeVisible(); + await expect(c.queryByText("Dotta’s OpenCode connection")).not.toBeInTheDocument(); +} };