diff --git a/apps/mesh/src/api/app.ts b/apps/mesh/src/api/app.ts index a9a4533da0..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"); @@ -1862,10 +1876,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/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 7efa489916..d4e3604afd 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, @@ -319,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 @@ -385,11 +419,50 @@ 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); } + // 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://")) { + return studioProtectedResourceMetadata(c.req.raw); + } + 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/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/core/context-factory.ts b/apps/mesh/src/core/context-factory.ts index a91f75e18f..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,13 +689,20 @@ 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 + // 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 authoritative slug from the path. + const pathOrgSlug = getOrgSlugFromRequestPath(req); const membership = await timings.measure("auth_query_membership", () => { const base = db @@ -692,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) @@ -825,6 +859,7 @@ async function authenticateRequest( }, role, permissions: meshJwtPayload.permissions, + tokenOrganizationId: organizationId, organization, }; } @@ -840,6 +875,7 @@ async function authenticateRequest( valid?: boolean; key?: { id: string; + name?: string | null; userId: string; metadata?: { organization?: OrganizationContext }; permissions?: Permission; @@ -900,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, @@ -1403,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; 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/apps/mesh/src/web/components/account-popover.tsx b/apps/mesh/src/web/components/account-popover.tsx index abd3b687ce..f1440f2703 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 Agents", + icon: , + onClick: () => { + navigate({ + to: "/$org/settings/connect", + params: { org: currentOrg.slug }, + }); + }, + } satisfies MenuItem, ] : []), { 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/connect-dialog.tsx b/apps/mesh/src/web/components/connect/connect-dialog.tsx new file mode 100644 index 0000000000..b964cea52c --- /dev/null +++ b/apps/mesh/src/web/components/connect/connect-dialog.tsx @@ -0,0 +1,239 @@ +/** + * "Connect to Claude" modal (triggered from the sidebar footer). + * + * 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 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 { 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, + ShieldTick, + Terminal, +} from "@untitledui/icons"; +import { track } from "@/web/lib/posthog-client"; +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", + "Run your agents", + "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 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 ( +
+ + + + + + Connect {org.name} to Claude + + + Hand Claude this org's MCP endpoint. Once linked, Claude can: + + + +
    + {CAPABILITIES.map((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} + +
+ +
+ +

+ 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. +

+ + + )} +
+ + {/* 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} + + +
+
+ +
+ +
+
+ ); +} + +/** + * 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 ConnectDialog({ + open, + onOpenChange, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; +}) { + return ( + + + onOpenChange(false)} /> + + + ); +} 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..b7dbe65585 --- /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"; +import { connectServerName } from "@/web/components/connect/mcp-url"; + +export type ConnectClient = + | "claude-code" + | "cursor" + | "codex" + | "claude-desktop" + | "raw"; + +export type ConnectMode = "oauth" | "api-key"; + +interface SnippetBlock { + language: string; + code: string; + /** Optional preamble line (e.g. file path the user should edit). */ + pathHint?: string; +} + +function buildSnippet({ + client, + mode, + url, + apiKey, +}: { + client: ConnectClient; + mode: ConnectMode; + url: string; + 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 ${serverName} ${url}`, + }; + } + return { + language: "bash", + code: `claude mcp add --transport http --scope user ${serverName} ${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: { [serverName]: server } }, null, 2), + }; + } + + if (client === "codex") { + const lines = [`[mcp_servers.${serverName}]`, `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: { [serverName]: server } }, null, 2), + }; + } + + // raw + if (mode === "oauth") { + return { + language: "text", + code: `${url}\n\n# OAuth: clients that support MCP OAuth 2.1 will discover\n# the auth flow via the WWW-Authenticate header on 401.`, + }; + } + return { + language: "text", + code: `${url}\n\nAuthorization: Bearer ${key}`, + }; +} + +export function InstallSnippet({ + client, + mode, + url, + apiKey, +}: { + client: ConnectClient; + mode: ConnectMode; + url: string; + apiKey?: string; +}) { + const snippet = buildSnippet({ client, mode, url, apiKey }); + const { handleCopy, copied } = useCopy(); + + return ( +
+
+ + {snippet.pathHint ?? snippet.language} + + +
+
+        {snippet.code}
+      
+
+ ); +} diff --git a/apps/mesh/src/web/components/connect/mcp-url.ts b/apps/mesh/src/web/components/connect/mcp-url.ts new file mode 100644 index 0000000000..6f25b2a2ce --- /dev/null +++ b/apps/mesh/src/web/components/connect/mcp-url.ts @@ -0,0 +1,56 @@ +/** + * 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 — derived from the current + * host so each Studio deployment gets a distinct entry and adding two never + * 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 { + const host = + typeof window === "undefined" ? "" : window.location.hostname || ""; + const sanitized = host + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); + return sanitized || "studio"; +} + +/** + * 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/self`; +} + +/** + * 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 claudeCodeCommandWithKey( + orgSlug: string, + apiKey: string, +): string { + return `claude mcp add --transport http --scope user ${connectServerName()} ${mcpUrl(orgSlug)} --header "Authorization: Bearer ${apiKey}"`; +} 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}`); 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 + + + ); } 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..9394daa191 --- /dev/null +++ b/apps/mesh/src/web/views/settings/org-connect.tsx @@ -0,0 +1,342 @@ +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 { mcpUrl } from "@/web/components/connect/mcp-url"; +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 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), + }, + ); + }; + + // 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 ( + + + + + 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")} + diff --git a/packages/mcp-utils/src/aggregate/gateway-client.test.ts b/packages/mcp-utils/src/aggregate/gateway-client.test.ts index 93c66622ce..0e3a3bad48 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")); }); }); @@ -177,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 () => { @@ -193,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 () => { @@ -219,7 +231,7 @@ describe("GatewayClient", () => { "res://ok", ]); expect((await gw.listPrompts()).prompts.map((p) => p.name)).toEqual([ - "healthy_ok_prompt", + ns("healthy", "ok_prompt"), ]); }); }); @@ -230,7 +242,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 +256,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 +269,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 +307,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 +378,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 +397,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 +412,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 +460,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 +476,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}`; } /** 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,