Skip to content
Closed
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 @@ -1758,10 +1772,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
9 changes: 9 additions & 0 deletions apps/mesh/src/api/routes/proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -412,6 +412,15 @@ export const createProxyRoutes = () => {
if (error instanceof CircuitOpenError) {
throw error;
}
// Per-user authorization required is an EXPECTED state for
// `auth_mode: "per_user"` connections when the caller has no token yet
// — not a downstream outage. Return the 401 challenge (with authorize
// URL) WITHOUT tripping the breaker or auto-disabling the connection.
// Handled before recordFailure below, which would otherwise open the
// circuit and 503 the connection (hiding the OAuth the error asks for).
if (isPerUserAuthorizationRequiredError(error)) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: The isPerUserAuthorizationRequiredError(error) early return gates the circuit-breaker failure accounting and auto-disable logic. If that cross-file classifier is overly broad (e.g., message-substring or regex based), non-auth downstream failures could be misclassified as expected per-user auth states. That would suppress recordFailure(...) and shouldDisable, hiding real outages behind repeated 401 responses and silently disabling circuit protection. Given the project convention [ID: 9aca6f72-ab03-43f2-bc16-ef06243974f8] to use exact message equality for internally-controlled errors, verify the classifier uses strict equality rather than substring/regex matching to prevent misclassification.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/mesh/src/api/routes/proxy.ts, line 421:

<comment>The `isPerUserAuthorizationRequiredError(error)` early return gates the circuit-breaker failure accounting and auto-disable logic. If that cross-file classifier is overly broad (e.g., message-substring or regex based), non-auth downstream failures could be misclassified as expected per-user auth states. That would suppress `recordFailure(...)` and `shouldDisable`, hiding real outages behind repeated 401 responses and silently disabling circuit protection. Given the project convention [ID: 9aca6f72-ab03-43f2-bc16-ef06243974f8] to use exact message equality for internally-controlled errors, verify the classifier uses strict equality rather than substring/regex matching to prevent misclassification.</comment>

<file context>
@@ -412,6 +412,15 @@ export const createProxyRoutes = () => {
+        // URL) WITHOUT tripping the breaker or auto-disabling the connection.
+        // Handled before recordFailure below, which would otherwise open the
+        // circuit and 503 the connection (hiding the OAuth the error asks for).
+        if (isPerUserAuthorizationRequiredError(error)) {
+          return renderPerUserAuthorizationRequired(error);
+        }
</file context>

return renderPerUserAuthorizationRequired(error);
}
// Check if this is an auth error - if so, return appropriate 401
// Note: This only applies to HTTP connections
const connection = await ctx.storage.connections.findById(
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
10 changes: 9 additions & 1 deletion apps/mesh/src/mcp-clients/lazy-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import {
recordSuccess,
} from "./circuit-breaker";
import { clientFromConnection } from "./client";
import { isPerUserAuthorizationRequiredError } from "./outbound/errors";
import { invalidateConnectionCaches } from "./mcp-cache-invalidation";
import {
fetchWithCache,
Expand Down Expand Up @@ -119,7 +120,14 @@ export function createLazyClient(
// Clear cached promise so transient failures don't permanently
// break the client — next call will retry the connection.
realClientPromise = null;
recordFailure(connection.id);
// A per-user authorization prompt is an expected state for
// `auth_mode: "per_user"` connections without a token for the caller,
// NOT a downstream outage. Counting it as a failure trips the circuit
// breaker, which then 503s the connection — hiding the "Connect your
// account" UI and blocking the very OAuth the error is asking for.
if (!isPerUserAuthorizationRequiredError(err)) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: The added suppression prevents per-user auth errors from tripping the circuit breaker, which is good, but it doesn't help when the circuit is already open. Because assertCircuitClosed(connection.id) runs before clientFromConnection, any caller whose connection already has an open circuit gets a 503 and never reaches the per-user auth prompt—even though their caller merely needs to authorize. Since the breaker is keyed by connection.id and shared across all users, a prior unrelated downstream failure can hide the "Connect your account" flow from every subsequent caller. Consider elevating the per-user-auth bypass so it also skips an already-open circuit, or segment the breaker state so expected auth prompts aren't blocked by unrelated outages.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/mesh/src/mcp-clients/lazy-client.ts, line 128:

<comment>The added suppression prevents per-user auth errors from tripping the circuit breaker, which is good, but it doesn't help when the circuit is already open. Because `assertCircuitClosed(connection.id)` runs before `clientFromConnection`, any caller whose connection already has an open circuit gets a 503 and never reaches the per-user auth prompt—even though their caller merely needs to authorize. Since the breaker is keyed by `connection.id` and shared across all users, a prior unrelated downstream failure can hide the "Connect your account" flow from every subsequent caller. Consider elevating the per-user-auth bypass so it also skips an already-open circuit, or segment the breaker state so expected auth prompts aren't blocked by unrelated outages.</comment>

<file context>
@@ -119,7 +120,14 @@ export function createLazyClient(
+          // NOT a downstream outage. Counting it as a failure trips the circuit
+          // breaker, which then 503s the connection — hiding the "Connect your
+          // account" UI and blocking the very OAuth the error is asking for.
+          if (!isPerUserAuthorizationRequiredError(err)) {
+            recordFailure(connection.id);
+          }
</file context>

recordFailure(connection.id);
}
throw err;
});
}
Expand Down
11 changes: 10 additions & 1 deletion apps/mesh/src/web/components/details/connection/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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}`);
Expand Down
Loading