Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions apps/cloud/src/auth/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
32 changes: 30 additions & 2 deletions apps/cloud/src/auth/v2-import.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -123,7 +146,7 @@ export interface V2ImportAnswer {
* its eligibility is never read. Never fails.
*/
export const v2ImportAnswer = <RImport, RMoved>(input: {
readonly env: V2EdgeEnv;
readonly env: V2MovedEnv;
readonly importSource: Effect.Effect<V2ImportSource, never, RImport>;
readonly movedSource: Effect.Effect<V2MovedSource, never, RMoved>;
readonly userId: string;
Expand All @@ -133,7 +156,12 @@ export const v2ImportAnswer = <RImport, RMoved>(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,
Expand Down
11 changes: 10 additions & 1 deletion apps/cloud/src/auth/v2-moved.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,13 +31,20 @@
readonly v2Slug: string;
/** When the import finished, normalized to `Date#toISOString`. */
readonly movedAt: string;
/** v2's MCP server, `<v2 MCP origin>/mcp`, for the moved org's agents. Not
* the org-pinned `/org/<slug>/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
* none or it is malformed. */
export const parseV2Moved = (
metadata: Readonly<Record<string, unknown>>,
v2Origin: URL,
v2McpOrigin: URL | null,
): V2Moved | null => {
const slug = metadata[V2_MOVED_SLUG_KEY];
const movedAt = metadata[V2_MOVED_AT_KEY];
Expand All @@ -51,6 +58,7 @@
v2Url: new URL(`/org/${slug}`, v2Origin).href,
v2Slug: slug,
movedAt: new Date(time).toISOString(),
...(v2McpOrigin === null ? {} : { v2McpUrl: new URL("/mcp", v2McpOrigin).href }),

Check failure on line 61 in apps/cloud/src/auth/v2-moved.ts

View workflow job for this annotation

GitHub Actions / Test

src/auth/v2-moved.test.ts > v2 moved live read > reads the move from the WorkOS organization's metadata

TypeError: Invalid URL ❯ parseV2Moved src/auth/v2-moved.ts:61:49 ❯ src/auth/v2-moved.ts:81:30 ❯ ../../node_modules/.bun/effect@4.0.0-beta.59/node_modules/effect/src/internal/effect.ts:1684:80 ❯ body ../../node_modules/.bun/effect@4.0.0-beta.59/node_modules/effect/src/Utils.ts:242:11 ❯ internalCall ../../node_modules/.bun/effect@4.0.0-beta.59/node_modules/effect/src/internal/effect.ts:1684:60 ❯ Object.~effect/Effect/evaluate ../../node_modules/.bun/effect@4.0.0-beta.59/node_modules/effect/src/internal/core.ts:531:30 ❯ FiberImpl.runLoop ../../node_modules/.bun/effect@4.0.0-beta.59/node_modules/effect/src/internal/effect.ts:633:39 ❯ runLoop ../../node_modules/.bun/effect@4.0.0-beta.59/node_modules/effect/src/internal/effect.ts:593:22 ❯ evaluate ../../node_modules/.bun/effect@4.0.0-beta.59/node_modules/effect/src/internal/effect.ts:5142:8 ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ Serialized Error: { code: 'ERR_INVALID_URL', input: '/mcp' }

Check failure on line 61 in apps/cloud/src/auth/v2-moved.ts

View workflow job for this annotation

GitHub Actions / Test

src/auth/v2-moved.test.ts > v2 moved read > reads the selected org's metadata

TypeError: Invalid URL ❯ parseV2Moved src/auth/v2-moved.ts:61:49 ❯ src/auth/v2-moved.ts:81:30 ❯ ../../node_modules/.bun/effect@4.0.0-beta.59/node_modules/effect/src/internal/effect.ts:1684:80 ❯ body ../../node_modules/.bun/effect@4.0.0-beta.59/node_modules/effect/src/Utils.ts:242:11 ❯ internalCall ../../node_modules/.bun/effect@4.0.0-beta.59/node_modules/effect/src/internal/effect.ts:1684:60 ❯ Object.~effect/Effect/evaluate ../../node_modules/.bun/effect@4.0.0-beta.59/node_modules/effect/src/internal/core.ts:531:30 ❯ FiberImpl.runLoop ../../node_modules/.bun/effect@4.0.0-beta.59/node_modules/effect/src/internal/effect.ts:633:39 ❯ runLoop ../../node_modules/.bun/effect@4.0.0-beta.59/node_modules/effect/src/internal/effect.ts:593:22 ❯ evaluate ../../node_modules/.bun/effect@4.0.0-beta.59/node_modules/effect/src/internal/effect.ts:5142:8 ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ Serialized Error: { code: 'ERR_INVALID_URL', input: '/mcp' }

Check failure on line 61 in apps/cloud/src/auth/v2-moved.ts

View workflow job for this annotation

GitHub Actions / Test

src/auth/v2-moved.test.ts > v2 moved metadata > is not moved when the slug is not a v2 slug

TypeError: Invalid URL ❯ parseV2Moved src/auth/v2-moved.ts:61:49 ❯ src/auth/v2-moved.test.ts:76:7 ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ Serialized Error: { code: 'ERR_INVALID_URL', input: '/mcp' }

Check failure on line 61 in apps/cloud/src/auth/v2-moved.ts

View workflow job for this annotation

GitHub Actions / Test

src/auth/v2-moved.test.ts > v2 moved metadata > normalizes a timestamp with an offset to UTC

TypeError: Invalid URL ❯ parseV2Moved src/auth/v2-moved.ts:61:49 ❯ src/auth/v2-moved.test.ts:44:7 ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ Serialized Error: { code: 'ERR_INVALID_URL', input: '/mcp' }

Check failure on line 61 in apps/cloud/src/auth/v2-moved.ts

View workflow job for this annotation

GitHub Actions / Test

src/auth/v2-moved.test.ts > v2 moved metadata > ignores unrelated metadata keys

TypeError: Invalid URL ❯ parseV2Moved src/auth/v2-moved.ts:61:49 ❯ src/auth/v2-moved.test.ts:37:12 ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ Serialized Error: { code: 'ERR_INVALID_URL', input: '/mcp' }

Check failure on line 61 in apps/cloud/src/auth/v2-moved.ts

View workflow job for this annotation

GitHub Actions / Test

src/auth/v2-moved.test.ts > v2 moved metadata > reads the v2 slug and move time into the org's v2 page

TypeError: Invalid URL ❯ parseV2Moved src/auth/v2-moved.ts:61:49 ❯ src/auth/v2-moved.test.ts:29:12 ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ Serialized Error: { code: 'ERR_INVALID_URL', input: '/mcp' }
};
};

Expand All @@ -67,9 +75,10 @@
source: V2MovedSource,
organizationId: string,
v2Origin: URL,
v2McpOrigin: URL | null,
): Effect.Effect<V2Moved | null> =>
source.organizationMetadata(organizationId).pipe(
Effect.map((metadata) => parseV2Moved(metadata, v2Origin)),
Effect.map((metadata) => parseV2Moved(metadata, v2Origin, v2McpOrigin)),
Effect.orElseSucceed(() => null),
Effect.withSpan("v2Moved.read"),
);
Expand Down
6 changes: 6 additions & 0 deletions apps/cloud/src/env-augment.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 `<origin>/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;
Expand Down
139 changes: 122 additions & 17 deletions apps/cloud/src/mcp/session-durable-object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -197,6 +205,85 @@ export class McpSessionDOSqlite extends McpAgentSessionDOBase<Env, CloudSessionD
// call site below for why this is free to compute.
private lastSessionFootprint: Record<string, number> = {};

/** 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<void> {
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<boolean> {
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<string | undefined> {
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<string | undefined> {
const self = this;
return Effect.gen(function* () {
if (self.deliveredV2MoveNotice) return self.deliveredV2MoveNotice.text;
const text = yield* Effect.promise(() =>
self.ctx.storage.get<string>(V2_MOVE_NOTICE_AT_INITIALIZE_KEY),
);
self.deliveredV2MoveNotice = { text };
return text;
});
}

protected override sessionFootprintAttributes(): Record<string, number> {
return this.lastSessionFootprint;
}
Expand Down Expand Up @@ -344,24 +431,41 @@ export class McpSessionDOSqlite extends McpAgentSessionDOBase<Env, CloudSessionD
>,
);

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.
Expand Down Expand Up @@ -401,6 +505,7 @@ export class McpSessionDOSqlite extends McpAgentSessionDOBase<Env, CloudSessionD
// the negotiated apps support comes back from storage instead.
restoredAppsEnabled: sessionMeta.appsEnabled ?? false,
onAppsEnabledChange: (appsEnabled) => self.persistAppsEnabled(appsEnabled),
hostInstructions,
loadAppShellHtml,
smokeRenderArtifact: smokeRenderArtifactAfterQuickJsPreload,
artifactUrl: artifactUrlFor(
Expand Down
82 changes: 82 additions & 0 deletions apps/cloud/src/mcp/v2-moved-instructions.ts
Original file line number Diff line number Diff line change
@@ -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<string | undefined, never, WorkOSClient> =>
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;
Loading
Loading