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: +

+ +
+ )} +