History

Add org-scoped MCP routes for the cloud app (#878)

Let an MCP client pin a specific organization in the URL (`/<org_id>/mcp`) instead of relying on the organization claim in the OAuth access token. This unblocks clients that hold a single sign-in session but need to target one of several organizations. Server: - Serve `/<org_id>/mcp` and the matching `/.well-known/oauth-protected-resource/<org_id>/mcp` discovery document alongside the existing bare `/mcp`. - Take the organization from the URL when present, falling back to the token claim otherwise. Membership is verified against the auth provider on every request, so the URL is a selector, not a trust boundary. - Consolidate MCP path ownership behind one predicate shared by the request middleware, the handler, and the test worker. Only `org_`-shaped segments are claimed, so unrelated `/<segment>/mcp` paths still fall through to routing. Frontend: - The cloud "Connect your MCP client" page and the install card now hand out the org-scoped URL. The shared install card stays unscoped for local/desktop, where there is no organization. Tests cover org-scoped discovery metadata, URL-supplied organization, membership denial before session creation, non-org paths falling through, and org-scoped URL/command rendering.

Rhys Sullivan committed 5 months ago

Commit f892e2e1 parent 5ae2ce89 files · +287 −52Browse files

Unified · Split

9 files
  1. apps/cloud/src/mcp-flow.test.ts +78 −3
  2. apps/cloud/src/mcp.ts +103 −41
  3. apps/cloud/src/routes/__root.tsx +5 −2
  4. apps/cloud/src/start.ts +5 −2
  5. apps/cloud/src/test-worker.ts +4 −1
  6. apps/cloud/src/web/pages/setup-mcp.tsx +6 −0
  7. packages/react/src/api/organization-context.tsx +22 −0
  8. packages/react/src/components/mcp-install-card.test.ts +50 −0
  9. packages/react/src/components/mcp-install-card.tsx +14 −3
apps/cloud/src/mcp-flow.test.ts+78−3
@@ -98,20 +98,27 @@ type McpPostInit = { · 97 unmodified lines
9898 readonly accept?: string;
9999 };
100100
101−const mcpPost = (init: McpPostInit): Promise<Response> => {
101+const mcpPostTo = (url: string, init: McpPostInit): Promise<Response> => {
102102 const headers: Record<string, string> = {
103103 "content-type": CONTENT_TYPE_JSON,
104104 accept: init.accept ?? JSON_AND_SSE,
105105 };
106106 if (init.bearer) headers.authorization = `Bearer ${init.bearer}`;
107107 if (init.sessionId) headers["mcp-session-id"] = init.sessionId;
108− return SELF.fetch(MCP_URL, {
108+ return SELF.fetch(url, {
109109 method: "POST",
110110 headers,
111111 body: JSON.stringify(init.body),
112112 });
113113 };
114114
115+const mcpPost = (init: McpPostInit): Promise<Response> => mcpPostTo(MCP_URL, init);
116+
117+const orgScopedMcpUrl = (organizationId: string): string => `${BASE}/${organizationId}/mcp`;
118+
119+const orgScopedResourceUrl = (organizationId: string): string =>
120+ `${BASE}/.well-known/oauth-protected-resource/${organizationId}/mcp`;
121+
115122 const mcpGet = (init: { readonly bearer: string; readonly sessionId: string }): Promise<Response> =>
116123 SELF.fetch(MCP_URL, {
117124 method: "GET",
@@ -189,6 +196,72 @@ describe("/.well-known/oauth-protected-resource", () => { · 71 unmodified lines
189196 });
190197 });
191198
199+// ---------------------------------------------------------------------------
200+// 2b. Org-scoped routing: /<org_id>/mcp
201+// ---------------------------------------------------------------------------
202+//
203+// The active org can be pinned in the URL instead of relying on the token's
204+// `org_id`. Membership is still verified per-request, so the URL is a selector,
205+// not a trust boundary. Only `org_…`-shaped segments are claimed; anything else
206+// falls through to TanStack routing.
207+// ---------------------------------------------------------------------------
208+
209+describe("/:orgId/mcp org-scoped routing", () => {
210+ it("serves protected-resource metadata pointing at the org-scoped resource", async () => {
211+ const orgId = nextOrgId();
212+ const response = await SELF.fetch(orgScopedResourceUrl(orgId));
213+ expect(response.status).toBe(200);
214+ expect(await response.json()).toEqual({
215+ resource: `${BASE}/${orgId}/mcp`,
216+ authorization_servers: ["https://test-authkit.example.com"],
217+ bearer_methods_supported: ["header"],
218+ scopes_supported: [],
219+ });
220+ });
221+
222+ it("takes the active org from the URL even when the token carries no org", async () => {
223+ const orgId = nextOrgId();
224+ const accountId = nextAccountId();
225+ await seedOrg(orgId);
226+
227+ // A no-org token is a 403 on bare /mcp (see "without org" below); here the
228+ // URL supplies the org, so the session initializes.
229+ const response = await mcpPostTo(orgScopedMcpUrl(orgId), {
230+ bearer: makeTestBearer(accountId, null),
231+ body: INITIALIZE_REQUEST,
232+ });
233+ expect(response.status).toBe(200);
234+ expect(response.headers.get("mcp-session-id")).toBeTruthy();
235+ await response.text();
236+ }, 15_000);
237+
238+ it("rejects a URL org the user is not a member of before creating a session", async () => {
239+ const organizationId = `org_revoked_${crypto.randomUUID().slice(0, 8)}`;
240+ const response = await mcpPostTo(orgScopedMcpUrl(organizationId), {
241+ bearer: makeTestBearer(nextAccountId(), null),
242+ body: INITIALIZE_REQUEST,
243+ });
244+ expect(response.status).toBe(403);
245+ const body = (await response.json()) as {
246+ jsonrpc: string;
247+ error: { code: number; message: string };
248+ };
249+ expect(body.jsonrpc).toBe("2.0");
250+ expect(body.error.code).toBe(-32001);
251+ expect(body.error.message).toMatch(/No organization/i);
252+ });
253+
254+ it("does not claim a non-org-shaped /<seg>/mcp path", async () => {
255+ // `/settings/mcp` must fall through to TanStack rather than be swallowed by
256+ // the MCP filter; the test worker surfaces that as its own 404.
257+ const response = await mcpPostTo(`${BASE}/settings/mcp`, {
258+ bearer: makeTestBearer(nextAccountId(), nextOrgId()),
259+ body: INITIALIZE_REQUEST,
260+ });
261+ expect(response.status).toBe(404);
262+ });
263+});
264+
192265 // ---------------------------------------------------------------------------
193266 // 3. POST /mcp without Authorization
194267 // ---------------------------------------------------------------------------
@@ -321,7 +394,9 @@ describe("/mcp notification responses", () => { · 126 unmodified lines
321394 expect(notificationResponse.status).toBe(202);
322395 expect(notificationResponse.headers.get("content-type")).toBeNull();
323396 expect(await notificationResponse.text()).toBe("");
324− });
397+ // 15s like the other session-spinning tests here — the default 5s is
398+ // borderline for DO runtime cold-start under full-suite scheduler load.
399+ }, 15_000);
325400 });
326401
327402 describe("/mcp session restore", () => {
apps/cloud/src/mcp.ts+103−41
@@ -76,6 +76,19 @@ const PROTECTED_RESOURCE_METADATA_PATH = "/.well-known/oauth-protected-resource/ · 75 unmodified lines
7676 const PROTECTED_RESOURCE_METADATA_URL = `${RESOURCE_ORIGIN}${PROTECTED_RESOURCE_METADATA_PATH}`;
7777 const RESOURCE_URL = `${RESOURCE_ORIGIN}${MCP_PATH}`;
7878
79+// Org-scoped variants: `/org_xxx/mcp` lets a client pin a specific org without
80+// relying on the token's `org_id` claim. The org is taken from the URL and
81+// re-checked against live WorkOS membership per request, so the URL is a
82+// selector, not a trust boundary. (When web routes move under `/:org`, this
83+// becomes the handle form; for now we accept the raw WorkOS org id.)
84+const resourceUrlFor = (organizationId: string | null): string =>
85+ organizationId ? `${RESOURCE_ORIGIN}/${organizationId}${MCP_PATH}` : RESOURCE_URL;
86+
87+const protectedResourceMetadataUrlFor = (organizationId: string | null): string =>
88+ organizationId
89+ ? `${RESOURCE_ORIGIN}/.well-known/oauth-protected-resource/${organizationId}/mcp`
90+ : PROTECTED_RESOURCE_METADATA_URL;
91+
7992 type McpUnauthorizedReason = "missing_bearer" | "invalid_token";
8093
8194 type McpAuthorizedResult = {
@@ -458,14 +471,15 @@ const annotateMcpRequest = ( · 376 unmodified lines
458471 // OAuth metadata endpoints
459472 // ---------------------------------------------------------------------------
460473
461−const protectedResourceMetadata = Effect.sync(() =>
462− jsonResponse({
463− resource: RESOURCE_URL,
464− authorization_servers: [AUTHKIT_DOMAIN],
465− bearer_methods_supported: ["header"],
466− scopes_supported: [],
467− }),
468−);
474+const protectedResourceMetadata = (organizationId: string | null) =>
475+ Effect.sync(() =>
476+ jsonResponse({
477+ resource: resourceUrlFor(organizationId),
478+ authorization_servers: [AUTHKIT_DOMAIN],
479+ bearer_methods_supported: ["header"],
480+ scopes_supported: [],
481+ }),
482+ );
469483
470484 const authorizationServerMetadata = Effect.tryPromise({
471485 try: async () => {
@@ -522,10 +536,14 @@ const withPropagationHeaders = ( · 50 unmodified lines
522536 return new Request(request, { headers });
523537 };
524538
525−const withVerifiedIdentityHeaders = (request: Request, token: VerifiedToken): Request => {
539+const withVerifiedIdentityHeaders = (
540+ request: Request,
541+ accountId: string,
542+ organizationId: string,
543+): Request => {
526544 const headers = new Headers(request.headers);
527− headers.set(INTERNAL_ACCOUNT_ID_HEADER, token.accountId);
528− headers.set(INTERNAL_ORGANIZATION_ID_HEADER, token.organizationId ?? "");
545+ headers.set(INTERNAL_ACCOUNT_ID_HEADER, accountId);
546+ headers.set(INTERNAL_ORGANIZATION_ID_HEADER, organizationId);
529547 return new Request(request, { headers });
530548 };
531549
@@ -569,13 +587,14 @@ const forwardToExistingSession = ( · 37 unmodified lines
569587 sessionId: string,
570588 peek: boolean,
571589 token: VerifiedToken,
590+ organizationId: string,
572591 ) =>
573592 Effect.gen(function* () {
574593 const ns = env.MCP_SESSION;
575594 const stub = ns.get(ns.idFromString(sessionId));
576595 const propagation = yield* currentPropagationHeaders(request);
577596 const propagated = withPropagationHeaders(
578− withVerifiedIdentityHeaders(request, token),
597+ withVerifiedIdentityHeaders(request, token.accountId, organizationId),
579598 propagation,
580599 );
581600 const raw = yield* Effect.promise(
@@ -608,10 +627,10 @@ const clearExistingSession = (request: Request, sessionId: string) => · 26 unmodified lines
608627 const authorizeMcpOrganization = (
609628 request: Request,
610629 token: VerifiedToken,
630+ organizationId: string | null,
611631 sessionId: string | null,
612632 ) =>
613633 Effect.gen(function* () {
614− const organizationId = token.organizationId;
615634 if (!organizationId) {
616635 return jsonRpcError(403, -32001, "No organization in session — log in via the web app first");
617636 }
@@ -638,14 +657,14 @@ const authorizeMcpOrganization = ( · 20 unmodified lines
638657 return jsonRpcError(403, -32001, "No organization in session — log in via the web app first");
639658 });
640659
641−const dispatchPost = (request: Request, token: VerifiedToken) =>
660+const dispatchPost = (request: Request, token: VerifiedToken, organizationId: string | null) =>
642661 Effect.gen(function* () {
643662 const sessionId = request.headers.get("mcp-session-id");
644− const authError = yield* authorizeMcpOrganization(request, token, sessionId);
663+ const authError = yield* authorizeMcpOrganization(request, token, organizationId, sessionId);
645664 if (authError) return authError;
646− const organizationId = token.organizationId!;
665+ const orgId = organizationId!;
647666
648− if (sessionId) return yield* forwardToExistingSession(request, sessionId, true, token);
667+ if (sessionId) return yield* forwardToExistingSession(request, sessionId, true, token, orgId);
649668
650669 const ns = env.MCP_SESSION;
651670 const stub = ns.get(ns.newUniqueId());
@@ -653,7 +672,7 @@ const dispatchPost = (request: Request, token: VerifiedToken) => · 1 unmodified line
653672 yield* Effect.promise(() =>
654673 stub.init(
655674 {
656− organizationId,
675+ organizationId: orgId,
657676 userId: token.accountId,
658677 elicitationMode: readElicitationMode(request),
659678 },
@@ -665,7 +684,7 @@ const dispatchPost = (request: Request, token: VerifiedToken) => · 5 unmodified lines
665684 }),
666685 );
667686 const propagated = withPropagationHeaders(
668− withVerifiedIdentityHeaders(request, token),
687+ withVerifiedIdentityHeaders(request, token.accountId, orgId),
669688 propagation,
670689 );
671690 const raw = yield* Effect.promise(
@@ -682,24 +701,24 @@ const dispatchPost = (request: Request, token: VerifiedToken) => · 10 unmodified lines
682701 return HttpServerResponse.raw(withMcpResponseHeaders(annotated));
683702 });
684703
685−const dispatchGet = (request: Request, token: VerifiedToken) => {
704+const dispatchGet = (request: Request, token: VerifiedToken, organizationId: string | null) => {
686705 const sessionId = request.headers.get("mcp-session-id");
687706 if (!sessionId)
688707 return Effect.succeed(jsonRpcError(400, -32000, "mcp-session-id header required for SSE"));
689708 return Effect.gen(function* () {
690− const authError = yield* authorizeMcpOrganization(request, token, sessionId);
709+ const authError = yield* authorizeMcpOrganization(request, token, organizationId, sessionId);
691710 if (authError) return authError;
692− return yield* forwardToExistingSession(request, sessionId, false, token);
711+ return yield* forwardToExistingSession(request, sessionId, false, token, organizationId!);
693712 });
694713 };
695714
696−const dispatchDelete = (request: Request, token: VerifiedToken) => {
715+const dispatchDelete = (request: Request, token: VerifiedToken, organizationId: string | null) => {
697716 const sessionId = request.headers.get("mcp-session-id");
698717 if (!sessionId) return Effect.succeed(HttpServerResponse.empty({ status: 204 }));
699718 return Effect.gen(function* () {
700− const authError = yield* authorizeMcpOrganization(request, token, sessionId);
719+ const authError = yield* authorizeMcpOrganization(request, token, organizationId, sessionId);
701720 if (authError) return authError;
702− return yield* forwardToExistingSession(request, sessionId, true, token);
721+ return yield* forwardToExistingSession(request, sessionId, true, token, organizationId!);
703722 });
704723 };
705724
@@ -707,21 +726,57 @@ const dispatchDelete = (request: Request, token: VerifiedToken) => { · 1 unmodified line
707726 // App
708727 // ---------------------------------------------------------------------------
709728
710−type McpRoute = "mcp" | "oauth-protected-resource" | "oauth-authorization-server" | null;
729+type McpRouteKind = "mcp" | "oauth-protected-resource" | "oauth-authorization-server";
730+
731+type McpRoute = {
732+ readonly kind: McpRouteKind;
733+ /** Org id pinned in the URL (`/org_xxx/mcp`), or `null` for the bare path. */
734+ readonly organizationId: string | null;
735+} | null;
736+
737+const PRM_PREFIX = "/.well-known/oauth-protected-resource";
738+
739+// A path segment counts as an org selector only when it has the WorkOS org id
740+// shape (`org_…`). This keeps the MCP fall-through filter from claiming an
741+// unrelated `/<seg>/mcp` path instead of letting TanStack route it.
742+const orgIdSegment = (segment: string | undefined): string | null =>
743+ segment && segment.startsWith("org_") ? segment : null;
744+
745+// Matches a trailing MCP endpoint — `mcp` (bare) or `<org>/mcp`. Returns the org
746+// id, `null` for the bare form, or `undefined` when the segments are neither.
747+const matchMcpSuffix = (segments: readonly string[]): string | null | undefined => {
748+ if (segments.length === 1 && segments[0] === "mcp") return null;
749+ if (segments.length === 2 && segments[1] === "mcp") return orgIdSegment(segments[0]) ?? undefined;
750+ return undefined;
751+};
711752
712753 /**
713− * Returns the MCP route type for a pathname, or `null` if the path isn't owned
714− * by the MCP handler.
754+ * Returns the MCP route (kind + optional URL-pinned org) for a pathname, or
755+ * `null` if the path isn't owned by the MCP handler.
715756 *
716− * Exported so the test worker can share the exact same predicate the middleware
717− * uses — we avoid duplicating the "is this an MCP path?" logic across entry
718− * points.
757+ * This is THE ownership predicate: `start.ts`'s request middleware uses it to
758+ * decide whether to hand a request to `mcpFetch` (vs. fall through to TanStack
759+ * Start), `mcpApp` uses it to dispatch, and the test worker reuses it too — so
760+ * the set of MCP paths lives in exactly one place.
719761 */
720762 export const classifyMcpPath = (pathname: string): McpRoute => {
721− if (pathname === MCP_PATH) return "mcp";
722− if (pathname === PROTECTED_RESOURCE_METADATA_PATH) return "oauth-protected-resource";
723− if (pathname === "/.well-known/oauth-authorization-server") return "oauth-authorization-server";
724− return null;
763+ if (pathname === "/.well-known/oauth-authorization-server") {
764+ return { kind: "oauth-authorization-server", organizationId: null };
765+ }
766+ const segments = pathname.split("/").filter((segment) => segment.length > 0);
767+
768+ // Protected-resource metadata: `${PRM_PREFIX}/mcp` or `${PRM_PREFIX}/<org>/mcp`.
769+ // The org sits after the well-known prefix (RFC 9728), not at the path root.
770+ if (pathname.startsWith(`${PRM_PREFIX}/`)) {
771+ const organizationId = matchMcpSuffix(segments.slice(2));
772+ return organizationId === undefined
773+ ? null
774+ : { kind: "oauth-protected-resource", organizationId };
775+ }
776+
777+ // MCP transport: `/mcp` or `/<org>/mcp`.
778+ const organizationId = matchMcpSuffix(segments);
779+ return organizationId === undefined ? null : { kind: "mcp", organizationId };
725780 };
726781
727782 /**
@@ -737,10 +792,13 @@ export const mcpApp: Effect.Effect< · 9 unmodified lines
737792 const httpRequest = yield* HttpServerRequest.HttpServerRequest;
738793 const request = httpRequest.source as Request;
739794 const route = classifyMcpPath(new URL(request.url).pathname);
795+ const pathOrganizationId = route?.organizationId ?? null;
740796
741797 if (request.method === "OPTIONS") return corsPreflight;
742− if (route === "oauth-protected-resource") return yield* protectedResourceMetadata;
743− if (route === "oauth-authorization-server") return yield* authorizationServerMetadata;
798+ if (route?.kind === "oauth-protected-resource") {
799+ return yield* protectedResourceMetadata(pathOrganizationId);
800+ }
801+ if (route?.kind === "oauth-authorization-server") return yield* authorizationServerMetadata;
744802
745803 const auth = yield* McpAuth;
746804 const authResult = yield* auth.verifyBearer(request).pipe(Effect.result);
@@ -763,13 +821,17 @@ export const mcpApp: Effect.Effect< · 16 unmodified lines
763821 });
764822
765823 if (isMcpUnauthorized(authValue)) {
766− return unauthorized(authValue, PROTECTED_RESOURCE_METADATA_URL);
824+ return unauthorized(authValue, protectedResourceMetadataUrlFor(pathOrganizationId));
767825 }
768826 const token = authValue.token;
827+ // URL is the source of truth for the active org when present (`/org_xxx/mcp`);
828+ // the bare `/mcp` path falls back to the token's `org_id`. Either way the org
829+ // is verified against live WorkOS membership in authorizeMcpOrganization.
830+ const organizationId = pathOrganizationId ?? token.organizationId;
769831 const dispatchEffect = Match.value(request.method).pipe(
770− Match.when("POST", () => dispatchPost(request, token)),
771− Match.when("GET", () => dispatchGet(request, token)),
772− Match.when("DELETE", () => dispatchDelete(request, token)),
832+ Match.when("POST", () => dispatchPost(request, token, organizationId)),
833+ Match.when("GET", () => dispatchGet(request, token, organizationId)),
834+ Match.when("DELETE", () => dispatchDelete(request, token, organizationId)),
773835 Match.option,
774836 );
775837 if (Option.isSome(dispatchEffect)) {
apps/cloud/src/routes/__root.tsx+5−2
@@ -13,6 +13,7 @@ import posthog from "posthog-js"; · 12 unmodified lines
1313 import { PostHogProvider } from "posthog-js/react";
1414 import type { FrontendErrorReporter } from "@executor-js/react/api/error-reporting";
1515 import { ExecutorProvider } from "@executor-js/react/api/provider";
16+import { OrganizationProvider } from "@executor-js/react/api/organization-context";
1617 import { Skeleton } from "@executor-js/react/components/skeleton";
1718 import { Toaster } from "@executor-js/react/components/sonner";
1819 import { ExecutorPluginsProvider } from "@executor-js/sdk/client";
@@ -240,8 +241,10 @@ function AuthGate() { · 221 unmodified lines
240241 <Sentry.ErrorBoundary fallback={<ShellErrorFallback />} showDialog={false}>
241242 <ExecutorProvider fallback={<ShellSkeleton />} onHandledError={captureFrontendError}>
242243 <ExecutorPluginsProvider plugins={clientPlugins}>
243− <Shell />
244− <Toaster />
244+ <OrganizationProvider organizationId={auth.organization.id}>
245+ <Shell />
246+ <Toaster />
247+ </OrganizationProvider>
245248 </ExecutorPluginsProvider>
246249 </ExecutorProvider>
247250 </Sentry.ErrorBoundary>
apps/cloud/src/start.ts+5−2
@@ -2,7 +2,7 @@ import { env } from "cloudflare:workers"; · 1 unmodified line
22 import { createMiddleware, createStart } from "@tanstack/react-start";
33 import { Effect } from "effect";
44 import { handleApiRequest } from "./api";
5−import { mcpFetch } from "./mcp";
5+import { classifyMcpPath, mcpFetch } from "./mcp";
66 import { handleSentryTunnelRequest } from "./sentry-tunnel";
77
88 // ---------------------------------------------------------------------------
@@ -66,7 +66,10 @@ const parseCookie = (cookieHeader: string | null, name: string): string | null = · 57 unmodified lines
6666
6767 const mcpRequestMiddleware = createMiddleware({ type: "request" }).server(
6868 async ({ pathname, request, next }) => {
69− if (pathname === "/mcp" || pathname.startsWith("/.well-known/")) {
69+ // Single source of truth for MCP path ownership (incl. `/org_xxx/mcp` and
70+ // the org-scoped `.well-known` resource metadata). `mcpFetch` re-checks and
71+ // can still return null, in which case we fall through to TanStack routing.
72+ if (classifyMcpPath(pathname) !== null) {
7073 const response = await mcpFetch(request);
7174 if (response) return response;
7275 }
apps/cloud/src/test-worker.ts+4−1
@@ -55,7 +55,10 @@ const TestMcpAuthLive = Layer.succeed(McpAuth)({ · 54 unmodified lines
5555 });
5656
5757 const TestMcpOrganizationAuthLive = Layer.succeed(McpOrganizationAuth)({
58− authorize: (_accountId, organizationId) => Effect.succeed(!organizationId.startsWith("revoked_")),
58+ // Deny on any org id containing "revoked" (rather than a strict prefix) so a
59+ // routable, prefix-valid org id like `org_revoked_…` still exercises the
60+ // membership-denied path through the `/org_xxx/mcp` URL route.
61+ authorize: (_accountId, organizationId) => Effect.succeed(!organizationId.includes("revoked")),
5962 });
6063
6164 // ---------------------------------------------------------------------------
apps/cloud/src/web/pages/setup-mcp.tsx+6−0
@@ -15,8 +15,12 @@ import { · 14 unmodified lines
1515 } from "@executor-js/react/components/collapsible";
1616 import { NativeSelect, NativeSelectOption } from "@executor-js/react/components/native-select";
1717
18+import { useAuth } from "../auth";
19+
1820 export const SetupMcpPage = () => {
1921 const navigate = useNavigate();
22+ const auth = useAuth();
23+ const organizationId = auth.status === "authenticated" ? (auth.organization?.id ?? null) : null;
2024 const [origin, setOrigin] = useState<string | null>(null);
2125 const [advancedOpen, setAdvancedOpen] = useState(false);
2226 const [elicitationMode, setElicitationMode] = useState<McpElicitationMode>("model");
@@ -30,6 +34,7 @@ export const SetupMcpPage = () => { · 7 unmodified lines
3034 origin,
3135 desktop: null,
3236 elicitationMode,
37+ organizationId,
3338 })
3439 : "";
3540 const command = origin
@@ -38,6 +43,7 @@ export const SetupMcpPage = () => { · 2 unmodified lines
3843 isDev: false,
3944 origin,
4045 elicitationMode,
46+ organizationId,
4147 })
4248 : "";
4349
packages/react/src/api/organization-context.tsx+22−0
@@ -0,0 +1,22 @@
1+import * as React from "react";
2+
3+// The active WorkOS organization for org-scoped hosts (the cloud app). Local and
4+// desktop aren't org-scoped, so they leave it unset and consumers fall back to
5+// unscoped behaviour. This is purely a UI hint (e.g. which org to pin in an MCP
6+// install URL); access is always enforced server-side.
7+const OrganizationContext = React.createContext<string | null>(null);
8+
9+export function OrganizationProvider(
10+ props: React.PropsWithChildren<{ readonly organizationId: string | null }>,
11+) {
12+ return (
13+ <OrganizationContext.Provider value={props.organizationId}>
14+ {props.children}
15+ </OrganizationContext.Provider>
16+ );
17+}
18+
19+/** Returns the active organization id, or `null` when the host isn't org-scoped. */
20+export function useOrganizationId(): string | null {
21+ return React.useContext(OrganizationContext);
22+}
packages/react/src/components/mcp-install-card.test.ts+50−0
@@ -109,4 +109,54 @@ describe("MCP install command rendering", () => { · 108 unmodified lines
109109 }),
110110 ).toBe("npx add-mcp 'executor mcp --elicitation-mode browser' --name executor");
111111 });
112+
113+ it("pins the HTTP endpoint to the org id when one is supplied", () => {
114+ expect(
115+ buildMcpHttpEndpoint({
116+ origin: "https://executor.example",
117+ desktop: null,
118+ organizationId: "org_123",
119+ }),
120+ ).toBe("https://executor.example/org_123/mcp");
121+
122+ expect(
123+ buildMcpInstallCommand({
124+ mode: "http",
125+ isDev: false,
126+ origin: "https://executor.example",
127+ organizationId: "org_123",
128+ }),
129+ ).toBe("npx add-mcp https://executor.example/org_123/mcp --transport http --name executor");
130+ });
131+
132+ it("keeps the bare /mcp path when no org id is supplied", () => {
133+ expect(
134+ buildMcpHttpEndpoint({
135+ origin: "https://executor.example",
136+ desktop: null,
137+ organizationId: null,
138+ }),
139+ ).toBe("https://executor.example/mcp");
140+ });
141+
142+ it("combines the org id with an explicit elicitation mode", () => {
143+ expect(
144+ buildMcpHttpEndpoint({
145+ origin: "https://executor.example",
146+ desktop: null,
147+ organizationId: "org_123",
148+ elicitationMode: "browser",
149+ }),
150+ ).toBe("https://executor.example/org_123/mcp?elicitation_mode=browser");
151+ });
152+
153+ it("does not org-scope the desktop sidecar endpoint", () => {
154+ expect(
155+ buildMcpHttpEndpoint({
156+ origin: null,
157+ desktop: { port: 4788 },
158+ organizationId: "org_123",
159+ }),
160+ ).toBe("http://127.0.0.1:4788/mcp");
161+ });
112162 });
packages/react/src/components/mcp-install-card.tsx+14−3
@@ -10,6 +10,7 @@ import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "./collapsib · 9 unmodified lines
1010 import { NativeSelect, NativeSelectOption } from "./native-select";
1111 import { cn } from "../lib/utils";
1212 import { useScopeInfo } from "../api/scope-context";
13+import { useOrganizationId } from "../api/organization-context";
1314 import {
1415 getExecutorServerAuthorizationHeader,
1516 useExecutorServerConnection,
@@ -47,12 +48,18 @@ export const buildMcpHttpEndpoint = (input: { · 31 unmodified lines
4748 readonly port: number;
4849 } | null;
4950 readonly elicitationMode?: McpElicitationMode;
51+ // Cloud only: pins the URL to `/<org_id>/mcp`. Desktop/local pass nothing and
52+ // get the bare `/mcp` path.
53+ readonly organizationId?: string | null;
5054 }): string => {
55+ // The desktop sidecar isn't org-scoped, so the org only applies to the
56+ // origin/remote forms.
57+ const mcpPath = input.organizationId && !input.desktop ? `/${input.organizationId}/mcp` : "/mcp";
5158 const endpoint = input.desktop
52− ? `http://127.0.0.1:${input.desktop.port}/mcp`
59+ ? `http://127.0.0.1:${input.desktop.port}${mcpPath}`
5360 : input.origin
54− ? `${input.origin}/mcp`
55− : "<this-server>/mcp";
61+ ? `${input.origin}${mcpPath}`
62+ : `<this-server>${mcpPath}`;
5663 if (!input.elicitationMode || input.elicitationMode === "model") return endpoint;
5764
5865 if (endpoint.startsWith("<")) return `${endpoint}?elicitation_mode=${input.elicitationMode}`;
@@ -83,12 +90,14 @@ export const buildMcpInstallCommand = (input: { · 24 unmodified lines
8390 readonly authorizationHeader?: string | null;
8491 readonly elicitationMode?: McpElicitationMode;
8592 readonly devCliCwd?: string;
93+ readonly organizationId?: string | null;
8694 }): string => {
8795 if (input.mode === "http") {
8896 const endpoint = buildMcpHttpEndpoint({
8997 origin: input.origin,
9098 desktop: input.desktop ? { port: input.desktop.port } : null,
9199 elicitationMode: input.elicitationMode,
100+ organizationId: input.organizationId,
92101 });
93102 const headerFlags: string[] = [];
94103 if (input.authorizationHeader) {
@@ -122,6 +131,7 @@ export function McpInstallCard(props: { className?: string }) { · 27 unmodified lines
122131 const [advancedOpen, setAdvancedOpen] = useState(false);
123132 const [httpElicitationMode, setHttpElicitationMode] = useState<McpElicitationMode>("model");
124133 const scopeInfo = useScopeInfo();
134+ const organizationId = useOrganizationId();
125135 const serverConnection = useExecutorServerConnection();
126136 // Desktop hosts ship Electron without putting an `executor` binary on
127137 // PATH, and the bundled sidecar is locked to the running app. Force the
@@ -140,6 +150,7 @@ export function McpInstallCard(props: { className?: string }) { · 12 unmodified lines
140150 authorizationHeader,
141151 elicitationMode,
142152 devCliCwd,
153+ organizationId,
143154 });
144155
145156 const subtitle =