Skip to content

Commit 72b2c61

Browse files
vibeguiclaude
andcommitted
feat(connect-studio): add settings page to plug Studio MCP into IDEs
Adds /$org/settings/connect with paste-ready install snippets for Claude Code, Cursor, Codex, Claude Desktop, and a raw URL. Each client has an OAuth tab (no token, browser pops on first use) and an API key tab that mints a key via API_KEY_CREATE. Claude Code commands default to user scope so the MCP is available across all projects. Adds a Connect Studio entry to the main sidebar footer (next to Connections) and a sibling settings nav item under Organization. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 61ceb16 commit 72b2c61

10 files changed

Lines changed: 744 additions & 1 deletion

File tree

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import { useState } from "react";
2+
import { Link } from "@tanstack/react-router";
3+
import { Alert, AlertDescription } from "@deco/ui/components/alert.tsx";
4+
import { Button } from "@deco/ui/components/button.tsx";
5+
import { useProjectContext } from "@decocms/mesh-sdk";
6+
import { ArrowRight, LinkExternal01, XClose } from "@untitledui/icons";
7+
8+
function storageKey(orgId: string) {
9+
return `connect-banner-dismissed:${orgId}`;
10+
}
11+
12+
function readDismissed(orgId: string): boolean {
13+
if (typeof window === "undefined") return true;
14+
try {
15+
return localStorage.getItem(storageKey(orgId)) === "1";
16+
} catch {
17+
return false;
18+
}
19+
}
20+
21+
export function ConnectBanner() {
22+
const { org } = useProjectContext();
23+
const [dismissed, setDismissed] = useState(() => readDismissed(org.id));
24+
25+
if (dismissed) return null;
26+
27+
const handleDismiss = () => {
28+
setDismissed(true);
29+
try {
30+
localStorage.setItem(storageKey(org.id), "1");
31+
} catch {
32+
// ignore
33+
}
34+
};
35+
36+
return (
37+
<Alert variant="info" className="items-center">
38+
<LinkExternal01 />
39+
<AlertDescription className="flex-1 flex items-center justify-between gap-2 text-sm">
40+
<span>
41+
Use Studio MCP anywhere — paste a command into Claude Code, Cursor,
42+
Codex, or any MCP client.
43+
</span>
44+
<div className="flex items-center gap-1 shrink-0">
45+
<Button asChild size="sm" variant="ghost" className="text-xs gap-1">
46+
<Link to="/$org/settings/connect" params={{ org: org.slug }}>
47+
Connect to clients <ArrowRight size={12} />
48+
</Link>
49+
</Button>
50+
<Button
51+
variant="ghost"
52+
size="icon"
53+
className="size-7"
54+
onClick={handleDismiss}
55+
aria-label="Dismiss"
56+
>
57+
<XClose size={14} />
58+
</Button>
59+
</div>
60+
</AlertDescription>
61+
</Alert>
62+
);
63+
}
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
import { Button } from "@deco/ui/components/button.tsx";
2+
import { useCopy } from "@deco/ui/hooks/use-copy.ts";
3+
import { Check, Copy01 } from "@untitledui/icons";
4+
5+
export type ConnectClient =
6+
| "claude-code"
7+
| "cursor"
8+
| "codex"
9+
| "claude-desktop"
10+
| "raw";
11+
12+
export type ConnectMode = "oauth" | "api-key";
13+
14+
const SERVER_NAME = "studio";
15+
16+
interface SnippetBlock {
17+
language: string;
18+
code: string;
19+
/** Optional preamble line (e.g. file path the user should edit). */
20+
pathHint?: string;
21+
}
22+
23+
export function buildSnippet({
24+
client,
25+
mode,
26+
url,
27+
apiKey,
28+
}: {
29+
client: ConnectClient;
30+
mode: ConnectMode;
31+
url: string;
32+
apiKey?: string;
33+
}): SnippetBlock {
34+
const key = apiKey ?? "<paste-your-api-key>";
35+
36+
if (client === "claude-code") {
37+
if (mode === "oauth") {
38+
return {
39+
language: "bash",
40+
code: `claude mcp add --transport http --scope user ${SERVER_NAME} ${url}`,
41+
};
42+
}
43+
return {
44+
language: "bash",
45+
code: `claude mcp add --transport http --scope user ${SERVER_NAME} ${url} \\\n --header "Authorization: Bearer ${key}"`,
46+
};
47+
}
48+
49+
if (client === "cursor") {
50+
const server: Record<string, unknown> = { url };
51+
if (mode === "api-key") {
52+
server.headers = { Authorization: `Bearer ${key}` };
53+
}
54+
return {
55+
language: "json",
56+
pathHint: "~/.cursor/mcp.json",
57+
code: JSON.stringify({ mcpServers: { [SERVER_NAME]: server } }, null, 2),
58+
};
59+
}
60+
61+
if (client === "codex") {
62+
const lines = [`[mcp_servers.${SERVER_NAME}]`, `url = "${url}"`];
63+
if (mode === "api-key") {
64+
lines.push(`http_headers = { "Authorization" = "Bearer ${key}" }`);
65+
}
66+
return {
67+
language: "toml",
68+
pathHint: "~/.codex/config.toml",
69+
code: lines.join("\n"),
70+
};
71+
}
72+
73+
if (client === "claude-desktop") {
74+
const server: Record<string, unknown> = { type: "http", url };
75+
if (mode === "api-key") {
76+
server.headers = { Authorization: `Bearer ${key}` };
77+
}
78+
return {
79+
language: "json",
80+
pathHint: "claude_desktop_config.json",
81+
code: JSON.stringify({ mcpServers: { [SERVER_NAME]: server } }, null, 2),
82+
};
83+
}
84+
85+
// raw
86+
if (mode === "oauth") {
87+
return {
88+
language: "text",
89+
code: `${url}\n\n# OAuth: clients that support MCP OAuth 2.1 will discover\n# the auth flow via the WWW-Authenticate header on 401.`,
90+
};
91+
}
92+
return {
93+
language: "text",
94+
code: `${url}\n\nAuthorization: Bearer ${key}`,
95+
};
96+
}
97+
98+
export function InstallSnippet({
99+
client,
100+
mode,
101+
url,
102+
apiKey,
103+
}: {
104+
client: ConnectClient;
105+
mode: ConnectMode;
106+
url: string;
107+
apiKey?: string;
108+
}) {
109+
const snippet = buildSnippet({ client, mode, url, apiKey });
110+
const { handleCopy, copied } = useCopy();
111+
112+
return (
113+
<div className="relative rounded-md border border-border bg-muted/40">
114+
<div className="flex items-center justify-between px-3 py-1.5 border-b border-border">
115+
<span className="text-[11px] text-muted-foreground font-medium uppercase tracking-wider">
116+
{snippet.pathHint ?? snippet.language}
117+
</span>
118+
<Button
119+
variant="ghost"
120+
size="icon"
121+
className="size-7 shrink-0"
122+
onClick={() => handleCopy(snippet.code)}
123+
aria-label="Copy snippet"
124+
>
125+
{copied ? <Check size={14} /> : <Copy01 size={14} />}
126+
</Button>
127+
</div>
128+
<pre className="p-3 text-xs overflow-x-auto font-mono">
129+
<code>{snippet.code}</code>
130+
</pre>
131+
</div>
132+
);
133+
}

apps/mesh/src/web/components/sidebar/footer/inbox.tsx

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,13 @@ import {
1717
TooltipTrigger,
1818
} from "@deco/ui/components/tooltip.tsx";
1919
import { Button } from "@deco/ui/components/button.tsx";
20-
import { ArrowLeft, Inbox01, Settings02, ZapSquare } from "@untitledui/icons";
20+
import {
21+
ArrowLeft,
22+
Inbox01,
23+
LinkExternal01,
24+
Settings02,
25+
ZapSquare,
26+
} from "@untitledui/icons";
2127
import { useState, type ReactNode } from "react";
2228
import { useProjectContext } from "@decocms/mesh-sdk";
2329
import { useNavigate } from "@tanstack/react-router";
@@ -226,6 +232,50 @@ function ConnectionsIconButton() {
226232
);
227233
}
228234

235+
function ConnectStudioFullButton() {
236+
const navigate = useNavigate();
237+
const { org } = useProjectContext();
238+
return (
239+
<SidebarMenuButton
240+
tooltip="Connect Studio"
241+
onClick={() => {
242+
track("connect_studio_opened", { source: "sidebar_footer" });
243+
navigate({
244+
to: "/$org/settings/connect",
245+
params: { org: org.slug },
246+
});
247+
}}
248+
>
249+
<LinkExternal01 />
250+
<span>Connect Studio</span>
251+
</SidebarMenuButton>
252+
);
253+
}
254+
255+
function ConnectStudioIconButton() {
256+
const navigate = useNavigate();
257+
const { org } = useProjectContext();
258+
return (
259+
<Tooltip delayDuration={300}>
260+
<TooltipTrigger asChild>
261+
<ToolbarIconButton
262+
aria-label="Connect Studio"
263+
onClick={() => {
264+
track("connect_studio_opened", { source: "sidebar_footer" });
265+
navigate({
266+
to: "/$org/settings/connect",
267+
params: { org: org.slug },
268+
});
269+
}}
270+
>
271+
<LinkExternal01 className="size-4" />
272+
</ToolbarIconButton>
273+
</TooltipTrigger>
274+
<TooltipContent side="top">Connect Studio</TooltipContent>
275+
</Tooltip>
276+
);
277+
}
278+
229279
function SettingsFullButton() {
230280
const navigate = useNavigate();
231281
const { org } = useProjectContext();
@@ -280,6 +330,9 @@ export function SidebarInboxFooter() {
280330
<SidebarMenuItem>
281331
<ConnectionsFullButton />
282332
</SidebarMenuItem>
333+
<SidebarMenuItem>
334+
<ConnectStudioFullButton />
335+
</SidebarMenuItem>
283336
<SidebarMenuItem>
284337
<InboxFullButton />
285338
</SidebarMenuItem>
@@ -306,6 +359,7 @@ export function SidebarInboxFooter() {
306359
</div>
307360
<SettingsIconButton />
308361
<InboxIconButton />
362+
<ConnectStudioIconButton />
309363
<ConnectionsIconButton />
310364
<SidebarTopActionsInline />
311365
</div>

0 commit comments

Comments
 (0)