From 569100476e000d24b6eae8636e98dee594668cf0 Mon Sep 17 00:00:00 2001 From: Guilherme Rodrigues Date: Thu, 28 May 2026 13:38:11 -0300 Subject: [PATCH 01/12] 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) --- .../web/components/connect/connect-banner.tsx | 63 ++++ .../components/connect/install-snippet.tsx | 133 +++++++ .../web/components/sidebar/footer/inbox.tsx | 56 ++- apps/mesh/src/web/hooks/use-api-keys.ts | 120 ++++++ apps/mesh/src/web/index.tsx | 9 + apps/mesh/src/web/layouts/settings-layout.tsx | 7 + apps/mesh/src/web/lib/query-keys.ts | 4 + .../src/web/routes/orgs/settings/connect.tsx | 5 + .../src/web/views/settings/org-connect.tsx | 346 ++++++++++++++++++ .../src/web/views/settings/org-general.tsx | 2 + 10 files changed, 744 insertions(+), 1 deletion(-) create mode 100644 apps/mesh/src/web/components/connect/connect-banner.tsx create mode 100644 apps/mesh/src/web/components/connect/install-snippet.tsx create mode 100644 apps/mesh/src/web/hooks/use-api-keys.ts create mode 100644 apps/mesh/src/web/routes/orgs/settings/connect.tsx create mode 100644 apps/mesh/src/web/views/settings/org-connect.tsx diff --git a/apps/mesh/src/web/components/connect/connect-banner.tsx b/apps/mesh/src/web/components/connect/connect-banner.tsx new file mode 100644 index 0000000000..8afd9903a0 --- /dev/null +++ b/apps/mesh/src/web/components/connect/connect-banner.tsx @@ -0,0 +1,63 @@ +import { useState } from "react"; +import { Link } from "@tanstack/react-router"; +import { Alert, AlertDescription } from "@deco/ui/components/alert.tsx"; +import { Button } from "@deco/ui/components/button.tsx"; +import { useProjectContext } from "@decocms/mesh-sdk"; +import { ArrowRight, LinkExternal01, XClose } from "@untitledui/icons"; + +function storageKey(orgId: string) { + return `connect-banner-dismissed:${orgId}`; +} + +function readDismissed(orgId: string): boolean { + if (typeof window === "undefined") return true; + try { + return localStorage.getItem(storageKey(orgId)) === "1"; + } catch { + return false; + } +} + +export function ConnectBanner() { + const { org } = useProjectContext(); + const [dismissed, setDismissed] = useState(() => readDismissed(org.id)); + + if (dismissed) return null; + + const handleDismiss = () => { + setDismissed(true); + try { + localStorage.setItem(storageKey(org.id), "1"); + } catch { + // ignore + } + }; + + return ( + + + + + Use Studio MCP anywhere — paste a command into Claude Code, Cursor, + Codex, or any MCP client. + +
+ + +
+
+
+ ); +} diff --git a/apps/mesh/src/web/components/connect/install-snippet.tsx b/apps/mesh/src/web/components/connect/install-snippet.tsx new file mode 100644 index 0000000000..cddb817ee6 --- /dev/null +++ b/apps/mesh/src/web/components/connect/install-snippet.tsx @@ -0,0 +1,133 @@ +import { Button } from "@deco/ui/components/button.tsx"; +import { useCopy } from "@deco/ui/hooks/use-copy.ts"; +import { Check, Copy01 } from "@untitledui/icons"; + +export type ConnectClient = + | "claude-code" + | "cursor" + | "codex" + | "claude-desktop" + | "raw"; + +export type ConnectMode = "oauth" | "api-key"; + +const SERVER_NAME = "studio"; + +interface SnippetBlock { + language: string; + code: string; + /** Optional preamble line (e.g. file path the user should edit). */ + pathHint?: string; +} + +export function buildSnippet({ + client, + mode, + url, + apiKey, +}: { + client: ConnectClient; + mode: ConnectMode; + url: string; + apiKey?: string; +}): SnippetBlock { + const key = apiKey ?? ""; + + if (client === "claude-code") { + if (mode === "oauth") { + return { + language: "bash", + code: `claude mcp add --transport http --scope user ${SERVER_NAME} ${url}`, + }; + } + return { + language: "bash", + code: `claude mcp add --transport http --scope user ${SERVER_NAME} ${url} \\\n --header "Authorization: Bearer ${key}"`, + }; + } + + if (client === "cursor") { + const server: Record = { url }; + if (mode === "api-key") { + server.headers = { Authorization: `Bearer ${key}` }; + } + return { + language: "json", + pathHint: "~/.cursor/mcp.json", + code: JSON.stringify({ mcpServers: { [SERVER_NAME]: server } }, null, 2), + }; + } + + if (client === "codex") { + const lines = [`[mcp_servers.${SERVER_NAME}]`, `url = "${url}"`]; + if (mode === "api-key") { + lines.push(`http_headers = { "Authorization" = "Bearer ${key}" }`); + } + return { + language: "toml", + pathHint: "~/.codex/config.toml", + code: lines.join("\n"), + }; + } + + if (client === "claude-desktop") { + const server: Record = { type: "http", url }; + if (mode === "api-key") { + server.headers = { Authorization: `Bearer ${key}` }; + } + return { + language: "json", + pathHint: "claude_desktop_config.json", + code: JSON.stringify({ mcpServers: { [SERVER_NAME]: server } }, null, 2), + }; + } + + // raw + if (mode === "oauth") { + return { + language: "text", + 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.`, + }; + } + return { + language: "text", + code: `${url}\n\nAuthorization: Bearer ${key}`, + }; +} + +export function InstallSnippet({ + client, + mode, + url, + apiKey, +}: { + client: ConnectClient; + mode: ConnectMode; + url: string; + apiKey?: string; +}) { + const snippet = buildSnippet({ client, mode, url, apiKey }); + const { handleCopy, copied } = useCopy(); + + return ( +
+
+ + {snippet.pathHint ?? snippet.language} + + +
+
+        {snippet.code}
+      
+
+ ); +} diff --git a/apps/mesh/src/web/components/sidebar/footer/inbox.tsx b/apps/mesh/src/web/components/sidebar/footer/inbox.tsx index 8c59858144..1e14f1049d 100644 --- a/apps/mesh/src/web/components/sidebar/footer/inbox.tsx +++ b/apps/mesh/src/web/components/sidebar/footer/inbox.tsx @@ -17,7 +17,13 @@ import { TooltipTrigger, } from "@deco/ui/components/tooltip.tsx"; import { Button } from "@deco/ui/components/button.tsx"; -import { ArrowLeft, Inbox01, Settings02, ZapSquare } from "@untitledui/icons"; +import { + ArrowLeft, + Inbox01, + LinkExternal01, + Settings02, + ZapSquare, +} from "@untitledui/icons"; import { useState, type ReactNode } from "react"; import { useProjectContext } from "@decocms/mesh-sdk"; import { useNavigate } from "@tanstack/react-router"; @@ -240,6 +246,50 @@ function ConnectionsIconButton() { ); } +function ConnectStudioFullButton() { + const navigate = useNavigate(); + const { org } = useProjectContext(); + return ( + { + track("connect_studio_opened", { source: "sidebar_footer" }); + navigate({ + to: "/$org/settings/connect", + params: { org: org.slug }, + }); + }} + > + + Connect Studio + + ); +} + +function ConnectStudioIconButton() { + const navigate = useNavigate(); + const { org } = useProjectContext(); + return ( + + + { + track("connect_studio_opened", { source: "sidebar_footer" }); + navigate({ + to: "/$org/settings/connect", + params: { org: org.slug }, + }); + }} + > + + + + Connect Studio + + ); +} + function SettingsFullButton() { const navigate = useNavigate(); const { org } = useProjectContext(); @@ -299,6 +349,9 @@ export function SidebarInboxFooter() { + + + @@ -326,6 +379,7 @@ export function SidebarInboxFooter() { + diff --git a/apps/mesh/src/web/hooks/use-api-keys.ts b/apps/mesh/src/web/hooks/use-api-keys.ts new file mode 100644 index 0000000000..b908b6da20 --- /dev/null +++ b/apps/mesh/src/web/hooks/use-api-keys.ts @@ -0,0 +1,120 @@ +import { + SELF_MCP_ALIAS_ID, + useMCPClient, + useProjectContext, +} from "@decocms/mesh-sdk"; +import { + useMutation, + useQuery, + useQueryClient, + type UseMutationResult, + type UseQueryResult, +} from "@tanstack/react-query"; +import { KEYS } from "@/web/lib/query-keys"; + +export interface ApiKey { + id: string; + name: string; + userId: string; + permissions: Record; + expiresAt?: string | null; + createdAt: string; +} + +export interface CreatedApiKey extends ApiKey { + key: string; +} + +interface ToolEnvelope { + structuredContent?: T; + isError?: boolean; + content?: Array<{ type?: string; text?: string }>; +} + +function unwrap(result: ToolEnvelope, fallbackMessage: string): T { + if (result?.isError) { + throw new Error(result.content?.[0]?.text ?? fallbackMessage); + } + if (!result.structuredContent) { + throw new Error(fallbackMessage); + } + return result.structuredContent; +} + +export function useApiKeysList(): UseQueryResult { + const { org } = useProjectContext(); + const client = useMCPClient({ + connectionId: SELF_MCP_ALIAS_ID, + orgId: org.id, + orgSlug: org.slug, + }); + + return useQuery({ + queryKey: KEYS.apiKeysList(org.id), + queryFn: async () => { + const result = (await client.callTool({ + name: "API_KEY_LIST", + arguments: {}, + })) as ToolEnvelope<{ items: ApiKey[] }>; + return unwrap(result, "Failed to list API keys").items; + }, + staleTime: 30_000, + }); +} + +export function useCreateApiKey(): UseMutationResult< + CreatedApiKey, + Error, + { name: string; permissions?: Record } +> { + const { org } = useProjectContext(); + const client = useMCPClient({ + connectionId: SELF_MCP_ALIAS_ID, + orgId: org.id, + orgSlug: org.slug, + }); + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (input) => { + const result = (await client.callTool({ + name: "API_KEY_CREATE", + arguments: { + name: input.name, + permissions: input.permissions ?? { "*": ["*"] }, + }, + })) as ToolEnvelope; + return unwrap(result, "Failed to create API key"); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: KEYS.apiKeysList(org.id) }); + }, + }); +} + +export function useDeleteApiKey(): UseMutationResult< + { success: boolean; keyId: string }, + Error, + string +> { + const { org } = useProjectContext(); + const client = useMCPClient({ + connectionId: SELF_MCP_ALIAS_ID, + orgId: org.id, + orgSlug: org.slug, + }); + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (keyId) => { + const result = (await client.callTool({ + name: "API_KEY_DELETE", + arguments: { keyId }, + })) as ToolEnvelope<{ success: boolean; keyId: string }>; + return unwrap(result, "Failed to delete API key"); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: KEYS.apiKeysList(org.id) }); + }, + }); +} diff --git a/apps/mesh/src/web/index.tsx b/apps/mesh/src/web/index.tsx index 5af3d7a56b..308aee26a1 100644 --- a/apps/mesh/src/web/index.tsx +++ b/apps/mesh/src/web/index.tsx @@ -405,6 +405,14 @@ const settingsGeneralRoute = createRoute({ ), }); +const settingsConnectRoute = createRoute({ + getParentRoute: () => settingsLayout, + path: "/connect", + component: lazyRouteComponent( + () => import("./routes/orgs/settings/connect.tsx"), + ), +}); + const settingsBrandContextRoute = createRoute({ getParentRoute: () => settingsLayout, path: "/brand-context", @@ -536,6 +544,7 @@ const settingsWithChildren = settingsLayout.addChildren([ settingsAutomationsRoute, monitoringRoute, settingsGeneralRoute, + settingsConnectRoute, settingsBrandContextRoute, settingsAiProvidersRoute, settingsSecretsRoute, diff --git a/apps/mesh/src/web/layouts/settings-layout.tsx b/apps/mesh/src/web/layouts/settings-layout.tsx index 0a6ab76133..42a950217f 100644 --- a/apps/mesh/src/web/layouts/settings-layout.tsx +++ b/apps/mesh/src/web/layouts/settings-layout.tsx @@ -47,6 +47,7 @@ import { Zap, Key01, HardDrive, + LinkExternal01, } from "@untitledui/icons"; import { useProjectContext } from "@decocms/mesh-sdk"; import { useCapabilities, type CapabilityId } from "@/web/hooks/use-capability"; @@ -97,6 +98,12 @@ function useSettingsSidebarGroups(): SettingsNavGroup[] { to: "/$org/settings/general", requires: "org:manage", }, + { + key: "connect", + label: "Connect to clients", + icon: , + to: "/$org/settings/connect", + }, { key: "brand-context", label: "Brand Context", diff --git a/apps/mesh/src/web/lib/query-keys.ts b/apps/mesh/src/web/lib/query-keys.ts index 52ea66a5c1..142685fe74 100644 --- a/apps/mesh/src/web/lib/query-keys.ts +++ b/apps/mesh/src/web/lib/query-keys.ts @@ -124,6 +124,10 @@ export const KEYS = { organizationSettings: (organizationId: string) => ["organization-settings", organizationId] as const, + // API keys (scoped by organization; the LIST tool filters by org server-side) + apiKeysList: (organizationId: string) => + ["api-keys", organizationId] as const, + // Active organization activeOrganization: (org: string | undefined) => ["activeOrganization", org] as const, diff --git a/apps/mesh/src/web/routes/orgs/settings/connect.tsx b/apps/mesh/src/web/routes/orgs/settings/connect.tsx new file mode 100644 index 0000000000..a8188be99f --- /dev/null +++ b/apps/mesh/src/web/routes/orgs/settings/connect.tsx @@ -0,0 +1,5 @@ +import { OrgConnectPage } from "@/web/views/settings/org-connect"; + +export default function ConnectRoute() { + return ; +} diff --git a/apps/mesh/src/web/views/settings/org-connect.tsx b/apps/mesh/src/web/views/settings/org-connect.tsx new file mode 100644 index 0000000000..0523452ddd --- /dev/null +++ b/apps/mesh/src/web/views/settings/org-connect.tsx @@ -0,0 +1,346 @@ +import { useState } from "react"; +import { toast } from "sonner"; +import { Alert, AlertDescription } from "@deco/ui/components/alert.tsx"; +import { Button } from "@deco/ui/components/button.tsx"; +import { Card } from "@deco/ui/components/card.tsx"; +import { + Tabs, + TabsContent, + TabsList, + TabsTrigger, +} from "@deco/ui/components/tabs.tsx"; +import { useCopy } from "@deco/ui/hooks/use-copy.ts"; +import { useProjectContext } from "@decocms/mesh-sdk"; +import { + AlertTriangle, + Check, + Copy01, + Key01, + LinkExternal01, + Trash01, +} from "@untitledui/icons"; +import { Page } from "@/web/components/page"; +import { SettingsPage } from "@/web/components/settings/settings-section"; +import { + type ConnectClient, + InstallSnippet, +} from "@/web/components/connect/install-snippet"; +import { + useApiKeysList, + useCreateApiKey, + useDeleteApiKey, +} from "@/web/hooks/use-api-keys"; + +const KEY_NAME_PREFIX = "Connect: "; + +const CLIENTS: { id: ConnectClient; label: string }[] = [ + { id: "claude-code", label: "Claude Code" }, + { id: "cursor", label: "Cursor" }, + { id: "codex", label: "Codex" }, + { id: "claude-desktop", label: "Claude Desktop" }, + { id: "raw", label: "Raw URL" }, +]; + +function clientLabel(id: ConnectClient): string { + return CLIENTS.find((c) => c.id === id)?.label ?? id; +} + +function hostnameLabel(): string { + if (typeof window === "undefined") return "unknown"; + return window.location.hostname; +} + +function mcpUrl(orgSlug: string): string { + const origin = + typeof window === "undefined" + ? "http://localhost:3000" + : window.location.origin; + return `${origin}/api/${orgSlug}/mcp`; +} + +function CopyInline({ text }: { text: string }) { + const { handleCopy, copied } = useCopy(); + return ( + + ); +} + +function ClientPanel({ + client, + url, + newKey, + onGenerate, + isGenerating, + onClearNewKey, +}: { + client: ConnectClient; + url: string; + newKey: string | null; + onGenerate: () => void; + isGenerating: boolean; + onClearNewKey: () => void; +}) { + return ( + + + OAuth + API key + + + +

+ Recommended for your laptop. Browser will open on first use to sign in + — no token to manage. +

+ +
+ + +

+ For CI, Conductor, or headless agents that can't open a browser. +

+ {newKey ? ( + <> + + + + Copy this snippet now — the key won't be shown again. You can + revoke it later from the list below. + + + + + + ) : ( + <> + + + + )} +
+
+ ); +} + +function ConnectKeysList() { + const { data, isLoading, error } = useApiKeysList(); + const deleteKey = useDeleteApiKey(); + + const connectKeys = + data?.filter((k) => k.name.startsWith(KEY_NAME_PREFIX)) ?? []; + + if (isLoading) { + return ( +

Loading active keys…

+ ); + } + + if (error) { + return ( +

+ Failed to load keys: {error.message} +

+ ); + } + + if (connectKeys.length === 0) { + return ( +

+ No connect keys minted yet. Generate one from a client tab above for + headless setups. +

+ ); + } + + return ( +
    + {connectKeys.map((key) => ( +
  • +
    +
    + {key.name.replace(KEY_NAME_PREFIX, "")} +
    +
    + Created {new Date(key.createdAt).toLocaleDateString()} +
    +
    + +
  • + ))} +
+ ); +} + +export function OrgConnectPage() { + const { org } = useProjectContext(); + const url = mcpUrl(org.slug); + const createKey = useCreateApiKey(); + const [newKeys, setNewKeys] = useState< + Partial> + >({}); + + const handleGenerate = (client: ConnectClient) => { + const name = `${KEY_NAME_PREFIX}${clientLabel(client)} on ${hostnameLabel()}`; + createKey.mutate( + { name, permissions: { "*": ["*"] } }, + { + onSuccess: (key) => { + setNewKeys((prev) => ({ ...prev, [client]: key.key })); + toast.success("Key created"); + }, + onError: (err) => toast.error(err.message), + }, + ); + }; + + const oauthMetadataUrl = `${url.replace(/\/api\/.*$/, "")}/.well-known/oauth-protected-resource`; + + return ( + + + + + Connect to clients + + +
+
+ +
+
+

+ Your org's unified MCP +

+

+ Plug this URL into any MCP client to give that runtime every + connection enabled in this org, governed by your Decopilot + rules. +

+
+
+
+ {url} + +
+
+ + Wiring a custom client? + +
+

+ OAuth 2.1 Protected Resource Metadata is advertised on 401: +

+
+ + {oauthMetadataUrl} + + +
+
+
+
+ + + + {CLIENTS.map((c) => ( + + {c.label} + + ))} + + + {CLIENTS.map((c) => ( + + handleGenerate(c.id)} + isGenerating={ + createKey.isPending && + createKey.variables?.name?.startsWith( + `${KEY_NAME_PREFIX}${c.label}`, + ) === true + } + onClearNewKey={() => + setNewKeys((prev) => { + const next = { ...prev }; + delete next[c.id]; + return next; + }) + } + /> + + ))} + + +
+
+

+ Active keys +

+

+ Keys you've generated for headless clients. Revoke any time. +

+
+ +
+
+
+
+
+ ); +} diff --git a/apps/mesh/src/web/views/settings/org-general.tsx b/apps/mesh/src/web/views/settings/org-general.tsx index 9cc5110b2d..bdd54628c0 100644 --- a/apps/mesh/src/web/views/settings/org-general.tsx +++ b/apps/mesh/src/web/views/settings/org-general.tsx @@ -1,4 +1,5 @@ import { Page } from "@/web/components/page"; +import { ConnectBanner } from "@/web/components/connect/connect-banner"; import { OrganizationForm } from "@/web/components/settings/organization-form"; import { DomainSettings } from "@/web/components/settings/domain-settings"; import { DeleteOrganizationSection } from "@/web/components/settings/delete-organization-section"; @@ -11,6 +12,7 @@ export function OrgGeneralPage() { Organization + From bbe00df12830f0f1e63e71213e7f86dfacc4f631 Mon Sep 17 00:00:00 2001 From: Guilherme Rodrigues Date: Thu, 28 May 2026 13:40:53 -0300 Subject: [PATCH 02/12] fix(connect-studio): drop unused buildSnippet export to satisfy knip Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/mesh/src/web/components/connect/install-snippet.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/mesh/src/web/components/connect/install-snippet.tsx b/apps/mesh/src/web/components/connect/install-snippet.tsx index cddb817ee6..4c95cd6e6f 100644 --- a/apps/mesh/src/web/components/connect/install-snippet.tsx +++ b/apps/mesh/src/web/components/connect/install-snippet.tsx @@ -20,7 +20,7 @@ interface SnippetBlock { pathHint?: string; } -export function buildSnippet({ +function buildSnippet({ client, mode, url, From 9dba77731510d1404c9319149d8af1dbfec1d1e4 Mon Sep 17 00:00:00 2001 From: Guilherme Rodrigues Date: Thu, 11 Jun 2026 12:26:41 -0300 Subject: [PATCH 03/12] feat(connect-studio): add Connect to Claude entry in account menu Surfaces the org's unified MCP connect page from the account popover/drawer (alongside "Add to Home Screen") so it's always reachable. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/mesh/src/web/components/account-popover.tsx | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/apps/mesh/src/web/components/account-popover.tsx b/apps/mesh/src/web/components/account-popover.tsx index 13f496dad0..64320eb1ed 100644 --- a/apps/mesh/src/web/components/account-popover.tsx +++ b/apps/mesh/src/web/components/account-popover.tsx @@ -25,6 +25,7 @@ import { Download01, File06, Globe01, + LinkExternal01, LogOut01, Monitor01, Moon01, @@ -577,6 +578,20 @@ export function AccountPopover() { }); }, } satisfies MenuItem, + // Connect this org's unified MCP to Claude (Code/Desktop) and + // other MCP clients. Always available inside an org so it's easy + // to find from anywhere. + { + key: "connect-clients", + label: "Connect to Claude", + icon: , + onClick: () => { + navigate({ + to: "/$org/settings/connect", + params: { org: currentOrg.slug }, + }); + }, + } satisfies MenuItem, ] : []), { From 66d91c2ca6463e76e52083574b5c62d643e57273 Mon Sep 17 00:00:00 2001 From: Guilherme Rodrigues Date: Thu, 11 Jun 2026 12:29:05 -0300 Subject: [PATCH 04/12] fix(connect-studio): rename account menu entry to "Connect to Agents" Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/mesh/src/web/components/account-popover.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/mesh/src/web/components/account-popover.tsx b/apps/mesh/src/web/components/account-popover.tsx index 64320eb1ed..8fcae26a2d 100644 --- a/apps/mesh/src/web/components/account-popover.tsx +++ b/apps/mesh/src/web/components/account-popover.tsx @@ -583,7 +583,7 @@ export function AccountPopover() { // to find from anywhere. { key: "connect-clients", - label: "Connect to Claude", + label: "Connect to Agents", icon: , onClick: () => { navigate({ From 62523724a52010a42de7c31e15437e70a2045dca Mon Sep 17 00:00:00 2001 From: Guilherme Rodrigues Date: Sat, 11 Jul 2026 14:13:57 -0300 Subject: [PATCH 05/12] feat(connect-studio): topbar LINK button + one-click Connect to Claude modal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 … ` 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) --- .../web/components/connect/connect-dialog.tsx | 170 ++++++++++++++++++ .../src/web/components/connect/mcp-url.ts | 26 +++ .../web/layouts/org-shell-layout/index.tsx | 2 + .../src/web/views/settings/org-connect.tsx | 14 +- 4 files changed, 203 insertions(+), 9 deletions(-) create mode 100644 apps/mesh/src/web/components/connect/connect-dialog.tsx create mode 100644 apps/mesh/src/web/components/connect/mcp-url.ts diff --git a/apps/mesh/src/web/components/connect/connect-dialog.tsx b/apps/mesh/src/web/components/connect/connect-dialog.tsx new file mode 100644 index 0000000000..2689dbf51a --- /dev/null +++ b/apps/mesh/src/web/components/connect/connect-dialog.tsx @@ -0,0 +1,170 @@ +/** + * Topbar "LINK" button + one-click "Connect to Claude" modal. + * + * The org's unified MCP endpoint (`/api//mcp`) already exposes every + * connection enabled in the org — the library filesystem, your agents, and any + * MCP tool — behind OAuth 2.1. So "connecting Claude" is just handing Claude + * that one URL. This dialog does exactly that with a single primary action: + * • Claude Code → copy the `claude mcp add …` one-liner (paste in a terminal) + * • Claude Desktop / claude.ai → copy the URL to add as a custom connector + * + * The full Connect settings page (Cursor, Codex, API keys, key management) + * stays reachable via the footer link for power users. + */ + +import { useState } from "react"; +import { Link } from "@tanstack/react-router"; +import { toast } from "sonner"; +import { Button } from "@deco/ui/components/button.tsx"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@deco/ui/components/dialog.tsx"; +import { useCopy } from "@deco/ui/hooks/use-copy.ts"; +import { useProjectContext } from "@decocms/mesh-sdk"; +import { + ArrowRight, + Check, + Copy01, + FolderCode, + Link01, + Terminal, + Zap, +} from "@untitledui/icons"; +import { track } from "@/web/lib/posthog-client"; +import { claudeCodeCommand, mcpUrl } from "@/web/components/connect/mcp-url"; + +const CAPABILITIES = [ + "Browse and edit your Library files", + "Run your agents", + "Enable and call any MCP tool in this org", +]; + +function ConnectDialogBody({ onClose }: { onClose: () => void }) { + const { org } = useProjectContext(); + const url = mcpUrl(org.slug); + const command = claudeCodeCommand(org.slug); + + const commandCopy = useCopy(); + const urlCopy = useCopy(); + + return ( + <> + + + + + + Connect {org.name} to Claude + + + Hand Claude this org's unified MCP endpoint. Once linked, Claude can: + + + +
    + {CAPABILITIES.map((cap) => ( +
  • + + {cap} +
  • + ))} +
+ + {/* Claude Code — the true one-click: copy, paste, done. */} +
+
+ + Claude Code +
+
+ {command} +
+ +
+ + {/* Claude Desktop / claude.ai — paste the URL as a custom connector. */} +
+
+ + Claude Desktop or claude.ai +
+

+ Add a custom connector in Settings → Connectors and paste this URL. + Claude signs in with OAuth on first use. +

+
+ {url} + +
+
+ +
+ +
+ + ); +} + +/** + * The "LINK" affordance for the app topbar. Self-contained: owns its own open + * state so it can be dropped into any header slot. + */ +export function ConnectLinkButton() { + const [open, setOpen] = useState(false); + + return ( + <> + + + + setOpen(false)} /> + + + + ); +} diff --git a/apps/mesh/src/web/components/connect/mcp-url.ts b/apps/mesh/src/web/components/connect/mcp-url.ts new file mode 100644 index 0000000000..d378b267ef --- /dev/null +++ b/apps/mesh/src/web/components/connect/mcp-url.ts @@ -0,0 +1,26 @@ +/** + * Shared helpers for building this org's unified MCP endpoint URL and the + * one-line `claude mcp add` command. Kept in one place so the topbar "LINK" + * dialog and the full Connect settings page can't drift apart. + */ + +/** MCP server name registered in the client's config (e.g. `studio`). */ +const CONNECT_SERVER_NAME = "studio"; + +/** The org-scoped unified MCP endpoint: `/api//mcp`. */ +export function mcpUrl(orgSlug: string): string { + const origin = + typeof window === "undefined" + ? "http://localhost:3000" + : window.location.origin; + return `${origin}/api/${orgSlug}/mcp`; +} + +/** + * One-liner that adds this org to Claude Code over OAuth. Pasting it into a + * terminal is the closest thing to a one-click "connect to Claude" — the + * browser opens on first use to sign in, then every tool in the org is live. + */ +export function claudeCodeCommand(orgSlug: string): string { + return `claude mcp add --transport http --scope user ${CONNECT_SERVER_NAME} ${mcpUrl(orgSlug)}`; +} diff --git a/apps/mesh/src/web/layouts/org-shell-layout/index.tsx b/apps/mesh/src/web/layouts/org-shell-layout/index.tsx index c6b2fbfb51..d070293544 100644 --- a/apps/mesh/src/web/layouts/org-shell-layout/index.tsx +++ b/apps/mesh/src/web/layouts/org-shell-layout/index.tsx @@ -30,6 +30,7 @@ import { StudioSidebar, StudioSidebarMobile } from "@/web/components/sidebar"; import { ChatPrefsProvider } from "@/web/components/chat/context"; import { ThreadManagerProvider } from "@/web/components/chat/store/hooks"; import { LinkedDesktopIndicator } from "@/web/components/header/linked-desktop-indicator"; +import { ConnectLinkButton } from "@/web/components/connect/connect-dialog"; import { Toolbar } from "@/web/layouts/agent-shell-layout/toolbar"; import { MobileSidebarSheet, @@ -83,6 +84,7 @@ export default function OrgShellLayout() {
+ diff --git a/apps/mesh/src/web/views/settings/org-connect.tsx b/apps/mesh/src/web/views/settings/org-connect.tsx index 0523452ddd..9394daa191 100644 --- a/apps/mesh/src/web/views/settings/org-connect.tsx +++ b/apps/mesh/src/web/views/settings/org-connect.tsx @@ -25,6 +25,7 @@ import { type ConnectClient, InstallSnippet, } from "@/web/components/connect/install-snippet"; +import { mcpUrl } from "@/web/components/connect/mcp-url"; import { useApiKeysList, useCreateApiKey, @@ -50,14 +51,6 @@ function hostnameLabel(): string { return window.location.hostname; } -function mcpUrl(orgSlug: string): string { - const origin = - typeof window === "undefined" - ? "http://localhost:3000" - : window.location.origin; - return `${origin}/api/${orgSlug}/mcp`; -} - function CopyInline({ text }: { text: string }) { const { handleCopy, copied } = useCopy(); return ( @@ -246,7 +239,10 @@ export function OrgConnectPage() { ); }; - const oauthMetadataUrl = `${url.replace(/\/api\/.*$/, "")}/.well-known/oauth-protected-resource`; + // Protected-resource metadata is served at the aggregate MCP endpoint itself + // (`/api/:org/mcp/.well-known/oauth-protected-resource`), not the origin root + // — that's the path clients discover from the 401 WWW-Authenticate header. + const oauthMetadataUrl = `${url}/.well-known/oauth-protected-resource`; return ( From f5b4d64997813e89ca1c7c69586bfa5227c4e866 Mon Sep 17 00:00:00 2001 From: AriOliv Date: Thu, 2 Jul 2026 12:43:10 -0300 Subject: [PATCH 06/12] fix(mcp-oauth): let external OAuth clients use aggregate/virtual MCP endpoints External MCP clients (Claude Desktop/Code, any RFC 9728 client) could not connect to an org's aggregate (`/api/:org/mcp`) or virtual-MCP endpoints: - The aggregate exposed no oauth-protected-resource metadata (404), and virtual MCPs tried to *proxy* a `virtual://` downstream authorization server and 502'd ("protocol must be http/https/s3"). Neither advertised Studio's own Better Auth MCP authorization server (which supports Dynamic Client Registration), so external clients had no auth server that would accept their own redirect_uri. The connection `oauth-proxy` only accepts Studio's own origin, so it can't serve external clients. - `WWW-Authenticate` advertised an `http://` resource_metadata URL behind a TLS-terminating reverse proxy; https-only clients (e.g. Claude) reject it. - MCP OAuth sessions resolved the member role from `x-org-*` headers or the user's single membership. External clients send neither and the org is in the URL path, so multi-org members resolved to no role, lost the owner/admin bypass, and every connection tool call 403'd `Access denied to: `. Fixes: - api/app.ts (mcpAuth): honor `X-Forwarded-Proto` when building the resource_metadata origin so it advertises https behind a proxy. - api/routes/org-scoped.ts: serve Better Auth protected-resource metadata for the aggregate `/api/:org/mcp/.well-known/oauth-protected-resource`. - api/routes/oauth-proxy.ts: for `virtual://` connections, return Better Auth metadata instead of proxying a nonexistent downstream AS. - core/context-factory.ts: derive an org-slug hint from the request path (`/api/:org/...`) for MCP OAuth membership/role resolution. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/mesh/src/api/app.ts | 9 ++++++++- apps/mesh/src/api/routes/oauth-proxy.ts | 16 ++++++++++++++++ apps/mesh/src/api/routes/org-scoped.ts | 12 ++++++++++++ apps/mesh/src/core/context-factory.ts | 20 ++++++++++++++++++++ 4 files changed, 56 insertions(+), 1 deletion(-) diff --git a/apps/mesh/src/api/app.ts b/apps/mesh/src/api/app.ts index 525cf83bbc..b5e18bfca4 100644 --- a/apps/mesh/src/api/app.ts +++ b/apps/mesh/src/api/app.ts @@ -1781,10 +1781,17 @@ export async function createApp(options: CreateAppOptions = {}) { // Require either user or API key authentication if (!meshContext.auth.user?.id && !meshContext.auth.apiKey?.id) { const url = new URL(c.req.url); + // Behind a TLS-terminating reverse proxy (e.g. Caddy/nginx) the request + // reaches us over http, so `url.origin` would advertise an http:// + // resource_metadata URL and OAuth-capable clients that require https + // (e.g. Claude) reject it. Honor X-Forwarded-Proto so the advertised + // URL matches the public scheme. + const fwdProto = c.req.header("x-forwarded-proto")?.split(",")[0]?.trim(); + const origin = fwdProto ? `${fwdProto}://${url.host}` : url.origin; return (c.res = new Response(null, { status: 401, headers: { - "WWW-Authenticate": `Bearer realm="mcp",resource_metadata="${url.origin}${url.pathname}/.well-known/oauth-protected-resource"`, + "WWW-Authenticate": `Bearer realm="mcp",resource_metadata="${origin}${url.pathname}/.well-known/oauth-protected-resource"`, }, })); } diff --git a/apps/mesh/src/api/routes/oauth-proxy.ts b/apps/mesh/src/api/routes/oauth-proxy.ts index ca6dbe5fea..5756e0e8de 100644 --- a/apps/mesh/src/api/routes/oauth-proxy.ts +++ b/apps/mesh/src/api/routes/oauth-proxy.ts @@ -13,8 +13,10 @@ */ import { Hono } from "hono"; +import { oAuthProtectedResourceMetadata } from "better-auth/plugins"; import { ContextFactory } from "../../core/context-factory"; import type { StudioContext } from "../../core/studio-context"; +import { auth } from "../../auth"; import { retry, RetryError } from "@decocms/std"; import { authorizationServerMetadataUrls, @@ -390,6 +392,20 @@ export const protectedResourceMetadataHandler = async (c: { return c.json({ error: "Connection not found" }, 404); } + // Virtual MCPs (`virtual://`) are Studio-native aggregators with no + // downstream OAuth server to proxy — trying to fetch protected-resource + // metadata from `virtual://` fails ("protocol must be http/https/s3"). Their + // OAuth resource is Studio itself: the Better Auth MCP authorization server, + // which supports Dynamic Client Registration and therefore accepts an + // external MCP client's own `redirect_uri` (e.g. Claude Desktop). The + // connection `oauth-proxy` only accepts Studio's own origin, so it can't + // serve external clients. Hand back Better Auth's metadata instead. + if (connectionUrl.startsWith("virtual://")) { + const res = await oAuthProtectedResourceMetadata(auth)(c.req.raw); + const data = await res.json(); + return Response.json(data, res); + } + const prefix = buildPathPrefix(orgSlug); const proxyResourceUrl = `${requestUrl.origin}${prefix}/mcp/${connectionId}`; // Auth-server URL (the value advertised in `authorization_servers`) stays on diff --git a/apps/mesh/src/api/routes/org-scoped.ts b/apps/mesh/src/api/routes/org-scoped.ts index c262a06e30..3db683620a 100644 --- a/apps/mesh/src/api/routes/org-scoped.ts +++ b/apps/mesh/src/api/routes/org-scoped.ts @@ -134,6 +134,18 @@ export const createOrgScopedApi = (deps: OrgScopedDeps) => { deps.betterAuthProtectedResourceHandler, ); + // Aggregate (Decopilot) MCP endpoint at `/api/:org/mcp` has no connectionId, + // so its OAuth resource is Studio itself — the Better Auth MCP authorization + // server (with Dynamic Client Registration). This lets external MCP clients + // (e.g. Claude Desktop) register their own redirect_uri and log in against + // Studio, instead of the connection `oauth-proxy` which only accepts Studio's + // own origin. Mounted BEFORE the proxy catch-all so the well-known suffix is + // not swallowed as a `:connectionId`. + app.get( + "/mcp/.well-known/oauth-protected-resource", + deps.betterAuthProtectedResourceHandler, + ); + app.route("/mcp", createVirtualMcpRoutes()); app.route("/mcp/self", createSelfRoutes()); app.route("/mcp", createProxyRoutes()); diff --git a/apps/mesh/src/core/context-factory.ts b/apps/mesh/src/core/context-factory.ts index 0e06651fbf..87612362a4 100644 --- a/apps/mesh/src/core/context-factory.ts +++ b/apps/mesh/src/core/context-factory.ts @@ -670,6 +670,21 @@ async function authenticateRequest( // ctx.organization on every request that doesn't target their first org. const orgIdHint = req.headers.get("x-org-id"); const orgSlugHint = req.headers.get("x-org-slug"); + // External MCP clients (Claude Desktop/Code) authenticate via OAuth and + // do NOT send x-org-* headers — the org they target is in the request + // path (`/api/:org/mcp/...`). Without honoring it, a multi-org member + // falls through to the single-membership guard below, resolves to NO + // role, and loses the admin/owner bypass (every connection tool call + // 403s "Access denied"). Derive the slug from the path as a hint. + const pathOrgSlug = (() => { + try { + const segs = new URL(req.url).pathname.split("/").filter(Boolean); + if (segs[0] === "api" && segs[1]) return decodeURIComponent(segs[1]); + } catch { + // Malformed URL — fall through to header/single-membership logic. + } + return undefined; + })(); const membership = await timings.measure("auth_query_membership", () => { const base = db @@ -695,6 +710,11 @@ async function authenticateRequest( .where("organization.slug", "=", orgSlugHint) .executeTakeFirst(); } + if (pathOrgSlug) { + return base + .where("organization.slug", "=", pathOrgSlug) + .executeTakeFirst(); + } // No org hint — only resolve when the user has exactly one membership. // For multi-org users without a hint, return undefined so callers get // no org context instead of a non-deterministic pick (the previous From eb6919326fdac7ec2e9cc138ced38ffb557066de Mon Sep 17 00:00:00 2001 From: AriOliv Date: Fri, 3 Jul 2026 11:49:47 -0300 Subject: [PATCH 07/12] fix(oauth-proxy): per-connection resource indicator override The oauth-proxy hardcoded the RFC 8707 `resource` parameter to `connection.connection_url` when forwarding the authorize/token legs to a downstream MCP's authorization server. This is correct for servers that validate the resource equals their exact endpoint (e.g. Supabase), but breaks servers that only accept the origin: Pipedream (`https://mcp.pipedream.net/v2`) rejects the path-bearing resource with `invalid_request: resource: Invalid or unauthorized resource parameter`, and gates its protected-resource metadata so RFC 9728 discovery can't resolve the canonical value either. Forward `resource = connection.metadata.oauthResource ?? connection.connection_url`, computed once and reused on both the authorize redirect and the token form-body rewrite. Endpoint-strict servers keep the default; origin-only servers set `metadata.oauthResource` (e.g. `https://mcp.pipedream.net`). Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/mesh/src/api/app.ts | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/apps/mesh/src/api/app.ts b/apps/mesh/src/api/app.ts index b5e18bfca4..bd3d91a33d 100644 --- a/apps/mesh/src/api/app.ts +++ b/apps/mesh/src/api/app.ts @@ -346,6 +346,20 @@ const oauthProxyHandler: MiddlewareHandler = async (c) => { let originAuthServer: string | undefined; const connUrl = new URL(connection.connection_url); + // RFC 8707 resource indicator forwarded to the downstream authorization + // server on the authorize/token legs. Defaults to the connection's MCP + // endpoint URL — what most servers validate against (e.g. Supabase requires + // the exact endpoint). Some servers only accept the *origin* and reject a + // path-bearing resource (e.g. Pipedream returns "Invalid or unauthorized + // resource parameter" for ".../v2"). Allow a per-connection override via + // `metadata.oauthResource` for those, falling back to the connection URL. + const resourceOverride = + typeof connection.metadata?.oauthResource === "string" && + connection.metadata.oauthResource.length > 0 + ? connection.metadata.oauthResource + : undefined; + const resourceIndicator = resourceOverride ?? connection.connection_url; + if (resourceRes.ok) { // Origin has Protected Resource Metadata - use authorization_servers from it const resourceData = (await resourceRes.json()) as { @@ -441,7 +455,7 @@ const oauthProxyHandler: MiddlewareHandler = async (c) => { // Some auth servers (like Supabase) validate that the resource is their actual endpoint, // not our proxy. We keep the proxy URL for redirect_uri since that's where we handle the callback. if (targetUrl.searchParams.has("resource")) { - targetUrl.searchParams.set("resource", connection.connection_url); + targetUrl.searchParams.set("resource", resourceIndicator); } // Add smart OAuth params for deco-hosted MCPs to skip org/project selection @@ -514,7 +528,7 @@ const oauthProxyHandler: MiddlewareHandler = async (c) => { // Parse form body and rewrite resource if present const formData = await c.req.formData(); if (formData.has("resource")) { - formData.set("resource", connection.connection_url); + formData.set("resource", resourceIndicator); } const cidRaw = formData.get("client_id"); const csRaw = formData.get("client_secret"); From abc9aa9f0c4dff1c590e388db93e20cd5fe5f2bb Mon Sep 17 00:00:00 2001 From: AriOliv Date: Fri, 3 Jul 2026 18:15:48 -0300 Subject: [PATCH 08/12] fix(aggregate): short namespace code for aggregated tool names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The GatewayClient namespaced each aggregated tool as `${slugify(connectionId)}_${toolName}`. A connection id slug is ~26 chars, so when a downstream MCP client adds its OWN prefix (e.g. Hermes prepends `mcp__`, ~21 chars) the combined name blew past the 64-char tool-name limit (`^[A-Za-z0-9_-]{1,64}$`) — ~40 of 91 tools in a 3-connection aggregate were rejected. Replace the slug prefix with `namespaceCode()`: a 7-char stable FNV-1a hash (`a` + 6 base36, no underscore, so resolveToolTarget's split on the first `_` still works). Worst case drops from ~81 to ~62 chars, fitting even with a second client prefix. Reversible via the same code in stripToolNamespace + the slugToKey map; role permissions and selected_tools are unaffected (they key on connection id, not the namespaced tool name). Aggregated tool names change (clients re-list on handshake, so it's transparent). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/aggregate/gateway-client.test.ts | 77 +++++++++++-------- .../mcp-utils/src/aggregate/gateway-client.ts | 31 +++++++- 2 files changed, 71 insertions(+), 37 deletions(-) diff --git a/packages/mcp-utils/src/aggregate/gateway-client.test.ts b/packages/mcp-utils/src/aggregate/gateway-client.test.ts index f46ee9634b..688b47bd1f 100644 --- a/packages/mcp-utils/src/aggregate/gateway-client.test.ts +++ b/packages/mcp-utils/src/aggregate/gateway-client.test.ts @@ -3,10 +3,14 @@ import type { IClient } from "../client-like.ts"; import { GatewayClient, displayToolName, + namespaceCode, slugify, stripToolNamespace, } from "./gateway-client.ts"; +// Namespaced tool name for a client key, mirroring GatewayClient's scheme. +const ns = (key: string, tool: string) => `${namespaceCode(key)}_${tool}`; + function createMockClient( tools: { name: string }[] = [], resources: { uri: string; name: string }[] = [], @@ -74,7 +78,7 @@ describe("slugify", () => { describe("stripToolNamespace", () => { it("strips clientId prefix", () => { - expect(stripToolNamespace("my-conn_SOME_TOOL", "my-conn")).toBe( + expect(stripToolNamespace(ns("my-conn", "SOME_TOOL"), "my-conn")).toBe( "SOME_TOOL", ); }); @@ -90,18 +94,16 @@ describe("stripToolNamespace", () => { }); it("strips real connection ID prefix", () => { - expect( - stripToolNamespace( - "conn-dvitqc2ooobdzmrd5ky24_hello_world", - "conn-dvitqc2ooobdzmrd5ky24", - ), - ).toBe("hello_world"); + const cid = "conn-dvitqc2ooobdzmrd5ky24"; + expect(stripToolNamespace(ns(cid, "hello_world"), cid)).toBe("hello_world"); }); }); describe("displayToolName", () => { it("strips clientId prefix and formats for display", () => { - expect(displayToolName("my-conn_SOME_TOOL", "my-conn")).toBe("some tool"); + expect(displayToolName(ns("my-conn", "SOME_TOOL"), "my-conn")).toBe( + "some tool", + ); }); it("returns formatted name when no clientId", () => { @@ -111,7 +113,7 @@ describe("displayToolName", () => { describe("GatewayClient", () => { describe("tool namespacing", () => { - it("prefixes tool names with slugified client key", async () => { + it("prefixes tool names with the client key namespace code", async () => { const clientA = createMockClient([{ name: "toolA" }]); const clientB = createMockClient([{ name: "toolB" }]); @@ -123,8 +125,17 @@ describe("GatewayClient", () => { expect(result.tools).toHaveLength(2); const names = result.tools.map((t) => t.name); - expect(names).toContain("a_toolA"); - expect(names).toContain("b_toolB"); + expect(names).toContain(ns("a", "toolA")); + expect(names).toContain(ns("b", "toolB")); + }); + + it("keeps namespaced tool names short (fits 64-char limit under a second prefix)", () => { + // conn ids are ~26 chars; the namespace code must be short so a + // downstream client can add its own prefix and still stay <= 64. + expect(namespaceCode("conn_MMGhTBDv1JmlGbsdHmSn5").length).toBeLessThan( + 9, + ); + expect(namespaceCode("conn_MMGhTBDv1JmlGbsdHmSn5")).not.toContain("_"); }); it("tags tools with _meta.gatewayClientId", async () => { @@ -132,7 +143,7 @@ describe("GatewayClient", () => { const gw = new GatewayClient({ myKey: { client: clientA } }); const result = await gw.listTools(); - expect(result.tools[0].name).toBe("mykey_toolA"); + expect(result.tools[0].name).toBe(ns("myKey", "toolA")); expect((result.tools[0]._meta as any).gatewayClientId).toBe("myKey"); }); @@ -147,18 +158,15 @@ describe("GatewayClient", () => { const result = await gw.listTools(); expect(result.tools).toHaveLength(2); - expect(result.tools.map((t) => t.name)).toEqual(["a_search", "b_search"]); + expect(result.tools.map((t) => t.name)).toEqual([ + ns("a", "search"), + ns("b", "search"), + ]); }); - it("throws on duplicate slugified keys", () => { - const client = createMockClient(); - expect( - () => - new GatewayClient({ - "My Server": { client }, - "my--server": { client }, - }), - ).toThrow(/duplicate slug/); + it("gives distinct keys distinct namespace codes", () => { + expect(namespaceCode("alpha")).not.toBe(namespaceCode("beta")); + expect(namespaceCode("conn_a")).not.toBe(namespaceCode("conn_b")); }); }); @@ -168,7 +176,7 @@ describe("GatewayClient", () => { const gw = new GatewayClient({ server: { client } }); const result = await gw.listPrompts(); - expect(result.prompts[0].name).toBe("server_greet"); + expect(result.prompts[0].name).toBe(ns("server", "greet")); }); }); @@ -182,7 +190,7 @@ describe("GatewayClient", () => { b: { client: clientB }, }); - await gw.callTool({ name: "b_toolB", arguments: {} }); + await gw.callTool({ name: ns("b", "toolB"), arguments: {} }); expect(clientB.callTool).toHaveBeenCalledWith( { name: "toolB", arguments: {} }, undefined, @@ -195,7 +203,7 @@ describe("GatewayClient", () => { const client = createMockClient([{ name: "doStuff" }]); const gw = new GatewayClient({ srv: { client } }); - await gw.callTool({ name: "srv_doStuff", arguments: { x: 1 } }); + await gw.callTool({ name: ns("srv", "doStuff"), arguments: { x: 1 } }); expect(client.callTool).toHaveBeenCalledWith( { name: "doStuff", arguments: { x: 1 } }, undefined, @@ -233,7 +241,7 @@ describe("GatewayClient", () => { b: { client: clientB }, }); - await gw.getPrompt({ name: "b_promptB", arguments: {} }); + await gw.getPrompt({ name: ns("b", "promptB"), arguments: {} }); expect(clientB.getPrompt).toHaveBeenCalledWith({ name: "promptB", arguments: {}, @@ -304,7 +312,7 @@ describe("GatewayClient", () => { const result = await gw.listTools(); expect(result.tools).toHaveLength(1); - expect(result.tools[0].name).toBe("async_async_tool"); + expect(result.tools[0].name).toBe(ns("async", "async_tool")); }); }); @@ -323,9 +331,9 @@ describe("GatewayClient", () => { const result = await gw.listTools(); expect(result.tools).toHaveLength(2); const names = result.tools.map((t) => t.name); - expect(names).toContain("c_toolA"); - expect(names).toContain("c_toolC"); - expect(names).not.toContain("c_toolB"); + expect(names).toContain(ns("c", "toolA")); + expect(names).toContain(ns("c", "toolC")); + expect(names).not.toContain(ns("c", "toolB")); }); it("empty tools array blocks all tools", async () => { @@ -338,7 +346,7 @@ describe("GatewayClient", () => { }); const result = await gw.listTools(); - expect(result.tools.map((t) => t.name)).toEqual(["b_t2"]); + expect(result.tools.map((t) => t.name)).toEqual([ns("b", "t2")]); }); it("filters resources by selected URIs", async () => { @@ -386,7 +394,7 @@ describe("GatewayClient", () => { const result = await gw.listPrompts(); expect(result.prompts).toHaveLength(1); - expect(result.prompts[0].name).toBe("c_p2"); + expect(result.prompts[0].name).toBe(ns("c", "p2")); }); it("per-client selection across multiple clients", async () => { @@ -402,7 +410,10 @@ describe("GatewayClient", () => { }); const result = await gw.listTools(); - expect(result.tools.map((t) => t.name)).toEqual(["a_a1", "b_b2"]); + expect(result.tools.map((t) => t.name)).toEqual([ + ns("a", "a1"), + ns("b", "b2"), + ]); }); }); diff --git a/packages/mcp-utils/src/aggregate/gateway-client.ts b/packages/mcp-utils/src/aggregate/gateway-client.ts index b5e52f6cd8..e3ac617624 100644 --- a/packages/mcp-utils/src/aggregate/gateway-client.ts +++ b/packages/mcp-utils/src/aggregate/gateway-client.ts @@ -63,6 +63,29 @@ export function slugify(input: string): string { .replace(/^-|-$/g, ""); } +/** + * Short, stable namespace code for a connection key. + * + * The aggregated tool name is `${namespaceCode(key)}_${toolName}`. Downstream + * clients often add their OWN prefix on top (e.g. Hermes prepends + * `mcp__`, ~21 chars), and tool names are capped at 64 chars by the + * Anthropic API (`^[A-Za-z0-9_-]{1,64}$`). Using the full slugified connection + * id (~26 chars) as the prefix blew the budget once a second prefix was added. + * + * This produces a 7-char code (`a` + 6 base36 chars of an FNV-1a hash) with NO + * underscore, so `resolveToolTarget`'s split on the first `_` still cleanly + * separates the prefix from the (possibly `_`-containing) tool name. Collisions + * are astronomically unlikely for a handful of connections and, if they ever + * happen, the GatewayClient constructor throws on a duplicate code. + */ +export function namespaceCode(input: string): string { + let h = 0x811c9dc5; + for (let i = 0; i < input.length; i++) { + h = Math.imul(h ^ input.charCodeAt(i), 0x01000193); + } + return `a${(h >>> 0).toString(36).padStart(6, "0").slice(-6)}`; +} + /** * Extract `gatewayClientId` from an item's `_meta` object. * Returns `undefined` when the field is absent or not a string. @@ -89,7 +112,7 @@ export function stripToolNamespace( clientId?: string, ): string { if (!clientId) return namespacedName; - const prefix = `${slugify(clientId)}_`; + const prefix = `${namespaceCode(clientId)}_`; return namespacedName.startsWith(prefix) ? namespacedName.slice(prefix.length) : namespacedName; @@ -151,10 +174,10 @@ export class GatewayClient extends Client { }); this.clients = clients; for (const key of Object.keys(clients)) { - const slug = slugify(key); + const slug = namespaceCode(key); if (this.slugToKey.has(slug)) { throw new Error( - `GatewayClient: duplicate slug "${slug}" from keys "${this.slugToKey.get(slug)}" and "${key}"`, + `GatewayClient: duplicate namespace code "${slug}" from keys "${this.slugToKey.get(slug)}" and "${key}"`, ); } this.slugToKey.set(slug, key); @@ -166,7 +189,7 @@ export class GatewayClient extends Client { // --------------------------------------------------------------------------- private namespace(clientKey: string, name: string): string { - return `${slugify(clientKey)}_${name}`; + return `${namespaceCode(clientKey)}_${name}`; } /** From de3771420bf887b23191b349b2888d2603a71b94 Mon Sep 17 00:00:00 2001 From: AriOliv Date: Sat, 11 Jul 2026 14:24:45 -0300 Subject: [PATCH 09/12] fix(mcp-oauth): don't force offline_access on per-connection OAuth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Only request scopes the connection has explicitly configured. offline_access is an OIDC-ism many MCP providers don't advertise; passing it into Dynamic Client Registration makes strict servers reject the registration outright (e.g. Pipedrive returns HTTP 400 on /register). Refresh tokens are already requested via grant_types, so omitting scope lets such servers grant their default set. Cherry-picked from #4263 (AriOliv). The paired circuit-breaker change to lazy-client.ts is omitted here — it depends on an outbound/errors helper that isn't yet on main. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/web/components/details/connection/index.tsx | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/apps/mesh/src/web/components/details/connection/index.tsx b/apps/mesh/src/web/components/details/connection/index.tsx index cd86c90928..b43c9986f8 100644 --- a/apps/mesh/src/web/components/details/connection/index.tsx +++ b/apps/mesh/src/web/components/details/connection/index.tsx @@ -304,10 +304,19 @@ function ConnectionInspectorViewWithConnection({ }; const handleAuthenticateForId = async (connId: string) => { + // Only request scopes the connection has explicitly configured. Do NOT + // hardcode "offline_access": it's an OIDC-ism many MCP providers don't + // advertise, and passing it into Dynamic Client Registration makes strict + // servers reject the registration outright (e.g. Pipedrive returns HTTP 400 + // on /register). Refresh tokens are already requested via grant_types, so + // omitting scope lets such servers grant their default scope set. + const configuredScopes = connection.configuration_scopes?.length + ? connection.configuration_scopes.join(" ") + : undefined; const { token, tokenInfo, error } = await authenticateMcp({ connectionId: connId, orgSlug: projectOrg.slug, - scope: "offline_access", + ...(configuredScopes ? { scope: configuredScopes } : {}), }); if (error || !token) { toast.error(`Authentication failed: ${error}`); From 81265d863d0590ff9100e3b3f4b674ddfc4e0c34 Mon Sep 17 00:00:00 2001 From: Guilherme Rodrigues Date: Sat, 11 Jul 2026 16:22:50 -0300 Subject: [PATCH 10/12] feat(connect-studio): one-command Claude Code connect (no OAuth), Link button left MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses the "I don't want to run /mcp and auth" flow. The OAuth path requires an interactive `/mcp` browser login (and was failing with HTTP 400 on reconnect). The modal's primary Claude Code action now mints a scoped full-access API key and embeds it in the command: claude mcp add --transport http --scope user studio \ --header "Authorization: Bearer " Claude Code sends the token on the first request — no /mcp, no browser login, tools live immediately. Best-effort auto-copy on generate with a visible copy button as fallback, plus a warning that the command carries a full-access token (revocable in Settings → Connect). Also per feedback: rename the topbar button "LINK" → "Link" and move it to the left column (next to the sidebar trigger). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../web/components/connect/connect-dialog.tsx | 121 ++++++++++++++---- .../src/web/components/connect/mcp-url.ts | 15 ++- .../web/layouts/org-shell-layout/index.tsx | 2 +- 3 files changed, 106 insertions(+), 32 deletions(-) diff --git a/apps/mesh/src/web/components/connect/connect-dialog.tsx b/apps/mesh/src/web/components/connect/connect-dialog.tsx index 2689dbf51a..c6e65ea4a1 100644 --- a/apps/mesh/src/web/components/connect/connect-dialog.tsx +++ b/apps/mesh/src/web/components/connect/connect-dialog.tsx @@ -1,20 +1,23 @@ /** - * Topbar "LINK" button + one-click "Connect to Claude" modal. + * Topbar "Link" button + one-command "Connect to Claude" modal. * * The org's unified MCP endpoint (`/api//mcp`) already exposes every * connection enabled in the org — the library filesystem, your agents, and any - * MCP tool — behind OAuth 2.1. So "connecting Claude" is just handing Claude - * that one URL. This dialog does exactly that with a single primary action: - * • Claude Code → copy the `claude mcp add …` one-liner (paste in a terminal) - * • Claude Desktop / claude.ai → copy the URL to add as a custom connector + * MCP tool. So "connecting Claude" is just handing Claude that one URL plus a + * credential. The primary path here is designed to "just work" with ZERO + * interactive auth: we mint a scoped API key and embed it in the + * `claude mcp add … --header "Authorization: Bearer "` command, so Claude + * Code connects on the first request — no `/mcp`, no browser login. * - * The full Connect settings page (Cursor, Codex, API keys, key management) - * stays reachable via the footer link for power users. + * Claude Desktop / claude.ai can't take a custom header, so those still use the + * URL + OAuth connector flow. The full Connect settings page (Cursor, Codex, + * OAuth, key management) stays reachable via the footer link. */ import { useState } from "react"; import { Link } from "@tanstack/react-router"; import { toast } from "sonner"; +import { Alert, AlertDescription } from "@deco/ui/components/alert.tsx"; import { Button } from "@deco/ui/components/button.tsx"; import { Dialog, @@ -26,6 +29,7 @@ import { import { useCopy } from "@deco/ui/hooks/use-copy.ts"; import { useProjectContext } from "@decocms/mesh-sdk"; import { + AlertTriangle, ArrowRight, Check, Copy01, @@ -35,7 +39,11 @@ import { Zap, } from "@untitledui/icons"; import { track } from "@/web/lib/posthog-client"; -import { claudeCodeCommand, mcpUrl } from "@/web/components/connect/mcp-url"; +import { + claudeCodeCommandWithKey, + mcpUrl, +} from "@/web/components/connect/mcp-url"; +import { useCreateApiKey } from "@/web/hooks/use-api-keys"; const CAPABILITIES = [ "Browse and edit your Library files", @@ -43,14 +51,45 @@ const CAPABILITIES = [ "Enable and call any MCP tool in this org", ]; +const KEY_NAME_PREFIX = "Connect: "; + +function hostnameLabel(): string { + if (typeof window === "undefined") return "unknown host"; + return window.location.hostname; +} + function ConnectDialogBody({ onClose }: { onClose: () => void }) { const { org } = useProjectContext(); const url = mcpUrl(org.slug); - const command = claudeCodeCommand(org.slug); + const createKey = useCreateApiKey(); + const [command, setCommand] = useState(null); const commandCopy = useCopy(); const urlCopy = useCopy(); + const handleGenerate = () => { + createKey.mutate( + { + name: `${KEY_NAME_PREFIX}Claude Code on ${hostnameLabel()}`, + permissions: { "*": ["*"] }, + }, + { + onSuccess: (key) => { + const cmd = claudeCodeCommandWithKey(org.slug, key.key); + setCommand(cmd); + track("connect_studio_generate", { target: "claude-code" }); + // Best-effort auto-copy so it's truly one click; the visible copy + // button is the reliable fallback if the browser blocks it. + navigator.clipboard?.writeText(cmd).then( + () => toast.success("Command copied — paste it in your terminal"), + () => toast.success("Command ready — copy it below"), + ); + }, + onError: (err) => toast.error(err.message), + }, + ); + }; + return ( <> @@ -74,27 +113,57 @@ function ConnectDialogBody({ onClose }: { onClose: () => void }) { ))} - {/* Claude Code — the true one-click: copy, paste, done. */} + {/* Claude Code — one command, no login. We mint a scoped token and embed + it so `claude mcp add` connects on the first request. */}
Claude Code
-
- {command} -
- + + {command ? ( + <> +
+ {command} +
+ + + + + Runs with no login step — the command embeds a full-access token + for this org. Treat it like a password; revoke it any time in + Settings → Connect. + + + + ) : ( + <> +

+ One command, no browser login. We'll mint a scoped access token + and embed it so Claude Code connects instantly. +

+ + + )}
{/* Claude Desktop / claude.ai — paste the URL as a custom connector. */} @@ -158,7 +227,7 @@ export function ConnectLinkButton() { }} > - LINK + Link diff --git a/apps/mesh/src/web/components/connect/mcp-url.ts b/apps/mesh/src/web/components/connect/mcp-url.ts index d378b267ef..fc357f5acb 100644 --- a/apps/mesh/src/web/components/connect/mcp-url.ts +++ b/apps/mesh/src/web/components/connect/mcp-url.ts @@ -17,10 +17,15 @@ export function mcpUrl(orgSlug: string): string { } /** - * One-liner that adds this org to Claude Code over OAuth. Pasting it into a - * terminal is the closest thing to a one-click "connect to Claude" — the - * browser opens on first use to sign in, then every tool in the org is live. + * One-liner that adds this org to Claude Code with a pre-minted bearer token + * baked in as an `Authorization` header. Unlike the OAuth variant this needs + * NO `/mcp` step and NO browser login — Claude Code sends the token on the + * first request and every tool is live immediately. The token is a real + * credential, so this command should be treated like a password. */ -export function claudeCodeCommand(orgSlug: string): string { - return `claude mcp add --transport http --scope user ${CONNECT_SERVER_NAME} ${mcpUrl(orgSlug)}`; +export function claudeCodeCommandWithKey( + orgSlug: string, + apiKey: string, +): string { + return `claude mcp add --transport http --scope user ${CONNECT_SERVER_NAME} ${mcpUrl(orgSlug)} --header "Authorization: Bearer ${apiKey}"`; } diff --git a/apps/mesh/src/web/layouts/org-shell-layout/index.tsx b/apps/mesh/src/web/layouts/org-shell-layout/index.tsx index d070293544..1a91bd4005 100644 --- a/apps/mesh/src/web/layouts/org-shell-layout/index.tsx +++ b/apps/mesh/src/web/layouts/org-shell-layout/index.tsx @@ -77,6 +77,7 @@ export default function OrgShellLayout() { + @@ -84,7 +85,6 @@ export default function OrgShellLayout() {
- From 576b0faab5b598879cd35b5f17541b9ba9f22dfc Mon Sep 17 00:00:00 2001 From: Guilherme Rodrigues Date: Sat, 11 Jul 2026 16:44:54 -0300 Subject: [PATCH 11/12] fix(connect-studio): make the connect command actually work end-to-end MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two real bugs surfaced by testing the generated command against a live local Studio (Claude Code → HTTP 400, then 0 tools): 1. Aggregate org resolution (HTTP 400). `/api/:org/mcp` → handleVirtualMcpRequest resolved the org ONLY from x-org-id/x-org-slug headers, which external MCP clients (Claude Code/Desktop) never send — the org is in the URL path, already in ctx.organization via resolveOrgFromPath. Fall back to it so the endpoint stops 400ing with "Agent ID or organization ID is required". 2. Empty toolset. The bare aggregate resolves to the Decopilot agent, which is a pure orchestrator with connections:[] (routes via subtask) — so an external client sees ZERO tools. Point the connect URL at `/api/:org/mcp/self` instead: the org's real management surface (Library files, agents, connections, automations, brand, AI providers, secrets). Verified live: initialize + tools/list (142 tools) + a real ORGANIZATION_LIST call all succeed over the API-key command. Also: derive the MCP server name in the command from the host (`belo-horizonte.localhost` locally, `studio.decocms.com` in prod) so each deployment gets a distinct entry and adding two never collides. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/mesh/src/api/routes/virtual-mcp.ts | 10 +++++-- .../components/connect/install-snippet.tsx | 14 +++++----- .../src/web/components/connect/mcp-url.ts | 28 +++++++++++++++---- 3 files changed, 38 insertions(+), 14 deletions(-) diff --git a/apps/mesh/src/api/routes/virtual-mcp.ts b/apps/mesh/src/api/routes/virtual-mcp.ts index 804cf40d43..63dbbd63e6 100644 --- a/apps/mesh/src/api/routes/virtual-mcp.ts +++ b/apps/mesh/src/api/routes/virtual-mcp.ts @@ -47,7 +47,13 @@ export async function handleVirtualMcpRequest( const ctx = c.get("meshContext"); try { - // Prefer x-org-id header (no DB lookup) over x-org-slug (requires DB lookup) + // Prefer x-org-id header (no DB lookup) over x-org-slug (requires DB lookup). + // External MCP clients (Claude Code/Desktop) send NEITHER — the org is in + // the URL path (`/api/:org/mcp`), already resolved into `ctx.organization` + // by the resolveOrgFromPath middleware. Fall back to it so the aggregate + // (Decopilot) endpoint works without the internal UI's x-org-* headers; + // otherwise organizationId stays null and the request 400s with + // "Agent ID or organization ID is required". const orgId = c.req.header("x-org-id"); const orgSlug = c.req.header("x-org-slug"); @@ -60,7 +66,7 @@ export async function handleVirtualMcpRequest( .where("slug", "=", orgSlug) .executeTakeFirst() .then((org) => org?.id) - : null; + : (ctx.organization?.id ?? null); const virtualId = virtualMcpId ? virtualMcpId diff --git a/apps/mesh/src/web/components/connect/install-snippet.tsx b/apps/mesh/src/web/components/connect/install-snippet.tsx index 4c95cd6e6f..b7dbe65585 100644 --- a/apps/mesh/src/web/components/connect/install-snippet.tsx +++ b/apps/mesh/src/web/components/connect/install-snippet.tsx @@ -1,6 +1,7 @@ import { Button } from "@deco/ui/components/button.tsx"; import { useCopy } from "@deco/ui/hooks/use-copy.ts"; import { Check, Copy01 } from "@untitledui/icons"; +import { connectServerName } from "@/web/components/connect/mcp-url"; export type ConnectClient = | "claude-code" @@ -11,8 +12,6 @@ export type ConnectClient = export type ConnectMode = "oauth" | "api-key"; -const SERVER_NAME = "studio"; - interface SnippetBlock { language: string; code: string; @@ -32,17 +31,18 @@ function buildSnippet({ apiKey?: string; }): SnippetBlock { const key = apiKey ?? ""; + const serverName = connectServerName(); if (client === "claude-code") { if (mode === "oauth") { return { language: "bash", - code: `claude mcp add --transport http --scope user ${SERVER_NAME} ${url}`, + code: `claude mcp add --transport http --scope user ${serverName} ${url}`, }; } return { language: "bash", - code: `claude mcp add --transport http --scope user ${SERVER_NAME} ${url} \\\n --header "Authorization: Bearer ${key}"`, + code: `claude mcp add --transport http --scope user ${serverName} ${url} \\\n --header "Authorization: Bearer ${key}"`, }; } @@ -54,12 +54,12 @@ function buildSnippet({ return { language: "json", pathHint: "~/.cursor/mcp.json", - code: JSON.stringify({ mcpServers: { [SERVER_NAME]: server } }, null, 2), + code: JSON.stringify({ mcpServers: { [serverName]: server } }, null, 2), }; } if (client === "codex") { - const lines = [`[mcp_servers.${SERVER_NAME}]`, `url = "${url}"`]; + const lines = [`[mcp_servers.${serverName}]`, `url = "${url}"`]; if (mode === "api-key") { lines.push(`http_headers = { "Authorization" = "Bearer ${key}" }`); } @@ -78,7 +78,7 @@ function buildSnippet({ return { language: "json", pathHint: "claude_desktop_config.json", - code: JSON.stringify({ mcpServers: { [SERVER_NAME]: server } }, null, 2), + code: JSON.stringify({ mcpServers: { [serverName]: server } }, null, 2), }; } diff --git a/apps/mesh/src/web/components/connect/mcp-url.ts b/apps/mesh/src/web/components/connect/mcp-url.ts index fc357f5acb..c7b80862d4 100644 --- a/apps/mesh/src/web/components/connect/mcp-url.ts +++ b/apps/mesh/src/web/components/connect/mcp-url.ts @@ -4,16 +4,34 @@ * dialog and the full Connect settings page can't drift apart. */ -/** MCP server name registered in the client's config (e.g. `studio`). */ -const CONNECT_SERVER_NAME = "studio"; +/** + * MCP server name registered in the client's config — derived from the current + * host so each Studio deployment gets a distinct entry and adding two never + * collides: `studio.decocms.com` in prod, `belo-horizonte.localhost` locally. + * Falls back to `studio` during SSR (no `window`). + */ +export function connectServerName(): string { + if (typeof window === "undefined") return "studio"; + return window.location.hostname || "studio"; +} -/** The org-scoped unified MCP endpoint: `/api//mcp`. */ +/** + * The org-scoped MCP endpoint a client should connect to: + * `/api//mcp/self`. + * + * NOT the bare aggregate `/api//mcp` — that resolves to the Decopilot + * agent, which is a pure orchestrator with NO directly-callable tools (it routes + * everything through sub-agents via `subtask`), so an external client sees zero + * tools. `/mcp/self` is the org's own management surface: Library files, agents, + * connections, automations, brand, AI providers, secrets — i.e. everything you + * need to actually drive the org from Claude. + */ export function mcpUrl(orgSlug: string): string { const origin = typeof window === "undefined" ? "http://localhost:3000" : window.location.origin; - return `${origin}/api/${orgSlug}/mcp`; + return `${origin}/api/${orgSlug}/mcp/self`; } /** @@ -27,5 +45,5 @@ export function claudeCodeCommandWithKey( orgSlug: string, apiKey: string, ): string { - return `claude mcp add --transport http --scope user ${CONNECT_SERVER_NAME} ${mcpUrl(orgSlug)} --header "Authorization: Bearer ${apiKey}"`; + return `claude mcp add --transport http --scope user ${connectServerName()} ${mcpUrl(orgSlug)} --header "Authorization: Bearer ${apiKey}"`; } From f0b9b426fbda4870838bf893c7fb46124590fde8 Mon Sep 17 00:00:00 2001 From: Guilherme Rodrigues Date: Sat, 11 Jul 2026 22:36:30 -0300 Subject: [PATCH 12/12] test(aggregate): align PassthroughClient namespacing test with namespace codes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The cherry-picked "short namespace code for aggregated tool names" change switched tool prefixes from slugify(connectionId) to namespaceCode(...), and updated gateway-client.test.ts — but passthrough-client.test.ts still asserted the old slugify scheme, failing on CI (Expected "conn-aaa_search", got "ak4m99x_search"). Switch its 5 namespace assertions to namespaceCode and export namespaceCode from @decocms/mcp-utils/aggregate. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../virtual-mcp/passthrough-client.test.ts | 14 +++++++------- packages/mcp-utils/src/aggregate/index.ts | 1 + 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/apps/mesh/src/mcp-clients/virtual-mcp/passthrough-client.test.ts b/apps/mesh/src/mcp-clients/virtual-mcp/passthrough-client.test.ts index b0c3909146..7af7700870 100644 --- a/apps/mesh/src/mcp-clients/virtual-mcp/passthrough-client.test.ts +++ b/apps/mesh/src/mcp-clients/virtual-mcp/passthrough-client.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, mock, beforeEach } from "bun:test"; -import { slugify } from "@decocms/mcp-utils/aggregate"; +import { namespaceCode } from "@decocms/mcp-utils/aggregate"; import type { ConnectionEntity } from "../../tools/connection/schema"; import type { VirtualMCPConnection, @@ -143,7 +143,7 @@ describe("PassthroughClient", () => { }); describe("tool namespacing", () => { - it("prefixes tool names with slugified connection ID", async () => { + it("prefixes tool names with the connection's namespace code", async () => { const connA = makeConnection("conn_aaa", "Server A"); const connB = makeConnection("conn_bbb", "Server B"); @@ -170,8 +170,8 @@ describe("PassthroughClient", () => { const result = await pt.listTools(); const names = result.tools.map((t) => t.name); - expect(names).toContain(`${slugify("conn_aaa")}_search`); - expect(names).toContain(`${slugify("conn_bbb")}_query`); + expect(names).toContain(`${namespaceCode("conn_aaa")}_search`); + expect(names).toContain(`${namespaceCode("conn_bbb")}_query`); }); }); @@ -190,7 +190,7 @@ describe("PassthroughClient", () => { mockCtx, ); - const namespacedName = `${slugify("conn_xyz")}_myTool`; + const namespacedName = `${namespaceCode("conn_xyz")}_myTool`; await pt.callTool({ name: namespacedName, arguments: { q: "test" } }); // GatewayClient strips namespace before calling upstream @@ -231,7 +231,7 @@ describe("PassthroughClient", () => { const names = result.tools.map((t) => t.name); expect(names).toHaveLength(1); - expect(names[0]).toBe(`${slugify("conn_b1")}_allowed`); + expect(names[0]).toBe(`${namespaceCode("conn_b1")}_allowed`); }); it("selected_tools filters to specified tools only", async () => { @@ -254,7 +254,7 @@ describe("PassthroughClient", () => { const names = result.tools.map((t) => t.name); expect(names).toHaveLength(1); - expect(names[0]).toBe(`${slugify("conn_sel")}_keep`); + expect(names[0]).toBe(`${namespaceCode("conn_sel")}_keep`); }); }); diff --git a/packages/mcp-utils/src/aggregate/index.ts b/packages/mcp-utils/src/aggregate/index.ts index cf886030e6..b2b0b73aef 100644 --- a/packages/mcp-utils/src/aggregate/index.ts +++ b/packages/mcp-utils/src/aggregate/index.ts @@ -1,6 +1,7 @@ export { GatewayClient, getGatewayClientId, + namespaceCode, slugify, stripToolNamespace, displayToolName,