From bafeb7d6671e0c8089f73ee55c59bd5d6cc50eab Mon Sep 17 00:00:00 2001 From: Guilherme Rodrigues Date: Thu, 28 May 2026 13:38:11 -0300 Subject: [PATCH 01/16] 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 +++++++ 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 + 9 files changed, 689 insertions(+) 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/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 40fb80360e..e391690176 100644 --- a/apps/mesh/src/web/index.tsx +++ b/apps/mesh/src/web/index.tsx @@ -491,6 +491,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", @@ -622,6 +630,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 8edc019398..a2b1447060 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 { useT } from "@/web/i18n/use-t.ts"; @@ -102,6 +103,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: t("settings.nav.brandContext"), diff --git a/apps/mesh/src/web/lib/query-keys.ts b/apps/mesh/src/web/lib/query-keys.ts index c9beb3cf6d..361d42c93e 100644 --- a/apps/mesh/src/web/lib/query-keys.ts +++ b/apps/mesh/src/web/lib/query-keys.ts @@ -154,6 +154,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 15b64eb36c..4572db3974 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"; @@ -13,6 +14,7 @@ export function OrgGeneralPage() { {t("settings.orgGeneral.organization")} + From cca2438d9a09e5c2c7aadd38bc13b5e2401bbb6c Mon Sep 17 00:00:00 2001 From: Guilherme Rodrigues Date: Thu, 28 May 2026 13:40:53 -0300 Subject: [PATCH 02/16] 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 afea41bb9cc3353f4480b68ba1cb95927a8be273 Mon Sep 17 00:00:00 2001 From: Guilherme Rodrigues Date: Thu, 11 Jun 2026 12:26:41 -0300 Subject: [PATCH 03/16] 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 abd3b687ce..96b1291134 100644 --- a/apps/mesh/src/web/components/account-popover.tsx +++ b/apps/mesh/src/web/components/account-popover.tsx @@ -24,6 +24,7 @@ import { Download01, File06, Globe01, + LinkExternal01, LogOut01, Monitor01, Moon01, @@ -385,6 +386,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 9cc51c033e73136de487edec123c75657426600b Mon Sep 17 00:00:00 2001 From: Guilherme Rodrigues Date: Thu, 11 Jun 2026 12:29:05 -0300 Subject: [PATCH 04/16] 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 96b1291134..f1440f2703 100644 --- a/apps/mesh/src/web/components/account-popover.tsx +++ b/apps/mesh/src/web/components/account-popover.tsx @@ -391,7 +391,7 @@ export function AccountPopover() { // to find from anywhere. { key: "connect-clients", - label: "Connect to Claude", + label: "Connect to Agents", icon: , onClick: () => { navigate({ From 394d5b8d9ab8eb712a19d74f2c9dfa678e05f2e9 Mon Sep 17 00:00:00 2001 From: Guilherme Rodrigues Date: Sat, 11 Jul 2026 14:13:57 -0300 Subject: [PATCH 05/16] 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 +++ .../src/web/views/settings/org-connect.tsx | 14 +- 3 files changed, 201 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/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 3e1148320531f227609d594428a87936b0768341 Mon Sep 17 00:00:00 2001 From: AriOliv Date: Thu, 2 Jul 2026 12:43:10 -0300 Subject: [PATCH 06/16] 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 a9a4533da0..7835e3eb18 100644 --- a/apps/mesh/src/api/app.ts +++ b/apps/mesh/src/api/app.ts @@ -1862,10 +1862,17 @@ export async function createApp(options: CreateAppOptions = {}) { // Require either user or API key authentication if (!studioContext.auth.user?.id && !studioContext.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 7efa489916..366bb39bb4 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 59f654200a..f450a42414 100644 --- a/apps/mesh/src/api/routes/org-scoped.ts +++ b/apps/mesh/src/api/routes/org-scoped.ts @@ -136,6 +136,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 a91f75e18f..e93871237c 100644 --- a/apps/mesh/src/core/context-factory.ts +++ b/apps/mesh/src/core/context-factory.ts @@ -677,6 +677,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 @@ -702,6 +717,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 4c5ca0e573ed6c999b75077fde953f119b783a5a Mon Sep 17 00:00:00 2001 From: AriOliv Date: Fri, 3 Jul 2026 11:49:47 -0300 Subject: [PATCH 07/16] 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 7835e3eb18..a98ae0666d 100644 --- a/apps/mesh/src/api/app.ts +++ b/apps/mesh/src/api/app.ts @@ -380,6 +380,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 { @@ -475,7 +489,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 @@ -548,7 +562,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 94a3d5b4b6a3c8971efd01c5bd7b268b2ce481ec Mon Sep 17 00:00:00 2001 From: AriOliv Date: Fri, 3 Jul 2026 18:15:48 -0300 Subject: [PATCH 08/16] 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 93c66622ce..13c45db388 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")); }); }); @@ -230,7 +238,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")); }); }); @@ -244,7 +252,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, @@ -257,7 +265,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, @@ -295,7 +303,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: {}, @@ -366,7 +374,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")); }); }); @@ -385,9 +393,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 () => { @@ -400,7 +408,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 () => { @@ -448,7 +456,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 () => { @@ -464,7 +472,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 3e225b4cbf..34c1a260c8 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 f189a09373d07501a5f9c06933419b6ea50d5a80 Mon Sep 17 00:00:00 2001 From: AriOliv Date: Sat, 11 Jul 2026 14:24:45 -0300 Subject: [PATCH 09/16] 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 9a5fe230b7..d2dbd6c56f 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 46df34d1d653df386bfb18a5222f2454177aa4f4 Mon Sep 17 00:00:00 2001 From: Guilherme Rodrigues Date: Sat, 11 Jul 2026 16:22:50 -0300 Subject: [PATCH 10/16] 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 ++- 2 files changed, 105 insertions(+), 31 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}"`; } From 32cb0a427a0dd08f7c609d19dc98f20758df161e Mon Sep 17 00:00:00 2001 From: Guilherme Rodrigues Date: Sat, 11 Jul 2026 16:44:54 -0300 Subject: [PATCH 11/16] 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 b11d985c95..d67e3a32b1 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("studioContext"); 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 a9bf548ae5ce24c59fa449a8b90ed44a4401b791 Mon Sep 17 00:00:00 2001 From: Guilherme Rodrigues Date: Sat, 11 Jul 2026 22:36:30 -0300 Subject: [PATCH 12/16] 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 6fda46aab8..e13b05b111 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, From 99c0dc8ba4c0493c84ff720eee57fa69121b2eb9 Mon Sep 17 00:00:00 2001 From: Guilherme Rodrigues Date: Thu, 16 Jul 2026 10:51:12 -0300 Subject: [PATCH 13/16] fix(connect-studio): valid mcp name, readable modal, move trigger to sidebar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three fixes from testing/feedback: 1. Invalid server name. `claude mcp add ` only accepts letters, numbers, hyphens and underscores, but the host-derived name had dots (belo-horizonte.localhost) and was rejected. Sanitize the hostname: belo-horizonte.localhost → belo-horizonte-localhost, studio.decocms.com → studio-decocms-com. 2. Modal contrast/polish. The security note used Alert variant="warning", whose warning-foreground text was near-invisible on the light amber background. Replaced with a readable note (text-foreground/80 + shield icon). Also tightened spacing, hierarchy, the command block, and marked Claude Code as the recommended path. 3. Move the trigger out of the topbar into the sidebar footer, alongside "Invite members" / "Add connection" and before "Connect desktop". The dialog is now a controlled ; the old topbar ConnectLinkButton is removed. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../web/components/connect/connect-dialog.tsx | 136 +++++++++--------- .../src/web/components/connect/mcp-url.ts | 15 +- .../sidebar/footer/sidebar-footer.tsx | 21 ++- 3 files changed, 99 insertions(+), 73 deletions(-) diff --git a/apps/mesh/src/web/components/connect/connect-dialog.tsx b/apps/mesh/src/web/components/connect/connect-dialog.tsx index c6e65ea4a1..dd37595c71 100644 --- a/apps/mesh/src/web/components/connect/connect-dialog.tsx +++ b/apps/mesh/src/web/components/connect/connect-dialog.tsx @@ -1,23 +1,21 @@ /** - * Topbar "Link" button + one-command "Connect to Claude" modal. + * "Connect to Claude" modal (triggered from the sidebar footer). * - * 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. 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 org's MCP endpoint (`/api//mcp/self`) exposes the org's control + * surface — Library files, agents, connections. "Connecting Claude" is just + * handing Claude that URL plus a credential. The primary path 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. * - * 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. + * Claude Desktop / claude.ai can't take a custom header, so those 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, @@ -29,14 +27,13 @@ import { import { useCopy } from "@deco/ui/hooks/use-copy.ts"; import { useProjectContext } from "@decocms/mesh-sdk"; import { - AlertTriangle, ArrowRight, Check, Copy01, FolderCode, Link01, + ShieldTick, Terminal, - Zap, } from "@untitledui/icons"; import { track } from "@/web/lib/posthog-client"; import { @@ -93,38 +90,45 @@ function ConnectDialogBody({ onClose }: { onClose: () => void }) { return ( <> - - - + + + Connect {org.name} to Claude - Hand Claude this org's unified MCP endpoint. Once linked, Claude can: + Hand Claude this org's MCP endpoint. Once linked, Claude can: -
    +
      {CAPABILITIES.map((cap) => ( -
    • - - {cap} +
    • + + + + {cap}
    • ))}
    {/* 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 + + Recommended +
    {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. - - +
    + +

    + No login step — this command embeds a{" "} + + full-access token + + . 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.

    @@ -164,20 +171,22 @@ function ConnectDialogBody({ onClose }: { onClose: () => void }) { )} -
    +
    {/* 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} +
    + + {url} +
    -
    +
    -
    +
    @@ -209,31 +218,22 @@ function ConnectDialogBody({ onClose }: { onClose: () => void }) { } /** - * The "LINK" affordance for the app topbar. Self-contained: owns its own open - * state so it can be dropped into any header slot. + * Controlled "Connect to Claude" dialog. The trigger lives elsewhere (sidebar + * footer) and drives `open`; the body remounts on each open so the generated + * command/state resets cleanly. */ -export function ConnectLinkButton() { - const [open, setOpen] = useState(false); - +export function ConnectDialog({ + open, + onOpenChange, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; +}) { return ( - <> - - - - setOpen(false)} /> - - - + + + onOpenChange(false)} /> + + ); } diff --git a/apps/mesh/src/web/components/connect/mcp-url.ts b/apps/mesh/src/web/components/connect/mcp-url.ts index c7b80862d4..6f25b2a2ce 100644 --- a/apps/mesh/src/web/components/connect/mcp-url.ts +++ b/apps/mesh/src/web/components/connect/mcp-url.ts @@ -7,12 +7,19 @@ /** * 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`). + * collides. `claude mcp add ` only accepts letters, numbers, hyphens and + * underscores, so the host's dots are sanitized to hyphens: + * `studio.decocms.com` → `studio-decocms-com`, `belo-horizonte.localhost` → + * `belo-horizonte-localhost`. Falls back to `studio` during SSR (no `window`). */ export function connectServerName(): string { - if (typeof window === "undefined") return "studio"; - return window.location.hostname || "studio"; + const host = + typeof window === "undefined" ? "" : window.location.hostname || ""; + const sanitized = host + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); + return sanitized || "studio"; } /** diff --git a/apps/mesh/src/web/components/sidebar/footer/sidebar-footer.tsx b/apps/mesh/src/web/components/sidebar/footer/sidebar-footer.tsx index 866ef177d9..8d38afc2b8 100644 --- a/apps/mesh/src/web/components/sidebar/footer/sidebar-footer.tsx +++ b/apps/mesh/src/web/components/sidebar/footer/sidebar-footer.tsx @@ -6,15 +6,17 @@ import { SidebarMenuItem, useSidebar, } from "@deco/ui/components/sidebar.tsx"; -import { Settings02, UserPlus01, ZapSquare } from "@untitledui/icons"; +import { Link01, Settings02, UserPlus01, ZapSquare } from "@untitledui/icons"; import { useState } from "react"; import { InviteMemberDialog } from "@/web/components/invite-member-dialog"; import { AddConnectionDialog } from "@/web/views/virtual-mcp/add-connection-dialog"; +import { ConnectDialog } from "@/web/components/connect/connect-dialog"; import { useProjectContext } from "@decocms/mesh-sdk"; import { useNavigate } from "@tanstack/react-router"; import { LinkedDesktopIndicator } from "@/web/components/header/linked-desktop-indicator"; import { SidebarTopActions } from "@/web/components/sidebar/top-actions"; import { useReportsOnly } from "@/web/hooks/use-organization-settings"; +import { track } from "@/web/lib/posthog-client"; function SettingsFullButton() { const navigate = useNavigate(); @@ -54,6 +56,7 @@ function SettingsIconButton() { function SidebarExtraActions() { const [connectionsOpen, setConnectionsOpen] = useState(false); + const [connectClaudeOpen, setConnectClaudeOpen] = useState(false); return ( <> @@ -76,12 +79,28 @@ function SidebarExtraActions() { Add connection + + { + track("connect_studio_opened", { source: "sidebar_footer" }); + setConnectClaudeOpen(true); + }} + > + + Connect to Claude + + + ); } From 25884e91b1cdc8c1fe91906ae394747b46af1ec1 Mon Sep 17 00:00:00 2001 From: Guilherme Rodrigues Date: Thu, 16 Jul 2026 10:56:59 -0300 Subject: [PATCH 14/16] fix(connect-studio): stop dialog content overflowing its right padding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DialogContent is a CSS grid; grid/flex children default to min-width:auto, so the long MCP URL (flex-1 + truncate, no min-w-0) couldn't shrink and pushed the row past the right padding — the right margin looked broken. Wrap the body in a single min-w-0 column and give the URL row/code min-w-0 so truncate works and nothing overflows. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/mesh/src/web/components/connect/connect-dialog.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/mesh/src/web/components/connect/connect-dialog.tsx b/apps/mesh/src/web/components/connect/connect-dialog.tsx index dd37595c71..b964cea52c 100644 --- a/apps/mesh/src/web/components/connect/connect-dialog.tsx +++ b/apps/mesh/src/web/components/connect/connect-dialog.tsx @@ -88,7 +88,7 @@ function ConnectDialogBody({ onClose }: { onClose: () => void }) { }; return ( - <> +
    @@ -183,8 +183,8 @@ function ConnectDialogBody({ onClose }: { onClose: () => void }) { Add a custom connector in Settings → Connectors and paste this URL. Claude signs in with OAuth on first use.

    -
    - +
    + {url}
    - +
    ); } From 20fd514a27fb2684d85dc9da5da8c6ef428f2ee4 Mon Sep 17 00:00:00 2001 From: Guilherme Rodrigues Date: Sat, 18 Jul 2026 14:38:43 -0300 Subject: [PATCH 15/16] test(aggregate): fix 3 more namespace-code assertions after rebase Rebasing onto main pulled in 3 new "resilience to failing connections" tests in gateway-client.test.ts that assert the old slugify-based prefix ("healthy_ok_tool"), but the namespaceCode() change on this branch produces a short hash prefix instead. Switch them to the existing `ns()` test helper, matching the rest of the file. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../mcp-utils/src/aggregate/gateway-client.test.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/packages/mcp-utils/src/aggregate/gateway-client.test.ts b/packages/mcp-utils/src/aggregate/gateway-client.test.ts index 13c45db388..0e3a3bad48 100644 --- a/packages/mcp-utils/src/aggregate/gateway-client.test.ts +++ b/packages/mcp-utils/src/aggregate/gateway-client.test.ts @@ -185,7 +185,9 @@ describe("GatewayClient", () => { }); const result = await gw.listTools(); - expect(result.tools.map((t) => t.name)).toEqual(["healthy_ok_tool"]); + expect(result.tools.map((t) => t.name)).toEqual([ + ns("healthy", "ok_tool"), + ]); }); it("skips a connection whose lazy factory throws on resolve", async () => { @@ -201,7 +203,9 @@ describe("GatewayClient", () => { }); const result = await gw.listTools(); - expect(result.tools.map((t) => t.name)).toEqual(["healthy_ok_tool"]); + expect(result.tools.map((t) => t.name)).toEqual([ + ns("healthy", "ok_tool"), + ]); }); it("degrades resources and prompts the same way", async () => { @@ -227,7 +231,7 @@ describe("GatewayClient", () => { "res://ok", ]); expect((await gw.listPrompts()).prompts.map((p) => p.name)).toEqual([ - "healthy_ok_prompt", + ns("healthy", "ok_prompt"), ]); }); }); From 0b9b070de1d586155babc4c5bf08608d872f7775 Mon Sep 17 00:00:00 2001 From: Deco Studio Date: Wed, 22 Jul 2026 10:18:56 -0300 Subject: [PATCH 16/16] fix(auth): enforce org-bound credentials and discovery --- .../api/middleware/resolve-org-from-path.ts | 58 ++++++++++++++ .../src/api/org-scoped.integration.test.ts | 79 +++++++++++++------ apps/mesh/src/api/routes/oauth-proxy.ts | 63 ++++++++++++++- apps/mesh/src/core/context-factory.ts | 74 +++++++++++------ apps/mesh/src/core/studio-context.ts | 7 ++ 5 files changed, 230 insertions(+), 51 deletions(-) diff --git a/apps/mesh/src/api/middleware/resolve-org-from-path.ts b/apps/mesh/src/api/middleware/resolve-org-from-path.ts index bfd3a7ead2..3afe3754d3 100644 --- a/apps/mesh/src/api/middleware/resolve-org-from-path.ts +++ b/apps/mesh/src/api/middleware/resolve-org-from-path.ts @@ -21,6 +21,39 @@ function isPublicSharePath(c: Context): boolean { ); } +/** + * Return the organization bound into an API key's metadata, if present. + * Keys created for org-scoped access carry `metadata.organization.id`. + * Legacy/internal keys without that field are left to their existing route + * authorization rules; a malformed explicit organization binding fails closed. + */ +function getApiKeyOrganizationBinding(ctx: StudioContext): { + present: boolean; + id?: string; +} { + const metadata = ctx.auth?.apiKey?.metadata; + if ( + !metadata || + typeof metadata !== "object" || + Array.isArray(metadata) || + !("organization" in metadata) + ) { + return { present: false }; + } + + const organization = metadata.organization; + if ( + !organization || + typeof organization !== "object" || + Array.isArray(organization) + ) { + return { present: true }; + } + + const id = (organization as Record).id; + return { present: true, id: typeof id === "string" ? id : undefined }; +} + /** * The exhaustive list of service-token routes that resolve the org by ID — * their machine caller (commerce-discovery) holds the org id, not the slug. @@ -89,6 +122,31 @@ export const resolveOrgFromPath: MiddlewareHandler<{ return c.json({ error: `organization "${slug}" not found` }, 404); } + // API keys are capabilities bound to the organization that minted them. + // Do this check before membership/rebinding so a valid key from org B cannot + // be reused against org A merely because its owner is also an A member. + const apiKeyBinding = getApiKeyOrganizationBinding(ctx); + if ( + ctx.auth?.apiKey?.id && + apiKeyBinding.present && + apiKeyBinding.id !== org.id + ) { + return c.json( + { error: "forbidden: API key is scoped to another organization" }, + 403, + ); + } + + if ( + ctx.auth?.tokenOrganizationId && + ctx.auth.tokenOrganizationId !== org.id + ) { + return c.json( + { error: "forbidden: token is scoped to another organization" }, + 403, + ); + } + // Archived (soft-deleted) orgs are invisible to the API. Treat them exactly // like a missing org: bounce browser navigations into the SPA (the shell // shows the branded "Organization unavailable" screen), and return JSON 404 diff --git a/apps/mesh/src/api/org-scoped.integration.test.ts b/apps/mesh/src/api/org-scoped.integration.test.ts index 4cb6172f95..3c50184304 100644 --- a/apps/mesh/src/api/org-scoped.integration.test.ts +++ b/apps/mesh/src/api/org-scoped.integration.test.ts @@ -175,6 +175,43 @@ describe("org-scoped API coexistence", () => { // Deprecation-log assertions remain above (Playwright can't capture // dev-server stdout). + it("serves Better Auth metadata for the org-scoped self MCP aliases", async () => { + const paths = [ + "/api/org_1/mcp/self/.well-known/oauth-protected-resource", + "/.well-known/oauth-protected-resource/api/org_1/mcp/self", + ]; + + for (const path of paths) { + const res = await app.fetch(new Request(`http://mesh.localhost${path}`)); + + expect(res.status, path).toBe(200); + const body = (await res.json()) as { + resource: string; + authorization_servers?: string[]; + }; + expect(body.resource, path).toBe( + "http://mesh.localhost/api/org_1/mcp/self", + ); + expect(body.authorization_servers?.length, path).toBeGreaterThan(0); + } + }); + + it("rejects an API key whose organization differs from the URL org", async () => { + mockApiKey("user_1", "org_2", "org_2"); + + const res = await app.fetch( + new Request("http://test/api/org_1/mcp/self", { + method: "POST", + headers: { Authorization: "Bearer test-key" }, + }), + ); + + expect(res.status).toBe(403); + expect(await res.json()).toEqual({ + error: "forbidden: API key is scoped to another organization", + }); + }); + it("well-known prefix discovery for org-scoped MCP resolves the right org", async () => { // The MCP SDK probes /.well-known/oauth-protected-resource{resource-path} // (RFC 9728 Format 2 / Smithery-style) to discover OAuth metadata. With @@ -247,19 +284,14 @@ describe("org-scoped API coexistence", () => { } }); - it("well-known prefix discovery uses the path slug, not the session's active org", async () => { + it("well-known prefix discovery uses the path slug", async () => { // Regression for #3272 fallout: multi-org users hitting another org's // URL would 404 here because the handler resolved `orgSlug` as // `ctx.organization?.slug ?? c.req.param("org")`. The well-known prefix // route is mounted at the URL root (outside `/api/:org`), so - // `resolveOrgFromPath` doesn't run — `ctx.organization` falls through to - // the session's `activeOrganizationId`, which silently overrode the path - // slug. For a user whose active org is `org_456`, a discovery probe at - // `/api/org_1/mcp/conn_1` would scope the lookup to `org_456` and 404 - // even though the path AND the connection both belong to `org_1`. Fix: - // path param takes priority — `c.req.param("org") ?? ctx.organization?.slug`. - mockApiKey("user_1", "org_456", "org_456"); - + // `resolveOrgFromPath` doesn't run. The path parameter must therefore be + // selected before any context fallback — `c.req.param("org") ?? + // ctx.organization?.slug` — or the connection lookup loses its tenant. const fetchSpy = vi.spyOn(globalThis, "fetch").mockImplementation((async ( _input, init, @@ -281,7 +313,6 @@ describe("org-scoped API coexistence", () => { const res = await app.fetch( new Request( "http://mesh.localhost/.well-known/oauth-protected-resource/api/org_1/mcp/conn_1", - { headers: { Authorization: "Bearer test-key" } }, ), ); @@ -393,22 +424,24 @@ describe("org-scoped API coexistence", () => { } }); - it("DCR survives a multi-org user whose session active org differs from the path", async () => { + it("DCR uses the URL org for a multi-org API-key owner", async () => { // The popup-not-opening bug surfaced because DCR (the SDK's // POST /register call right before opening the authorize popup) hit the - // legacy `/oauth-proxy/:connectionId/*` mount and 404'd against the - // session's `activeOrganizationId`. With the AS metadata now pointing at - // `/api/:org/oauth-proxy/...`, `resolveOrgFromPath` resolves the org from - // the URL and verifies membership instead — independent of session state. + // legacy `/oauth-proxy/:connectionId/*` mount and 404'd against a stale + // tenant. With the AS metadata now pointing at `/api/:org/oauth-proxy/...`, + // `resolveOrgFromPath` resolves the org from the URL and verifies + // membership instead. - // Seed user_1 into a second org and switch the active session there. The - // path under test still names org_1 (where conn_1 lives). + // Seed user_1 into a second org. The path under test still names org_1 + // (where conn_1 lives), and the API key is explicitly bound to org_1. await sql` INSERT INTO "member" (id, "userId", "organizationId", role, "createdAt") VALUES ('mem_1_456', 'user_1', 'org_456', 'member', ${new Date().toISOString()}) ON CONFLICT (id) DO NOTHING `.execute(database.db); - mockApiKey("user_1", "org_456", "org_456"); + // The credential is scoped to org_1, even though its owner is also a + // member of org_456. The URL's org remains the sole tenant selector. + mockApiKey("user_1", "org_1", "org_1"); const fetchSpy = vi.spyOn(globalThis, "fetch").mockImplementation((async ( input: string | URL | Request, @@ -474,9 +507,8 @@ describe("org-scoped API coexistence", () => { it("oauth-proxy refuses slug-spoofing on the org-scoped mount", async () => { // Member of both org_1 and org_456 asks for an org_1 connection under - // org_456's slug. The path-resolved org scopes the connection lookup, so - // findById returns null and the handler 404s — preventing OAuth proxying - // for connections that don't belong to the URL's org. + // org_456's slug with an org_1-bound API key. Reject the credential/path + // mismatch before looking up or proxying the connection. const now = new Date().toISOString(); await sql` INSERT INTO "member" (id, "userId", "organizationId", role, "createdAt") @@ -498,7 +530,10 @@ describe("org-scoped API coexistence", () => { ), ); - expect(res.status).toBe(404); + expect(res.status).toBe(403); + expect(await res.json()).toEqual({ + error: "forbidden: API key is scoped to another organization", + }); }); it("DCR injects the connection's owning org into the registration metadata", async () => { diff --git a/apps/mesh/src/api/routes/oauth-proxy.ts b/apps/mesh/src/api/routes/oauth-proxy.ts index 366bb39bb4..d4e3604afd 100644 --- a/apps/mesh/src/api/routes/oauth-proxy.ts +++ b/apps/mesh/src/api/routes/oauth-proxy.ts @@ -321,6 +321,38 @@ const fixProtocol = (url: URL) => { return url; }; +/** + * Convert either RFC 9728 discovery URL shape back to the protected resource + * URL that the client originally requested. Better Auth's generic metadata + * handler intentionally advertises the auth server's origin; the org-scoped + * self endpoint needs its concrete `/api/:org/mcp/self` resource instead. + */ +function protectedResourceUrlFromDiscovery(url: URL): string { + const resourceRelativeSuffix = "/.well-known/oauth-protected-resource"; + const wellKnownPrefix = resourceRelativeSuffix; + let pathname = url.pathname; + + if (pathname.endsWith(resourceRelativeSuffix)) { + pathname = pathname.slice(0, -resourceRelativeSuffix.length) || "/"; + } else if (pathname.startsWith(wellKnownPrefix)) { + pathname = pathname.slice(wellKnownPrefix.length) || "/"; + } + + return `${url.origin}${pathname}`; +} + +async function studioProtectedResourceMetadata( + request: Request, + resource?: string, +): Promise { + const res = await oAuthProtectedResourceMetadata(auth)(request); + const data = (await res.json()) as Record; + if (resource) { + data.resource = resource; + } + return Response.json(data, res); +} + /** * Handler for proxying OAuth protected resource metadata * Rewrites resource to /mcp/:connectionId and authorization_servers to /oauth-proxy/:connectionId @@ -387,6 +419,33 @@ export const protectedResourceMetadataHandler = async (c: { } } + // The RFC 9728 prefix form is mounted at the root, outside + // `resolveOrgFromPath`. Keep the same bearer-token tenant fence here so a + // token issued for org B cannot probe metadata for org A through that public + // route. Anonymous discovery remains public because it has no token binding. + if ( + scopedOrgId && + ctx.auth?.tokenOrganizationId && + ctx.auth.tokenOrganizationId !== scopedOrgId + ) { + return c.json( + { error: "forbidden: token is scoped to another organization" }, + 403, + ); + } + + // `self` is the org's built-in management MCP alias, not a persisted + // connection row. Its OAuth resource is Studio itself, so both RFC 9728 + // discovery shapes (`/mcp/self/.well-known/...` and the origin-anchored + // `/.well-known/.../api/:org/mcp/self`) must return Better Auth metadata + // instead of falling through to a connection lookup for id "self". + if (connectionId === "self") { + return studioProtectedResourceMetadata( + c.req.raw, + protectedResourceUrlFromDiscovery(requestUrl), + ); + } + const connectionUrl = await getConnectionUrl(connectionId, ctx, scopedOrgId); if (!connectionUrl) { return c.json({ error: "Connection not found" }, 404); @@ -401,9 +460,7 @@ export const protectedResourceMetadataHandler = async (c: { // 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); + return studioProtectedResourceMetadata(c.req.raw); } const prefix = buildPathPrefix(orgSlug); diff --git a/apps/mesh/src/core/context-factory.ts b/apps/mesh/src/core/context-factory.ts index e93871237c..09ff828cf7 100644 --- a/apps/mesh/src/core/context-factory.ts +++ b/apps/mesh/src/core/context-factory.ts @@ -176,6 +176,23 @@ interface AuthenticatedUser { role?: string; } +/** + * Extract the canonical organization slug from an org-scoped API path. + * Header hints are still supported for legacy, unscoped routes, but the URL + * path is authoritative whenever it is present. + */ +function getOrgSlugFromRequestPath(req: Request): string | undefined { + try { + const segments = new URL(req.url).pathname.split("/").filter(Boolean); + if (segments[0] === "api" && segments[1]) { + return decodeURIComponent(segments[1]); + } + } catch { + // Fall through to legacy header/single-membership resolution. + } + return undefined; +} + // Type for the hasPermission API (from @decocms/better-auth organization plugin) type HasPermissionAPI = (params: { headers: Headers; @@ -646,6 +663,8 @@ async function authenticateRequest( role?: string; permissions?: Permission; // Permissions from API key or custom role (for non-browser sessions) apiKeyId?: string; + apiKey?: StudioContext["auth"]["apiKey"]; + tokenOrganizationId?: string; organization?: OrganizationContext; }> { const authHeader = req.headers.get("Authorization"); @@ -670,11 +689,11 @@ async function authenticateRequest( // For MCP OAuth sessions we need to query the database directly because // getFullOrganization requires a browser session (cookies). The OAuth - // grant doesn't carry org context, so prefer an explicit hint from the - // request (x-org-id / x-org-slug) and fall back to the user's first - // membership only when no hint is given. Without the hint, multi-org - // users get a non-deterministic pick and end up with the wrong - // ctx.organization on every request that doesn't target their first org. + // grant doesn't carry org context. A canonical `/api/:org` path is + // authoritative; legacy callers may still provide x-org-id / x-org-slug + // headers, and we fall back to the user's only membership when neither + // source is present. Without a deterministic hint, multi-org users would + // get the wrong ctx.organization. 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 @@ -682,16 +701,8 @@ async function authenticateRequest( // 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; - })(); + // 403s "Access denied"). Derive the authoritative slug from the path. + const pathOrgSlug = getOrgSlugFromRequestPath(req); const membership = await timings.measure("auth_query_membership", () => { const base = db @@ -707,6 +718,14 @@ async function authenticateRequest( ]) .where("member.userId", "=", userId); + // The canonical org path is authoritative. External MCP clients do not + // send x-org-* headers, and accepting a stale header ahead of the path + // can bind permissions to one org while the route serves another. + if (pathOrgSlug) { + return base + .where("organization.slug", "=", pathOrgSlug) + .executeTakeFirst(); + } if (orgIdHint) { return base .where("organization.id", "=", orgIdHint) @@ -717,11 +736,6 @@ 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 @@ -845,6 +859,7 @@ async function authenticateRequest( }, role, permissions: meshJwtPayload.permissions, + tokenOrganizationId: organizationId, organization, }; } @@ -860,6 +875,7 @@ async function authenticateRequest( valid?: boolean; key?: { id: string; + name?: string | null; userId: string; metadata?: { organization?: OrganizationContext }; permissions?: Permission; @@ -920,6 +936,15 @@ async function authenticateRequest( user: onBehalfOf ?? { id: result.key.userId, role }, role: onBehalfOf ? onBehalfOf.role : role, permissions, // Store the API key's permissions + apiKey: { + id: result.key.id, + name: result.key.name ?? "", + userId: result.key.userId, + metadata: result.key.metadata as + | Record + | undefined, + }, + tokenOrganizationId: orgMetadata?.id, organization: orgMetadata ? { id: orgMetadata.id, @@ -1423,14 +1448,11 @@ export async function createStudioContextFactory( // Build auth object for StudioContext const studioAuth: StudioContext["auth"] = { user: authResult.user, + tokenOrganizationId: authResult.tokenOrganizationId, }; - if (authResult.apiKeyId) { - studioAuth.apiKey = { - id: authResult.apiKeyId, - name: "", // Not needed for access control - userId: "", // Not needed for access control - }; + if (authResult.apiKey) { + studioAuth.apiKey = authResult.apiKey; } // Organization from Better Auth (OAuth session or API key metadata) diff --git a/apps/mesh/src/core/studio-context.ts b/apps/mesh/src/core/studio-context.ts index 91983d4218..9483f32c75 100644 --- a/apps/mesh/src/core/studio-context.ts +++ b/apps/mesh/src/core/studio-context.ts @@ -194,6 +194,13 @@ export interface BoundAuthClient { * Authentication state from Better Auth */ export interface MeshAuth { + /** + * Organization encoded in a bearer credential (API key or mesh JWT). + * Org-scoped middleware must not rebind a token issued for one org to a + * different org named in the request path. + */ + tokenOrganizationId?: string; + user?: { id: string; connectionId?: string;