Skip to content

fix(mcp-oauth): let external OAuth clients use aggregate/virtual MCP endpoints - #4263

Closed
AriOliv wants to merge 5 commits into
decocms:mainfrom
AriOliv:fix/external-mcp-oauth-clients
Closed

fix(mcp-oauth): let external OAuth clients use aggregate/virtual MCP endpoints#4263
AriOliv wants to merge 5 commits into
decocms:mainfrom
AriOliv:fix/external-mcp-oauth-clients

Conversation

@AriOliv

@AriOliv AriOliv commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

Problem

External MCP clients (Claude Desktop / Claude Code, or any RFC 9728 / MCP OAuth client) can't connect to an org's aggregate (/api/:org/mcp) or virtual-MCP endpoints and call tools. Three independent causes:

  1. No usable OAuth authorization server advertised.

    • The aggregate endpoint exposes no oauth-protected-resource metadata → 404.
    • Virtual-MCP endpoints try to proxy the downstream connection's OAuth metadata, but a virtual MCP's connection_url is virtual://<id> — nothing to fetch → 502 (protocol must be http/https/s3).
    • Neither advertises Studio's own Better Auth MCP authorization server (which supports Dynamic Client Registration). The connection oauth-proxy only accepts Studio's own redirect_uri, so it can't serve an external client that brings its own callback → invalid_request: redirect_uri is not allowed.
  2. WWW-Authenticate advertises an http:// resource_metadata URL when Studio runs behind a TLS-terminating reverse proxy (Caddy/nginx forward over http). https-only clients (e.g. Claude) reject the non-https metadata URL.

  3. MCP OAuth sessions resolve the member role from the wrong org. External clients don't send x-org-id / x-org-slug; the org is in the URL path (/api/:org/...). A multi-org member falls through to the "single membership only" guard, resolves to no role, loses the owner/admin bypass, and every connection tool call 403s Access denied to: <tool>.

Fix

File Change
api/app.ts (mcpAuth) Honor X-Forwarded-Proto when building the resource_metadata origin so it advertises https:// behind a proxy.
api/routes/org-scoped.ts Serve Better Auth protected-resource metadata at the aggregate /api/:org/mcp/.well-known/oauth-protected-resource (mounted before the proxy catch-all).
api/routes/oauth-proxy.ts For virtual:// connections, return Better Auth metadata instead of proxying a nonexistent downstream AS.
core/context-factory.ts Derive an org-slug hint from the request path for MCP OAuth membership/role resolution.

Net effect: an external client discovers Studio's own Better Auth AS, registers via DCR (its own redirect_uri accepted), logs in against Studio, and the token resolves to the caller's real role in the path org. Downstream per-user OAuth still happens lazily when a per_user connection's tool is first invoked. RBAC is unchanged — owner/admin bypass; non-admins still need an explicit connection grant.

Testing

Validated end-to-end against a deployed Studio (behind Caddy TLS) with Claude Code:

  • POST /api/:org/mcp/<virtual-mcp-id>401 + WWW-Authenticate with an https resource_metadata.
  • Well-known → 200 advertising Studio's Better Auth AS (authorization_servers: [<studio>]).
  • DCR with a claude.ai redirect_uri → accepted; /authorize302 (no redirect_uri is not allowed).
  • After connect, a multi-org admin member's tool calls succeed (previously 403 Access denied).

🤖 Generated with Claude Code


Summary by cubic

Enable external MCP OAuth clients (e.g. Claude) to connect to org aggregate /api/:org/mcp and virtual MCP endpoints. Also shorten aggregated tool name prefixes so names fit the 64-char limit.

  • Bug Fixes
    • Serve Studio better-auth protected-resource metadata for aggregate and virtual:// MCP endpoints, enabling DCR and external redirect_uris.
    • Use X-Forwarded-Proto when building resource_metadata so https is advertised behind proxies.
    • Resolve org from /api/:org/... path when headers are absent to fix RBAC for multi-org members.
    • Allow per-connection RFC 8707 resource via connection.metadata.oauthResource; default to connection.connection_url.
    • Per-user OAuth: don’t force offline_access; return the 401 challenge without recording failures in both lazy-client and proxy paths.
    • Aggregate tooling: replace long slug prefixes with a short, stable namespace code in tool/prompt names to stay under 64 chars.

Written for commit e51ad76. Summary will update on new commits.

Review in cubic

…endpoints

External MCP clients (Claude Desktop/Code, any RFC 9728 client) could not
connect to an org's aggregate (`/api/:org/mcp`) or virtual-MCP endpoints:

- The aggregate exposed no oauth-protected-resource metadata (404), and virtual
  MCPs tried to *proxy* a `virtual://` downstream authorization server and 502'd
  ("protocol must be http/https/s3"). Neither advertised Studio's own Better
  Auth MCP authorization server (which supports Dynamic Client Registration), so
  external clients had no auth server that would accept their own redirect_uri.
  The connection `oauth-proxy` only accepts Studio's own origin, so it can't
  serve external clients.
- `WWW-Authenticate` advertised an `http://` resource_metadata URL behind a
  TLS-terminating reverse proxy; https-only clients (e.g. Claude) reject it.
- MCP OAuth sessions resolved the member role from `x-org-*` headers or the
  user's single membership. External clients send neither and the org is in the
  URL path, so multi-org members resolved to no role, lost the owner/admin
  bypass, and every connection tool call 403'd `Access denied to: <tool>`.

Fixes:
- api/app.ts (mcpAuth): honor `X-Forwarded-Proto` when building the
  resource_metadata origin so it advertises https behind a proxy.
- api/routes/org-scoped.ts: serve Better Auth protected-resource metadata for
  the aggregate `/api/:org/mcp/.well-known/oauth-protected-resource`.
- api/routes/oauth-proxy.ts: for `virtual://` connections, return Better Auth
  metadata instead of proxying a nonexistent downstream AS.
- core/context-factory.ts: derive an org-slug hint from the request path
  (`/api/:org/...`) for MCP OAuth membership/role resolution.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

@cubic-dev-ai cubic-dev-ai Bot left a comment

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.

No issues found across 4 files

Re-trigger cubic

AriOliv and others added 2 commits July 3, 2026 11:49
The oauth-proxy hardcoded the RFC 8707 `resource` parameter to
`connection.connection_url` when forwarding the authorize/token legs to a
downstream MCP's authorization server. This is correct for servers that
validate the resource equals their exact endpoint (e.g. Supabase), but breaks
servers that only accept the origin: Pipedream (`https://mcp.pipedream.net/v2`)
rejects the path-bearing resource with `invalid_request: resource: Invalid or
unauthorized resource parameter`, and gates its protected-resource metadata so
RFC 9728 discovery can't resolve the canonical value either.

Forward `resource = connection.metadata.oauthResource ?? connection.connection_url`,
computed once and reused on both the authorize redirect and the token form-body
rewrite. Endpoint-strict servers keep the default; origin-only servers set
`metadata.oauthResource` (e.g. `https://mcp.pipedream.net`).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…-user auth

Two fixes that together unblock per-user OAuth connections whose downstream is a
strict OAuth server (e.g. Pipedrive's official MCP, mcp.pipedrive.ai):

- web/connection connect: stop hardcoding scope "offline_access" in the
  DCR/authorize call. Many MCP providers don't advertise it, and passing it into
  Dynamic Client Registration makes strict servers reject /register with HTTP
  400 (Pipedrive does). Use the connection's configured scopes when set, else
  omit scope (refresh is already requested via grant_types).
- lazy-client: do not count PerUserAuthorizationRequiredError as a circuit-
  breaker failure. A per_user connection without a token for the caller throwing
  "needs authorization" is an expected state, not a downstream outage. Counting
  it opened the breaker, which 503'd the connection and hid the "Connect your
  account" UI — blocking the very OAuth the error asks for.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

@cubic-dev-ai cubic-dev-ai Bot left a comment

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.

1 issue found across 2 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="apps/mesh/src/mcp-clients/lazy-client.ts">

<violation number="1" location="apps/mesh/src/mcp-clients/lazy-client.ts:128">
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.</violation>
</file>

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

// 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>

…ired

The MCP proxy route's inner catch ran recordFailure + auto-disable before the
error reached the outer handleError (which already renders per-user auth as a
401). For an `auth_mode: "per_user"` connection whose caller has no token yet,
the handshake throws PerUserAuthorizationRequiredError — an expected state — so
the inner catch was opening the breaker and 503'ing the connection (and, in an
aggregate, crashing the whole agent's tool calls).

Return renderPerUserAuthorizationRequired(error) at the top of the inner catch,
before recordFailure, so the caller gets the OAuth challenge without tripping the
breaker or disabling the connection. Complements the lazy-client guard (client-
creation path); this covers the proxy handshake/call path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

@cubic-dev-ai cubic-dev-ai Bot left a comment

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.

1 issue found across 1 file (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="apps/mesh/src/api/routes/proxy.ts">

<violation number="1" location="apps/mesh/src/api/routes/proxy.ts:421">
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.</violation>
</file>

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

// 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>

The GatewayClient namespaced each aggregated tool as
`${slugify(connectionId)}_${toolName}`. A connection id slug is ~26 chars, so
when a downstream MCP client adds its OWN prefix (e.g. Hermes prepends
`mcp_<server>_`, ~21 chars) the combined name blew past the 64-char tool-name
limit (`^[A-Za-z0-9_-]{1,64}$`) — ~40 of 91 tools in a 3-connection aggregate
were rejected.

Replace the slug prefix with `namespaceCode()`: a 7-char stable FNV-1a hash
(`a` + 6 base36, no underscore, so resolveToolTarget's split on the first `_`
still works). Worst case drops from ~81 to ~62 chars, fitting even with a second
client prefix. Reversible via the same code in stripToolNamespace + the
slugToKey map; role permissions and selected_tools are unaffected (they key on
connection id, not the namespaced tool name). Aggregated tool names change
(clients re-list on handshake, so it's transparent).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

@cubic-dev-ai cubic-dev-ai Bot left a comment

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.

1 issue found across 2 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="packages/mcp-utils/src/aggregate/gateway-client.ts">

<violation number="1" location="packages/mcp-utils/src/aggregate/gateway-client.ts:115">
P1: Switching the namespace prefix from a `slugify`-based slug to a hash-based `namespaceCode` breaks resolution for any persisted namespaced identifiers. The `resolveToolTarget`/`resolvePromptTarget` fallback only handles un-namespaced names and new-format namespaced names, but not old-format namespaced names. If external systems or saved configurations store namespaced tool/prompt names (e.g., workflow steps, allow-lists, cached selections), they will fail to resolve after this change.</violation>
</file>

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

): string {
if (!clientId) return namespacedName;
const prefix = `${slugify(clientId)}_`;
const prefix = `${namespaceCode(clientId)}_`;

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: Switching the namespace prefix from a slugify-based slug to a hash-based namespaceCode breaks resolution for any persisted namespaced identifiers. The resolveToolTarget/resolvePromptTarget fallback only handles un-namespaced names and new-format namespaced names, but not old-format namespaced names. If external systems or saved configurations store namespaced tool/prompt names (e.g., workflow steps, allow-lists, cached selections), they will fail to resolve after this change.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/mcp-utils/src/aggregate/gateway-client.ts, line 115:

<comment>Switching the namespace prefix from a `slugify`-based slug to a hash-based `namespaceCode` breaks resolution for any persisted namespaced identifiers. The `resolveToolTarget`/`resolvePromptTarget` fallback only handles un-namespaced names and new-format namespaced names, but not old-format namespaced names. If external systems or saved configurations store namespaced tool/prompt names (e.g., workflow steps, allow-lists, cached selections), they will fail to resolve after this change.</comment>

<file context>
@@ -89,7 +112,7 @@ export function stripToolNamespace(
 ): string {
   if (!clientId) return namespacedName;
-  const prefix = `${slugify(clientId)}_`;
+  const prefix = `${namespaceCode(clientId)}_`;
   return namespacedName.startsWith(prefix)
     ? namespacedName.slice(prefix.length)
</file context>

@vibegui

vibegui commented Jul 4, 2026

Copy link
Copy Markdown
Contributor

Thanks for the contribution @AriOliv ! One thing: did you forget to add an outbound/errors.ts file?

Claude:

🔴 Critical finding: this PR does not compile

The per-user-auth robustness fix (#5) references two functions and one module that don't exist anywhere on the PR branch:

  • apps/mesh/src/api/routes/proxy.ts:421-422 calls isPerUserAuthorizationRequiredError(error) and renderPerUserAuthorizationRequired(error) — and proxy.ts imports neither (I read the full import block, lines 14-39; they're absent).
  • apps/mesh/src/mcp-clients/lazy-client.ts:32 does import { isPerUserAuthorizationRequiredError } from "./outbound/errors" — but apps/mesh/src/mcp-clients/outbound/errors.ts does not exist on the branch (confirmed via git ls-tree and full-tree git grep).

There is no definition of either function anywhere in the branch. The author almost certainly forgot to git add a new outbound/errors.ts. Consequences:

  • bun run check (typecheck) fails — undefined import + two unresolved identifiers.
  • proxy.ts would throw ReferenceError at runtime if that path executed.
  • bun test would fail to load the modules.

The only green check on the PR is cubic · AI code reviewer; the actual typecheck/test CI either isn't wired to this PR or hasn't reported — the PR is mergeStateStatus: BLOCKED. This must be fixed before any merge. Ask AriOliv for the missing outbound/errors.ts.

vibegui added a commit that referenced this pull request Jul 11, 2026
…e modal

Adds a prominent "LINK" button to the app topbar (desktop) that opens a
focused "Connect to Claude" modal built around a single action:

- Claude Code: one button copies the `claude mcp add … <org-mcp-url>`
  one-liner (paste in a terminal; OAuth on first use).
- Claude Desktop / claude.ai: copy the aggregate MCP URL to add as a
  custom connector.

The modal spells out what Claude gets — Library files, agents, and the
ability to enable + call any MCP tool in the org — since the unified
`/api/:org/mcp` endpoint already exposes all of it behind OAuth 2.1.

Also:
- Extract mcpUrl/claudeCodeCommand into components/connect/mcp-url.ts and
  reuse from the Connect settings page so the two can't drift.
- Fix the advertised OAuth protected-resource metadata URL on the Connect
  settings page: it's served at the aggregate endpoint
  (`/api/:org/mcp/.well-known/oauth-protected-resource`), not the origin
  root. Matches the backend contract in #4263.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
vibegui pushed a commit that referenced this pull request Jul 11, 2026
Only request scopes the connection has explicitly configured. offline_access
is an OIDC-ism many MCP providers don't advertise; passing it into Dynamic
Client Registration makes strict servers reject the registration outright
(e.g. Pipedrive returns HTTP 400 on /register). Refresh tokens are already
requested via grant_types, so omitting scope lets such servers grant their
default set.

Cherry-picked from #4263 (AriOliv). The paired circuit-breaker change to
lazy-client.ts is omitted here — it depends on an outbound/errors helper
that isn't yet on main.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
vibegui added a commit that referenced this pull request Jul 12, 2026
…e modal

Adds a prominent "LINK" button to the app topbar (desktop) that opens a
focused "Connect to Claude" modal built around a single action:

- Claude Code: one button copies the `claude mcp add … <org-mcp-url>`
  one-liner (paste in a terminal; OAuth on first use).
- Claude Desktop / claude.ai: copy the aggregate MCP URL to add as a
  custom connector.

The modal spells out what Claude gets — Library files, agents, and the
ability to enable + call any MCP tool in the org — since the unified
`/api/:org/mcp` endpoint already exposes all of it behind OAuth 2.1.

Also:
- Extract mcpUrl/claudeCodeCommand into components/connect/mcp-url.ts and
  reuse from the Connect settings page so the two can't drift.
- Fix the advertised OAuth protected-resource metadata URL on the Connect
  settings page: it's served at the aggregate endpoint
  (`/api/:org/mcp/.well-known/oauth-protected-resource`), not the origin
  root. Matches the backend contract in #4263.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
vibegui pushed a commit that referenced this pull request Jul 12, 2026
Only request scopes the connection has explicitly configured. offline_access
is an OIDC-ism many MCP providers don't advertise; passing it into Dynamic
Client Registration makes strict servers reject the registration outright
(e.g. Pipedrive returns HTTP 400 on /register). Refresh tokens are already
requested via grant_types, so omitting scope lets such servers grant their
default set.

Cherry-picked from #4263 (AriOliv). The paired circuit-breaker change to
lazy-client.ts is omitted here — it depends on an outbound/errors helper
that isn't yet on main.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
vibegui added a commit that referenced this pull request Jul 16, 2026
…e modal

Adds a prominent "LINK" button to the app topbar (desktop) that opens a
focused "Connect to Claude" modal built around a single action:

- Claude Code: one button copies the `claude mcp add … <org-mcp-url>`
  one-liner (paste in a terminal; OAuth on first use).
- Claude Desktop / claude.ai: copy the aggregate MCP URL to add as a
  custom connector.

The modal spells out what Claude gets — Library files, agents, and the
ability to enable + call any MCP tool in the org — since the unified
`/api/:org/mcp` endpoint already exposes all of it behind OAuth 2.1.

Also:
- Extract mcpUrl/claudeCodeCommand into components/connect/mcp-url.ts and
  reuse from the Connect settings page so the two can't drift.
- Fix the advertised OAuth protected-resource metadata URL on the Connect
  settings page: it's served at the aggregate endpoint
  (`/api/:org/mcp/.well-known/oauth-protected-resource`), not the origin
  root. Matches the backend contract in #4263.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
vibegui pushed a commit that referenced this pull request Jul 16, 2026
Only request scopes the connection has explicitly configured. offline_access
is an OIDC-ism many MCP providers don't advertise; passing it into Dynamic
Client Registration makes strict servers reject the registration outright
(e.g. Pipedrive returns HTTP 400 on /register). Refresh tokens are already
requested via grant_types, so omitting scope lets such servers grant their
default set.

Cherry-picked from #4263 (AriOliv). The paired circuit-breaker change to
lazy-client.ts is omitted here — it depends on an outbound/errors helper
that isn't yet on main.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
vibegui added a commit that referenced this pull request Jul 18, 2026
…e modal

Adds a prominent "LINK" button to the app topbar (desktop) that opens a
focused "Connect to Claude" modal built around a single action:

- Claude Code: one button copies the `claude mcp add … <org-mcp-url>`
  one-liner (paste in a terminal; OAuth on first use).
- Claude Desktop / claude.ai: copy the aggregate MCP URL to add as a
  custom connector.

The modal spells out what Claude gets — Library files, agents, and the
ability to enable + call any MCP tool in the org — since the unified
`/api/:org/mcp` endpoint already exposes all of it behind OAuth 2.1.

Also:
- Extract mcpUrl/claudeCodeCommand into components/connect/mcp-url.ts and
  reuse from the Connect settings page so the two can't drift.
- Fix the advertised OAuth protected-resource metadata URL on the Connect
  settings page: it's served at the aggregate endpoint
  (`/api/:org/mcp/.well-known/oauth-protected-resource`), not the origin
  root. Matches the backend contract in #4263.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
vibegui pushed a commit that referenced this pull request Jul 18, 2026
Only request scopes the connection has explicitly configured. offline_access
is an OIDC-ism many MCP providers don't advertise; passing it into Dynamic
Client Registration makes strict servers reject the registration outright
(e.g. Pipedrive returns HTTP 400 on /register). Refresh tokens are already
requested via grant_types, so omitting scope lets such servers grant their
default set.

Cherry-picked from #4263 (AriOliv). The paired circuit-breaker change to
lazy-client.ts is omitted here — it depends on an outbound/errors helper
that isn't yet on main.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
tlgimenes pushed a commit that referenced this pull request Jul 22, 2026
…e modal

Adds a prominent "LINK" button to the app topbar (desktop) that opens a
focused "Connect to Claude" modal built around a single action:

- Claude Code: one button copies the `claude mcp add … <org-mcp-url>`
  one-liner (paste in a terminal; OAuth on first use).
- Claude Desktop / claude.ai: copy the aggregate MCP URL to add as a
  custom connector.

The modal spells out what Claude gets — Library files, agents, and the
ability to enable + call any MCP tool in the org — since the unified
`/api/:org/mcp` endpoint already exposes all of it behind OAuth 2.1.

Also:
- Extract mcpUrl/claudeCodeCommand into components/connect/mcp-url.ts and
  reuse from the Connect settings page so the two can't drift.
- Fix the advertised OAuth protected-resource metadata URL on the Connect
  settings page: it's served at the aggregate endpoint
  (`/api/:org/mcp/.well-known/oauth-protected-resource`), not the origin
  root. Matches the backend contract in #4263.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
tlgimenes pushed a commit that referenced this pull request Jul 22, 2026
Only request scopes the connection has explicitly configured. offline_access
is an OIDC-ism many MCP providers don't advertise; passing it into Dynamic
Client Registration makes strict servers reject the registration outright
(e.g. Pipedrive returns HTTP 400 on /register). Refresh tokens are already
requested via grant_types, so omitting scope lets such servers grant their
default set.

Cherry-picked from #4263 (AriOliv). The paired circuit-breaker change to
lazy-client.ts is omitted here — it depends on an outbound/errors helper
that isn't yet on main.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
tlgimenes pushed a commit that referenced this pull request Jul 22, 2026
…r MCP + OAuth (#2765)

* feat(connect-studio): add settings page to plug Studio MCP into IDEs

Adds /$org/settings/connect with paste-ready install snippets for Claude
Code, Cursor, Codex, Claude Desktop, and a raw URL. Each client has an
OAuth tab (no token, browser pops on first use) and an API key tab that
mints a key via API_KEY_CREATE. Claude Code commands default to user
scope so the MCP is available across all projects.

Adds a Connect Studio entry to the main sidebar footer (next to
Connections) and a sibling settings nav item under Organization.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(connect-studio): drop unused buildSnippet export to satisfy knip

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(connect-studio): add Connect to Claude entry in account menu

Surfaces the org's unified MCP connect page from the account
popover/drawer (alongside "Add to Home Screen") so it's always reachable.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(connect-studio): rename account menu entry to "Connect to Agents"

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(connect-studio): topbar LINK button + one-click Connect to Claude modal

Adds a prominent "LINK" button to the app topbar (desktop) that opens a
focused "Connect to Claude" modal built around a single action:

- Claude Code: one button copies the `claude mcp add … <org-mcp-url>`
  one-liner (paste in a terminal; OAuth on first use).
- Claude Desktop / claude.ai: copy the aggregate MCP URL to add as a
  custom connector.

The modal spells out what Claude gets — Library files, agents, and the
ability to enable + call any MCP tool in the org — since the unified
`/api/:org/mcp` endpoint already exposes all of it behind OAuth 2.1.

Also:
- Extract mcpUrl/claudeCodeCommand into components/connect/mcp-url.ts and
  reuse from the Connect settings page so the two can't drift.
- Fix the advertised OAuth protected-resource metadata URL on the Connect
  settings page: it's served at the aggregate endpoint
  (`/api/:org/mcp/.well-known/oauth-protected-resource`), not the origin
  root. Matches the backend contract in #4263.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(mcp-oauth): let external OAuth clients use aggregate/virtual MCP endpoints

External MCP clients (Claude Desktop/Code, any RFC 9728 client) could not
connect to an org's aggregate (`/api/:org/mcp`) or virtual-MCP endpoints:

- The aggregate exposed no oauth-protected-resource metadata (404), and virtual
  MCPs tried to *proxy* a `virtual://` downstream authorization server and 502'd
  ("protocol must be http/https/s3"). Neither advertised Studio's own Better
  Auth MCP authorization server (which supports Dynamic Client Registration), so
  external clients had no auth server that would accept their own redirect_uri.
  The connection `oauth-proxy` only accepts Studio's own origin, so it can't
  serve external clients.
- `WWW-Authenticate` advertised an `http://` resource_metadata URL behind a
  TLS-terminating reverse proxy; https-only clients (e.g. Claude) reject it.
- MCP OAuth sessions resolved the member role from `x-org-*` headers or the
  user's single membership. External clients send neither and the org is in the
  URL path, so multi-org members resolved to no role, lost the owner/admin
  bypass, and every connection tool call 403'd `Access denied to: <tool>`.

Fixes:
- api/app.ts (mcpAuth): honor `X-Forwarded-Proto` when building the
  resource_metadata origin so it advertises https behind a proxy.
- api/routes/org-scoped.ts: serve Better Auth protected-resource metadata for
  the aggregate `/api/:org/mcp/.well-known/oauth-protected-resource`.
- api/routes/oauth-proxy.ts: for `virtual://` connections, return Better Auth
  metadata instead of proxying a nonexistent downstream AS.
- core/context-factory.ts: derive an org-slug hint from the request path
  (`/api/:org/...`) for MCP OAuth membership/role resolution.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(oauth-proxy): per-connection resource indicator override

The oauth-proxy hardcoded the RFC 8707 `resource` parameter to
`connection.connection_url` when forwarding the authorize/token legs to a
downstream MCP's authorization server. This is correct for servers that
validate the resource equals their exact endpoint (e.g. Supabase), but breaks
servers that only accept the origin: Pipedream (`https://mcp.pipedream.net/v2`)
rejects the path-bearing resource with `invalid_request: resource: Invalid or
unauthorized resource parameter`, and gates its protected-resource metadata so
RFC 9728 discovery can't resolve the canonical value either.

Forward `resource = connection.metadata.oauthResource ?? connection.connection_url`,
computed once and reused on both the authorize redirect and the token form-body
rewrite. Endpoint-strict servers keep the default; origin-only servers set
`metadata.oauthResource` (e.g. `https://mcp.pipedream.net`).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(aggregate): short namespace code for aggregated tool names

The GatewayClient namespaced each aggregated tool as
`${slugify(connectionId)}_${toolName}`. A connection id slug is ~26 chars, so
when a downstream MCP client adds its OWN prefix (e.g. Hermes prepends
`mcp_<server>_`, ~21 chars) the combined name blew past the 64-char tool-name
limit (`^[A-Za-z0-9_-]{1,64}$`) — ~40 of 91 tools in a 3-connection aggregate
were rejected.

Replace the slug prefix with `namespaceCode()`: a 7-char stable FNV-1a hash
(`a` + 6 base36, no underscore, so resolveToolTarget's split on the first `_`
still works). Worst case drops from ~81 to ~62 chars, fitting even with a second
client prefix. Reversible via the same code in stripToolNamespace + the
slugToKey map; role permissions and selected_tools are unaffected (they key on
connection id, not the namespaced tool name). Aggregated tool names change
(clients re-list on handshake, so it's transparent).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(mcp-oauth): don't force offline_access on per-connection OAuth

Only request scopes the connection has explicitly configured. offline_access
is an OIDC-ism many MCP providers don't advertise; passing it into Dynamic
Client Registration makes strict servers reject the registration outright
(e.g. Pipedrive returns HTTP 400 on /register). Refresh tokens are already
requested via grant_types, so omitting scope lets such servers grant their
default set.

Cherry-picked from #4263 (AriOliv). The paired circuit-breaker change to
lazy-client.ts is omitted here — it depends on an outbound/errors helper
that isn't yet on main.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(connect-studio): one-command Claude Code connect (no OAuth), Link button left

Addresses the "I don't want to run /mcp and auth" flow. The OAuth path
requires an interactive `/mcp` browser login (and was failing with HTTP 400
on reconnect). The modal's primary Claude Code action now mints a scoped
full-access API key and embeds it in the command:

  claude mcp add --transport http --scope user studio <url> \
    --header "Authorization: Bearer <key>"

Claude Code sends the token on the first request — no /mcp, no browser
login, tools live immediately. Best-effort auto-copy on generate with a
visible copy button as fallback, plus a warning that the command carries a
full-access token (revocable in Settings → Connect).

Also per feedback: rename the topbar button "LINK" → "Link" and move it to
the left column (next to the sidebar trigger).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(connect-studio): make the connect command actually work end-to-end

Two real bugs surfaced by testing the generated command against a live
local Studio (Claude Code → HTTP 400, then 0 tools):

1. Aggregate org resolution (HTTP 400). `/api/:org/mcp` →
   handleVirtualMcpRequest resolved the org ONLY from x-org-id/x-org-slug
   headers, which external MCP clients (Claude Code/Desktop) never send —
   the org is in the URL path, already in ctx.organization via
   resolveOrgFromPath. Fall back to it so the endpoint stops 400ing with
   "Agent ID or organization ID is required".

2. Empty toolset. The bare aggregate resolves to the Decopilot agent,
   which is a pure orchestrator with connections:[] (routes via subtask) —
   so an external client sees ZERO tools. Point the connect URL at
   `/api/:org/mcp/self` instead: the org's real management surface (Library
   files, agents, connections, automations, brand, AI providers, secrets).
   Verified live: initialize + tools/list (142 tools) + a real
   ORGANIZATION_LIST call all succeed over the API-key command.

Also: derive the MCP server name in the command from the host
(`belo-horizonte.localhost` locally, `studio.decocms.com` in prod) so each
deployment gets a distinct entry and adding two never collides.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(aggregate): align PassthroughClient namespacing test with namespace codes

The cherry-picked "short namespace code for aggregated tool names" change
switched tool prefixes from slugify(connectionId) to namespaceCode(...), and
updated gateway-client.test.ts — but passthrough-client.test.ts still asserted
the old slugify scheme, failing on CI (Expected "conn-aaa_search", got
"ak4m99x_search"). Switch its 5 namespace assertions to namespaceCode and
export namespaceCode from @decocms/mcp-utils/aggregate.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(connect-studio): valid mcp name, readable modal, move trigger to sidebar

Three fixes from testing/feedback:

1. Invalid server name. `claude mcp add <name>` only accepts letters, numbers,
   hyphens and underscores, but the host-derived name had dots
   (belo-horizonte.localhost) and was rejected. Sanitize the hostname:
   belo-horizonte.localhost → belo-horizonte-localhost,
   studio.decocms.com → studio-decocms-com.

2. Modal contrast/polish. The security note used Alert variant="warning",
   whose warning-foreground text was near-invisible on the light amber
   background. Replaced with a readable note (text-foreground/80 + shield
   icon). Also tightened spacing, hierarchy, the command block, and marked
   Claude Code as the recommended path.

3. Move the trigger out of the topbar into the sidebar footer, alongside
   "Invite members" / "Add connection" and before "Connect desktop". The
   dialog is now a controlled <ConnectDialog>; the old topbar
   ConnectLinkButton is removed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(connect-studio): stop dialog content overflowing its right padding

DialogContent is a CSS grid; grid/flex children default to min-width:auto, so
the long MCP URL (flex-1 + truncate, no min-w-0) couldn't shrink and pushed the
row past the right padding — the right margin looked broken. Wrap the body in a
single min-w-0 column and give the URL row/code min-w-0 so truncate works and
nothing overflows.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(aggregate): fix 3 more namespace-code assertions after rebase

Rebasing onto main pulled in 3 new "resilience to failing connections" tests
in gateway-client.test.ts that assert the old slugify-based prefix
("healthy_ok_tool"), but the namespaceCode() change on this branch produces a
short hash prefix instead. Switch them to the existing `ns()` test helper,
matching the rest of the file.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(auth): enforce org-bound credentials and discovery

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: AriOliv <ariguilherme.aguiar@hotmail.com>
Co-authored-by: Deco Studio <studio@deco.cx>
@AriOliv

AriOliv commented Aug 13, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for the catch @vibegui — you were right, outbound/errors.ts never made it into this branch. Rather than patching it here (this branch predates the apps/meshapps/api restructure, and the other pieces — namespaceCode, metadata.oauthResource — were already cherry-picked in dc1dca2), I've re-implemented the whole per-user OAuth feature on the current layout, including the missing file and the circuit-breaker piece: #6023. Closing this one in favor of it.

@AriOliv AriOliv closed this Aug 13, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants