Skip to content

Commit 5ddadbd

Browse files
vibeguiclaude
andcommitted
feat(connect-studio): topbar LINK button + one-click Connect to Claude modal
Adds a prominent "LINK" button to the app topbar (desktop) that opens a focused "Connect to Claude" modal built around a single action: - Claude Code: one button copies the `claude mcp add … <org-mcp-url>` one-liner (paste in a terminal; OAuth on first use). - Claude Desktop / claude.ai: copy the aggregate MCP URL to add as a custom connector. The modal spells out what Claude gets — Library files, agents, and the ability to enable + call any MCP tool in the org — since the unified `/api/:org/mcp` endpoint already exposes all of it behind OAuth 2.1. Also: - Extract mcpUrl/claudeCodeCommand into components/connect/mcp-url.ts and reuse from the Connect settings page so the two can't drift. - Fix the advertised OAuth protected-resource metadata URL on the Connect settings page: it's served at the aggregate endpoint (`/api/:org/mcp/.well-known/oauth-protected-resource`), not the origin root. Matches the backend contract in #4263. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 269e235 commit 5ddadbd

4 files changed

Lines changed: 203 additions & 9 deletions

File tree

Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
/**
2+
* Topbar "LINK" button + one-click "Connect to Claude" modal.
3+
*
4+
* The org's unified MCP endpoint (`/api/<slug>/mcp`) already exposes every
5+
* connection enabled in the org — the library filesystem, your agents, and any
6+
* MCP tool — behind OAuth 2.1. So "connecting Claude" is just handing Claude
7+
* that one URL. This dialog does exactly that with a single primary action:
8+
* • Claude Code → copy the `claude mcp add …` one-liner (paste in a terminal)
9+
* • Claude Desktop / claude.ai → copy the URL to add as a custom connector
10+
*
11+
* The full Connect settings page (Cursor, Codex, API keys, key management)
12+
* stays reachable via the footer link for power users.
13+
*/
14+
15+
import { useState } from "react";
16+
import { Link } from "@tanstack/react-router";
17+
import { toast } from "sonner";
18+
import { Button } from "@deco/ui/components/button.tsx";
19+
import {
20+
Dialog,
21+
DialogContent,
22+
DialogDescription,
23+
DialogHeader,
24+
DialogTitle,
25+
} from "@deco/ui/components/dialog.tsx";
26+
import { useCopy } from "@deco/ui/hooks/use-copy.ts";
27+
import { useProjectContext } from "@decocms/mesh-sdk";
28+
import {
29+
ArrowRight,
30+
Check,
31+
Copy01,
32+
FolderCode,
33+
Link01,
34+
Terminal,
35+
Zap,
36+
} from "@untitledui/icons";
37+
import { track } from "@/web/lib/posthog-client";
38+
import { claudeCodeCommand, mcpUrl } from "@/web/components/connect/mcp-url";
39+
40+
const CAPABILITIES = [
41+
"Browse and edit your Library files",
42+
"Run your agents",
43+
"Enable and call any MCP tool in this org",
44+
];
45+
46+
function ConnectDialogBody({ onClose }: { onClose: () => void }) {
47+
const { org } = useProjectContext();
48+
const url = mcpUrl(org.slug);
49+
const command = claudeCodeCommand(org.slug);
50+
51+
const commandCopy = useCopy();
52+
const urlCopy = useCopy();
53+
54+
return (
55+
<>
56+
<DialogHeader>
57+
<DialogTitle className="flex items-center gap-2">
58+
<span className="size-7 rounded-md bg-special/15 text-special flex items-center justify-center shrink-0">
59+
<Link01 size={16} />
60+
</span>
61+
Connect {org.name} to Claude
62+
</DialogTitle>
63+
<DialogDescription>
64+
Hand Claude this org's unified MCP endpoint. Once linked, Claude can:
65+
</DialogDescription>
66+
</DialogHeader>
67+
68+
<ul className="flex flex-col gap-1.5 text-sm">
69+
{CAPABILITIES.map((cap) => (
70+
<li key={cap} className="flex items-center gap-2">
71+
<Zap size={14} className="text-special shrink-0" />
72+
<span>{cap}</span>
73+
</li>
74+
))}
75+
</ul>
76+
77+
{/* Claude Code — the true one-click: copy, paste, done. */}
78+
<div className="rounded-lg border border-border p-3 flex flex-col gap-2.5">
79+
<div className="flex items-center gap-2 text-sm font-medium">
80+
<Terminal size={15} className="text-muted-foreground" />
81+
Claude Code
82+
</div>
83+
<div className="flex items-center gap-2 rounded-md border border-border bg-muted/40 px-3 py-2">
84+
<code className="text-xs flex-1 truncate font-mono">{command}</code>
85+
</div>
86+
<Button
87+
size="sm"
88+
className="gap-1.5 self-start"
89+
onClick={() => {
90+
commandCopy.handleCopy(command);
91+
track("connect_studio_copy", { target: "claude-code" });
92+
toast.success("Command copied — paste it in your terminal");
93+
}}
94+
>
95+
{commandCopy.copied ? <Check size={14} /> : <Copy01 size={14} />}
96+
{commandCopy.copied ? "Copied" : "Copy connect command"}
97+
</Button>
98+
</div>
99+
100+
{/* Claude Desktop / claude.ai — paste the URL as a custom connector. */}
101+
<div className="rounded-lg border border-border p-3 flex flex-col gap-2.5">
102+
<div className="flex items-center gap-2 text-sm font-medium">
103+
<FolderCode size={15} className="text-muted-foreground" />
104+
Claude Desktop or claude.ai
105+
</div>
106+
<p className="text-xs text-muted-foreground">
107+
Add a custom connector in Settings → Connectors and paste this URL.
108+
Claude signs in with OAuth on first use.
109+
</p>
110+
<div className="flex items-center gap-2 rounded-md border border-border bg-muted/40 px-3 py-1.5">
111+
<code className="text-xs flex-1 truncate font-mono">{url}</code>
112+
<Button
113+
variant="ghost"
114+
size="icon"
115+
className="size-7 shrink-0"
116+
aria-label="Copy URL"
117+
onClick={() => {
118+
urlCopy.handleCopy(url);
119+
track("connect_studio_copy", { target: "claude-connector" });
120+
}}
121+
>
122+
{urlCopy.copied ? <Check size={14} /> : <Copy01 size={14} />}
123+
</Button>
124+
</div>
125+
</div>
126+
127+
<div className="flex items-center justify-between pt-1">
128+
<Button asChild variant="link" size="sm" className="px-0 text-xs gap-1">
129+
<Link
130+
to="/$org/settings/connect"
131+
params={{ org: org.slug }}
132+
onClick={onClose}
133+
>
134+
More clients & API keys <ArrowRight size={12} />
135+
</Link>
136+
</Button>
137+
</div>
138+
</>
139+
);
140+
}
141+
142+
/**
143+
* The "LINK" affordance for the app topbar. Self-contained: owns its own open
144+
* state so it can be dropped into any header slot.
145+
*/
146+
export function ConnectLinkButton() {
147+
const [open, setOpen] = useState(false);
148+
149+
return (
150+
<>
151+
<Button
152+
variant="outline"
153+
size="sm"
154+
className="gap-1.5"
155+
onClick={() => {
156+
track("connect_studio_opened", { source: "topbar" });
157+
setOpen(true);
158+
}}
159+
>
160+
<Link01 size={14} />
161+
LINK
162+
</Button>
163+
<Dialog open={open} onOpenChange={setOpen}>
164+
<DialogContent className="max-w-md gap-4">
165+
<ConnectDialogBody onClose={() => setOpen(false)} />
166+
</DialogContent>
167+
</Dialog>
168+
</>
169+
);
170+
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
/**
2+
* Shared helpers for building this org's unified MCP endpoint URL and the
3+
* one-line `claude mcp add` command. Kept in one place so the topbar "LINK"
4+
* dialog and the full Connect settings page can't drift apart.
5+
*/
6+
7+
/** MCP server name registered in the client's config (e.g. `studio`). */
8+
const CONNECT_SERVER_NAME = "studio";
9+
10+
/** The org-scoped unified MCP endpoint: `<origin>/api/<slug>/mcp`. */
11+
export function mcpUrl(orgSlug: string): string {
12+
const origin =
13+
typeof window === "undefined"
14+
? "http://localhost:3000"
15+
: window.location.origin;
16+
return `${origin}/api/${orgSlug}/mcp`;
17+
}
18+
19+
/**
20+
* One-liner that adds this org to Claude Code over OAuth. Pasting it into a
21+
* terminal is the closest thing to a one-click "connect to Claude" — the
22+
* browser opens on first use to sign in, then every tool in the org is live.
23+
*/
24+
export function claudeCodeCommand(orgSlug: string): string {
25+
return `claude mcp add --transport http --scope user ${CONNECT_SERVER_NAME} ${mcpUrl(orgSlug)}`;
26+
}

apps/mesh/src/web/layouts/org-shell-layout/index.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import { StudioSidebar, StudioSidebarMobile } from "@/web/components/sidebar";
3030
import { ChatPrefsProvider } from "@/web/components/chat/context";
3131
import { ThreadManagerProvider } from "@/web/components/chat/store/hooks";
3232
import { LinkedDesktopIndicator } from "@/web/components/header/linked-desktop-indicator";
33+
import { ConnectLinkButton } from "@/web/components/connect/connect-dialog";
3334
import { Toolbar } from "@/web/layouts/agent-shell-layout/toolbar";
3435
import {
3536
MobileSidebarSheet,
@@ -83,6 +84,7 @@ export default function OrgShellLayout() {
8384
<div className="min-w-0 flex-1 overflow-x-auto [scrollbar-width:none] flex justify-end">
8485
<Toolbar.TabsSlot />
8586
</div>
87+
<ConnectLinkButton />
8688
<Toolbar.RightSlot />
8789
</Toolbar.RightColumn>
8890
</Toolbar.Header>

apps/mesh/src/web/views/settings/org-connect.tsx

Lines changed: 5 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import {
2525
type ConnectClient,
2626
InstallSnippet,
2727
} from "@/web/components/connect/install-snippet";
28+
import { mcpUrl } from "@/web/components/connect/mcp-url";
2829
import {
2930
useApiKeysList,
3031
useCreateApiKey,
@@ -50,14 +51,6 @@ function hostnameLabel(): string {
5051
return window.location.hostname;
5152
}
5253

53-
function mcpUrl(orgSlug: string): string {
54-
const origin =
55-
typeof window === "undefined"
56-
? "http://localhost:3000"
57-
: window.location.origin;
58-
return `${origin}/api/${orgSlug}/mcp`;
59-
}
60-
6154
function CopyInline({ text }: { text: string }) {
6255
const { handleCopy, copied } = useCopy();
6356
return (
@@ -246,7 +239,10 @@ export function OrgConnectPage() {
246239
);
247240
};
248241

249-
const oauthMetadataUrl = `${url.replace(/\/api\/.*$/, "")}/.well-known/oauth-protected-resource`;
242+
// Protected-resource metadata is served at the aggregate MCP endpoint itself
243+
// (`/api/:org/mcp/.well-known/oauth-protected-resource`), not the origin root
244+
// — that's the path clients discover from the 401 WWW-Authenticate header.
245+
const oauthMetadataUrl = `${url}/.well-known/oauth-protected-resource`;
250246

251247
return (
252248
<Page>

0 commit comments

Comments
 (0)