fix(mcp): surface connection status failures and guide setup

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-10-05 16:34:36 -05:00
1 parent baeb1ffe2e
commit c1f1342a96
6 files changed
+74 -10

No files matched your search

+15 -3
View File
@@ -3,7 +3,7 @@ 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";
import { AssistantConnection, AssistantConnectionCard } 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 } }) }));
@@ -13,8 +13,8 @@ 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>));
async function render(element: React.ReactNode = <AssistantConnection initialAssistant="opencode" />) {
await act(async () => root.render(<QueryClientProvider client={client}>{element}</QueryClientProvider>));
await flush();
}
beforeEach(() => {
@@ -26,6 +26,18 @@ beforeEach(() => {
});
afterEach(async () => { await act(async () => root.unmount()); client.clear(); container.remove(); vi.clearAllMocks(); });
describe("assistant setup from Connections", () => {
it("surfaces catalog status failures and recovers without claiming there are no connections", async () => {
mocks.connections.mockRejectedValue(new Error("offline"));
await render(<AssistantConnectionCard onNavigate={vi.fn()} />);
expect(container.querySelector('[role="alert"]')?.textContent).toContain("Couldn’t load your connection status");
expect(container.querySelector('[aria-label="Set up Assistant Connection (MCP)"]')).toBeNull();
expect(container.querySelector('[data-connected]')).toBeNull();
mocks.connections.mockResolvedValue([grant]);
await act(async () => Array.from(container.querySelectorAll('button')).find(b => b.textContent === "Try again")!.click());
await flush();
expect(container.querySelector('[aria-label="Manage Assistant Connection (MCP)"]')).not.toBeNull();
expect(container.querySelector('[role="alert"]')).toBeNull();
});
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("{"))!;
+11 -5
View File
@@ -26,18 +26,24 @@ function useConnections(poll = false) {
/** 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">
const connections = useConnections();
const active = connections.rows.filter(row => !row.revokedAt);
const action = !connections.isSuccess ? "Open" : active.length ? "Manage" : "Set up";
return <div role="listitem" data-app-slug="assistant-connection" data-connected={connections.isSuccess ? String(active.length > 0) : undefined} 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>
<Button type="button" size="sm" variant="outline" onClick={() => onNavigate(ASSISTANT_CONNECTION_PATH)} aria-label={`${action} Assistant Connection (MCP)`}>{action}</Button>
</div>
{active.length > 0 && <div className="flex flex-wrap items-center gap-2 border-t border-border px-4 py-3 text-sm">
{connections.isPending && <p className="border-t border-border px-4 py-3 text-xs text-muted-foreground">Checking your connection status…</p>}
{connections.isError && <div role="alert" className="flex flex-wrap items-center justify-between gap-3 border-t border-border px-4 py-3">
<p className="text-xs text-destructive">Couldn’t load your connection status.</p>
<Button size="sm" variant="ghost" disabled={connections.isFetching} onClick={() => void connections.refetch()}>Try again</Button>
</div>}
{connections.isSuccess && 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>
+2 -1
View File
@@ -18,6 +18,7 @@ export interface PublicMcpFixture {
request?: Partial<McpConnectionRequest>;
loading?: boolean;
unavailable?: boolean;
connectionsUnavailable?: boolean;
pending?: boolean;
mutationError?: boolean;
empty?: boolean;
@@ -46,7 +47,7 @@ export function installPublicMcpFixture(fixture: PublicMcpFixture = {}) {
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/connections") return fixture.connectionsUnavailable ? error("Connection status is unavailable.") : Response.json(rows);
if (path === `/api/mcp/requests/${request.id}`) return Response.json({ ...request, ...fixture.request });
} else {
if (fixture.pending) return new Promise<Response>(() => {});
@@ -18,6 +18,7 @@ const chooseOrganization: NonNullable<Story["play"]> = async ({ canvasElement })
await userEvent.click(await c.findByRole("radio", { name: "Acme Research" }));
};
export const ChooseOrganization: Story = {};
export const OpenCodeOrganization: Story = { parameters: { fixture: { request: { clientName: "OpenCode", redirectOrigin: "http://127.0.0.1:19876", companies: [{ ...request.companies[0], name: "Paperclip Storybook" }] } } } };
export const AllowDelegation: Story = { play: async context => {
await chooseOrganization(context);
const c = within(context.canvasElement);
@@ -6,6 +6,7 @@ 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 { PublicMcpPresenter } from "./public-mcp-presenter";
import { installPublicMcpFixture } from "../fixtures/publicMcp";
function ConnectionJourney({ assistant = "opencode" }: { assistant?: "codex" | "claude" | "opencode" | "other" }) {
@@ -30,7 +31,10 @@ const meta = {
} satisfies Meta<typeof ConnectionJourney>;
export default meta;
type Story = StoryObj<typeof meta>;
export const GuidedWalkthrough: Story = {};
export const GuidedWalkthrough: Story = { render: () => <PublicMcpPresenter /> };
export const Connections: Story = {};
export const EnableSetup: Story = { parameters: { initialEntries: ["/PAP/apps/assistant-connection"] } };
export const ConnectionStatusUnavailable: Story = { parameters: { fixture: { enabled: true, connectionsUnavailable: true } } };
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" } };
@@ -0,0 +1,40 @@
import { useState } from "react";
import { Button } from "@/components/ui/button";
const chapters = [
{ title: "Start inside Paperclip", place: "Paperclip → Connectors", action: "You are already in your organization. Open Connectors in the sidebar and find Assistant Connection (MCP). Click Set up.", result: "This connects an outside assistant to Paperclip using your human account. There is no agent picker.", story: "assistant-connections-start-here--connections" },
{ title: "Enable the experiment", place: "Setup → Experimental settings → Setup", action: "When access is off, follow Open Experimental settings. Enable Assistant connections (MCP), then use the Set up an assistant connection link beside the setting to return.", result: "Enabling makes connection setup available. No assistant is authorized yet.", story: "assistant-connections-start-here--enable-setup" },
{ title: "Configure and sign in from OpenCode", place: "Paperclip setup → your terminal", action: "Choose OpenCode. Merge the displayed entry into opencode.json, then run opencode mcp auth paperclip from that directory. That command opens browser sign-in and consent. The server URL and command are both supplied here.", result: "The assistant creates the authorization request and owns its callback. Paperclip does not provide a reusable consent URL. These commands are instructions in Storybook; it does not run your terminal.", story: "assistant-connections-start-here--open-code-setup" },
{ title: "Approve your organization", place: "Browser sign-in → Paperclip consent", action: "Sign in if needed, select your organization, review write access, and click Connect organization. The browser returns authorization to OpenCode. The preview below is a fixture and issues no credentials.", result: "The grant uses your permissions in this organization. Selecting an agent to receive work later does not change your identity.", story: "assistant-connections-consent--open-code-organization" },
{ title: "Review your organization in OpenCode", place: "Terminal → opencode web → new conversation", action: "After sign-in completes, start opencode web in the same directory. Ask which Paperclip organization is connected and which agents and tasks it has.", result: "The assistant should read live organization and agent data before suggesting an assignee.", scene: ["Example request: “Which Paperclip organization am I connected to? Show me its agents and open tasks.”", "Expected behavior: use the connection, agent-list, and task-search tools. This is an example, not a captured OpenCode response."] },
{ title: "Delegate a task", place: "OpenCode → Paperclip task", action: "Ask an available agent to produce a small result, such as three names for a neighborhood plant nursery. Ask for the task link.", result: "One durable task is created as you. Paperclip schedules the agent with its configured credentials, environment, budget, and approvals. A created task is not proof that work has completed.", scene: ["Example request: “Have our researcher propose three nursery names, explain each briefly, and save a report on the task.”", "Expected behavior: return a durable task reference and disclose the scheduling effect. No task is created by this Storybook panel."] },
{ title: "Return for results and feedback", place: "A later OpenCode conversation", action: "Open a new conversation, find the task, and ask for its saved report. Add feedback as yourself if write access was approved.", result: "The task and report persist outside the original chat. Feedback can wake the agent and use the organization’s execution budget.", scene: ["Example request: “Find my nursery naming task and summarize the completed report.”", "Example feedback: “Add my comment: Meadow & Root is my favorite.”", "Expected behavior: read current progress and the stored document, then attribute any requested comment to the connected person. These are illustrative prompts, not live results."] },
{ title: "Return to Connections", place: "Paperclip → Connectors → Assistant Connection (MCP)", action: "Open the same catalog entry to inspect your connected assistants and their read/write access. Revoke OpenCode if you want to stop future calls.", result: "The list shows your actual grants for the selected organization. Revocation does not cancel work already delegated. This checkpoint uses sample grant data.", story: "assistant-connections-start-here--connected" },
];
/** Presenter controls stay outside the actual product iframe. */
export function PublicMcpPresenter() {
const [index, setIndex] = useState(0);
const chapter = chapters[index]!;
return <main className="space-y-5 p-5" aria-label="Assistant connection guided walkthrough">
<header className="space-y-2">
<p className="text-xs font-medium text-muted-foreground">STORYBOOK WALKTHROUGH · SAMPLE DATA</p>
<h1 className="text-xl font-semibold">Start in Paperclip. Bring your organization into your assistant.</h1>
<p className="text-sm text-muted-foreground">Click through the real product screens below. Presenter chapters explain the terminal and assistant handoffs; they do not simulate live OAuth or model execution.</p>
</header>
<nav aria-label="Journey chapters" className="flex flex-wrap gap-2">{chapters.map((item, n) => <Button key={item.title} size="sm" variant={n === index ? "secondary" : "ghost"} aria-current={n === index ? "step" : undefined} onClick={() => setIndex(n)}>{n + 1}. {item.title}</Button>)}</nav>
<section className="space-y-2 rounded-lg border border-border p-4" aria-live="polite">
<p className="text-xs text-muted-foreground">{chapter.place}</p>
<h2 className="text-lg font-semibold">{index + 1}. {chapter.title}</h2>
<p className="text-sm">{chapter.action}</p>
<p className="text-sm text-muted-foreground">{chapter.result}</p>
<nav aria-label="Walkthrough controls" className="flex items-center justify-between gap-3 pt-3">
<Button variant="outline" disabled={index === 0} onClick={() => setIndex(n => n - 1)}>Previous chapter</Button>
<span className="text-xs text-muted-foreground">{index + 1} / {chapters.length}</span>
<Button onClick={() => setIndex(n => n === chapters.length - 1 ? 0 : n + 1)}>{index === chapters.length - 1 ? "Restart walkthrough" : "Next chapter"}</Button>
</nav>
</section>
{chapter.story ? <iframe key={chapter.story} className="h-screen w-full rounded-lg border border-border" src={`./iframe.html?id=${chapter.story}&viewMode=story&globals=theme:dark`} title={`${chapter.title} — real product screen with mocked services`} />
: <section className="space-y-4 rounded-lg border border-border p-6"><p className="text-xs font-medium text-muted-foreground">ILLUSTRATIVE CONVERSATION · NO LIVE EXECUTION</p>{chapter.scene?.map(text => <p className="text-sm" key={text}>{text}</p>)}</section>}
</main>;
}