Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 24 additions & 3 deletions apps/mesh/src/api/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -346,6 +346,20 @@ const oauthProxyHandler: MiddlewareHandler<Env> = 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 {
Expand Down Expand Up @@ -441,7 +455,7 @@ const oauthProxyHandler: MiddlewareHandler<Env> = 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
Expand Down Expand Up @@ -514,7 +528,7 @@ const oauthProxyHandler: MiddlewareHandler<Env> = 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");
Expand Down Expand Up @@ -1781,10 +1795,17 @@ export async function createApp(options: CreateAppOptions = {}) {
// Require either user or API key authentication
if (!meshContext.auth.user?.id && !meshContext.auth.apiKey?.id) {
const url = new URL(c.req.url);
// Behind a TLS-terminating reverse proxy (e.g. Caddy/nginx) the request
// reaches us over http, so `url.origin` would advertise an http://
// resource_metadata URL and OAuth-capable clients that require https
// (e.g. Claude) reject it. Honor X-Forwarded-Proto so the advertised
// URL matches the public scheme.
const fwdProto = c.req.header("x-forwarded-proto")?.split(",")[0]?.trim();
const origin = fwdProto ? `${fwdProto}://${url.host}` : url.origin;
return (c.res = new Response(null, {
status: 401,
headers: {
"WWW-Authenticate": `Bearer realm="mcp",resource_metadata="${url.origin}${url.pathname}/.well-known/oauth-protected-resource"`,
"WWW-Authenticate": `Bearer realm="mcp",resource_metadata="${origin}${url.pathname}/.well-known/oauth-protected-resource"`,
},
}));
}
Expand Down
16 changes: 16 additions & 0 deletions apps/mesh/src/api/routes/oauth-proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -390,6 +392,20 @@ export const protectedResourceMetadataHandler = async (c: {
return c.json({ error: "Connection not found" }, 404);
}

// Virtual MCPs (`virtual://<id>`) 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
Expand Down
12 changes: 12 additions & 0 deletions apps/mesh/src/api/routes/org-scoped.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,18 @@ export const createOrgScopedApi = (deps: OrgScopedDeps) => {
deps.betterAuthProtectedResourceHandler,
);

// Aggregate (Decopilot) MCP endpoint at `/api/:org/mcp` has no connectionId,
// so its OAuth resource is Studio itself — the Better Auth MCP authorization
// server (with Dynamic Client Registration). This lets external MCP clients
// (e.g. Claude Desktop) register their own redirect_uri and log in against
// Studio, instead of the connection `oauth-proxy` which only accepts Studio's
// own origin. Mounted BEFORE the proxy catch-all so the well-known suffix is
// not swallowed as a `:connectionId`.
app.get(
"/mcp/.well-known/oauth-protected-resource",
deps.betterAuthProtectedResourceHandler,
);

app.route("/mcp", createVirtualMcpRoutes());
app.route("/mcp/self", createSelfRoutes());
app.route("/mcp", createProxyRoutes());
Expand Down
10 changes: 8 additions & 2 deletions apps/mesh/src/api/routes/virtual-mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,13 @@ export async function handleVirtualMcpRequest(
const ctx = c.get("meshContext");

try {
// Prefer x-org-id header (no DB lookup) over x-org-slug (requires DB lookup)
// Prefer x-org-id header (no DB lookup) over x-org-slug (requires DB lookup).
// External MCP clients (Claude Code/Desktop) send NEITHER — the org is in
// the URL path (`/api/:org/mcp`), already resolved into `ctx.organization`
// by the resolveOrgFromPath middleware. Fall back to it so the aggregate
// (Decopilot) endpoint works without the internal UI's x-org-* headers;
// otherwise organizationId stays null and the request 400s with
// "Agent ID or organization ID is required".
const orgId = c.req.header("x-org-id");
const orgSlug = c.req.header("x-org-slug");

Expand All @@ -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
Expand Down
20 changes: 20 additions & 0 deletions apps/mesh/src/core/context-factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -670,6 +670,21 @@ async function authenticateRequest(
// ctx.organization on every request that doesn't target their first org.
const orgIdHint = req.headers.get("x-org-id");
const orgSlugHint = req.headers.get("x-org-slug");
// External MCP clients (Claude Desktop/Code) authenticate via OAuth and
// do NOT send x-org-* headers — the org they target is in the request
// path (`/api/:org/mcp/...`). Without honoring it, a multi-org member
// falls through to the single-membership guard below, resolves to NO
// role, and loses the admin/owner bypass (every connection tool call
// 403s "Access denied"). Derive the slug from the path as a hint.
const pathOrgSlug = (() => {
try {
const segs = new URL(req.url).pathname.split("/").filter(Boolean);
if (segs[0] === "api" && segs[1]) return decodeURIComponent(segs[1]);
} catch {
// Malformed URL — fall through to header/single-membership logic.
}
return undefined;
})();

const membership = await timings.measure("auth_query_membership", () => {
const base = db
Expand All @@ -695,6 +710,11 @@ async function authenticateRequest(
.where("organization.slug", "=", orgSlugHint)
.executeTakeFirst();
}
if (pathOrgSlug) {
return base
.where("organization.slug", "=", pathOrgSlug)
.executeTakeFirst();
}
// No org hint — only resolve when the user has exactly one membership.
// For multi-org users without a hint, return undefined so callers get
// no org context instead of a non-deterministic pick (the previous
Expand Down
14 changes: 7 additions & 7 deletions apps/mesh/src/mcp-clients/virtual-mcp/passthrough-client.test.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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");

Expand All @@ -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`);
});
});

Expand All @@ -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
Expand Down Expand Up @@ -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 () => {
Expand All @@ -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`);
});
});

Expand Down
15 changes: 15 additions & 0 deletions apps/mesh/src/web/components/account-popover.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
Download01,
File06,
Globe01,
LinkExternal01,
LogOut01,
Monitor01,
Moon01,
Expand Down Expand Up @@ -577,6 +578,20 @@ export function AccountPopover() {
});
},
} satisfies MenuItem,
// Connect this org's unified MCP to Claude (Code/Desktop) and
// other MCP clients. Always available inside an org so it's easy
// to find from anywhere.
{
key: "connect-clients",
label: "Connect to Agents",
icon: <LinkExternal01 size={16} />,
onClick: () => {
navigate({
to: "/$org/settings/connect",
params: { org: currentOrg.slug },
});
},
} satisfies MenuItem,
]
: []),
{
Expand Down
63 changes: 63 additions & 0 deletions apps/mesh/src/web/components/connect/connect-banner.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Alert variant="info" className="items-center">
<LinkExternal01 />
<AlertDescription className="flex-1 flex items-center justify-between gap-2 text-sm">
<span>
Use Studio MCP anywhere — paste a command into Claude Code, Cursor,
Codex, or any MCP client.
</span>
<div className="flex items-center gap-1 shrink-0">
<Button asChild size="sm" variant="ghost" className="text-xs gap-1">
<Link to="/$org/settings/connect" params={{ org: org.slug }}>
Connect to clients <ArrowRight size={12} />
</Link>
</Button>
<Button
variant="ghost"
size="icon"
className="size-7"
onClick={handleDismiss}
aria-label="Dismiss"
>
<XClose size={14} />
</Button>
</div>
</AlertDescription>
</Alert>
);
}
Loading
Loading