From cf09dd937ec387096963c86b96d40879ec124cca Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Mon, 12 Oct 2026 15:17:02 -0700 Subject: [PATCH 1/3] Show a moved org's v2 MCP URL and tell its MCP sessions --- apps/cloud/src/auth/api.ts | 4 + apps/cloud/src/auth/v2-import.ts | 32 +++- apps/cloud/src/auth/v2-moved.ts | 9 +- apps/cloud/src/env-augment.d.ts | 2 + apps/cloud/src/mcp/session-durable-object.ts | 46 +++-- apps/cloud/src/mcp/v2-moved-instructions.ts | 58 ++++++ .../src/web/components/v2-moved-notice.tsx | 11 +- apps/cloud/src/web/pages/setup-mcp.tsx | 173 ++++++++++-------- apps/cloud/src/web/shell.tsx | 32 ++-- apps/cloud/src/web/v2-mcp-move.ts | 21 +++ apps/cloud/wrangler.jsonc | 4 + packages/hosts/mcp/src/tool-server.ts | 24 ++- packages/react/src/api/mcp-server-move.tsx | 32 ++++ .../react/src/components/mcp-install-card.tsx | 141 +++++++++++--- 14 files changed, 452 insertions(+), 137 deletions(-) create mode 100644 apps/cloud/src/mcp/v2-moved-instructions.ts create mode 100644 apps/cloud/src/web/v2-mcp-move.ts create mode 100644 packages/react/src/api/mcp-server-move.tsx diff --git a/apps/cloud/src/auth/api.ts b/apps/cloud/src/auth/api.ts index 47ad43cfa7..f00223d390 100644 --- a/apps/cloud/src/auth/api.ts +++ b/apps/cloud/src/auth/api.ts @@ -41,10 +41,14 @@ const AuthOrganizationsResponse = Schema.Struct({ // `moved` is set instead when the org has already been imported (v2 records // the move on the WorkOS organization): where it lives in v2 and when it // moved. It drives a notice only; the org keeps working in v1. +// +// `v2McpUrl` is the org's MCP server in v2, for agents to reconnect to. It is +// omitted where v1 is not configured with v2's MCP origin. const V2MovedResponse = Schema.Struct({ v2Url: Schema.String, v2Slug: Schema.String, movedAt: Schema.String, + v2McpUrl: Schema.optionalKey(Schema.String), }); const V2ImportResponse = Schema.Struct({ diff --git a/apps/cloud/src/auth/v2-import.ts b/apps/cloud/src/auth/v2-import.ts index 10a811dd3b..661789d41b 100644 --- a/apps/cloud/src/auth/v2-import.ts +++ b/apps/cloud/src/auth/v2-import.ts @@ -32,6 +32,29 @@ export const v2AppOrigin = (env: V2EdgeEnv): URL | null => { return new URL(edge.signUpUrl.origin); }; +/** v1's settings for where an organization lives in v2: the edge's, plus + * v2's MCP origin. */ +export interface V2MovedEnv extends V2EdgeEnv { + /** v2's MCP origin (production: `https://mcp.executor.sh`). */ + readonly V2_MCP_ORIGIN?: string; +} + +/** + * v2's MCP origin, or `null` when `V2_MCP_ORIGIN` is unset or is not a bare + * http(s) origin. A moved org's MCP URL is shown only from this setting, never + * guessed from another host. + */ +export const v2McpOrigin = (env: V2MovedEnv): URL | null => { + const raw = env.V2_MCP_ORIGIN; + if (raw === undefined) return null; + const parsed = URL.parse(raw); + if (parsed === null || (parsed.protocol !== "https:" && parsed.protocol !== "http:")) { + return null; + } + if (parsed.href !== `${parsed.origin}/`) return null; + return new URL(parsed.origin); +}; + /** The absolute URL of v2's import page, or `null` where v1 has no v2 host. */ export const v2ImportUrl = (env: V2EdgeEnv): URL | null => { const origin = v2AppOrigin(env); @@ -123,7 +146,7 @@ export interface V2ImportAnswer { * its eligibility is never read. Never fails. */ export const v2ImportAnswer = (input: { - readonly env: V2EdgeEnv; + readonly env: V2MovedEnv; readonly importSource: Effect.Effect; readonly movedSource: Effect.Effect; readonly userId: string; @@ -133,7 +156,12 @@ export const v2ImportAnswer = (input: { const v2Origin = v2AppOrigin(input.env); const importUrl = v2ImportUrl(input.env); if (v2Origin === null || importUrl === null) return { url: null, moved: null }; - const moved = yield* v2Moved(yield* input.movedSource, input.organizationId, v2Origin); + const moved = yield* v2Moved( + yield* input.movedSource, + input.organizationId, + v2Origin, + v2McpOrigin(input.env), + ); if (moved !== null) return { url: null, moved }; const eligible = yield* v2ImportEligible( yield* input.importSource, diff --git a/apps/cloud/src/auth/v2-moved.ts b/apps/cloud/src/auth/v2-moved.ts index 4d8da5c811..af8a410954 100644 --- a/apps/cloud/src/auth/v2-moved.ts +++ b/apps/cloud/src/auth/v2-moved.ts @@ -31,6 +31,10 @@ export interface V2Moved { readonly v2Slug: string; /** When the import finished, normalized to `Date#toISOString`. */ readonly movedAt: string; + /** The v2 organization's MCP server, `/org//mcp`: the + * org-pinned form, the one-to-one counterpart of v1's `//mcp`. Absent + * where v1 does not know v2's MCP origin (`V2_MCP_ORIGIN` unset). */ + readonly v2McpUrl?: string; } /** The move recorded in an organization's metadata, or `null` when there is @@ -38,6 +42,7 @@ export interface V2Moved { export const parseV2Moved = ( metadata: Readonly>, v2Origin: URL, + v2McpOrigin: URL | null, ): V2Moved | null => { const slug = metadata[V2_MOVED_SLUG_KEY]; const movedAt = metadata[V2_MOVED_AT_KEY]; @@ -51,6 +56,7 @@ export const parseV2Moved = ( v2Url: new URL(`/org/${slug}`, v2Origin).href, v2Slug: slug, movedAt: new Date(time).toISOString(), + ...(v2McpOrigin === null ? {} : { v2McpUrl: new URL(`/org/${slug}/mcp`, v2McpOrigin).href }), }; }; @@ -67,9 +73,10 @@ export const v2Moved = ( source: V2MovedSource, organizationId: string, v2Origin: URL, + v2McpOrigin: URL | null, ): Effect.Effect => source.organizationMetadata(organizationId).pipe( - Effect.map((metadata) => parseV2Moved(metadata, v2Origin)), + Effect.map((metadata) => parseV2Moved(metadata, v2Origin, v2McpOrigin)), Effect.orElseSucceed(() => null), Effect.withSpan("v2Moved.read"), ); diff --git a/apps/cloud/src/env-augment.d.ts b/apps/cloud/src/env-augment.d.ts index 3e1fb0d8c1..0b3d15308b 100644 --- a/apps/cloud/src/env-augment.d.ts +++ b/apps/cloud/src/env-augment.d.ts @@ -143,6 +143,8 @@ declare global { V2?: Fetcher; /** Absolute URL `/sign-up` and `/signup` redirect to. */ V2_SIGN_UP_URL?: string; + /** v2's MCP origin; a moved org's MCP URL is `/org//mcp`. */ + V2_MCP_ORIGIN?: string; /** Prefix of v2's connected-account OAuth `state`; `/api/oauth/callback` * requests whose state starts with it go to v2. */ V2_OAUTH_STATE_PREFIX?: string; diff --git a/apps/cloud/src/mcp/session-durable-object.ts b/apps/cloud/src/mcp/session-durable-object.ts index 81d9b50d4e..7a7ebb5aa5 100644 --- a/apps/cloud/src/mcp/session-durable-object.ts +++ b/apps/cloud/src/mcp/session-durable-object.ts @@ -65,6 +65,7 @@ import { acquireBuildSlot, type BuildSlotHandle } from "./session-build-semaphor import { CoreSharedServices } from "../auth/workos"; import { UserStoreService } from "../auth/context"; import { resolveSessionMetaForToken } from "./session-meta"; +import { v2MovedMcpInstructions } from "./v2-moved-instructions"; import { DbService, combinedSchema, @@ -344,23 +345,33 @@ export class McpSessionDOSqlite extends McpAgentSessionDOBase, ); - const { executor, engine } = yield* makeExecutionStack( - sessionMeta.userId, - sessionMeta.organizationId, - sessionMeta.organizationName, - { - mcpResource: sessionMeta.resource, - orgWrites: "request", - }, - ).pipe( - // The metered stack tracks each execution to Autumn. It requires - // `AutumnService | DbService`; `AutumnService.Default` is provided here - // (it only reads `env`, no further deps), and `DbService` flows from the - // outer `makeSessionServices`. When `AUTUMN_SECRET_KEY` is unset the - // billing service degrades to a no-op tracker, so this stays inert in - // cloud dev/preview environments that run without a billing backend. - Effect.provide(CloudMeteredExecutionStackLayer.pipe(Layer.provide(AutumnService.Default))), - Effect.withSpan("McpSessionDOSqlite.makeExecutionStack"), + // The org's move to v2, read beside the stack build so it adds no + // latency: a moved org's sessions tell the agent where to reconnect. + const [{ executor, engine }, hostInstructions] = yield* Effect.all( + [ + makeExecutionStack( + sessionMeta.userId, + sessionMeta.organizationId, + sessionMeta.organizationName, + { + mcpResource: sessionMeta.resource, + orgWrites: "request", + }, + ).pipe( + // The metered stack tracks each execution to Autumn. It requires + // `AutumnService | DbService`; `AutumnService.Default` is provided here + // (it only reads `env`, no further deps), and `DbService` flows from the + // outer `makeSessionServices`. When `AUTUMN_SECRET_KEY` is unset the + // billing service degrades to a no-op tracker, so this stays inert in + // cloud dev/preview environments that run without a billing backend. + Effect.provide( + CloudMeteredExecutionStackLayer.pipe(Layer.provide(AutumnService.Default)), + ), + Effect.withSpan("McpSessionDOSqlite.makeExecutionStack"), + ), + v2MovedMcpInstructions(env, sessionMeta.organizationId), + ], + { concurrency: 2 }, ); // Build the description here so `executor.connections.list()` stays under // the DO startup span and the MCP SDK receives a concrete string instead @@ -401,6 +412,7 @@ export class McpSessionDOSqlite extends McpAgentSessionDOBase self.persistAppsEnabled(appsEnabled), + hostInstructions, loadAppShellHtml, smokeRenderArtifact: smokeRenderArtifactAfterQuickJsPreload, artifactUrl: artifactUrlFor( diff --git a/apps/cloud/src/mcp/v2-moved-instructions.ts b/apps/cloud/src/mcp/v2-moved-instructions.ts new file mode 100644 index 0000000000..23eb2f2707 --- /dev/null +++ b/apps/cloud/src/mcp/v2-moved-instructions.ts @@ -0,0 +1,58 @@ +// --------------------------------------------------------------------------- +// What a moved organization's MCP sessions tell the connected agent. +// +// An organization v2 has imported keeps working in v1, MCP included, so an +// agent connected here would never learn it moved. Its sessions carry MCP +// server `instructions` saying where the org's MCP server is now. Tools behave +// exactly as before. +// +// The move is read once per session build (an `initialize`, or a cold restore +// of an evicted session), never per request, and in parallel with the +// execution stack. The read is bounded: a slow or failing WorkOS answers "not +// moved", so it can neither fail nor stall a session. +// --------------------------------------------------------------------------- + +import { Effect } from "effect"; + +import { v2AppOrigin, v2McpOrigin, type V2MovedEnv } from "../auth/v2-import"; +import { liveV2MovedSource, v2Moved, type V2Moved } from "../auth/v2-moved"; +import type { WorkOSClient } from "../auth/workos"; + +/** Longest the move read may add to a session build. */ +export const V2_MOVED_READ_TIMEOUT_MS = 1_000; + +const movedDate = (movedAt: string): string => + new Intl.DateTimeFormat("en-US", { + month: "long", + day: "numeric", + year: "numeric", + timeZone: "UTC", + }).format(new Date(movedAt)); + +/** The instructions for a moved org, or `null` without a v2 MCP URL. */ +export const v2MovedInstructionsText = (moved: V2Moved): string | null => + moved.v2McpUrl === undefined + ? null + : `This organization moved to Executor v2 on ${movedDate(moved.movedAt)}. ` + + `Its MCP server is now ${moved.v2McpUrl}; ask the user to reconnect their agent there. ` + + "Changes made through this server won't reach v2."; + +/** The session's instructions when its organization has moved to v2 and v1 + * knows v2's MCP origin; otherwise `undefined`. Never fails. */ +export const v2MovedMcpInstructions = ( + env: V2MovedEnv, + organizationId: string, +): Effect.Effect => + Effect.gen(function* () { + const appOrigin = v2AppOrigin(env); + const mcpOrigin = v2McpOrigin(env); + if (appOrigin === null || mcpOrigin === null) return undefined; + const moved = yield* v2Moved(yield* liveV2MovedSource, organizationId, appOrigin, mcpOrigin); + return (moved === null ? null : v2MovedInstructionsText(moved)) ?? undefined; + }).pipe( + Effect.timeoutOrElse({ + duration: `${V2_MOVED_READ_TIMEOUT_MS} millis`, + orElse: () => Effect.succeed(undefined), + }), + Effect.withSpan("mcp.session.v2_moved_instructions"), + ); diff --git a/apps/cloud/src/web/components/v2-moved-notice.tsx b/apps/cloud/src/web/components/v2-moved-notice.tsx index 4c7f981e3d..5452ede03f 100644 --- a/apps/cloud/src/web/components/v2-moved-notice.tsx +++ b/apps/cloud/src/web/components/v2-moved-notice.tsx @@ -12,6 +12,7 @@ import { DialogTitle, } from "@executor-js/react/components/dialog"; import { Label } from "@executor-js/react/components/label"; +import { McpServerUrl } from "@executor-js/react/components/mcp-install-card"; import { Textarea } from "@executor-js/react/components/textarea"; import { useAuth, v2ImportAtom } from "../auth"; import { @@ -28,7 +29,8 @@ import { // once per dashboard load unless snoozed for this org in this browser: // - ✕ / Escape / opening v2 hidden for 7 days, nothing asked // - "Keep using v1" step 2 asks why; sending hides it for 30 days -// v1 keeps working for a moved org; this only tells the user where it went. +// v1 keeps working for a moved org; this only tells the user where it went, +// and where its agents should connect (the org's v2 MCP URL, when known). // --------------------------------------------------------------------------- type Step = "notice" | "feedback"; @@ -131,6 +133,13 @@ export function V2MovedNotice() { Changes made here won't sync to v2. + {moved.v2McpUrl !== undefined && ( +
+

Connect your agents to v2 instead:

+ +
+ )} +