diff --git a/apps/cloud/src/auth/api.ts b/apps/cloud/src/auth/api.ts
index 47ad43cfa7..c5d142b9cf 100644
--- a/apps/cloud/src/auth/api.ts
+++ b/apps/cloud/src/auth/api.ts
@@ -41,10 +41,15 @@ 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 v2's MCP server, for the org's agents to reconnect to; they
+// choose the org (`v2Slug`) when signing in. 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..e013d49cb1 100644
--- a/apps/cloud/src/auth/v2-moved.ts
+++ b/apps/cloud/src/auth/v2-moved.ts
@@ -31,6 +31,12 @@ export interface V2Moved {
readonly v2Slug: string;
/** When the import finished, normalized to `Date#toISOString`. */
readonly movedAt: string;
+ /** v2's MCP server, `/mcp`, for the moved org's agents. Not
+ * the org-pinned `/org//mcp`: that path only accepts personal access
+ * tokens, since v2's OAuth resource is `/mcp` and the organization is chosen
+ * on its consent screen (as `v2Slug`). 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 +44,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 +58,7 @@ export const parseV2Moved = (
v2Url: new URL(`/org/${slug}`, v2Origin).href,
v2Slug: slug,
movedAt: new Date(time).toISOString(),
+ ...(v2McpOrigin === null ? {} : { v2McpUrl: new URL("/mcp", v2McpOrigin).href }),
};
};
@@ -67,9 +75,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..5690fe2574 100644
--- a/apps/cloud/src/env-augment.d.ts
+++ b/apps/cloud/src/env-augment.d.ts
@@ -135,6 +135,9 @@ declare global {
/** Test-only override for the isolate-wide resident-runtime soft cap
* (see `RESIDENT_RUNTIME_SOFT_CAP`). Unset in production. */
MCP_RESIDENT_RUNTIME_SOFT_CAP?: string;
+ /** Test-only override for how often a resident MCP session re-reads its
+ * org's move to v2 (see `V2_MOVED_RECHECK_MS`). Unset in production. */
+ MCP_V2_MOVED_RECHECK_MS?: string;
NODE_ENV?: string;
// v2 on executor.sh (wrangler.jsonc `services` + `vars`). Optional so
@@ -143,6 +146,9 @@ 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 `/mcp`, and its
+ * agents choose the org when they sign in through OAuth. */
+ 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..b4588a011e 100644
--- a/apps/cloud/src/mcp/session-durable-object.ts
+++ b/apps/cloud/src/mcp/session-durable-object.ts
@@ -65,6 +65,11 @@ import { acquireBuildSlot, type BuildSlotHandle } from "./session-build-semaphor
import { CoreSharedServices } from "../auth/workos";
import { UserStoreService } from "../auth/context";
import { resolveSessionMetaForToken } from "./session-meta";
+import {
+ V2_MOVED_RECHECK_MS,
+ mustReinitializeForV2Move,
+ v2MovedMcpInstructions,
+} from "./v2-moved-instructions";
import {
DbService,
combinedSchema,
@@ -100,6 +105,9 @@ export type {
const LONG_LIVED_DB_IDLE_TIMEOUT_SECONDS = 5;
const LONG_LIVED_DB_MAX_LIFETIME_SECONDS = 120;
const TELEMETRY_FLUSH_TIMEOUT_MS = 1_000;
+/** The v2-move notice this session's client got in its `initialize` result;
+ * absent when it got none. */
+const V2_MOVE_NOTICE_AT_INITIALIZE_KEY = "v2-move-notice-at-initialize";
const positiveMilliseconds = (raw: string | undefined): number | undefined => {
if (!raw) return undefined;
@@ -197,6 +205,85 @@ export class McpSessionDOSqlite extends McpAgentSessionDOBase = {};
+ /** The org's v2-move notice as last read: the one this runtime's server
+ * sends at `initialize`, or a fresher re-read. `null` before a build. */
+ private v2MoveNotice: {
+ readonly organizationId: string;
+ readonly text: string | undefined;
+ readonly readAtMs: number;
+ } | null = null;
+
+ /** What this session's client got at `initialize`, once known. */
+ private deliveredV2MoveNotice: { readonly text: string | undefined } | null = null;
+
+ protected override onClientInitialize(): Effect.Effect {
+ const text = this.v2MoveNotice?.text;
+ this.deliveredV2MoveNotice = { text };
+ const storage = this.ctx.storage;
+ return Effect.tryPromise({
+ try: () =>
+ text === undefined
+ ? storage.delete(V2_MOVE_NOTICE_AT_INITIALIZE_KEY).then(() => undefined)
+ : storage.put(V2_MOVE_NOTICE_AT_INITIALIZE_KEY, text),
+ catch: (cause: unknown) => cause,
+ }).pipe(
+ // A lost write only costs one extra reconnect: a later restore reads "no
+ // notice" and, if the org has moved, ends the session.
+ Effect.ignore,
+ Effect.withSpan("McpSessionDOSqlite.recordV2MoveNoticeAtInitialize"),
+ );
+ }
+
+ /**
+ * A session whose client initialized without the org's current v2-move
+ * notice (it connected before the move) must re-initialize to receive it.
+ * Uses the read the runtime build made; re-reads WorkOS only when that read
+ * is older than `V2_MOVED_RECHECK_MS`, so a request never waits on WorkOS
+ * more than once per window.
+ */
+ protected override clientMustReinitialize(): Effect.Effect {
+ const self = this;
+ return Effect.gen(function* () {
+ const current = yield* self.currentV2MoveNotice();
+ if (current === undefined) return false;
+ const delivered = yield* self.loadDeliveredV2MoveNotice();
+ return mustReinitializeForV2Move(delivered, current);
+ }).pipe(Effect.withSpan("McpSessionDOSqlite.clientMustReinitialize"));
+ }
+
+ private v2MovedRecheckMs(): number {
+ return positiveMilliseconds(env.MCP_V2_MOVED_RECHECK_MS) ?? V2_MOVED_RECHECK_MS;
+ }
+
+ private currentV2MoveNotice(): Effect.Effect {
+ const self = this;
+ return Effect.gen(function* () {
+ const known = self.v2MoveNotice;
+ if (known === null) return undefined;
+ if (Date.now() - known.readAtMs < self.v2MovedRecheckMs()) return known.text;
+ const text = yield* v2MovedMcpInstructions(env, known.organizationId).pipe(
+ Effect.provide(CoreSharedServices),
+ // A WorkOS client that cannot be configured answers "not moved", like
+ // every other failed read.
+ Effect.orElseSucceed(() => undefined),
+ );
+ self.v2MoveNotice = { organizationId: known.organizationId, text, readAtMs: Date.now() };
+ return text;
+ });
+ }
+
+ private loadDeliveredV2MoveNotice(): Effect.Effect {
+ const self = this;
+ return Effect.gen(function* () {
+ if (self.deliveredV2MoveNotice) return self.deliveredV2MoveNotice.text;
+ const text = yield* Effect.promise(() =>
+ self.ctx.storage.get(V2_MOVE_NOTICE_AT_INITIALIZE_KEY),
+ );
+ self.deliveredV2MoveNotice = { text };
+ return text;
+ });
+ }
+
protected override sessionFootprintAttributes(): Record {
return this.lastSessionFootprint;
}
@@ -344,24 +431,41 @@ 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,
+ // and a session whose client was not told is ended (see
+ // `clientMustReinitialize`).
+ 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 },
);
+ self.v2MoveNotice = {
+ organizationId: sessionMeta.organizationId,
+ text: hostInstructions,
+ readAtMs: Date.now(),
+ };
// Build the description here so `executor.connections.list()` stays under
// the DO startup span and the MCP SDK receives a concrete string instead
// of invoking `engine.getDescription` across its async boundary.
@@ -401,6 +505,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..80892977ff
--- /dev/null
+++ b/apps/cloud/src/mcp/v2-moved-instructions.ts
@@ -0,0 +1,82 @@
+// ---------------------------------------------------------------------------
+// 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), in parallel with the execution stack, and again at
+// most once per `V2_MOVED_RECHECK_MS` while a runtime stays in memory. Never
+// per request. The read is bounded: a slow or failing WorkOS answers "not
+// moved", so it can neither fail nor stall a session.
+//
+// Instructions only reach a client in the `initialize` result, so a session
+// whose client was told something else (it initialized before the move) is
+// ended, and its client starts a new session that carries them. See
+// `mustReinitializeForV2Move`.
+// ---------------------------------------------------------------------------
+
+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;
+
+/** How old a resident session's move read may get before a request re-reads
+ * it. Bounds how long a session that is in constant use can go on without
+ * learning its org moved. */
+export const V2_MOVED_RECHECK_MS = 5 * 60 * 1000;
+
+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 ` +
+ `and choose the ${moved.v2Slug} organization when signing in. ` +
+ "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"),
+ );
+
+/**
+ * Whether a session's client must re-initialize to learn of the move: the org
+ * has moved (`current`), and the `initialize` its client got said something
+ * else (`delivered`, `undefined` for no notice). A session that already carries
+ * the current notice is left alone, so a reconnected client is never ended
+ * again, and an unknown or failed read (`current` undefined) never ends one.
+ */
+export const mustReinitializeForV2Move = (
+ delivered: string | undefined,
+ current: string | undefined,
+): boolean => current !== undefined && current !== delivered;
diff --git a/apps/cloud/src/web/components/v2-moved-notice.tsx b/apps/cloud/src/web/components/v2-moved-notice.tsx
index 4c7f981e3d..c0a049d016 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,16 @@ export function V2MovedNotice() {
Changes made here won't sync to v2.
+ {moved.v2McpUrl !== undefined && (
+
+ {/* The slug can be 45 characters with no break opportunity. */}
+
+ Connect your agents to v2 instead, and choose {moved.v2Slug} when you sign in:
+
+
+
+ )}
+
Connect your MCP client
- Executor exposes your sources, secrets, and tools to any MCP-compatible agent. Copy the
- URL into your client, or run the install command.
+ {move !== null
+ ? `This organization moved to ${move.destination}. Connect your agents there instead:`
+ : "Executor exposes your sources, secrets, and tools to any MCP-compatible agent. Copy the URL into your client, or run the install command."}
{/* oxlint-disable-next-line react/forbid-elements */}
diff --git a/apps/cloud/src/web/shell.tsx b/apps/cloud/src/web/shell.tsx
index 8cb2265204..4eade726cc 100644
--- a/apps/cloud/src/web/shell.tsx
+++ b/apps/cloud/src/web/shell.tsx
@@ -3,11 +3,13 @@ import type React from "react";
import { Shell as SharedShell, defaultShellNavItems } from "@executor-js/react/multiplayer/shell";
import { useAdminNavItems } from "@executor-js/react/multiplayer/use-admin-nav";
import { trackEvent } from "@executor-js/react/api/analytics";
+import { McpServerMoveProvider } from "@executor-js/react/api/mcp-server-move";
import { AUTH_PATHS } from "../auth/api";
import { OrgMenuSlot } from "./components/org-menu-slot";
import { SupportSlot } from "./components/support-slot";
import { V2ImportLink } from "./components/v2-import-link";
import { V2MovedNotice } from "./components/v2-moved-notice";
+import { useV2McpMove } from "./v2-mcp-move";
// ---------------------------------------------------------------------------
// Cloud shell — the SHARED multiplayer shell, identical to self-host, with
@@ -15,6 +17,7 @@ import { V2MovedNotice } from "./components/v2-moved-notice";
// - sign-out navigate through cloud's WorkOS logout, land home
// - nav items defaults + Organization + Billing (cloud-only sections)
// - org menu slot multi-org switcher + create-org dialog (cloud-only)
+// - MCP server move a moved org's connect card leads with its v2 MCP URL
// - support slot the "Move to Executor v2" link, shown only to users
// v2 would let import this org ("Open in Executor v2"
// once it has moved, with the moved notice), and the
@@ -52,19 +55,22 @@ const signOut = () => {
export function Shell(props: { readonly content?: React.ReactNode }) {
const items = useAdminNavItems(navItems, adminNavItems);
+ const mcpMove = useV2McpMove();
return (
- }
- supportSlot={
- <>
-
-
-
- >
- }
- content={props.content}
- />
+
+ }
+ supportSlot={
+ <>
+
+
+
+ >
+ }
+ content={props.content}
+ />
+
);
}
diff --git a/apps/cloud/src/web/v2-mcp-move.ts b/apps/cloud/src/web/v2-mcp-move.ts
new file mode 100644
index 0000000000..1f1c4fb3d5
--- /dev/null
+++ b/apps/cloud/src/web/v2-mcp-move.ts
@@ -0,0 +1,27 @@
+import { useMemo } from "react";
+import { useAtomValue } from "@effect/atom-react";
+import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
+import type { McpServerMove } from "@executor-js/react/api/mcp-server-move";
+
+import { v2ImportAtom } from "./auth";
+
+// ---------------------------------------------------------------------------
+// The selected org's MCP server in Executor v2, for the shared connect surfaces
+// (`McpInstallCard`, the onboarding setup page). Set only for an org v2 has
+// imported, and only where the server knows v2's MCP origin (`v2McpUrl`).
+// v2's MCP URL names no organization: agents choose it (`v2Slug`) at sign-in.
+// ---------------------------------------------------------------------------
+
+export function useV2McpMove(): McpServerMove | null {
+ const result = useAtomValue(v2ImportAtom);
+ const moved = AsyncResult.isSuccess(result) ? result.value.moved : null;
+ const url = moved?.v2McpUrl;
+ const organization = moved?.v2Slug;
+ return useMemo(
+ () =>
+ url === undefined || organization === undefined
+ ? null
+ : { url, destination: "Executor v2", previous: "v1", organization },
+ [url, organization],
+ );
+}
diff --git a/apps/cloud/wrangler.jsonc b/apps/cloud/wrangler.jsonc
index 21cba7aac1..0bb910198a 100644
--- a/apps/cloud/wrangler.jsonc
+++ b/apps/cloud/wrangler.jsonc
@@ -148,6 +148,11 @@
"VITE_PUBLIC_SITE_URL": "https://executor.sh",
// Where /sign-up and /signup redirect: v2's sign-up page on its app host.
"V2_SIGN_UP_URL": "https://app.executor.sh/login?mode=signup",
+ // v2's MCP origin. A moved org's dashboard and its v1 MCP sessions point
+ // agents at `/mcp` and name the v2 org to choose when they sign
+ // in through OAuth. Dashboard only: the front
+ // Worker (wrangler.edge.jsonc) does not need it.
+ "V2_MCP_ORIGIN": "https://mcp.executor.sh",
// v2 starts every connected-account OAuth `state` with this prefix, and
// /api/oauth/callback requests carrying it go to v2. It must equal the
// prefix v2 mints.
diff --git a/packages/hosts/cloudflare/src/mcp/agent-session-durable-object.ts b/packages/hosts/cloudflare/src/mcp/agent-session-durable-object.ts
index 805dd14d16..8260c2754b 100644
--- a/packages/hosts/cloudflare/src/mcp/agent-session-durable-object.ts
+++ b/packages/hosts/cloudflare/src/mcp/agent-session-durable-object.ts
@@ -3,6 +3,7 @@ import type * as Tracer from "effect/Tracer";
import type { Connection, ConnectionContext } from "agents";
import { McpAgent } from "agents/mcp";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
+import type { JSONRPCMessage } from "@modelcontextprotocol/sdk/types.js";
import { RequestOrgSlug, RequestWebOrigin } from "@executor-js/api/server";
import {
@@ -1598,6 +1599,70 @@ export abstract class McpAgentSessionDOBase<
);
}
+ /**
+ * A client's live `initialize` reached this session. The agents SDK stores
+ * the request here only for a real `initialize` POST, and the session's
+ * runtime is already built by then (`setName` starts it), so the server
+ * answering this `initialize` is the one in memory now. A cold restore
+ * replays the stored request straight into the transport and never calls
+ * this, which is what makes it the record of what the client was told.
+ */
+ override async setInitializeRequest(initializeRequest: JSONRPCMessage): Promise {
+ await super.setInitializeRequest(initializeRequest);
+ await Effect.runPromise(this.onClientInitialize());
+ }
+
+ /** Hook for {@link setInitializeRequest}: record what this `initialize`
+ * tells the client. Must not fail. */
+ protected onClientInitialize(): Effect.Effect {
+ return Effect.void;
+ }
+
+ /**
+ * Whether the client must start a new session because something its
+ * `initialize` result said is now out of date. Asked on every request for an
+ * existing session, after its runtime is restored, so a host must keep it
+ * cheap (no per-request remote read). Must not fail. Never, by default.
+ */
+ protected clientMustReinitialize(): Effect.Effect {
+ return Effect.succeed(false);
+ }
+
+ /**
+ * End this session so its client re-initializes, when
+ * {@link clientMustReinitialize} says so. Ending is how MCP's Streamable HTTP
+ * transport tells a client to start over: a request for an ended session
+ * gets HTTP 404, and the client must send a new `initialize`.
+ *
+ * It waits while the session holds work a new session cannot pick up: a
+ * paused execution (an approval or elicitation still open) or a request
+ * still running. Those finish in this session, and a later request ends it.
+ * The end itself is the DELETE path's `_cf_scheduleDestroy`.
+ */
+ private endForReinitialize(): Effect.Effect {
+ const self = this;
+ return Effect.gen(function* () {
+ if (!(yield* self.clientMustReinitialize())) return false;
+ const [pausedExecutionCount, runningExecutionCount] = yield* Effect.promise(() =>
+ Promise.all([self.pausedExecutionCount(), self.runningExecutionCount()]),
+ );
+ const busy = pausedExecutionCount > 0 || runningExecutionCount > 0;
+ yield* Effect.annotateCurrentSpan({
+ "mcp.session.reinitialize_required": true,
+ "mcp.session.reinitialize_deferred": busy,
+ });
+ if (busy) return false;
+ console.info(
+ JSON.stringify({
+ event: "mcp_session_ended_for_reinitialize",
+ sessionId: self.sessionIdForTelemetry(),
+ }),
+ );
+ yield* Effect.promise(() => self._cf_scheduleDestroy());
+ return true;
+ }).pipe(Effect.withSpan("McpSessionDO.endForReinitialize"));
+ }
+
async validateMcpSessionOwner(
identity: McpApprovalOwner,
resource: McpResource,
@@ -1633,7 +1698,10 @@ export abstract class McpAgentSessionDOBase<
identity.accountId === sessionMeta.userId &&
identity.organizationId === sessionMeta.organizationId;
const resourceMatches = mcpResourceKey(resource) === mcpResourceKey(sessionMeta.resource);
- return ownerMatches && resourceMatches ? ("ok" as const) : ("forbidden" as const);
+ if (!ownerMatches || !resourceMatches) return "forbidden" as const;
+ // Only the session's own owner can end it this way.
+ if (yield* self.endForReinitialize()) return "terminated" as const;
+ return "ok" as const;
}).pipe(
Effect.withSpan("McpSessionDO.validateMcpSessionOwner"),
// oxlint-disable-next-line executor/no-effect-escape-hatch -- boundary: DO RPC exposes Promise results
diff --git a/packages/hosts/mcp/src/tool-server.ts b/packages/hosts/mcp/src/tool-server.ts
index 91562be746..94ab6e183b 100644
--- a/packages/hosts/mcp/src/tool-server.ts
+++ b/packages/hosts/mcp/src/tool-server.ts
@@ -287,6 +287,13 @@ type SharedMcpServerConfig = {
* restore. Best-effort: failures are swallowed and never affect the session.
*/
readonly onAppsEnabledChange?: (appsEnabled: boolean) => Effect.Effect;
+ /**
+ * Host guidance for the connected agent, sent in the `initialize` result's
+ * `instructions` ahead of any mode guidance. Hosts use it for facts about
+ * this connection the agent should relay to the user (for example that the
+ * organization's MCP server has moved). It changes no tool behaviour.
+ */
+ readonly hostInstructions?: string;
};
/**
@@ -330,6 +337,17 @@ export class McpPassthroughUnavailableError extends Data.TaggedError(
"McpPassthroughUnavailableError",
)<{ readonly reason: string }> {}
+/** The `initialize` instructions: the host's guidance, then passthrough's. */
+const serverInstructions = (
+ hostInstructions: string | undefined,
+ passthrough: boolean,
+): { readonly instructions?: string } => {
+ const parts = [hostInstructions, passthrough ? passthroughInstructions() : undefined].filter(
+ (part): part is string => part !== undefined && part !== "",
+ );
+ return parts.length === 0 ? {} : { instructions: parts.join("\n\n") };
+};
+
export type ExecutorMcpServerConfig =
| (ExecutionEngineConfig & SharedMcpServerConfig)
| ({ readonly engine: ExecutionEngine } & SharedMcpServerConfig)
@@ -1653,11 +1671,7 @@ export const createExecutorMcpServer = (
// per host.
capabilities: { resources: {}, tools: {} },
jsonSchemaValidator: new CfWorkerJsonSchemaValidator(),
- ...(passthrough
- ? {
- instructions: passthroughInstructions(),
- }
- : {}),
+ ...serverInstructions(config.hostInstructions, passthrough),
},
),
).pipe(Effect.withSpan("mcp.host.create_server"));
diff --git a/packages/react/src/api/mcp-server-move.tsx b/packages/react/src/api/mcp-server-move.tsx
new file mode 100644
index 0000000000..6aae814bb0
--- /dev/null
+++ b/packages/react/src/api/mcp-server-move.tsx
@@ -0,0 +1,35 @@
+import * as React from "react";
+
+// Where the active organization's MCP server now lives, when the host knows
+// the organization has moved to another deployment (cloud: an org imported
+// into Executor v2). Hosts without moves leave it unset, and the connect
+// surfaces show only this server. Purely a UI hint for which URL to hand
+// agents; this server keeps answering either way.
+export interface McpServerMove {
+ /** The MCP server URL agents should connect to instead of this one. */
+ readonly url: string;
+ /** Where the organization moved, as the user knows it (e.g. "Executor v2"). */
+ readonly destination: string;
+ /** This deployment, as the user knows it (e.g. "v1"). */
+ readonly previous: string;
+ /** The organization to choose when signing in to the new server, whose
+ * OAuth sign-in picks the organization rather than the URL. */
+ readonly organization: string;
+}
+
+const McpServerMoveContext = React.createContext(null);
+
+export function McpServerMoveProvider(
+ props: React.PropsWithChildren<{ readonly move: McpServerMove | null }>,
+) {
+ return (
+
+ {props.children}
+
+ );
+}
+
+/** The active organization's moved MCP server, or `null` when it has not moved. */
+export function useMcpServerMove(): McpServerMove | null {
+ return React.useContext(McpServerMoveContext);
+}
diff --git a/packages/react/src/components/mcp-install-card.tsx b/packages/react/src/components/mcp-install-card.tsx
index eac93d9b1f..2beed96e42 100644
--- a/packages/react/src/components/mcp-install-card.tsx
+++ b/packages/react/src/components/mcp-install-card.tsx
@@ -6,6 +6,7 @@ import ClaudeIcon from "@lobehub/icons/es/Claude/components/Color";
import OpenCodeIcon from "@lobehub/icons/es/OpenCode/components/Mono";
import { ChevronDown } from "lucide-react";
import { CodeBlock } from "./code-block";
+import { CopyButton } from "./copy-button";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "./tabs";
import { CardStack, CardStackHeader, CardStackContent } from "./card-stack";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "./collapsible";
@@ -13,6 +14,7 @@ import { NativeSelect, NativeSelectOption } from "./native-select";
import { Switch } from "./switch";
import { cn } from "../lib/utils";
import { useOrganizationSlug } from "../api/organization-context";
+import { useMcpServerMove, type McpServerMove } from "../api/mcp-server-move";
import {
getExecutorServerAuthorizationHeader,
useExecutorServerConnection,
@@ -160,6 +162,10 @@ export const buildMcpHttpEndpoint = (input: {
return url.toString();
};
+/** The plain `npx add-mcp` command for a remote MCP server URL. */
+export const buildMcpUrlInstallCommand = (url: string): string =>
+ `npx add-mcp ${shellQuoteWord(url)} --transport http --name executor`;
+
export const buildMcpInstallCommand = (input: {
readonly mode: TransportMode;
readonly isDev: boolean;
@@ -190,10 +196,7 @@ export const buildMcpInstallCommand = (input: {
if (input.authorizationHeader) {
headerFlags.push(`--header ${shellQuoteWord(`Authorization: ${input.authorizationHeader}`)}`);
}
- const parts = [
- `npx add-mcp ${shellQuoteWord(endpoint)} --transport http --name executor`,
- ...headerFlags,
- ];
+ const parts = [buildMcpUrlInstallCommand(endpoint), ...headerFlags];
return parts.join(" ");
}
@@ -220,14 +223,92 @@ export const buildMcpInstallCommand = (input: {
return `npx add-mcp ${shellQuoteWord(innerArgs.map(shellQuoteWord).join(" "))} --name executor`;
};
+/** An MCP server URL on one line of mono, wrapped rather than cut, with a copy button. */
+export function McpServerUrl(props: {
+ readonly url: string;
+ readonly muted?: boolean;
+ readonly onCopy?: () => void;
+ readonly className?: string;
+}) {
+ return (
+
+
+ {props.url}
+
+
+
+ );
+}
+
+/**
+ * The connect body for an organization whose MCP server moved: the new URL and
+ * command first, with the organization to choose at sign-in, then this
+ * server's URL, labelled as the old one.
+ */
+export function McpServerMovedConnect(props: {
+ readonly move: McpServerMove;
+ /** This server's MCP URL for the organization. */
+ readonly oldUrl: string;
+ readonly onCopy?: () => void;
+}) {
+ const { move } = props;
+ return (
+
+
+
+ {move.destination} MCP server
+
+
+
+ {/* The organization is a slug of up to 45 characters with no break opportunity. */}
+
+ Choose {move.organization} when you sign in.
+
+
+
+
+ Old {move.previous} URL
+
+
+
+ Won't receive changes made in {move.destination}.
+
+
+
+ );
+}
+
export function McpInstallCard(props: { className?: string }) {
const organizationSlug = useOrganizationSlug();
+ const move = useMcpServerMove();
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
// HTTP path there; it routes through the active sidecar connection.
const showStdio =
- isLocal && serverConnection.kind !== "desktop-sidecar" && !hasDesktopConnectionBridge();
+ move === null &&
+ isLocal &&
+ serverConnection.kind !== "desktop-sidecar" &&
+ !hasDesktopConnectionBridge();
const storageKey = mcpInstallPreferencesStorageKey(organizationSlug);
const [preferences, setPreferences] = useState(() =>
readMcpInstallPreferences(storageKey),
@@ -292,11 +373,13 @@ export function McpInstallCard(props: { className?: string }) {
});
const subtitle =
- mode === "stdio"
- ? isDev
- ? "Uses the repo-local dev CLI from any agent working directory."
- : "Requires the executor CLI on your PATH."
- : "Paste this into Claude Code, Cursor, or any MCP client, and your agent gets every tool you connect here.";
+ move !== null
+ ? `This organization moved to ${move.destination}. Connect your agents there instead:`
+ : mode === "stdio"
+ ? isDev
+ ? "Uses the repo-local dev CLI from any agent working directory."
+ : "Requires the executor CLI on your PATH."
+ : "Paste this into Claude Code, Cursor, or any MCP client, and your agent gets every tool you connect here.";
const advancedControls = (
@@ -444,20 +527,35 @@ export function McpInstallCard(props: { className?: string }) {
const body = (
-