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
98 readonly accept?: string;98 readonly accept?: string;
99};99};
100 100
101const mcpPost = (init: McpPostInit): Promise<Response> => {101const mcpPostTo = (url: string, init: McpPostInit): Promise<Response> => {
102 const headers: Record<string, string> = {102 const headers: Record<string, string> = {
103 "content-type": CONTENT_TYPE_JSON,103 "content-type": CONTENT_TYPE_JSON,
104 accept: init.accept ?? JSON_AND_SSE,104 accept: init.accept ?? JSON_AND_SSE,
105 };105 };
106 if (init.bearer) headers.authorization = `Bearer ${init.bearer}`;106 if (init.bearer) headers.authorization = `Bearer ${init.bearer}`;
107 if (init.sessionId) headers["mcp-session-id"] = init.sessionId;107 if (init.sessionId) headers["mcp-session-id"] = init.sessionId;
108 return SELF.fetch(MCP_URL, {108 return SELF.fetch(url, {
109 method: "POST",109 method: "POST",
110 headers,110 headers,
111 body: JSON.stringify(init.body),111 body: JSON.stringify(init.body),
112 });112 });
113};113};
114 114
115const mcpPost = (init: McpPostInit): Promise<Response> => mcpPostTo(MCP_URL, init);
116
117const orgScopedMcpUrl = (organizationId: string): string => `${BASE}/${organizationId}/mcp`;
118
119const orgScopedResourceUrl = (organizationId: string): string =>
120 `${BASE}/.well-known/oauth-protected-resource/${organizationId}/mcp`;
121
115const mcpGet = (init: { readonly bearer: string; readonly sessionId: string }): Promise<Response> =>122const mcpGet = (init: { readonly bearer: string; readonly sessionId: string }): Promise<Response> =>
116 SELF.fetch(MCP_URL, {123 SELF.fetch(MCP_URL, {
117 method: "GET",124 method: "GET",
@@ -189,6 +196,72 @@ describe("/.well-known/oauth-protected-resource", () => { · 71 unmodified lines
189 });196 });
190});197});
191 198
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
209describe("/: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
192// ---------------------------------------------------------------------------265// ---------------------------------------------------------------------------
193// 3. POST /mcp without Authorization266// 3. POST /mcp without Authorization
194// ---------------------------------------------------------------------------267// ---------------------------------------------------------------------------
@@ -321,7 +394,9 @@ describe("/mcp notification responses", () => { · 126 unmodified lines
321 expect(notificationResponse.status).toBe(202);394 expect(notificationResponse.status).toBe(202);
322 expect(notificationResponse.headers.get("content-type")).toBeNull();395 expect(notificationResponse.headers.get("content-type")).toBeNull();
323 expect(await notificationResponse.text()).toBe("");396 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);
325});400});
326 401
327describe("/mcp session restore", () => {402describe("/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
76const PROTECTED_RESOURCE_METADATA_URL = `${RESOURCE_ORIGIN}${PROTECTED_RESOURCE_METADATA_PATH}`;76const PROTECTED_RESOURCE_METADATA_URL = `${RESOURCE_ORIGIN}${PROTECTED_RESOURCE_METADATA_PATH}`;
77const RESOURCE_URL = `${RESOURCE_ORIGIN}${MCP_PATH}`;77const RESOURCE_URL = `${RESOURCE_ORIGIN}${MCP_PATH}`;
78 78
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.)
84const resourceUrlFor = (organizationId: string | null): string =>
85 organizationId ? `${RESOURCE_ORIGIN}/${organizationId}${MCP_PATH}` : RESOURCE_URL;
86
87const protectedResourceMetadataUrlFor = (organizationId: string | null): string =>
88 organizationId
89 ? `${RESOURCE_ORIGIN}/.well-known/oauth-protected-resource/${organizationId}/mcp`
90 : PROTECTED_RESOURCE_METADATA_URL;
91
79type McpUnauthorizedReason = "missing_bearer" | "invalid_token";92type McpUnauthorizedReason = "missing_bearer" | "invalid_token";
80 93
81type McpAuthorizedResult = {94type McpAuthorizedResult = {
@@ -458,14 +471,15 @@ const annotateMcpRequest = ( · 376 unmodified lines
458// OAuth metadata endpoints471// OAuth metadata endpoints
459// ---------------------------------------------------------------------------472// ---------------------------------------------------------------------------
460 473
461const protectedResourceMetadata = Effect.sync(() =>474const protectedResourceMetadata = (organizationId: string | null) =>
462 jsonResponse({475 Effect.sync(() =>
463 resource: RESOURCE_URL,476 jsonResponse({
464 authorization_servers: [AUTHKIT_DOMAIN],477 resource: resourceUrlFor(organizationId),
465 bearer_methods_supported: ["header"],478 authorization_servers: [AUTHKIT_DOMAIN],
466 scopes_supported: [],479 bearer_methods_supported: ["header"],
467 }),480 scopes_supported: [],
468);481 }),
482 );
469 483
470const authorizationServerMetadata = Effect.tryPromise({484const authorizationServerMetadata = Effect.tryPromise({
471 try: async () => {485 try: async () => {
@@ -522,10 +536,14 @@ const withPropagationHeaders = ( · 50 unmodified lines
522 return new Request(request, { headers });536 return new Request(request, { headers });
523};537};
524 538
525const withVerifiedIdentityHeaders = (request: Request, token: VerifiedToken): Request => {539const withVerifiedIdentityHeaders = (
540 request: Request,
541 accountId: string,
542 organizationId: string,
543): Request => {
526 const headers = new Headers(request.headers);544 const headers = new Headers(request.headers);
527 headers.set(INTERNAL_ACCOUNT_ID_HEADER, token.accountId);545 headers.set(INTERNAL_ACCOUNT_ID_HEADER, accountId);
528 headers.set(INTERNAL_ORGANIZATION_ID_HEADER, token.organizationId ?? "");546 headers.set(INTERNAL_ORGANIZATION_ID_HEADER, organizationId);
529 return new Request(request, { headers });547 return new Request(request, { headers });
530};548};
531 549
@@ -569,13 +587,14 @@ const forwardToExistingSession = ( · 37 unmodified lines
569 sessionId: string,587 sessionId: string,
570 peek: boolean,588 peek: boolean,
571 token: VerifiedToken,589 token: VerifiedToken,
590 organizationId: string,
572) =>591) =>
573 Effect.gen(function* () {592 Effect.gen(function* () {
574 const ns = env.MCP_SESSION;593 const ns = env.MCP_SESSION;
575 const stub = ns.get(ns.idFromString(sessionId));594 const stub = ns.get(ns.idFromString(sessionId));
576 const propagation = yield* currentPropagationHeaders(request);595 const propagation = yield* currentPropagationHeaders(request);
577 const propagated = withPropagationHeaders(596 const propagated = withPropagationHeaders(
578 withVerifiedIdentityHeaders(request, token),597 withVerifiedIdentityHeaders(request, token.accountId, organizationId),
579 propagation,598 propagation,
580 );599 );
581 const raw = yield* Effect.promise(600 const raw = yield* Effect.promise(
@@ -608,10 +627,10 @@ const clearExistingSession = (request: Request, sessionId: string) => · 26 unmodified lines
608const authorizeMcpOrganization = (627const authorizeMcpOrganization = (
609 request: Request,628 request: Request,
610 token: VerifiedToken,629 token: VerifiedToken,
630 organizationId: string | null,
611 sessionId: string | null,631 sessionId: string | null,
612) =>632) =>
613 Effect.gen(function* () {633 Effect.gen(function* () {
614 const organizationId = token.organizationId;
615 if (!organizationId) {634 if (!organizationId) {
616 return jsonRpcError(403, -32001, "No organization in session — log in via the web app first");635 return jsonRpcError(403, -32001, "No organization in session — log in via the web app first");
617 }636 }
@@ -638,14 +657,14 @@ const authorizeMcpOrganization = ( · 20 unmodified lines
638 return jsonRpcError(403, -32001, "No organization in session — log in via the web app first");657 return jsonRpcError(403, -32001, "No organization in session — log in via the web app first");
639 });658 });
640 659
641const dispatchPost = (request: Request, token: VerifiedToken) =>660const dispatchPost = (request: Request, token: VerifiedToken, organizationId: string | null) =>
642 Effect.gen(function* () {661 Effect.gen(function* () {
643 const sessionId = request.headers.get("mcp-session-id");662 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);
645 if (authError) return authError;664 if (authError) return authError;
646 const organizationId = token.organizationId!;665 const orgId = organizationId!;
647 666
648 if (sessionId) return yield* forwardToExistingSession(request, sessionId, true, token);667 if (sessionId) return yield* forwardToExistingSession(request, sessionId, true, token, orgId);
649 668
650 const ns = env.MCP_SESSION;669 const ns = env.MCP_SESSION;
651 const stub = ns.get(ns.newUniqueId());670 const stub = ns.get(ns.newUniqueId());
@@ -653,7 +672,7 @@ const dispatchPost = (request: Request, token: VerifiedToken) => · 1 unmodified line
653 yield* Effect.promise(() =>672 yield* Effect.promise(() =>
654 stub.init(673 stub.init(
655 {674 {
656 organizationId,675 organizationId: orgId,
657 userId: token.accountId,676 userId: token.accountId,
658 elicitationMode: readElicitationMode(request),677 elicitationMode: readElicitationMode(request),
659 },678 },
@@ -665,7 +684,7 @@ const dispatchPost = (request: Request, token: VerifiedToken) => · 5 unmodified lines
665 }),684 }),
666 );685 );
667 const propagated = withPropagationHeaders(686 const propagated = withPropagationHeaders(
668 withVerifiedIdentityHeaders(request, token),687 withVerifiedIdentityHeaders(request, token.accountId, orgId),
669 propagation,688 propagation,
670 );689 );
671 const raw = yield* Effect.promise(690 const raw = yield* Effect.promise(
@@ -682,24 +701,24 @@ const dispatchPost = (request: Request, token: VerifiedToken) => · 10 unmodified lines
682 return HttpServerResponse.raw(withMcpResponseHeaders(annotated));701 return HttpServerResponse.raw(withMcpResponseHeaders(annotated));
683 });702 });
684 703
685const dispatchGet = (request: Request, token: VerifiedToken) => {704const dispatchGet = (request: Request, token: VerifiedToken, organizationId: string | null) => {
686 const sessionId = request.headers.get("mcp-session-id");705 const sessionId = request.headers.get("mcp-session-id");
687 if (!sessionId)706 if (!sessionId)
688 return Effect.succeed(jsonRpcError(400, -32000, "mcp-session-id header required for SSE"));707 return Effect.succeed(jsonRpcError(400, -32000, "mcp-session-id header required for SSE"));
689 return Effect.gen(function* () {708 return Effect.gen(function* () {
690 const authError = yield* authorizeMcpOrganization(request, token, sessionId);709 const authError = yield* authorizeMcpOrganization(request, token, organizationId, sessionId);
691 if (authError) return authError;710 if (authError) return authError;
692 return yield* forwardToExistingSession(request, sessionId, false, token);711 return yield* forwardToExistingSession(request, sessionId, false, token, organizationId!);
693 });712 });
694};713};
695 714
696const dispatchDelete = (request: Request, token: VerifiedToken) => {715const dispatchDelete = (request: Request, token: VerifiedToken, organizationId: string | null) => {
697 const sessionId = request.headers.get("mcp-session-id");716 const sessionId = request.headers.get("mcp-session-id");
698 if (!sessionId) return Effect.succeed(HttpServerResponse.empty({ status: 204 }));717 if (!sessionId) return Effect.succeed(HttpServerResponse.empty({ status: 204 }));
699 return Effect.gen(function* () {718 return Effect.gen(function* () {
700 const authError = yield* authorizeMcpOrganization(request, token, sessionId);719 const authError = yield* authorizeMcpOrganization(request, token, organizationId, sessionId);
701 if (authError) return authError;720 if (authError) return authError;
702 return yield* forwardToExistingSession(request, sessionId, true, token);721 return yield* forwardToExistingSession(request, sessionId, true, token, organizationId!);
703 });722 });
704};723};
705 724
@@ -707,21 +726,57 @@ const dispatchDelete = (request: Request, token: VerifiedToken) => { · 1 unmodified line
707// App726// App
708// ---------------------------------------------------------------------------727// ---------------------------------------------------------------------------
709 728
710type McpRoute = "mcp" | "oauth-protected-resource" | "oauth-authorization-server" | null;729type McpRouteKind = "mcp" | "oauth-protected-resource" | "oauth-authorization-server";
730
731type 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
737const 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.
742const 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.
747const 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};
711 752
712/**753/**
713 * Returns the MCP route type for a pathname, or `null` if the path isn't owned754 * Returns the MCP route (kind + optional URL-pinned org) for a pathname, or
714 * by the MCP handler.755 * `null` if the path isn't owned by the MCP handler.
715 *756 *
716 * Exported so the test worker can share the exact same predicate the middleware757 * This is THE ownership predicate: `start.ts`'s request middleware uses it to
717 * uses — we avoid duplicating the "is this an MCP path?" logic across entry758 * decide whether to hand a request to `mcpFetch` (vs. fall through to TanStack
718 * points.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.
719 */761 */
720export const classifyMcpPath = (pathname: string): McpRoute => {762export const classifyMcpPath = (pathname: string): McpRoute => {
721 if (pathname === MCP_PATH) return "mcp";763 if (pathname === "/.well-known/oauth-authorization-server") {
722 if (pathname === PROTECTED_RESOURCE_METADATA_PATH) return "oauth-protected-resource";764 return { kind: "oauth-authorization-server", organizationId: null };
723 if (pathname === "/.well-known/oauth-authorization-server") return "oauth-authorization-server";765 }
724 return null;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 };
725};780};
726 781
727/**782/**
@@ -737,10 +792,13 @@ export const mcpApp: Effect.Effect< · 9 unmodified lines
737 const httpRequest = yield* HttpServerRequest.HttpServerRequest;792 const httpRequest = yield* HttpServerRequest.HttpServerRequest;
738 const request = httpRequest.source as Request;793 const request = httpRequest.source as Request;
739 const route = classifyMcpPath(new URL(request.url).pathname);794 const route = classifyMcpPath(new URL(request.url).pathname);
795 const pathOrganizationId = route?.organizationId ?? null;
740 796
741 if (request.method === "OPTIONS") return corsPreflight;797 if (request.method === "OPTIONS") return corsPreflight;
742 if (route === "oauth-protected-resource") return yield* protectedResourceMetadata;798 if (route?.kind === "oauth-protected-resource") {
743 if (route === "oauth-authorization-server") return yield* authorizationServerMetadata;799 return yield* protectedResourceMetadata(pathOrganizationId);
800 }
801 if (route?.kind === "oauth-authorization-server") return yield* authorizationServerMetadata;
744 802
745 const auth = yield* McpAuth;803 const auth = yield* McpAuth;
746 const authResult = yield* auth.verifyBearer(request).pipe(Effect.result);804 const authResult = yield* auth.verifyBearer(request).pipe(Effect.result);
@@ -763,13 +821,17 @@ export const mcpApp: Effect.Effect< · 16 unmodified lines
763 });821 });
764 822
765 if (isMcpUnauthorized(authValue)) {823 if (isMcpUnauthorized(authValue)) {
766 return unauthorized(authValue, PROTECTED_RESOURCE_METADATA_URL);824 return unauthorized(authValue, protectedResourceMetadataUrlFor(pathOrganizationId));
767 }825 }
768 const token = authValue.token;826 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;
769 const dispatchEffect = Match.value(request.method).pipe(831 const dispatchEffect = Match.value(request.method).pipe(
770 Match.when("POST", () => dispatchPost(request, token)),832 Match.when("POST", () => dispatchPost(request, token, organizationId)),
771 Match.when("GET", () => dispatchGet(request, token)),833 Match.when("GET", () => dispatchGet(request, token, organizationId)),
772 Match.when("DELETE", () => dispatchDelete(request, token)),834 Match.when("DELETE", () => dispatchDelete(request, token, organizationId)),
773 Match.option,835 Match.option,
774 );836 );
775 if (Option.isSome(dispatchEffect)) {837 if (Option.isSome(dispatchEffect)) {
apps/cloud/src/routes/__root.tsx+5−2
@@ -13,6 +13,7 @@ import posthog from "posthog-js"; · 12 unmodified lines
13import { PostHogProvider } from "posthog-js/react";13import { PostHogProvider } from "posthog-js/react";
14import type { FrontendErrorReporter } from "@executor-js/react/api/error-reporting";14import type { FrontendErrorReporter } from "@executor-js/react/api/error-reporting";
15import { ExecutorProvider } from "@executor-js/react/api/provider";15import { ExecutorProvider } from "@executor-js/react/api/provider";
16import { OrganizationProvider } from "@executor-js/react/api/organization-context";
16import { Skeleton } from "@executor-js/react/components/skeleton";17import { Skeleton } from "@executor-js/react/components/skeleton";
17import { Toaster } from "@executor-js/react/components/sonner";18import { Toaster } from "@executor-js/react/components/sonner";
18import { ExecutorPluginsProvider } from "@executor-js/sdk/client";19import { ExecutorPluginsProvider } from "@executor-js/sdk/client";
@@ -240,8 +241,10 @@ function AuthGate() { · 221 unmodified lines
240 <Sentry.ErrorBoundary fallback={<ShellErrorFallback />} showDialog={false}>241 <Sentry.ErrorBoundary fallback={<ShellErrorFallback />} showDialog={false}>
241 <ExecutorProvider fallback={<ShellSkeleton />} onHandledError={captureFrontendError}>242 <ExecutorProvider fallback={<ShellSkeleton />} onHandledError={captureFrontendError}>
242 <ExecutorPluginsProvider plugins={clientPlugins}>243 <ExecutorPluginsProvider plugins={clientPlugins}>
243 <Shell />244 <OrganizationProvider organizationId={auth.organization.id}>
244 <Toaster />245 <Shell />
246 <Toaster />
247 </OrganizationProvider>
245 </ExecutorPluginsProvider>248 </ExecutorPluginsProvider>
246 </ExecutorProvider>249 </ExecutorProvider>
247 </Sentry.ErrorBoundary>250 </Sentry.ErrorBoundary>
apps/cloud/src/start.ts+5−2
@@ -2,7 +2,7 @@ import { env } from "cloudflare:workers"; · 1 unmodified line
2import { createMiddleware, createStart } from "@tanstack/react-start";2import { createMiddleware, createStart } from "@tanstack/react-start";
3import { Effect } from "effect";3import { Effect } from "effect";
4import { handleApiRequest } from "./api";4import { handleApiRequest } from "./api";
5import { mcpFetch } from "./mcp";5import { classifyMcpPath, mcpFetch } from "./mcp";
6import { handleSentryTunnelRequest } from "./sentry-tunnel";6import { handleSentryTunnelRequest } from "./sentry-tunnel";
7 7
8// ---------------------------------------------------------------------------8// ---------------------------------------------------------------------------
@@ -66,7 +66,10 @@ const parseCookie = (cookieHeader: string | null, name: string): string | null = · 57 unmodified lines
66 66
67const mcpRequestMiddleware = createMiddleware({ type: "request" }).server(67const mcpRequestMiddleware = createMiddleware({ type: "request" }).server(
68 async ({ pathname, request, next }) => {68 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) {
70 const response = await mcpFetch(request);73 const response = await mcpFetch(request);
71 if (response) return response;74 if (response) return response;
72 }75 }
apps/cloud/src/test-worker.ts+4−1
@@ -55,7 +55,10 @@ const TestMcpAuthLive = Layer.succeed(McpAuth)({ · 54 unmodified lines
55});55});
56 56
57const TestMcpOrganizationAuthLive = Layer.succeed(McpOrganizationAuth)({57const 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")),
59});62});
60 63
61// ---------------------------------------------------------------------------64// ---------------------------------------------------------------------------
apps/cloud/src/web/pages/setup-mcp.tsx+6−0
@@ -15,8 +15,12 @@ import { · 14 unmodified lines
15} from "@executor-js/react/components/collapsible";15} from "@executor-js/react/components/collapsible";
16import { NativeSelect, NativeSelectOption } from "@executor-js/react/components/native-select";16import { NativeSelect, NativeSelectOption } from "@executor-js/react/components/native-select";
17 17
18import { useAuth } from "../auth";
19
18export const SetupMcpPage = () => {20export const SetupMcpPage = () => {
19 const navigate = useNavigate();21 const navigate = useNavigate();
22 const auth = useAuth();
23 const organizationId = auth.status === "authenticated" ? (auth.organization?.id ?? null) : null;
20 const [origin, setOrigin] = useState<string | null>(null);24 const [origin, setOrigin] = useState<string | null>(null);
21 const [advancedOpen, setAdvancedOpen] = useState(false);25 const [advancedOpen, setAdvancedOpen] = useState(false);
22 const [elicitationMode, setElicitationMode] = useState<McpElicitationMode>("model");26 const [elicitationMode, setElicitationMode] = useState<McpElicitationMode>("model");
@@ -30,6 +34,7 @@ export const SetupMcpPage = () => { · 7 unmodified lines
30 origin,34 origin,
31 desktop: null,35 desktop: null,
32 elicitationMode,36 elicitationMode,
37 organizationId,
33 })38 })
34 : "";39 : "";
35 const command = origin40 const command = origin
@@ -38,6 +43,7 @@ export const SetupMcpPage = () => { · 2 unmodified lines
38 isDev: false,43 isDev: false,
39 origin,44 origin,
40 elicitationMode,45 elicitationMode,
46 organizationId,
41 })47 })
42 : "";48 : "";
43 49
packages/react/src/api/organization-context.tsx+22−0
@@ -0,0 +1,22 @@
1import * 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.
7const OrganizationContext = React.createContext<string | null>(null);
8
9export 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. */
20export 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
109 }),109 }),
110 ).toBe("npx add-mcp 'executor mcp --elicitation-mode browser' --name executor");110 ).toBe("npx add-mcp 'executor mcp --elicitation-mode browser' --name executor");
111 });111 });
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 });
112});162});
packages/react/src/components/mcp-install-card.tsx+14−3
@@ -10,6 +10,7 @@ import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "./collapsib · 9 unmodified lines
10import { NativeSelect, NativeSelectOption } from "./native-select";10import { NativeSelect, NativeSelectOption } from "./native-select";
11import { cn } from "../lib/utils";11import { cn } from "../lib/utils";
12import { useScopeInfo } from "../api/scope-context";12import { useScopeInfo } from "../api/scope-context";
13import { useOrganizationId } from "../api/organization-context";
13import {14import {
14 getExecutorServerAuthorizationHeader,15 getExecutorServerAuthorizationHeader,
15 useExecutorServerConnection,16 useExecutorServerConnection,
@@ -47,12 +48,18 @@ export const buildMcpHttpEndpoint = (input: { · 31 unmodified lines
47 readonly port: number;48 readonly port: number;
48 } | null;49 } | null;
49 readonly elicitationMode?: McpElicitationMode;50 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;
50}): string => {54}): 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";
51 const endpoint = input.desktop58 const endpoint = input.desktop
52 ? `http://127.0.0.1:${input.desktop.port}/mcp`59 ? `http://127.0.0.1:${input.desktop.port}${mcpPath}`
53 : input.origin60 : input.origin
54 ? `${input.origin}/mcp`61 ? `${input.origin}${mcpPath}`
55 : "<this-server>/mcp";62 : `<this-server>${mcpPath}`;
56 if (!input.elicitationMode || input.elicitationMode === "model") return endpoint;63 if (!input.elicitationMode || input.elicitationMode === "model") return endpoint;
57 64
58 if (endpoint.startsWith("<")) return `${endpoint}?elicitation_mode=${input.elicitationMode}`;65 if (endpoint.startsWith("<")) return `${endpoint}?elicitation_mode=${input.elicitationMode}`;
@@ -83,12 +90,14 @@ export const buildMcpInstallCommand = (input: { · 24 unmodified lines
83 readonly authorizationHeader?: string | null;90 readonly authorizationHeader?: string | null;
84 readonly elicitationMode?: McpElicitationMode;91 readonly elicitationMode?: McpElicitationMode;
85 readonly devCliCwd?: string;92 readonly devCliCwd?: string;
93 readonly organizationId?: string | null;
86}): string => {94}): string => {
87 if (input.mode === "http") {95 if (input.mode === "http") {
88 const endpoint = buildMcpHttpEndpoint({96 const endpoint = buildMcpHttpEndpoint({
89 origin: input.origin,97 origin: input.origin,
90 desktop: input.desktop ? { port: input.desktop.port } : null,98 desktop: input.desktop ? { port: input.desktop.port } : null,
91 elicitationMode: input.elicitationMode,99 elicitationMode: input.elicitationMode,
100 organizationId: input.organizationId,
92 });101 });
93 const headerFlags: string[] = [];102 const headerFlags: string[] = [];
94 if (input.authorizationHeader) {103 if (input.authorizationHeader) {
@@ -122,6 +131,7 @@ export function McpInstallCard(props: { className?: string }) { · 27 unmodified lines
122 const [advancedOpen, setAdvancedOpen] = useState(false);131 const [advancedOpen, setAdvancedOpen] = useState(false);
123 const [httpElicitationMode, setHttpElicitationMode] = useState<McpElicitationMode>("model");132 const [httpElicitationMode, setHttpElicitationMode] = useState<McpElicitationMode>("model");
124 const scopeInfo = useScopeInfo();133 const scopeInfo = useScopeInfo();
134 const organizationId = useOrganizationId();
125 const serverConnection = useExecutorServerConnection();135 const serverConnection = useExecutorServerConnection();
126 // Desktop hosts ship Electron without putting an `executor` binary on136 // Desktop hosts ship Electron without putting an `executor` binary on
127 // PATH, and the bundled sidecar is locked to the running app. Force the137 // 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
140 authorizationHeader,150 authorizationHeader,
141 elicitationMode,151 elicitationMode,
142 devCliCwd,152 devCliCwd,
153 organizationId,
143 });154 });
144 155
145 const subtitle =156 const subtitle =