Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
bafeb7d
feat(connect-studio): add settings page to plug Studio MCP into IDEs
vibegui May 28, 2026
cca2438
fix(connect-studio): drop unused buildSnippet export to satisfy knip
vibegui May 28, 2026
afea41b
feat(connect-studio): add Connect to Claude entry in account menu
vibegui Jun 11, 2026
9cc51c0
fix(connect-studio): rename account menu entry to "Connect to Agents"
vibegui Jun 11, 2026
394d5b8
feat(connect-studio): topbar LINK button + one-click Connect to Claud…
vibegui Jul 11, 2026
3e11483
fix(mcp-oauth): let external OAuth clients use aggregate/virtual MCP …
AriOliv Jul 2, 2026
4c5ca0e
fix(oauth-proxy): per-connection resource indicator override
AriOliv Jul 3, 2026
94a3d5b
fix(aggregate): short namespace code for aggregated tool names
AriOliv Jul 3, 2026
f189a09
fix(mcp-oauth): don't force offline_access on per-connection OAuth
AriOliv Jul 11, 2026
46df34d
feat(connect-studio): one-command Claude Code connect (no OAuth), Lin…
vibegui Jul 11, 2026
32cb0a4
fix(connect-studio): make the connect command actually work end-to-end
vibegui Jul 11, 2026
a9bf548
test(aggregate): align PassthroughClient namespacing test with namesp…
vibegui Jul 12, 2026
99c0dc8
fix(connect-studio): valid mcp name, readable modal, move trigger to …
vibegui Jul 16, 2026
25884e9
fix(connect-studio): stop dialog content overflowing its right padding
vibegui Jul 16, 2026
20fd514
test(aggregate): fix 3 more namespace-code assertions after rebase
vibegui Jul 18, 2026
0b9b070
fix(auth): enforce org-bound credentials and discovery
Jul 22, 2026
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 @@ -380,6 +380,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 @@ -475,7 +489,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 @@ -548,7 +562,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 @@ -1862,10 +1876,17 @@ export async function createApp(options: CreateAppOptions = {}) {
// Require either user or API key authentication
if (!studioContext.auth.user?.id && !studioContext.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
58 changes: 58 additions & 0 deletions apps/mesh/src/api/middleware/resolve-org-from-path.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,39 @@ function isPublicSharePath(c: Context): boolean {
);
}

/**
* Return the organization bound into an API key's metadata, if present.
* Keys created for org-scoped access carry `metadata.organization.id`.
* Legacy/internal keys without that field are left to their existing route
* authorization rules; a malformed explicit organization binding fails closed.
*/
function getApiKeyOrganizationBinding(ctx: StudioContext): {
present: boolean;
id?: string;
} {
const metadata = ctx.auth?.apiKey?.metadata;
if (
!metadata ||
typeof metadata !== "object" ||
Array.isArray(metadata) ||
!("organization" in metadata)
) {
return { present: false };
}

const organization = metadata.organization;
if (
!organization ||
typeof organization !== "object" ||
Array.isArray(organization)
) {
return { present: true };
}

const id = (organization as Record<string, unknown>).id;
return { present: true, id: typeof id === "string" ? id : undefined };
}

/**
* The exhaustive list of service-token routes that resolve the org by ID —
* their machine caller (commerce-discovery) holds the org id, not the slug.
Expand Down Expand Up @@ -89,6 +122,31 @@ export const resolveOrgFromPath: MiddlewareHandler<{
return c.json({ error: `organization "${slug}" not found` }, 404);
}

// API keys are capabilities bound to the organization that minted them.
// Do this check before membership/rebinding so a valid key from org B cannot
// be reused against org A merely because its owner is also an A member.
const apiKeyBinding = getApiKeyOrganizationBinding(ctx);
if (
ctx.auth?.apiKey?.id &&
apiKeyBinding.present &&
apiKeyBinding.id !== org.id
) {
return c.json(
{ error: "forbidden: API key is scoped to another organization" },
403,
);
}

if (
ctx.auth?.tokenOrganizationId &&
ctx.auth.tokenOrganizationId !== org.id
) {
return c.json(
{ error: "forbidden: token is scoped to another organization" },
403,
);
}

// Archived (soft-deleted) orgs are invisible to the API. Treat them exactly
// like a missing org: bounce browser navigations into the SPA (the shell
// shows the branded "Organization unavailable" screen), and return JSON 404
Expand Down
79 changes: 57 additions & 22 deletions apps/mesh/src/api/org-scoped.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,43 @@ describe("org-scoped API coexistence", () => {
// Deprecation-log assertions remain above (Playwright can't capture
// dev-server stdout).

it("serves Better Auth metadata for the org-scoped self MCP aliases", async () => {
const paths = [
"/api/org_1/mcp/self/.well-known/oauth-protected-resource",
"/.well-known/oauth-protected-resource/api/org_1/mcp/self",
];

for (const path of paths) {
const res = await app.fetch(new Request(`http://mesh.localhost${path}`));

expect(res.status, path).toBe(200);
const body = (await res.json()) as {
resource: string;
authorization_servers?: string[];
};
expect(body.resource, path).toBe(
"http://mesh.localhost/api/org_1/mcp/self",
);
expect(body.authorization_servers?.length, path).toBeGreaterThan(0);
}
});

it("rejects an API key whose organization differs from the URL org", async () => {
mockApiKey("user_1", "org_2", "org_2");

const res = await app.fetch(
new Request("http://test/api/org_1/mcp/self", {
method: "POST",
headers: { Authorization: "Bearer test-key" },
}),
);

expect(res.status).toBe(403);
expect(await res.json()).toEqual({
error: "forbidden: API key is scoped to another organization",
});
});

it("well-known prefix discovery for org-scoped MCP resolves the right org", async () => {
// The MCP SDK probes /.well-known/oauth-protected-resource{resource-path}
// (RFC 9728 Format 2 / Smithery-style) to discover OAuth metadata. With
Expand Down Expand Up @@ -247,19 +284,14 @@ describe("org-scoped API coexistence", () => {
}
});

it("well-known prefix discovery uses the path slug, not the session's active org", async () => {
it("well-known prefix discovery uses the path slug", async () => {
// Regression for #3272 fallout: multi-org users hitting another org's
// URL would 404 here because the handler resolved `orgSlug` as
// `ctx.organization?.slug ?? c.req.param("org")`. The well-known prefix
// route is mounted at the URL root (outside `/api/:org`), so
// `resolveOrgFromPath` doesn't run — `ctx.organization` falls through to
// the session's `activeOrganizationId`, which silently overrode the path
// slug. For a user whose active org is `org_456`, a discovery probe at
// `/api/org_1/mcp/conn_1` would scope the lookup to `org_456` and 404
// even though the path AND the connection both belong to `org_1`. Fix:
// path param takes priority — `c.req.param("org") ?? ctx.organization?.slug`.
mockApiKey("user_1", "org_456", "org_456");

// `resolveOrgFromPath` doesn't run. The path parameter must therefore be
// selected before any context fallback — `c.req.param("org") ??
// ctx.organization?.slug` — or the connection lookup loses its tenant.
const fetchSpy = vi.spyOn(globalThis, "fetch").mockImplementation((async (
_input,
init,
Expand All @@ -281,7 +313,6 @@ describe("org-scoped API coexistence", () => {
const res = await app.fetch(
new Request(
"http://mesh.localhost/.well-known/oauth-protected-resource/api/org_1/mcp/conn_1",
{ headers: { Authorization: "Bearer test-key" } },
),
);

Expand Down Expand Up @@ -393,22 +424,24 @@ describe("org-scoped API coexistence", () => {
}
});

it("DCR survives a multi-org user whose session active org differs from the path", async () => {
it("DCR uses the URL org for a multi-org API-key owner", async () => {
// The popup-not-opening bug surfaced because DCR (the SDK's
// POST /register call right before opening the authorize popup) hit the
// legacy `/oauth-proxy/:connectionId/*` mount and 404'd against the
// session's `activeOrganizationId`. With the AS metadata now pointing at
// `/api/:org/oauth-proxy/...`, `resolveOrgFromPath` resolves the org from
// the URL and verifies membership instead — independent of session state.
// legacy `/oauth-proxy/:connectionId/*` mount and 404'd against a stale
// tenant. With the AS metadata now pointing at `/api/:org/oauth-proxy/...`,
// `resolveOrgFromPath` resolves the org from the URL and verifies
// membership instead.

// Seed user_1 into a second org and switch the active session there. The
// path under test still names org_1 (where conn_1 lives).
// Seed user_1 into a second org. The path under test still names org_1
// (where conn_1 lives), and the API key is explicitly bound to org_1.
await sql`
INSERT INTO "member" (id, "userId", "organizationId", role, "createdAt")
VALUES ('mem_1_456', 'user_1', 'org_456', 'member', ${new Date().toISOString()})
ON CONFLICT (id) DO NOTHING
`.execute(database.db);
mockApiKey("user_1", "org_456", "org_456");
// The credential is scoped to org_1, even though its owner is also a
// member of org_456. The URL's org remains the sole tenant selector.
mockApiKey("user_1", "org_1", "org_1");

const fetchSpy = vi.spyOn(globalThis, "fetch").mockImplementation((async (
input: string | URL | Request,
Expand Down Expand Up @@ -474,9 +507,8 @@ describe("org-scoped API coexistence", () => {

it("oauth-proxy refuses slug-spoofing on the org-scoped mount", async () => {
// Member of both org_1 and org_456 asks for an org_1 connection under
// org_456's slug. The path-resolved org scopes the connection lookup, so
// findById returns null and the handler 404s — preventing OAuth proxying
// for connections that don't belong to the URL's org.
// org_456's slug with an org_1-bound API key. Reject the credential/path
// mismatch before looking up or proxying the connection.
const now = new Date().toISOString();
await sql`
INSERT INTO "member" (id, "userId", "organizationId", role, "createdAt")
Expand All @@ -498,7 +530,10 @@ describe("org-scoped API coexistence", () => {
),
);

expect(res.status).toBe(404);
expect(res.status).toBe(403);
expect(await res.json()).toEqual({
error: "forbidden: API key is scoped to another organization",
});
});

it("DCR injects the connection's owning org into the registration metadata", async () => {
Expand Down
73 changes: 73 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 @@ -319,6 +321,38 @@ const fixProtocol = (url: URL) => {
return url;
};

/**
* Convert either RFC 9728 discovery URL shape back to the protected resource
* URL that the client originally requested. Better Auth's generic metadata
* handler intentionally advertises the auth server's origin; the org-scoped
* self endpoint needs its concrete `/api/:org/mcp/self` resource instead.
*/
function protectedResourceUrlFromDiscovery(url: URL): string {
const resourceRelativeSuffix = "/.well-known/oauth-protected-resource";
const wellKnownPrefix = resourceRelativeSuffix;
let pathname = url.pathname;

if (pathname.endsWith(resourceRelativeSuffix)) {
pathname = pathname.slice(0, -resourceRelativeSuffix.length) || "/";
} else if (pathname.startsWith(wellKnownPrefix)) {
pathname = pathname.slice(wellKnownPrefix.length) || "/";
}

return `${url.origin}${pathname}`;
}

async function studioProtectedResourceMetadata(
request: Request,
resource?: string,
): Promise<Response> {
const res = await oAuthProtectedResourceMetadata(auth)(request);
const data = (await res.json()) as Record<string, unknown>;
if (resource) {
data.resource = resource;
}
return Response.json(data, res);
}

/**
* Handler for proxying OAuth protected resource metadata
* Rewrites resource to /mcp/:connectionId and authorization_servers to /oauth-proxy/:connectionId
Expand Down Expand Up @@ -385,11 +419,50 @@ export const protectedResourceMetadataHandler = async (c: {
}
}

// The RFC 9728 prefix form is mounted at the root, outside
// `resolveOrgFromPath`. Keep the same bearer-token tenant fence here so a
// token issued for org B cannot probe metadata for org A through that public
// route. Anonymous discovery remains public because it has no token binding.
if (
scopedOrgId &&
ctx.auth?.tokenOrganizationId &&
ctx.auth.tokenOrganizationId !== scopedOrgId
) {
return c.json(
{ error: "forbidden: token is scoped to another organization" },
403,
);
}

// `self` is the org's built-in management MCP alias, not a persisted
// connection row. Its OAuth resource is Studio itself, so both RFC 9728
// discovery shapes (`/mcp/self/.well-known/...` and the origin-anchored
// `/.well-known/.../api/:org/mcp/self`) must return Better Auth metadata
// instead of falling through to a connection lookup for id "self".
if (connectionId === "self") {
return studioProtectedResourceMetadata(
c.req.raw,
protectedResourceUrlFromDiscovery(requestUrl),
);
}

const connectionUrl = await getConnectionUrl(connectionId, ctx, scopedOrgId);
if (!connectionUrl) {
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://")) {
return studioProtectedResourceMetadata(c.req.raw);
}

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 @@ -136,6 +136,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
Loading
Loading