feat(connect): one-click LINK — connect your Studio org to Claude over MCP + OAuth#2765
feat(connect): one-click LINK — connect your Studio org to Claude over MCP + OAuth#2765vibegui wants to merge 12 commits into
Conversation
Release OptionsSuggested: Minor ( React with an emoji to override the release type:
Current version:
|
🧪 BenchmarkShould we run the Virtual MCP strategy benchmark for this PR? React with 👍 to run the benchmark.
Benchmark will run on the next push after you react. |
There was a problem hiding this comment.
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; |
There was a problem hiding this comment.
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.)
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>
| const userId = ctx.auth?.user?.id; | |
| const userId = getUserId(ctx); |
|
|
||
| app.get("/:org/decopilot/connect-studio/status", async (c) => { | ||
| const ctx = c.get("meshContext"); | ||
| if (!ctx.auth?.user?.id) { |
There was a problem hiding this comment.
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.)
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>
| if (!ctx.auth?.user?.id) { | |
| if (!getUserId(ctx)) { |
| return ( | ||
| typeof window !== "undefined" && | ||
| (window.location.hostname === "localhost" || | ||
| window.location.hostname === "127.0.0.1") |
There was a problem hiding this comment.
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>
685f855 to
01fbe89
Compare
01fbe89 to
0daa3db
Compare
33c5041 to
683b2b1
Compare
575c1e8 to
1026839
Compare
1026839 to
5ddadbd
Compare
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>
0e64313 to
576b0fa
Compare
…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>
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 connectionenabled 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
LINKbutton → focused "Connect to Claude" modal built around asingle action:
claude mcp add --transport http --scope user studio <url>one-liner (paste in a terminal; OAuth opens the browser on first use)./$org/settings/connect) for power users: per-clientinstall 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.Agents"), and a dismissible banner on Organization settings.
components/connect/mcp-url.tshelper (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:
/api/:org/mcp/.well-known/oauth-protected-resourceand forvirtual://connections — so clients can discover Studio's Better Auth authorization server
(with Dynamic Client Registration; the client's own
redirect_uriis accepted).X-Forwarded-Protowhen advertisingresource_metadata, so the URLis
https://behind a TLS-terminating proxy (Claude rejects non-https)./api/:org/...path for MCP OAuth sessions —fixes multi-org members
403-ing on every tool call (they don't sendx-org-*headers, so role resolution previously found no membership).resourceoverride (metadata.oauthResource),defaulting to the connection URL.
offline_accesson per-connection OAuth — strict serversreject it during DCR (e.g. Pipedrive
400 /register); refresh tokens arealready requested via
grant_types.the 64-char limit so every tool lists and works across clients.
Not included / follow-ups
(
proxy.ts,lazy-client.ts) are omitted — they depend on anmcp-clients/outbound/errors.tshelper that isn't onmainyet. The coreaggregate connect flow and all org-owned/shared-auth tools work without it; the
only gap is that a
per_userconnection's first tool call could trip thebreaker instead of cleanly returning a "connect your account" prompt. Can be
added once that helper lands.
double-land conflict.
Test plan
claude mcp add … <org-url>→ Claude Code completes OAuth against Studio and lists org tools.403 Access denied).GET /api/:org/mcp/.well-known/oauth-protected-resource→200, advertising Studio's Better Auth AS.WWW-Authenticateresource_metadataURL ishttps://.