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.
Unified · Split
| @@ -98,20 +98,27 @@ type McpPostInit = { · 97 unmodified lines | ||
| 98 | 98 | readonly accept?: string; |
| 99 | 99 | }; |
| 100 | 100 | |
| 101 | −const mcpPost = (init: McpPostInit): Promise<Response> => { | |
| 101 | +const mcpPostTo = (url: string, init: McpPostInit): Promise<Response> => { | |
| 102 | 102 | const headers: Record<string, string> = { |
| 103 | 103 | "content-type": CONTENT_TYPE_JSON, |
| 104 | 104 | accept: init.accept ?? JSON_AND_SSE, |
| 105 | 105 | }; |
| 106 | 106 | if (init.bearer) headers.authorization = `Bearer ${init.bearer}`; |
| 107 | 107 | if (init.sessionId) headers["mcp-session-id"] = init.sessionId; |
| 108 | − return SELF.fetch(MCP_URL, { | |
| 108 | + return SELF.fetch(url, { | |
| 109 | 109 | method: "POST", |
| 110 | 110 | headers, |
| 111 | 111 | body: JSON.stringify(init.body), |
| 112 | 112 | }); |
| 113 | 113 | }; |
| 114 | 114 | |
| 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 | + | |
| 115 | 122 | const mcpGet = (init: { readonly bearer: string; readonly sessionId: string }): Promise<Response> => |
| 116 | 123 | SELF.fetch(MCP_URL, { |
| 117 | 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 | + | |
| 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 | + | |
| 192 | 265 | // --------------------------------------------------------------------------- |
| 193 | 266 | // 3. POST /mcp without Authorization |
| 194 | 267 | // --------------------------------------------------------------------------- |
| @@ -321,7 +394,9 @@ describe("/mcp notification responses", () => { · 126 unmodified lines | ||
| 321 | 394 | expect(notificationResponse.status).toBe(202); |
| 322 | 395 | expect(notificationResponse.headers.get("content-type")).toBeNull(); |
| 323 | 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 | |
| 327 | 402 | describe("/mcp session restore", () => { |
| @@ -76,6 +76,19 @@ const PROTECTED_RESOURCE_METADATA_PATH = "/.well-known/oauth-protected-resource/ · 75 unmodified lines | ||
| 76 | 76 | const PROTECTED_RESOURCE_METADATA_URL = `${RESOURCE_ORIGIN}${PROTECTED_RESOURCE_METADATA_PATH}`; |
| 77 | 77 | const 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.) | |
| 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 | + | |
| 79 | 92 | type McpUnauthorizedReason = "missing_bearer" | "invalid_token"; |
| 80 | 93 | |
| 81 | 94 | type McpAuthorizedResult = { |
| @@ -458,14 +471,15 @@ const annotateMcpRequest = ( · 376 unmodified lines | ||
| 458 | 471 | // OAuth metadata endpoints |
| 459 | 472 | // --------------------------------------------------------------------------- |
| 460 | 473 | |
| 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 | + ); | |
| 469 | 483 | |
| 470 | 484 | const authorizationServerMetadata = Effect.tryPromise({ |
| 471 | 485 | try: async () => { |
| @@ -522,10 +536,14 @@ const withPropagationHeaders = ( · 50 unmodified lines | ||
| 522 | 536 | return new Request(request, { headers }); |
| 523 | 537 | }; |
| 524 | 538 | |
| 525 | −const withVerifiedIdentityHeaders = (request: Request, token: VerifiedToken): Request => { | |
| 539 | +const withVerifiedIdentityHeaders = ( | |
| 540 | + request: Request, | |
| 541 | + accountId: string, | |
| 542 | + organizationId: string, | |
| 543 | +): Request => { | |
| 526 | 544 | 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); | |
| 529 | 547 | return new Request(request, { headers }); |
| 530 | 548 | }; |
| 531 | 549 | |
| @@ -569,13 +587,14 @@ const forwardToExistingSession = ( · 37 unmodified lines | ||
| 569 | 587 | sessionId: string, |
| 570 | 588 | peek: boolean, |
| 571 | 589 | token: VerifiedToken, |
| 590 | + organizationId: string, | |
| 572 | 591 | ) => |
| 573 | 592 | Effect.gen(function* () { |
| 574 | 593 | const ns = env.MCP_SESSION; |
| 575 | 594 | const stub = ns.get(ns.idFromString(sessionId)); |
| 576 | 595 | const propagation = yield* currentPropagationHeaders(request); |
| 577 | 596 | const propagated = withPropagationHeaders( |
| 578 | − withVerifiedIdentityHeaders(request, token), | |
| 597 | + withVerifiedIdentityHeaders(request, token.accountId, organizationId), | |
| 579 | 598 | propagation, |
| 580 | 599 | ); |
| 581 | 600 | const raw = yield* Effect.promise( |
| @@ -608,10 +627,10 @@ const clearExistingSession = (request: Request, sessionId: string) => · 26 unmodified lines | ||
| 608 | 627 | const authorizeMcpOrganization = ( |
| 609 | 628 | request: Request, |
| 610 | 629 | token: VerifiedToken, |
| 630 | + organizationId: string | null, | |
| 611 | 631 | sessionId: string | null, |
| 612 | 632 | ) => |
| 613 | 633 | Effect.gen(function* () { |
| 614 | − const organizationId = token.organizationId; | |
| 615 | 634 | if (!organizationId) { |
| 616 | 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 | 657 | return jsonRpcError(403, -32001, "No organization in session — log in via the web app first"); |
| 639 | 658 | }); |
| 640 | 659 | |
| 641 | −const dispatchPost = (request: Request, token: VerifiedToken) => | |
| 660 | +const dispatchPost = (request: Request, token: VerifiedToken, organizationId: string | null) => | |
| 642 | 661 | Effect.gen(function* () { |
| 643 | 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 | 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 | 669 | const ns = env.MCP_SESSION; |
| 651 | 670 | const stub = ns.get(ns.newUniqueId()); |
| @@ -653,7 +672,7 @@ const dispatchPost = (request: Request, token: VerifiedToken) => · 1 unmodified line | ||
| 653 | 672 | yield* Effect.promise(() => |
| 654 | 673 | stub.init( |
| 655 | 674 | { |
| 656 | − organizationId, | |
| 675 | + organizationId: orgId, | |
| 657 | 676 | userId: token.accountId, |
| 658 | 677 | elicitationMode: readElicitationMode(request), |
| 659 | 678 | }, |
| @@ -665,7 +684,7 @@ const dispatchPost = (request: Request, token: VerifiedToken) => · 5 unmodified lines | ||
| 665 | 684 | }), |
| 666 | 685 | ); |
| 667 | 686 | const propagated = withPropagationHeaders( |
| 668 | − withVerifiedIdentityHeaders(request, token), | |
| 687 | + withVerifiedIdentityHeaders(request, token.accountId, orgId), | |
| 669 | 688 | propagation, |
| 670 | 689 | ); |
| 671 | 690 | const raw = yield* Effect.promise( |
| @@ -682,24 +701,24 @@ const dispatchPost = (request: Request, token: VerifiedToken) => · 10 unmodified lines | ||
| 682 | 701 | return HttpServerResponse.raw(withMcpResponseHeaders(annotated)); |
| 683 | 702 | }); |
| 684 | 703 | |
| 685 | −const dispatchGet = (request: Request, token: VerifiedToken) => { | |
| 704 | +const dispatchGet = (request: Request, token: VerifiedToken, organizationId: string | null) => { | |
| 686 | 705 | const sessionId = request.headers.get("mcp-session-id"); |
| 687 | 706 | if (!sessionId) |
| 688 | 707 | return Effect.succeed(jsonRpcError(400, -32000, "mcp-session-id header required for SSE")); |
| 689 | 708 | return Effect.gen(function* () { |
| 690 | − const authError = yield* authorizeMcpOrganization(request, token, sessionId); | |
| 709 | + const authError = yield* authorizeMcpOrganization(request, token, organizationId, sessionId); | |
| 691 | 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 | |
| 696 | −const dispatchDelete = (request: Request, token: VerifiedToken) => { | |
| 715 | +const dispatchDelete = (request: Request, token: VerifiedToken, organizationId: string | null) => { | |
| 697 | 716 | const sessionId = request.headers.get("mcp-session-id"); |
| 698 | 717 | if (!sessionId) return Effect.succeed(HttpServerResponse.empty({ status: 204 })); |
| 699 | 718 | return Effect.gen(function* () { |
| 700 | − const authError = yield* authorizeMcpOrganization(request, token, sessionId); | |
| 719 | + const authError = yield* authorizeMcpOrganization(request, token, organizationId, sessionId); | |
| 701 | 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 | 726 | // App |
| 708 | 727 | // --------------------------------------------------------------------------- |
| 709 | 728 | |
| 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 | +}; | |
| 711 | 752 | |
| 712 | 753 | /** |
| 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. | |
| 715 | 756 | * |
| 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. | |
| 719 | 761 | */ |
| 720 | 762 | 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 }; | |
| 725 | 780 | }; |
| 726 | 781 | |
| 727 | 782 | /** |
| @@ -737,10 +792,13 @@ export const mcpApp: Effect.Effect< · 9 unmodified lines | ||
| 737 | 792 | const httpRequest = yield* HttpServerRequest.HttpServerRequest; |
| 738 | 793 | const request = httpRequest.source as Request; |
| 739 | 794 | const route = classifyMcpPath(new URL(request.url).pathname); |
| 795 | + const pathOrganizationId = route?.organizationId ?? null; | |
| 740 | 796 | |
| 741 | 797 | 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; | |
| 744 | 802 | |
| 745 | 803 | const auth = yield* McpAuth; |
| 746 | 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 | 823 | if (isMcpUnauthorized(authValue)) { |
| 766 | − return unauthorized(authValue, PROTECTED_RESOURCE_METADATA_URL); | |
| 824 | + return unauthorized(authValue, protectedResourceMetadataUrlFor(pathOrganizationId)); | |
| 767 | 825 | } |
| 768 | 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 | 831 | 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)), | |
| 773 | 835 | Match.option, |
| 774 | 836 | ); |
| 775 | 837 | if (Option.isSome(dispatchEffect)) { |
| @@ -13,6 +13,7 @@ import posthog from "posthog-js"; · 12 unmodified lines | ||
| 13 | 13 | import { PostHogProvider } from "posthog-js/react"; |
| 14 | 14 | import type { FrontendErrorReporter } from "@executor-js/react/api/error-reporting"; |
| 15 | 15 | import { ExecutorProvider } from "@executor-js/react/api/provider"; |
| 16 | +import { OrganizationProvider } from "@executor-js/react/api/organization-context"; | |
| 16 | 17 | import { Skeleton } from "@executor-js/react/components/skeleton"; |
| 17 | 18 | import { Toaster } from "@executor-js/react/components/sonner"; |
| 18 | 19 | import { ExecutorPluginsProvider } from "@executor-js/sdk/client"; |
| @@ -240,8 +241,10 @@ function AuthGate() { · 221 unmodified lines | ||
| 240 | 241 | <Sentry.ErrorBoundary fallback={<ShellErrorFallback />} showDialog={false}> |
| 241 | 242 | <ExecutorProvider fallback={<ShellSkeleton />} onHandledError={captureFrontendError}> |
| 242 | 243 | <ExecutorPluginsProvider plugins={clientPlugins}> |
| 243 | − <Shell /> | |
| 244 | − <Toaster /> | |
| 244 | + <OrganizationProvider organizationId={auth.organization.id}> | |
| 245 | + <Shell /> | |
| 246 | + <Toaster /> | |
| 247 | + </OrganizationProvider> | |
| 245 | 248 | </ExecutorPluginsProvider> |
| 246 | 249 | </ExecutorProvider> |
| 247 | 250 | </Sentry.ErrorBoundary> |
| @@ -2,7 +2,7 @@ import { env } from "cloudflare:workers"; · 1 unmodified line | ||
| 2 | 2 | import { createMiddleware, createStart } from "@tanstack/react-start"; |
| 3 | 3 | import { Effect } from "effect"; |
| 4 | 4 | import { handleApiRequest } from "./api"; |
| 5 | −import { mcpFetch } from "./mcp"; | |
| 5 | +import { classifyMcpPath, mcpFetch } from "./mcp"; | |
| 6 | 6 | import { 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 | |
| 67 | 67 | const mcpRequestMiddleware = createMiddleware({ type: "request" }).server( |
| 68 | 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 | 73 | const response = await mcpFetch(request); |
| 71 | 74 | if (response) return response; |
| 72 | 75 | } |
| @@ -55,7 +55,10 @@ const TestMcpAuthLive = Layer.succeed(McpAuth)({ · 54 unmodified lines | ||
| 55 | 55 | }); |
| 56 | 56 | |
| 57 | 57 | 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")), | |
| 59 | 62 | }); |
| 60 | 63 | |
| 61 | 64 | // --------------------------------------------------------------------------- |
| @@ -15,8 +15,12 @@ import { · 14 unmodified lines | ||
| 15 | 15 | } from "@executor-js/react/components/collapsible"; |
| 16 | 16 | import { NativeSelect, NativeSelectOption } from "@executor-js/react/components/native-select"; |
| 17 | 17 | |
| 18 | +import { useAuth } from "../auth"; | |
| 19 | + | |
| 18 | 20 | export const SetupMcpPage = () => { |
| 19 | 21 | const navigate = useNavigate(); |
| 22 | + const auth = useAuth(); | |
| 23 | + const organizationId = auth.status === "authenticated" ? (auth.organization?.id ?? null) : null; | |
| 20 | 24 | const [origin, setOrigin] = useState<string | null>(null); |
| 21 | 25 | const [advancedOpen, setAdvancedOpen] = useState(false); |
| 22 | 26 | const [elicitationMode, setElicitationMode] = useState<McpElicitationMode>("model"); |
| @@ -30,6 +34,7 @@ export const SetupMcpPage = () => { · 7 unmodified lines | ||
| 30 | 34 | origin, |
| 31 | 35 | desktop: null, |
| 32 | 36 | elicitationMode, |
| 37 | + organizationId, | |
| 33 | 38 | }) |
| 34 | 39 | : ""; |
| 35 | 40 | const command = origin |
| @@ -38,6 +43,7 @@ export const SetupMcpPage = () => { · 2 unmodified lines | ||
| 38 | 43 | isDev: false, |
| 39 | 44 | origin, |
| 40 | 45 | elicitationMode, |
| 46 | + organizationId, | |
| 41 | 47 | }) |
| 42 | 48 | : ""; |
| 43 | 49 | |
| @@ -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 | +} | |
| @@ -109,4 +109,54 @@ describe("MCP install command rendering", () => { · 108 unmodified lines | ||
| 109 | 109 | }), |
| 110 | 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 | }); |
| @@ -10,6 +10,7 @@ import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "./collapsib · 9 unmodified lines | ||
| 10 | 10 | import { NativeSelect, NativeSelectOption } from "./native-select"; |
| 11 | 11 | import { cn } from "../lib/utils"; |
| 12 | 12 | import { useScopeInfo } from "../api/scope-context"; |
| 13 | +import { useOrganizationId } from "../api/organization-context"; | |
| 13 | 14 | import { |
| 14 | 15 | getExecutorServerAuthorizationHeader, |
| 15 | 16 | useExecutorServerConnection, |
| @@ -47,12 +48,18 @@ export const buildMcpHttpEndpoint = (input: { · 31 unmodified lines | ||
| 47 | 48 | readonly port: number; |
| 48 | 49 | } | null; |
| 49 | 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 | 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 | 58 | 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 | 60 | : input.origin |
| 54 | − ? `${input.origin}/mcp` | |
| 55 | − : "<this-server>/mcp"; | |
| 61 | + ? `${input.origin}${mcpPath}` | |
| 62 | + : `<this-server>${mcpPath}`; | |
| 56 | 63 | if (!input.elicitationMode || input.elicitationMode === "model") return endpoint; |
| 57 | 64 | |
| 58 | 65 | if (endpoint.startsWith("<")) return `${endpoint}?elicitation_mode=${input.elicitationMode}`; |
| @@ -83,12 +90,14 @@ export const buildMcpInstallCommand = (input: { · 24 unmodified lines | ||
| 83 | 90 | readonly authorizationHeader?: string | null; |
| 84 | 91 | readonly elicitationMode?: McpElicitationMode; |
| 85 | 92 | readonly devCliCwd?: string; |
| 93 | + readonly organizationId?: string | null; | |
| 86 | 94 | }): string => { |
| 87 | 95 | if (input.mode === "http") { |
| 88 | 96 | const endpoint = buildMcpHttpEndpoint({ |
| 89 | 97 | origin: input.origin, |
| 90 | 98 | desktop: input.desktop ? { port: input.desktop.port } : null, |
| 91 | 99 | elicitationMode: input.elicitationMode, |
| 100 | + organizationId: input.organizationId, | |
| 92 | 101 | }); |
| 93 | 102 | const headerFlags: string[] = []; |
| 94 | 103 | if (input.authorizationHeader) { |
| @@ -122,6 +131,7 @@ export function McpInstallCard(props: { className?: string }) { · 27 unmodified lines | ||
| 122 | 131 | const [advancedOpen, setAdvancedOpen] = useState(false); |
| 123 | 132 | const [httpElicitationMode, setHttpElicitationMode] = useState<McpElicitationMode>("model"); |
| 124 | 133 | const scopeInfo = useScopeInfo(); |
| 134 | + const organizationId = useOrganizationId(); | |
| 125 | 135 | const serverConnection = useExecutorServerConnection(); |
| 126 | 136 | // Desktop hosts ship Electron without putting an `executor` binary on |
| 127 | 137 | // 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 | 150 | authorizationHeader, |
| 141 | 151 | elicitationMode, |
| 142 | 152 | devCliCwd, |
| 153 | + organizationId, | |
| 143 | 154 | }); |
| 144 | 155 | |
| 145 | 156 | const subtitle = |