mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-06 14:02:36 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - App connections must work in both the operator UI and agent tool gateway. > - The first stack layer adds secure remote connections. > - Operators still need clear setup, test, and recovery states. > - This pull request adds the gateway behavior and the workspace connection experience. > - The benefit is a connection flow that is easier to understand and recover. ## Linked Issues or Issue Description Refs #11965 This is stack 2 of 11. It depends on stack 1 and replaces another reviewable part of #11965. ## What Changed - Improve remote tool gateway connection behavior. - Add clearer app setup, test, and recovery states. - Add focused server and UI tests for the new paths. - Keep the diff isolated from later identity and catalog work. - Stabilize DNS-pinned remote HTTP protocol fixtures and the managed-runtime public-origin fixture for this independently tested layer. ## Verification - `pnpm -r typecheck` - `pnpm --filter @paperclipai/server exec vitest run src/__tests__/tool-access-service.test.ts` (150 passed) - `pnpm test:run` - `pnpm check:token-gates` - `pnpm build` ## Risks - Gateway errors now surface through new user-facing states. - A stale connection can require a new setup attempt. - The change does not add a database migration. - The injected HTTP transport and public URL are test-only fixtures; production DNS pinning and runtime behavior are unchanged. > I checked `ROADMAP.md`. This stack continues the existing app connection work from #11965 and does not duplicate another planned item. ## Model Used OpenAI Codex, GPT-5. The runtime model ID and context window were not exposed. The model used reasoning, tool use, and code execution. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links - [x] My branch name describes the change and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
362 lines
13 KiB
TypeScript
362 lines
13 KiB
TypeScript
import { type ComponentType, useEffect, useMemo, useState } from "react";
|
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
|
import {
|
|
Bot,
|
|
Braces,
|
|
Check,
|
|
Code2,
|
|
Copy,
|
|
HelpCircle,
|
|
Link as LinkIcon,
|
|
MousePointer2,
|
|
TerminalSquare,
|
|
} from "lucide-react";
|
|
import type {
|
|
ToolMcpGatewayClientSnippet,
|
|
ToolMcpGatewayTokenCreated,
|
|
ToolMcpGatewayWithTokens,
|
|
} from "@paperclipai/shared";
|
|
import { toolsApi } from "@/api/tools";
|
|
import { SearchableSelect, type SearchableSelectGroup } from "@/components/SearchableSelect";
|
|
import { Button } from "@/components/ui/button";
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from "@/components/ui/dialog";
|
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
|
import { useToast } from "@/context/ToastContext";
|
|
import { copyTextToClipboard } from "@/lib/clipboard";
|
|
import { cn } from "@/lib/utils";
|
|
import {
|
|
defaultGatewayTokenName,
|
|
formatHydratedSnippetConfig,
|
|
maskedTokenLabel,
|
|
orderedSnippets,
|
|
tokenStatus,
|
|
} from "./gateway-helpers";
|
|
import { gatewaysQueryKey } from "./NewGatewayDialog";
|
|
|
|
type PanelKey = string;
|
|
type ClientIcon = ComponentType<{ className?: string }>;
|
|
|
|
const CLIENT_ICONS: Record<ToolMcpGatewayClientSnippet["client"], ClientIcon> = {
|
|
cursor: MousePointer2,
|
|
claude_desktop: Bot,
|
|
vscode: Code2,
|
|
claude_code: TerminalSquare,
|
|
opencode: Braces,
|
|
};
|
|
|
|
type TokenOption = {
|
|
key: string;
|
|
value: string;
|
|
label: string;
|
|
title: string;
|
|
searchText: string;
|
|
token: ToolMcpGatewayTokenCreated;
|
|
};
|
|
|
|
export function ConnectClientDialog({
|
|
gateway,
|
|
open,
|
|
onOpenChange,
|
|
createdTokens,
|
|
onTokenCreated,
|
|
}: {
|
|
gateway: ToolMcpGatewayWithTokens;
|
|
open: boolean;
|
|
onOpenChange: (open: boolean) => void;
|
|
createdTokens: ToolMcpGatewayTokenCreated[];
|
|
onTokenCreated: (token: ToolMcpGatewayTokenCreated) => void;
|
|
}) {
|
|
const queryClient = useQueryClient();
|
|
const { pushToast } = useToast();
|
|
const snippets = useMemo(() => orderedSnippets(gateway.clientSnippets ?? []), [gateway.clientSnippets]);
|
|
const endpoint = useMemo(() => {
|
|
const origin = typeof window !== "undefined" ? window.location.origin : "";
|
|
return `${origin}${gateway.endpointPath}`;
|
|
}, [gateway.endpointPath]);
|
|
|
|
const availableTokens = useMemo(
|
|
() => createdTokens.filter((createdToken) => {
|
|
const persisted = gateway.tokens.find((token) => token.id === createdToken.id);
|
|
const status = tokenStatus(persisted ?? createdToken);
|
|
return status === "active" || status === "expiring";
|
|
}),
|
|
[createdTokens, gateway.tokens],
|
|
);
|
|
const tokenGroups = useMemo<SearchableSelectGroup<string, TokenOption>[]>(() => [{
|
|
id: "tokens",
|
|
label: "Available this session",
|
|
options: availableTokens.map((token) => ({
|
|
key: token.id,
|
|
value: token.id,
|
|
label: token.name,
|
|
title: token.clientLabel,
|
|
searchText: `${token.name} ${token.clientLabel} ${token.tokenPrefix}`,
|
|
token,
|
|
})),
|
|
}], [availableTokens]);
|
|
|
|
const [active, setActive] = useState<PanelKey>(snippets[0]?.client ?? "raw_url");
|
|
const [selectedTokenId, setSelectedTokenId] = useState("");
|
|
const selectedToken = availableTokens.find((token) => token.id === selectedTokenId) ?? null;
|
|
|
|
useEffect(() => {
|
|
if (!open) return;
|
|
setActive(snippets[0]?.client ?? "raw_url");
|
|
setSelectedTokenId((current) =>
|
|
availableTokens.some((token) => token.id === current) ? current : availableTokens[0]?.id ?? "",
|
|
);
|
|
}, [availableTokens, open, snippets]);
|
|
|
|
const issueTokenMutation = useMutation({
|
|
mutationFn: () => {
|
|
const name = defaultGatewayTokenName(gateway);
|
|
return toolsApi.createGatewayToken(gateway.companyId, gateway.id, {
|
|
name,
|
|
clientLabel: name,
|
|
ownerNote: "",
|
|
allowedActions: ["tools/list", "tools/call"],
|
|
expiresAt: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000).toISOString(),
|
|
});
|
|
},
|
|
onSuccess: async (token) => {
|
|
onTokenCreated(token);
|
|
setSelectedTokenId(token.id);
|
|
pushToast({
|
|
title: "Token issued",
|
|
body: "The copy buttons now include its full Authorization header.",
|
|
tone: "success",
|
|
});
|
|
await queryClient.invalidateQueries({ queryKey: gatewaysQueryKey(gateway.companyId) });
|
|
},
|
|
onError: (error) => pushToast({
|
|
title: "Token was not issued",
|
|
body: error instanceof Error ? error.message : String(error),
|
|
tone: "error",
|
|
}),
|
|
});
|
|
|
|
async function copyText(value: string, label: string) {
|
|
try {
|
|
await copyTextToClipboard(value);
|
|
pushToast({ title: "Copied", body: label, tone: "success" });
|
|
} catch (error) {
|
|
pushToast({
|
|
title: "Copy failed",
|
|
body: error instanceof Error ? error.message : "Clipboard access is unavailable.",
|
|
tone: "error",
|
|
});
|
|
}
|
|
}
|
|
|
|
const activeSnippet = snippets.find((snippet) => snippet.client === active) ?? null;
|
|
const displayConfigText = activeSnippet
|
|
? formatHydratedSnippetConfig(activeSnippet.config, {
|
|
endpointPath: gateway.endpointPath,
|
|
endpoint,
|
|
token: selectedToken ? maskedTokenLabel(selectedToken) : "pcgw_•••",
|
|
})
|
|
: "";
|
|
const copyConfigText = activeSnippet && selectedToken
|
|
? formatHydratedSnippetConfig(activeSnippet.config, {
|
|
endpointPath: gateway.endpointPath,
|
|
endpoint,
|
|
token: selectedToken.token,
|
|
})
|
|
: null;
|
|
|
|
function issueToken() {
|
|
if (!issueTokenMutation.isPending) issueTokenMutation.mutate();
|
|
}
|
|
|
|
return (
|
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
<DialogContent className="sm:max-w-2xl">
|
|
<DialogHeader>
|
|
<DialogTitle className="flex items-center gap-2">
|
|
Client snippets
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<button type="button" aria-label="About client snippets" className="text-muted-foreground hover:text-foreground">
|
|
<HelpCircle className="h-4 w-4" />
|
|
</button>
|
|
</TooltipTrigger>
|
|
<TooltipContent className="max-w-xs text-xs">
|
|
Give this MCP gateway configuration to your tool. It does not give it access to Paperclip or
|
|
skills; it only gateways calls between the client and the tools exposed here.
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
</DialogTitle>
|
|
<DialogDescription>
|
|
Choose a client and copy a complete, authenticated configuration.
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
<div className="flex flex-wrap items-center gap-2 border-b border-border pb-3">
|
|
<span className="text-xs font-medium text-muted-foreground">Authorization</span>
|
|
{availableTokens.length > 0 ? (
|
|
<SearchableSelect<string, TokenOption>
|
|
value={selectedTokenId}
|
|
groups={tokenGroups}
|
|
onValueChange={setSelectedTokenId}
|
|
placeholder="Issue a token"
|
|
searchPlaceholder="Search tokens…"
|
|
emptyMessage="No copyable tokens."
|
|
contentWidth="auto"
|
|
triggerClassName="h-8 w-auto max-w-xs rounded-full px-3"
|
|
renderValue={(option) => option ? `${option.label} · ${maskedTokenLabel(option.token)}` : "Issue a token"}
|
|
renderOption={(option) => (
|
|
<span className="flex min-w-0 flex-col">
|
|
<span className="truncate">{option.label}</span>
|
|
<span className="truncate font-mono text-(length:--text-micro) text-muted-foreground">
|
|
{maskedTokenLabel(option.token)}
|
|
</span>
|
|
</span>
|
|
)}
|
|
createItem={{
|
|
render: () => <span>+ Issue a new token</span>,
|
|
onSelect: issueToken,
|
|
}}
|
|
/>
|
|
) : (
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="sm"
|
|
className="rounded-full"
|
|
disabled={issueTokenMutation.isPending}
|
|
onClick={issueToken}
|
|
>
|
|
{issueTokenMutation.isPending ? "Issuing…" : "Issue a token"}
|
|
</Button>
|
|
)}
|
|
{selectedToken ? (
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => void copyText(`Authorization: Bearer ${selectedToken.token}`, "Authorization header")}
|
|
>
|
|
<Copy className="mr-1 h-3.5 w-3.5" />
|
|
Copy header
|
|
</Button>
|
|
) : null}
|
|
</div>
|
|
|
|
{!selectedToken ? (
|
|
<p className="text-xs text-muted-foreground">
|
|
Issue a token before copying a snippet; the full <code>Authorization: Bearer …</code> header is
|
|
required. Existing token secrets cannot be retrieved again, so only tokens issued in this page
|
|
session can fill a snippet.
|
|
</p>
|
|
) : null}
|
|
|
|
<div className="grid gap-4 sm:grid-cols-(--gtc-10)">
|
|
<nav className="flex gap-1 overflow-x-auto sm:flex-col" aria-label="Clients">
|
|
{snippets.map((snippet) => {
|
|
const Icon = CLIENT_ICONS[snippet.client];
|
|
return (
|
|
<button
|
|
key={snippet.client}
|
|
type="button"
|
|
onClick={() => setActive(snippet.client)}
|
|
className={cn(
|
|
"flex shrink-0 items-center gap-2 rounded-md px-3 py-1.5 text-left text-sm transition-colors",
|
|
active === snippet.client
|
|
? "bg-muted font-medium text-foreground"
|
|
: "text-muted-foreground hover:bg-muted/60",
|
|
)}
|
|
>
|
|
<Icon className="h-4 w-4 shrink-0" />
|
|
{snippet.label}
|
|
</button>
|
|
);
|
|
})}
|
|
<button
|
|
type="button"
|
|
onClick={() => setActive("raw_url")}
|
|
className={cn(
|
|
"flex shrink-0 items-center gap-2 rounded-md px-3 py-1.5 text-left text-sm transition-colors",
|
|
active === "raw_url"
|
|
? "bg-muted font-medium text-foreground"
|
|
: "text-muted-foreground hover:bg-muted/60",
|
|
)}
|
|
>
|
|
<LinkIcon className="h-4 w-4 shrink-0" />
|
|
Raw URL
|
|
</button>
|
|
</nav>
|
|
|
|
<div className="min-w-0 space-y-3">
|
|
{active === "raw_url" ? (
|
|
<div className="space-y-3">
|
|
<div className="space-y-1.5">
|
|
<div className="text-sm font-medium text-foreground">Endpoint URL</div>
|
|
<div className="flex items-center gap-2">
|
|
<code className="min-w-0 flex-1 truncate rounded-md bg-muted px-3 py-2 font-mono text-xs text-muted-foreground">
|
|
{endpoint}
|
|
</code>
|
|
<Button variant="outline" size="sm" onClick={() => void copyText(endpoint, "Endpoint URL")}>
|
|
<Copy className="mr-1 h-3.5 w-3.5" />
|
|
Copy
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<div className="text-sm font-medium text-foreground">Authorization header</div>
|
|
<code className="block truncate rounded-md bg-muted px-3 py-2 font-mono text-xs text-muted-foreground">
|
|
{selectedToken ? `Authorization: Bearer ${maskedTokenLabel(selectedToken)}` : "Authorization: Bearer pcgw_•••"}
|
|
</code>
|
|
</div>
|
|
</div>
|
|
) : activeSnippet ? (
|
|
<div className="space-y-1.5">
|
|
<div className="flex items-center justify-between gap-2">
|
|
<div className="text-sm font-medium text-foreground">{activeSnippet.label}</div>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
disabled={!copyConfigText}
|
|
onClick={() => copyConfigText && void copyText(copyConfigText, `${activeSnippet.label} config`)}
|
|
>
|
|
<Copy className="mr-1 h-3.5 w-3.5" />
|
|
Copy
|
|
</Button>
|
|
</div>
|
|
<pre className="max-h-64 overflow-auto whitespace-pre-wrap break-words rounded-md bg-muted p-3 font-mono text-xs text-muted-foreground">
|
|
{displayConfigText}
|
|
</pre>
|
|
{activeSnippet.notes.length > 0 ? (
|
|
<ul className="space-y-1 text-xs text-muted-foreground">
|
|
{activeSnippet.notes.map((note) => <li key={note}>{note}</li>)}
|
|
</ul>
|
|
) : null}
|
|
</div>
|
|
) : (
|
|
<p className="text-sm text-muted-foreground">No client snippets available for this gateway.</p>
|
|
)}
|
|
|
|
<p className="text-xs text-muted-foreground">
|
|
Treat the token like a password. Anyone holding it can call the tools this gateway allows. Revoke
|
|
it if it leaks.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<DialogFooter>
|
|
<Button onClick={() => onOpenChange(false)}>
|
|
<Check className="mr-1.5 h-4 w-4" />
|
|
Done
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|