Skip to content

feat(connect): one-click LINK — connect your Studio org to Claude over MCP + OAuth#2765

Open
vibegui wants to merge 12 commits into
mainfrom
feat/connect-studio
Open

feat(connect): one-click LINK — connect your Studio org to Claude over MCP + OAuth#2765
vibegui wants to merge 12 commits into
mainfrom
feat/connect-studio

Conversation

@vibegui

@vibegui vibegui commented Mar 19, 2026

Copy link
Copy Markdown
Contributor

What this does

Adds a one-click "LINK" button to the app topbar that connects your Studio
org to Claude (and any MCP client) over its unified MCP endpoint — and brings in
the OAuth backend that makes that connection actually work end to end. Once
linked, Claude can browse/edit your Library, run your agents, and enable + call
any MCP tool in the org, all behind OAuth 2.1 and governed by your existing
RBAC/Decopilot rules.

The org's unified endpoint (/api/:org/mcp) already aggregates every connection
enabled in the org. This PR is the surface that hands that endpoint out, plus the
auth plumbing an external client needs to use it.

Frontend — surfacing the connection

  • Topbar LINK button → focused "Connect to Claude" modal built around a
    single action:
    • Claude Code: one button copies the claude mcp add --transport http --scope user studio <url> one-liner (paste in a terminal; OAuth opens the browser on first use).
    • Claude Desktop / claude.ai: copy the MCP URL to add as a custom connector.
    • The modal spells out exactly what Claude gets (Library, agents, MCP tools).
  • Connect settings page (/$org/settings/connect) for power users: per-client
    install snippets (Claude Code, Cursor, Codex, Claude Desktop, raw URL) with
    OAuth and API key modes; mint / list / revoke per-user keys via the
    self-MCP tools (API_KEY_CREATE/LIST/DELETE) for headless/CI clients.
  • Additional entry points: sidebar footer icon, account menu ("Connect to
    Agents"), and a dismissible banner on Organization settings.
  • Shared components/connect/mcp-url.ts helper (mcpUrl / claudeCodeCommand)
    reused by the modal and the settings page so the two can't drift.

Backend — external OAuth clients can use the org endpoint

Cherry-picked (authorship preserved) from #4263 by @AriOliv, the changes an
external MCP OAuth client needs to authenticate and call tools:

  • Serve OAuth Protected Resource Metadata at the aggregate endpoint
    /api/:org/mcp/.well-known/oauth-protected-resource and for virtual://
    connections — so clients can discover Studio's Better Auth authorization server
    (with Dynamic Client Registration; the client's own redirect_uri is accepted).
  • Honor X-Forwarded-Proto when advertising resource_metadata, so the URL
    is https:// behind a TLS-terminating proxy (Claude rejects non-https).
  • Resolve the org from the /api/:org/... path for MCP OAuth sessions —
    fixes multi-org members 403-ing on every tool call (they don't send
    x-org-* headers, so role resolution previously found no membership).
  • Per-connection RFC 8707 resource override (metadata.oauthResource),
    defaulting to the connection URL.
  • Don't force offline_access on per-connection OAuth — strict servers
    reject it during DCR (e.g. Pipedrive 400 /register); refresh tokens are
    already requested via grant_types.
  • Short, stable namespace code for aggregated tool names, keeping them under
    the 64-char limit so every tool lists and works across clients.

Not included / follow-ups

Test plan

  • Topbar LINK button opens the "Connect to Claude" modal; copy actions work.
  • claude mcp add … <org-url> → Claude Code completes OAuth against Studio and lists org tools.
  • As an owner/admin and as a multi-org member, tool calls succeed (no 403 Access denied).
  • GET /api/:org/mcp/.well-known/oauth-protected-resource200, advertising Studio's Better Auth AS.
  • Behind a TLS proxy, the 401 WWW-Authenticate resource_metadata URL is https://.
  • Settings → Connect: OAuth + API-key tabs render correct snippets; key mint/list/revoke works.
  • Aggregated tool names stay ≤ 64 chars (gateway-client tests pass).

@github-actions

github-actions Bot commented Mar 19, 2026

Copy link
Copy Markdown
Contributor

Release Options

Suggested: Minor (2.374.0) — based on feat: prefix

React with an emoji to override the release type:

Reaction Type Next Version
👍 Prerelease 2.373.8-alpha.1
🎉 Patch 2.373.8
❤️ Minor 2.374.0
🚀 Major 3.0.0

Current version: 2.373.7

Note: If multiple reactions exist, the smallest bump wins. If no reactions, the suggested bump is used (default: patch).

@github-actions

Copy link
Copy Markdown
Contributor

🧪 Benchmark

Should we run the Virtual MCP strategy benchmark for this PR?

React with 👍 to run the benchmark.

Reaction Action
👍 Run quick benchmark (10 & 128 tools)

Benchmark will run on the next push after you react.

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

3 issues found across 4 files

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/web/components/connect-studio/connect-studio-modal.tsx">

<violation number="1" location="apps/mesh/src/web/components/connect-studio/connect-studio-modal.tsx:43">
P3: Local environment detection misses IPv6 loopback (`::1`), which can hide the Connect action during local development.</violation>
</file>

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

<violation number="1" location="apps/mesh/src/api/routes/decopilot/routes.ts:327">
P1: Use `getUserId(ctx)` instead of `ctx.auth?.user?.id` — this will incorrectly return 401 for requests authenticated via API key. The DELETE endpoint already uses `getUserId(ctx)` correctly.

(Based on your team's feedback about using getUserId(ctx) for user-based or API-key authentication.) [FEEDBACK_USED]</violation>

<violation number="2" location="apps/mesh/src/api/routes/decopilot/routes.ts:343">
P1: Use `getUserId(ctx)` instead of `ctx.auth?.user?.id` — under API-key auth, this returns `undefined`, causing a spurious 401 and embedding `"undefined"` in the created API key name.

(Based on your team's feedback about using getUserId(ctx) for user-based or API-key authentication.) [FEEDBACK_USED]</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.

app.post("/:org/decopilot/connect-studio", async (c) => {
const ctx = c.get("meshContext");
const organization = ensureOrganization(c);
const userId = ctx.auth?.user?.id;

@cubic-dev-ai cubic-dev-ai Bot Mar 19, 2026

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: Use getUserId(ctx) instead of ctx.auth?.user?.id — under API-key auth, this returns undefined, causing a spurious 401 and embedding "undefined" in the created API key name.

(Based on your team's feedback about using getUserId(ctx) for user-based or API-key authentication.)

View Feedback

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/decopilot/routes.ts, line 343:

<comment>Use `getUserId(ctx)` instead of `ctx.auth?.user?.id` — under API-key auth, this returns `undefined`, causing a spurious 401 and embedding `"undefined"` in the created API key name.

(Based on your team's feedback about using getUserId(ctx) for user-based or API-key authentication.) </comment>

<file context>
@@ -192,6 +192,349 @@ export function createDecopilotRoutes(deps: DecopilotDeps) {
+  app.post("/:org/decopilot/connect-studio", async (c) => {
+    const ctx = c.get("meshContext");
+    const organization = ensureOrganization(c);
+    const userId = ctx.auth?.user?.id;
+    if (!userId) {
+      throw new HTTPException(401, { message: "Authentication required" });
</file context>
Suggested change
const userId = ctx.auth?.user?.id;
const userId = getUserId(ctx);
Fix with Cubic


app.get("/:org/decopilot/connect-studio/status", async (c) => {
const ctx = c.get("meshContext");
if (!ctx.auth?.user?.id) {

@cubic-dev-ai cubic-dev-ai Bot Mar 19, 2026

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: Use getUserId(ctx) instead of ctx.auth?.user?.id — this will incorrectly return 401 for requests authenticated via API key. The DELETE endpoint already uses getUserId(ctx) correctly.

(Based on your team's feedback about using getUserId(ctx) for user-based or API-key authentication.)

View Feedback

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/decopilot/routes.ts, line 327:

<comment>Use `getUserId(ctx)` instead of `ctx.auth?.user?.id` — this will incorrectly return 401 for requests authenticated via API key. The DELETE endpoint already uses `getUserId(ctx)` correctly.

(Based on your team's feedback about using getUserId(ctx) for user-based or API-key authentication.) </comment>

<file context>
@@ -192,6 +192,349 @@ export function createDecopilotRoutes(deps: DecopilotDeps) {
+
+  app.get("/:org/decopilot/connect-studio/status", async (c) => {
+    const ctx = c.get("meshContext");
+    if (!ctx.auth?.user?.id) {
+      throw new HTTPException(401, { message: "Authentication required" });
+    }
</file context>
Suggested change
if (!ctx.auth?.user?.id) {
if (!getUserId(ctx)) {
Fix with Cubic

return (
typeof window !== "undefined" &&
(window.location.hostname === "localhost" ||
window.location.hostname === "127.0.0.1")

@cubic-dev-ai cubic-dev-ai Bot Mar 19, 2026

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.

P3: Local environment detection misses IPv6 loopback (::1), which can hide the Connect action during local development.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/mesh/src/web/components/connect-studio/connect-studio-modal.tsx, line 43:

<comment>Local environment detection misses IPv6 loopback (`::1`), which can hide the Connect action during local development.</comment>

<file context>
@@ -0,0 +1,379 @@
+  return (
+    typeof window !== "undefined" &&
+    (window.location.hostname === "localhost" ||
+      window.location.hostname === "127.0.0.1")
+  );
+}
</file context>
Fix with Cubic

@vibegui vibegui force-pushed the feat/connect-studio branch 3 times, most recently from 685f855 to 01fbe89 Compare March 20, 2026 01:09
@vibegui vibegui force-pushed the feat/connect-studio branch from 01fbe89 to 0daa3db Compare March 21, 2026 21:35
@vibegui vibegui force-pushed the feat/connect-studio branch 3 times, most recently from 33c5041 to 683b2b1 Compare May 30, 2026 14:43
@vibegui vibegui force-pushed the feat/connect-studio branch 2 times, most recently from 575c1e8 to 1026839 Compare June 12, 2026 12:42
@vibegui vibegui force-pushed the feat/connect-studio branch from 1026839 to 5ddadbd Compare July 11, 2026 17:14
@vibegui vibegui changed the title feat(connect-studio): Connect Studio modal for IDE MCP configuration feat(connect): one-click LINK — connect your Studio org to Claude over MCP + OAuth Jul 11, 2026
vibegui and others added 11 commits July 11, 2026 22:19
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>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…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>
…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>
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>
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>
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>
…k 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>
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>
@vibegui vibegui force-pushed the feat/connect-studio branch from 0e64313 to 576b0fa Compare July 12, 2026 01:20
…ace 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>
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