diff --git a/apps/cloud/src/mcp-flow.test.ts b/apps/cloud/src/mcp-flow.test.ts index d395a3a65..651f4fe86 100644 --- a/apps/cloud/src/mcp-flow.test.ts +++ b/apps/cloud/src/mcp-flow.test.ts @@ -98,20 +98,27 @@ type McpPostInit = { readonly accept?: string; }; -const mcpPost = (init: McpPostInit): Promise => { +const mcpPostTo = (url: string, init: McpPostInit): Promise => { const headers: Record = { "content-type": CONTENT_TYPE_JSON, accept: init.accept ?? JSON_AND_SSE, }; if (init.bearer) headers.authorization = `Bearer ${init.bearer}`; if (init.sessionId) headers["mcp-session-id"] = init.sessionId; - return SELF.fetch(MCP_URL, { + return SELF.fetch(url, { method: "POST", headers, body: JSON.stringify(init.body), }); }; +const mcpPost = (init: McpPostInit): Promise => mcpPostTo(MCP_URL, init); + +const orgScopedMcpUrl = (organizationId: string): string => `${BASE}/${organizationId}/mcp`; + +const orgScopedResourceUrl = (organizationId: string): string => + `${BASE}/.well-known/oauth-protected-resource/${organizationId}/mcp`; + const mcpGet = (init: { readonly bearer: string; readonly sessionId: string }): Promise => SELF.fetch(MCP_URL, { method: "GET", @@ -189,6 +196,72 @@ describe("/.well-known/oauth-protected-resource", () => { }); }); +// --------------------------------------------------------------------------- +// 2b. Org-scoped routing: //mcp +// --------------------------------------------------------------------------- +// +// The active org can be pinned in the URL instead of relying on the token's +// `org_id`. Membership is still verified per-request, so the URL is a selector, +// not a trust boundary. Only `org_…`-shaped segments are claimed; anything else +// falls through to TanStack routing. +// --------------------------------------------------------------------------- + +describe("/:orgId/mcp org-scoped routing", () => { + it("serves protected-resource metadata pointing at the org-scoped resource", async () => { + const orgId = nextOrgId(); + const response = await SELF.fetch(orgScopedResourceUrl(orgId)); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ + resource: `${BASE}/${orgId}/mcp`, + authorization_servers: ["https://test-authkit.example.com"], + bearer_methods_supported: ["header"], + scopes_supported: [], + }); + }); + + it("takes the active org from the URL even when the token carries no org", async () => { + const orgId = nextOrgId(); + const accountId = nextAccountId(); + await seedOrg(orgId); + + // A no-org token is a 403 on bare /mcp (see "without org" below); here the + // URL supplies the org, so the session initializes. + const response = await mcpPostTo(orgScopedMcpUrl(orgId), { + bearer: makeTestBearer(accountId, null), + body: INITIALIZE_REQUEST, + }); + expect(response.status).toBe(200); + expect(response.headers.get("mcp-session-id")).toBeTruthy(); + await response.text(); + }, 15_000); + + it("rejects a URL org the user is not a member of before creating a session", async () => { + const organizationId = `org_revoked_${crypto.randomUUID().slice(0, 8)}`; + const response = await mcpPostTo(orgScopedMcpUrl(organizationId), { + bearer: makeTestBearer(nextAccountId(), null), + body: INITIALIZE_REQUEST, + }); + expect(response.status).toBe(403); + const body = (await response.json()) as { + jsonrpc: string; + error: { code: number; message: string }; + }; + expect(body.jsonrpc).toBe("2.0"); + expect(body.error.code).toBe(-32001); + expect(body.error.message).toMatch(/No organization/i); + }); + + it("does not claim a non-org-shaped //mcp path", async () => { + // `/settings/mcp` must fall through to TanStack rather than be swallowed by + // the MCP filter; the test worker surfaces that as its own 404. + const response = await mcpPostTo(`${BASE}/settings/mcp`, { + bearer: makeTestBearer(nextAccountId(), nextOrgId()), + body: INITIALIZE_REQUEST, + }); + expect(response.status).toBe(404); + }); +}); + // --------------------------------------------------------------------------- // 3. POST /mcp without Authorization // --------------------------------------------------------------------------- @@ -321,7 +394,9 @@ describe("/mcp notification responses", () => { expect(notificationResponse.status).toBe(202); expect(notificationResponse.headers.get("content-type")).toBeNull(); expect(await notificationResponse.text()).toBe(""); - }); + // 15s like the other session-spinning tests here — the default 5s is + // borderline for DO runtime cold-start under full-suite scheduler load. + }, 15_000); }); describe("/mcp session restore", () => { diff --git a/apps/cloud/src/mcp.ts b/apps/cloud/src/mcp.ts index c5ed3bac4..6057b688d 100644 --- a/apps/cloud/src/mcp.ts +++ b/apps/cloud/src/mcp.ts @@ -76,6 +76,19 @@ const PROTECTED_RESOURCE_METADATA_PATH = "/.well-known/oauth-protected-resource/ const PROTECTED_RESOURCE_METADATA_URL = `${RESOURCE_ORIGIN}${PROTECTED_RESOURCE_METADATA_PATH}`; const RESOURCE_URL = `${RESOURCE_ORIGIN}${MCP_PATH}`; +// Org-scoped variants: `/org_xxx/mcp` lets a client pin a specific org without +// relying on the token's `org_id` claim. The org is taken from the URL and +// re-checked against live WorkOS membership per request, so the URL is a +// selector, not a trust boundary. (When web routes move under `/:org`, this +// becomes the handle form; for now we accept the raw WorkOS org id.) +const resourceUrlFor = (organizationId: string | null): string => + organizationId ? `${RESOURCE_ORIGIN}/${organizationId}${MCP_PATH}` : RESOURCE_URL; + +const protectedResourceMetadataUrlFor = (organizationId: string | null): string => + organizationId + ? `${RESOURCE_ORIGIN}/.well-known/oauth-protected-resource/${organizationId}/mcp` + : PROTECTED_RESOURCE_METADATA_URL; + type McpUnauthorizedReason = "missing_bearer" | "invalid_token"; type McpAuthorizedResult = { @@ -458,14 +471,15 @@ const annotateMcpRequest = ( // OAuth metadata endpoints // --------------------------------------------------------------------------- -const protectedResourceMetadata = Effect.sync(() => - jsonResponse({ - resource: RESOURCE_URL, - authorization_servers: [AUTHKIT_DOMAIN], - bearer_methods_supported: ["header"], - scopes_supported: [], - }), -); +const protectedResourceMetadata = (organizationId: string | null) => + Effect.sync(() => + jsonResponse({ + resource: resourceUrlFor(organizationId), + authorization_servers: [AUTHKIT_DOMAIN], + bearer_methods_supported: ["header"], + scopes_supported: [], + }), + ); const authorizationServerMetadata = Effect.tryPromise({ try: async () => { @@ -522,10 +536,14 @@ const withPropagationHeaders = ( return new Request(request, { headers }); }; -const withVerifiedIdentityHeaders = (request: Request, token: VerifiedToken): Request => { +const withVerifiedIdentityHeaders = ( + request: Request, + accountId: string, + organizationId: string, +): Request => { const headers = new Headers(request.headers); - headers.set(INTERNAL_ACCOUNT_ID_HEADER, token.accountId); - headers.set(INTERNAL_ORGANIZATION_ID_HEADER, token.organizationId ?? ""); + headers.set(INTERNAL_ACCOUNT_ID_HEADER, accountId); + headers.set(INTERNAL_ORGANIZATION_ID_HEADER, organizationId); return new Request(request, { headers }); }; @@ -569,13 +587,14 @@ const forwardToExistingSession = ( sessionId: string, peek: boolean, token: VerifiedToken, + organizationId: string, ) => Effect.gen(function* () { const ns = env.MCP_SESSION; const stub = ns.get(ns.idFromString(sessionId)); const propagation = yield* currentPropagationHeaders(request); const propagated = withPropagationHeaders( - withVerifiedIdentityHeaders(request, token), + withVerifiedIdentityHeaders(request, token.accountId, organizationId), propagation, ); const raw = yield* Effect.promise( @@ -608,10 +627,10 @@ const clearExistingSession = (request: Request, sessionId: string) => const authorizeMcpOrganization = ( request: Request, token: VerifiedToken, + organizationId: string | null, sessionId: string | null, ) => Effect.gen(function* () { - const organizationId = token.organizationId; if (!organizationId) { return jsonRpcError(403, -32001, "No organization in session — log in via the web app first"); } @@ -638,14 +657,14 @@ const authorizeMcpOrganization = ( return jsonRpcError(403, -32001, "No organization in session — log in via the web app first"); }); -const dispatchPost = (request: Request, token: VerifiedToken) => +const dispatchPost = (request: Request, token: VerifiedToken, organizationId: string | null) => Effect.gen(function* () { const sessionId = request.headers.get("mcp-session-id"); - const authError = yield* authorizeMcpOrganization(request, token, sessionId); + const authError = yield* authorizeMcpOrganization(request, token, organizationId, sessionId); if (authError) return authError; - const organizationId = token.organizationId!; + const orgId = organizationId!; - if (sessionId) return yield* forwardToExistingSession(request, sessionId, true, token); + if (sessionId) return yield* forwardToExistingSession(request, sessionId, true, token, orgId); const ns = env.MCP_SESSION; const stub = ns.get(ns.newUniqueId()); @@ -653,7 +672,7 @@ const dispatchPost = (request: Request, token: VerifiedToken) => yield* Effect.promise(() => stub.init( { - organizationId, + organizationId: orgId, userId: token.accountId, elicitationMode: readElicitationMode(request), }, @@ -665,7 +684,7 @@ const dispatchPost = (request: Request, token: VerifiedToken) => }), ); const propagated = withPropagationHeaders( - withVerifiedIdentityHeaders(request, token), + withVerifiedIdentityHeaders(request, token.accountId, orgId), propagation, ); const raw = yield* Effect.promise( @@ -682,24 +701,24 @@ const dispatchPost = (request: Request, token: VerifiedToken) => return HttpServerResponse.raw(withMcpResponseHeaders(annotated)); }); -const dispatchGet = (request: Request, token: VerifiedToken) => { +const dispatchGet = (request: Request, token: VerifiedToken, organizationId: string | null) => { const sessionId = request.headers.get("mcp-session-id"); if (!sessionId) return Effect.succeed(jsonRpcError(400, -32000, "mcp-session-id header required for SSE")); return Effect.gen(function* () { - const authError = yield* authorizeMcpOrganization(request, token, sessionId); + const authError = yield* authorizeMcpOrganization(request, token, organizationId, sessionId); if (authError) return authError; - return yield* forwardToExistingSession(request, sessionId, false, token); + return yield* forwardToExistingSession(request, sessionId, false, token, organizationId!); }); }; -const dispatchDelete = (request: Request, token: VerifiedToken) => { +const dispatchDelete = (request: Request, token: VerifiedToken, organizationId: string | null) => { const sessionId = request.headers.get("mcp-session-id"); if (!sessionId) return Effect.succeed(HttpServerResponse.empty({ status: 204 })); return Effect.gen(function* () { - const authError = yield* authorizeMcpOrganization(request, token, sessionId); + const authError = yield* authorizeMcpOrganization(request, token, organizationId, sessionId); if (authError) return authError; - return yield* forwardToExistingSession(request, sessionId, true, token); + return yield* forwardToExistingSession(request, sessionId, true, token, organizationId!); }); }; @@ -707,21 +726,57 @@ const dispatchDelete = (request: Request, token: VerifiedToken) => { // App // --------------------------------------------------------------------------- -type McpRoute = "mcp" | "oauth-protected-resource" | "oauth-authorization-server" | null; +type McpRouteKind = "mcp" | "oauth-protected-resource" | "oauth-authorization-server"; + +type McpRoute = { + readonly kind: McpRouteKind; + /** Org id pinned in the URL (`/org_xxx/mcp`), or `null` for the bare path. */ + readonly organizationId: string | null; +} | null; + +const PRM_PREFIX = "/.well-known/oauth-protected-resource"; + +// A path segment counts as an org selector only when it has the WorkOS org id +// shape (`org_…`). This keeps the MCP fall-through filter from claiming an +// unrelated `//mcp` path instead of letting TanStack route it. +const orgIdSegment = (segment: string | undefined): string | null => + segment && segment.startsWith("org_") ? segment : null; + +// Matches a trailing MCP endpoint — `mcp` (bare) or `/mcp`. Returns the org +// id, `null` for the bare form, or `undefined` when the segments are neither. +const matchMcpSuffix = (segments: readonly string[]): string | null | undefined => { + if (segments.length === 1 && segments[0] === "mcp") return null; + if (segments.length === 2 && segments[1] === "mcp") return orgIdSegment(segments[0]) ?? undefined; + return undefined; +}; /** - * Returns the MCP route type for a pathname, or `null` if the path isn't owned - * by the MCP handler. + * Returns the MCP route (kind + optional URL-pinned org) for a pathname, or + * `null` if the path isn't owned by the MCP handler. * - * Exported so the test worker can share the exact same predicate the middleware - * uses — we avoid duplicating the "is this an MCP path?" logic across entry - * points. + * This is THE ownership predicate: `start.ts`'s request middleware uses it to + * decide whether to hand a request to `mcpFetch` (vs. fall through to TanStack + * Start), `mcpApp` uses it to dispatch, and the test worker reuses it too — so + * the set of MCP paths lives in exactly one place. */ export const classifyMcpPath = (pathname: string): McpRoute => { - if (pathname === MCP_PATH) return "mcp"; - if (pathname === PROTECTED_RESOURCE_METADATA_PATH) return "oauth-protected-resource"; - if (pathname === "/.well-known/oauth-authorization-server") return "oauth-authorization-server"; - return null; + if (pathname === "/.well-known/oauth-authorization-server") { + return { kind: "oauth-authorization-server", organizationId: null }; + } + const segments = pathname.split("/").filter((segment) => segment.length > 0); + + // Protected-resource metadata: `${PRM_PREFIX}/mcp` or `${PRM_PREFIX}//mcp`. + // The org sits after the well-known prefix (RFC 9728), not at the path root. + if (pathname.startsWith(`${PRM_PREFIX}/`)) { + const organizationId = matchMcpSuffix(segments.slice(2)); + return organizationId === undefined + ? null + : { kind: "oauth-protected-resource", organizationId }; + } + + // MCP transport: `/mcp` or `//mcp`. + const organizationId = matchMcpSuffix(segments); + return organizationId === undefined ? null : { kind: "mcp", organizationId }; }; /** @@ -737,10 +792,13 @@ export const mcpApp: Effect.Effect< const httpRequest = yield* HttpServerRequest.HttpServerRequest; const request = httpRequest.source as Request; const route = classifyMcpPath(new URL(request.url).pathname); + const pathOrganizationId = route?.organizationId ?? null; if (request.method === "OPTIONS") return corsPreflight; - if (route === "oauth-protected-resource") return yield* protectedResourceMetadata; - if (route === "oauth-authorization-server") return yield* authorizationServerMetadata; + if (route?.kind === "oauth-protected-resource") { + return yield* protectedResourceMetadata(pathOrganizationId); + } + if (route?.kind === "oauth-authorization-server") return yield* authorizationServerMetadata; const auth = yield* McpAuth; const authResult = yield* auth.verifyBearer(request).pipe(Effect.result); @@ -763,13 +821,17 @@ export const mcpApp: Effect.Effect< }); if (isMcpUnauthorized(authValue)) { - return unauthorized(authValue, PROTECTED_RESOURCE_METADATA_URL); + return unauthorized(authValue, protectedResourceMetadataUrlFor(pathOrganizationId)); } const token = authValue.token; + // URL is the source of truth for the active org when present (`/org_xxx/mcp`); + // the bare `/mcp` path falls back to the token's `org_id`. Either way the org + // is verified against live WorkOS membership in authorizeMcpOrganization. + const organizationId = pathOrganizationId ?? token.organizationId; const dispatchEffect = Match.value(request.method).pipe( - Match.when("POST", () => dispatchPost(request, token)), - Match.when("GET", () => dispatchGet(request, token)), - Match.when("DELETE", () => dispatchDelete(request, token)), + Match.when("POST", () => dispatchPost(request, token, organizationId)), + Match.when("GET", () => dispatchGet(request, token, organizationId)), + Match.when("DELETE", () => dispatchDelete(request, token, organizationId)), Match.option, ); if (Option.isSome(dispatchEffect)) { diff --git a/apps/cloud/src/routes/__root.tsx b/apps/cloud/src/routes/__root.tsx index 787206f06..154badefb 100644 --- a/apps/cloud/src/routes/__root.tsx +++ b/apps/cloud/src/routes/__root.tsx @@ -13,6 +13,7 @@ import posthog from "posthog-js"; import { PostHogProvider } from "posthog-js/react"; import type { FrontendErrorReporter } from "@executor-js/react/api/error-reporting"; import { ExecutorProvider } from "@executor-js/react/api/provider"; +import { OrganizationProvider } from "@executor-js/react/api/organization-context"; import { Skeleton } from "@executor-js/react/components/skeleton"; import { Toaster } from "@executor-js/react/components/sonner"; import { ExecutorPluginsProvider } from "@executor-js/sdk/client"; @@ -240,8 +241,10 @@ function AuthGate() { } showDialog={false}> } onHandledError={captureFrontendError}> - - + + + + diff --git a/apps/cloud/src/start.ts b/apps/cloud/src/start.ts index 908438fea..ac2a07b13 100644 --- a/apps/cloud/src/start.ts +++ b/apps/cloud/src/start.ts @@ -2,7 +2,7 @@ import { env } from "cloudflare:workers"; import { createMiddleware, createStart } from "@tanstack/react-start"; import { Effect } from "effect"; import { handleApiRequest } from "./api"; -import { mcpFetch } from "./mcp"; +import { classifyMcpPath, mcpFetch } from "./mcp"; import { handleSentryTunnelRequest } from "./sentry-tunnel"; // --------------------------------------------------------------------------- @@ -66,7 +66,10 @@ const parseCookie = (cookieHeader: string | null, name: string): string | null = const mcpRequestMiddleware = createMiddleware({ type: "request" }).server( async ({ pathname, request, next }) => { - if (pathname === "/mcp" || pathname.startsWith("/.well-known/")) { + // Single source of truth for MCP path ownership (incl. `/org_xxx/mcp` and + // the org-scoped `.well-known` resource metadata). `mcpFetch` re-checks and + // can still return null, in which case we fall through to TanStack routing. + if (classifyMcpPath(pathname) !== null) { const response = await mcpFetch(request); if (response) return response; } diff --git a/apps/cloud/src/test-worker.ts b/apps/cloud/src/test-worker.ts index 34e7403e4..142c22eb5 100644 --- a/apps/cloud/src/test-worker.ts +++ b/apps/cloud/src/test-worker.ts @@ -55,7 +55,10 @@ const TestMcpAuthLive = Layer.succeed(McpAuth)({ }); const TestMcpOrganizationAuthLive = Layer.succeed(McpOrganizationAuth)({ - authorize: (_accountId, organizationId) => Effect.succeed(!organizationId.startsWith("revoked_")), + // Deny on any org id containing "revoked" (rather than a strict prefix) so a + // routable, prefix-valid org id like `org_revoked_…` still exercises the + // membership-denied path through the `/org_xxx/mcp` URL route. + authorize: (_accountId, organizationId) => Effect.succeed(!organizationId.includes("revoked")), }); // --------------------------------------------------------------------------- diff --git a/apps/cloud/src/web/pages/setup-mcp.tsx b/apps/cloud/src/web/pages/setup-mcp.tsx index ddd2d08cc..d7d25ef6d 100644 --- a/apps/cloud/src/web/pages/setup-mcp.tsx +++ b/apps/cloud/src/web/pages/setup-mcp.tsx @@ -15,8 +15,12 @@ import { } from "@executor-js/react/components/collapsible"; import { NativeSelect, NativeSelectOption } from "@executor-js/react/components/native-select"; +import { useAuth } from "../auth"; + export const SetupMcpPage = () => { const navigate = useNavigate(); + const auth = useAuth(); + const organizationId = auth.status === "authenticated" ? (auth.organization?.id ?? null) : null; const [origin, setOrigin] = useState(null); const [advancedOpen, setAdvancedOpen] = useState(false); const [elicitationMode, setElicitationMode] = useState("model"); @@ -30,6 +34,7 @@ export const SetupMcpPage = () => { origin, desktop: null, elicitationMode, + organizationId, }) : ""; const command = origin @@ -38,6 +43,7 @@ export const SetupMcpPage = () => { isDev: false, origin, elicitationMode, + organizationId, }) : ""; diff --git a/packages/react/src/api/organization-context.tsx b/packages/react/src/api/organization-context.tsx new file mode 100644 index 000000000..6a6e6b493 --- /dev/null +++ b/packages/react/src/api/organization-context.tsx @@ -0,0 +1,22 @@ +import * as React from "react"; + +// The active WorkOS organization for org-scoped hosts (the cloud app). Local and +// desktop aren't org-scoped, so they leave it unset and consumers fall back to +// unscoped behaviour. This is purely a UI hint (e.g. which org to pin in an MCP +// install URL); access is always enforced server-side. +const OrganizationContext = React.createContext(null); + +export function OrganizationProvider( + props: React.PropsWithChildren<{ readonly organizationId: string | null }>, +) { + return ( + + {props.children} + + ); +} + +/** Returns the active organization id, or `null` when the host isn't org-scoped. */ +export function useOrganizationId(): string | null { + return React.useContext(OrganizationContext); +} diff --git a/packages/react/src/components/mcp-install-card.test.ts b/packages/react/src/components/mcp-install-card.test.ts index abb7a73e6..59e940eab 100644 --- a/packages/react/src/components/mcp-install-card.test.ts +++ b/packages/react/src/components/mcp-install-card.test.ts @@ -109,4 +109,54 @@ describe("MCP install command rendering", () => { }), ).toBe("npx add-mcp 'executor mcp --elicitation-mode browser' --name executor"); }); + + it("pins the HTTP endpoint to the org id when one is supplied", () => { + expect( + buildMcpHttpEndpoint({ + origin: "https://executor.example", + desktop: null, + organizationId: "org_123", + }), + ).toBe("https://executor.example/org_123/mcp"); + + expect( + buildMcpInstallCommand({ + mode: "http", + isDev: false, + origin: "https://executor.example", + organizationId: "org_123", + }), + ).toBe("npx add-mcp https://executor.example/org_123/mcp --transport http --name executor"); + }); + + it("keeps the bare /mcp path when no org id is supplied", () => { + expect( + buildMcpHttpEndpoint({ + origin: "https://executor.example", + desktop: null, + organizationId: null, + }), + ).toBe("https://executor.example/mcp"); + }); + + it("combines the org id with an explicit elicitation mode", () => { + expect( + buildMcpHttpEndpoint({ + origin: "https://executor.example", + desktop: null, + organizationId: "org_123", + elicitationMode: "browser", + }), + ).toBe("https://executor.example/org_123/mcp?elicitation_mode=browser"); + }); + + it("does not org-scope the desktop sidecar endpoint", () => { + expect( + buildMcpHttpEndpoint({ + origin: null, + desktop: { port: 4788 }, + organizationId: "org_123", + }), + ).toBe("http://127.0.0.1:4788/mcp"); + }); }); diff --git a/packages/react/src/components/mcp-install-card.tsx b/packages/react/src/components/mcp-install-card.tsx index 968bda676..5239ece58 100644 --- a/packages/react/src/components/mcp-install-card.tsx +++ b/packages/react/src/components/mcp-install-card.tsx @@ -10,6 +10,7 @@ import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "./collapsib import { NativeSelect, NativeSelectOption } from "./native-select"; import { cn } from "../lib/utils"; import { useScopeInfo } from "../api/scope-context"; +import { useOrganizationId } from "../api/organization-context"; import { getExecutorServerAuthorizationHeader, useExecutorServerConnection, @@ -47,12 +48,18 @@ export const buildMcpHttpEndpoint = (input: { readonly port: number; } | null; readonly elicitationMode?: McpElicitationMode; + // Cloud only: pins the URL to `//mcp`. Desktop/local pass nothing and + // get the bare `/mcp` path. + readonly organizationId?: string | null; }): string => { + // The desktop sidecar isn't org-scoped, so the org only applies to the + // origin/remote forms. + const mcpPath = input.organizationId && !input.desktop ? `/${input.organizationId}/mcp` : "/mcp"; const endpoint = input.desktop - ? `http://127.0.0.1:${input.desktop.port}/mcp` + ? `http://127.0.0.1:${input.desktop.port}${mcpPath}` : input.origin - ? `${input.origin}/mcp` - : "/mcp"; + ? `${input.origin}${mcpPath}` + : `${mcpPath}`; if (!input.elicitationMode || input.elicitationMode === "model") return endpoint; if (endpoint.startsWith("<")) return `${endpoint}?elicitation_mode=${input.elicitationMode}`; @@ -83,12 +90,14 @@ export const buildMcpInstallCommand = (input: { readonly authorizationHeader?: string | null; readonly elicitationMode?: McpElicitationMode; readonly devCliCwd?: string; + readonly organizationId?: string | null; }): string => { if (input.mode === "http") { const endpoint = buildMcpHttpEndpoint({ origin: input.origin, desktop: input.desktop ? { port: input.desktop.port } : null, elicitationMode: input.elicitationMode, + organizationId: input.organizationId, }); const headerFlags: string[] = []; if (input.authorizationHeader) { @@ -122,6 +131,7 @@ export function McpInstallCard(props: { className?: string }) { const [advancedOpen, setAdvancedOpen] = useState(false); const [httpElicitationMode, setHttpElicitationMode] = useState("model"); const scopeInfo = useScopeInfo(); + const organizationId = useOrganizationId(); const serverConnection = useExecutorServerConnection(); // Desktop hosts ship Electron without putting an `executor` binary on // PATH, and the bundled sidecar is locked to the running app. Force the @@ -140,6 +150,7 @@ export function McpInstallCard(props: { className?: string }) { authorizationHeader, elicitationMode, devCliCwd, + organizationId, }); const subtitle =