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 | readonly accept?: string; | 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 | 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 | ||
| 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 | const mcpGet = (init: { readonly bearer: string; readonly sessionId: string }): Promise<Response> => | 122 | const 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 | |||
| 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 | // 3. POST /mcp without Authorization | 266 | // 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 | ||
| 327 | describe("/mcp session restore", () => { | 402 | describe("/mcp session restore", () => { |
| @@ -76,6 +76,19 @@ const PROTECTED_RESOURCE_METADATA_PATH = "/.well-known/oauth-protected-resource/ · 75 unmodified lines | |||
| 76 | const PROTECTED_RESOURCE_METADATA_URL = `${RESOURCE_ORIGIN}${PROTECTED_RESOURCE_METADATA_PATH}`; | 76 | const PROTECTED_RESOURCE_METADATA_URL = `${RESOURCE_ORIGIN}${PROTECTED_RESOURCE_METADATA_PATH}`; |
| 77 | const RESOURCE_URL = `${RESOURCE_ORIGIN}${MCP_PATH}`; | 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 | type McpUnauthorizedReason = "missing_bearer" | "invalid_token"; | 92 | type McpUnauthorizedReason = "missing_bearer" | "invalid_token"; |
| 80 | 93 | ||
| 81 | type McpAuthorizedResult = { | 94 | type McpAuthorizedResult = { |
| @@ -458,14 +471,15 @@ const annotateMcpRequest = ( · 376 unmodified lines | |||
| 458 | // OAuth metadata endpoints | 471 | // OAuth metadata endpoints |
| 459 | // --------------------------------------------------------------------------- | 472 | // --------------------------------------------------------------------------- |
| 460 | 473 | ||
| 461 | const protectedResourceMetadata = Effect.sync(() => | 474 | const 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 | ||
| 470 | const authorizationServerMetadata = Effect.tryPromise({ | 484 | const 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 | ||
| 525 | const withVerifiedIdentityHeaders = (request: Request, token: VerifiedToken): Request => { | 539 | const 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 | |||
| 608 | const authorizeMcpOrganization = ( | 627 | const 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 | ||
| 641 | const dispatchPost = (request: Request, token: VerifiedToken) => | 660 | const 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 | ||
| 685 | const dispatchGet = (request: Request, token: VerifiedToken) => { | 704 | const 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 | ||
| 696 | const dispatchDelete = (request: Request, token: VerifiedToken) => { | 715 | const 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 | // App | 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 | 754 | * 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 middleware | 757 | * 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 entry | 758 | * 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 | */ |
| 720 | export const classifyMcpPath = (pathname: string): McpRoute => { | 762 | export 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)) { |
| @@ -13,6 +13,7 @@ import posthog from "posthog-js"; · 12 unmodified lines | |||
| 13 | import { PostHogProvider } from "posthog-js/react"; | 13 | import { PostHogProvider } from "posthog-js/react"; |
| 14 | import type { FrontendErrorReporter } from "@executor-js/react/api/error-reporting"; | 14 | import type { FrontendErrorReporter } from "@executor-js/react/api/error-reporting"; |
| 15 | import { ExecutorProvider } from "@executor-js/react/api/provider"; | 15 | import { ExecutorProvider } from "@executor-js/react/api/provider"; |
| 16 | import { OrganizationProvider } from "@executor-js/react/api/organization-context"; | ||
| 16 | import { Skeleton } from "@executor-js/react/components/skeleton"; | 17 | import { Skeleton } from "@executor-js/react/components/skeleton"; |
| 17 | import { Toaster } from "@executor-js/react/components/sonner"; | 18 | import { Toaster } from "@executor-js/react/components/sonner"; |
| 18 | import { ExecutorPluginsProvider } from "@executor-js/sdk/client"; | 19 | import { 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> |
| @@ -2,7 +2,7 @@ import { env } from "cloudflare:workers"; · 1 unmodified line | |||
| 2 | import { createMiddleware, createStart } from "@tanstack/react-start"; | 2 | import { createMiddleware, createStart } from "@tanstack/react-start"; |
| 3 | import { Effect } from "effect"; | 3 | import { Effect } from "effect"; |
| 4 | import { handleApiRequest } from "./api"; | 4 | import { handleApiRequest } from "./api"; |
| 5 | import { mcpFetch } from "./mcp"; | 5 | import { classifyMcpPath, mcpFetch } from "./mcp"; |
| 6 | import { handleSentryTunnelRequest } from "./sentry-tunnel"; | 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 | const mcpRequestMiddleware = createMiddleware({ type: "request" }).server( | 67 | const 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 | } |
| @@ -55,7 +55,10 @@ const TestMcpAuthLive = Layer.succeed(McpAuth)({ · 54 unmodified lines | |||
| 55 | }); | 55 | }); |
| 56 | 56 | ||
| 57 | const TestMcpOrganizationAuthLive = Layer.succeed(McpOrganizationAuth)({ | 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 | } from "@executor-js/react/components/collapsible"; | 15 | } from "@executor-js/react/components/collapsible"; |
| 16 | import { NativeSelect, NativeSelectOption } from "@executor-js/react/components/native-select"; | 16 | import { NativeSelect, NativeSelectOption } from "@executor-js/react/components/native-select"; |
| 17 | 17 | ||
| 18 | import { useAuth } from "../auth"; | ||
| 19 | |||
| 18 | export const SetupMcpPage = () => { | 20 | export 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 = origin | 40 | 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 | ||
| @@ -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 | ).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 | }); |
| @@ -10,6 +10,7 @@ import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "./collapsib · 9 unmodified lines | |||
| 10 | import { NativeSelect, NativeSelectOption } from "./native-select"; | 10 | import { NativeSelect, NativeSelectOption } from "./native-select"; |
| 11 | import { cn } from "../lib/utils"; | 11 | import { cn } from "../lib/utils"; |
| 12 | import { useScopeInfo } from "../api/scope-context"; | 12 | import { useScopeInfo } from "../api/scope-context"; |
| 13 | import { useOrganizationId } from "../api/organization-context"; | ||
| 13 | import { | 14 | import { |
| 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.desktop | 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 | : input.origin | 60 | : 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 on | 136 | // Desktop hosts ship Electron without putting an `executor` binary on |
| 127 | // PATH, and the bundled sidecar is locked to the running app. Force the | 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 | authorizationHeader, | 150 | authorizationHeader, |
| 141 | elicitationMode, | 151 | elicitationMode, |
| 142 | devCliCwd, | 152 | devCliCwd, |
| 153 | organizationId, | ||
| 143 | }); | 154 | }); |
| 144 | 155 | ||
| 145 | const subtitle = | 156 | const subtitle = |