From 0a69f33f0a43e22f94b063a4e43ec7dce31953d7 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Mon, 12 Oct 2026 15:17:03 -0700 Subject: [PATCH 1/3] Test the moved org's v2 MCP URL --- apps/cloud/src/auth/v2-moved.test.ts | 79 +++++++++-- apps/cloud/src/edge/production-config.test.ts | 27 +++- .../src/mcp/v2-moved-instructions.test.ts | 102 ++++++++++++++ e2e/cloud/v2-moved-notice.test.ts | 132 +++++++++++++++++- 4 files changed, 317 insertions(+), 23 deletions(-) create mode 100644 apps/cloud/src/mcp/v2-moved-instructions.test.ts diff --git a/apps/cloud/src/auth/v2-moved.test.ts b/apps/cloud/src/auth/v2-moved.test.ts index 10f2674cbc..6cac54b109 100644 --- a/apps/cloud/src/auth/v2-moved.test.ts +++ b/apps/cloud/src/auth/v2-moved.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "@effect/vitest"; import { Effect, Layer } from "effect"; -import { v2ImportAnswer, type V2ImportSource } from "./v2-import"; +import { v2ImportAnswer, v2McpOrigin, type V2ImportSource } from "./v2-import"; import { liveV2MovedSource, parseV2Moved, @@ -16,6 +16,7 @@ import { WorkOSError } from "./errors"; const USER = "user_signed_in"; const ORG = "org_example"; const V2_ORIGIN = new URL("https://app.example.test"); +const V2_MCP_ORIGIN = new URL("https://mcp.example.test"); const MOVED_AT = "2026-10-09T17:30:00.000Z"; const movedMetadata = (overrides: Record = {}) => ({ @@ -26,30 +27,39 @@ const movedMetadata = (overrides: Record = {}) => ({ describe("v2 moved metadata", () => { it("reads the v2 slug and move time into the org's v2 page", () => { - expect(parseV2Moved(movedMetadata(), V2_ORIGIN)).toEqual({ + expect(parseV2Moved(movedMetadata(), V2_ORIGIN, null)).toEqual({ v2Url: "https://app.example.test/org/example-team", v2Slug: "example-team", movedAt: MOVED_AT, }); }); + it("adds the org's v2 MCP server when v2's MCP origin is known", () => { + expect(parseV2Moved(movedMetadata(), V2_ORIGIN, V2_MCP_ORIGIN)?.v2McpUrl).toBe( + "https://mcp.example.test/org/example-team/mcp", + ); + }); + it("ignores unrelated metadata keys", () => { - expect(parseV2Moved(movedMetadata({ plan_note: "anything" }), V2_ORIGIN)?.v2Slug).toBe( + expect(parseV2Moved(movedMetadata({ plan_note: "anything" }), V2_ORIGIN, null)?.v2Slug).toBe( "example-team", ); }); it("normalizes a timestamp with an offset to UTC", () => { expect( - parseV2Moved(movedMetadata({ [V2_MOVED_AT_KEY]: "2026-10-09T10:30:00-07:00" }), V2_ORIGIN) - ?.movedAt, + parseV2Moved( + movedMetadata({ [V2_MOVED_AT_KEY]: "2026-10-09T10:30:00-07:00" }), + V2_ORIGIN, + null, + )?.movedAt, ).toBe(MOVED_AT); }); it("is not moved without both keys", () => { - expect(parseV2Moved({}, V2_ORIGIN)).toBeNull(); - expect(parseV2Moved({ [V2_MOVED_SLUG_KEY]: "example-team" }, V2_ORIGIN)).toBeNull(); - expect(parseV2Moved({ [V2_MOVED_AT_KEY]: MOVED_AT }, V2_ORIGIN)).toBeNull(); + expect(parseV2Moved({}, V2_ORIGIN, null)).toBeNull(); + expect(parseV2Moved({ [V2_MOVED_SLUG_KEY]: "example-team" }, V2_ORIGIN, null)).toBeNull(); + expect(parseV2Moved({ [V2_MOVED_AT_KEY]: MOVED_AT }, V2_ORIGIN, null)).toBeNull(); }); it("is not moved when the slug is not a v2 slug", () => { @@ -68,12 +78,12 @@ describe("v2 moved metadata", () => { null, ]) { expect( - parseV2Moved(movedMetadata({ [V2_MOVED_SLUG_KEY]: slug }), V2_ORIGIN), + parseV2Moved(movedMetadata({ [V2_MOVED_SLUG_KEY]: slug }), V2_ORIGIN, null), String(slug), ).toBeNull(); } expect( - parseV2Moved(movedMetadata({ [V2_MOVED_SLUG_KEY]: "a".repeat(45) }), V2_ORIGIN), + parseV2Moved(movedMetadata({ [V2_MOVED_SLUG_KEY]: "a".repeat(45) }), V2_ORIGIN, null), "45 characters is v2's longest slug", ).not.toBeNull(); }); @@ -90,7 +100,7 @@ describe("v2 moved metadata", () => { null, ]) { expect( - parseV2Moved(movedMetadata({ [V2_MOVED_AT_KEY]: movedAt }), V2_ORIGIN), + parseV2Moved(movedMetadata({ [V2_MOVED_AT_KEY]: movedAt }), V2_ORIGIN, null), String(movedAt), ).toBeNull(); } @@ -125,7 +135,7 @@ describe("v2 moved read", () => { it.effect("reads the selected org's metadata", () => Effect.gen(function* () { const { source, reads } = movedSourceOf(movedMetadata()); - expect((yield* v2Moved(source, ORG, V2_ORIGIN))?.v2Slug).toBe("example-team"); + expect((yield* v2Moved(source, ORG, V2_ORIGIN, null))?.v2Slug).toBe("example-team"); expect(reads).toEqual([ORG]); }), ); @@ -133,7 +143,7 @@ describe("v2 moved read", () => { it.effect("is not moved when the read fails", () => Effect.gen(function* () { const { source } = movedSourceOf("unavailable"); - expect(yield* v2Moved(source, ORG, V2_ORIGIN)).toBeNull(); + expect(yield* v2Moved(source, ORG, V2_ORIGIN, null)).toBeNull(); }), ); }); @@ -165,7 +175,7 @@ describe("v2 moved live read", () => { const movedLive = (layer: Layer.Layer) => Effect.gen(function* () { - return yield* v2Moved(yield* liveV2MovedSource, ORG, V2_ORIGIN); + return yield* v2Moved(yield* liveV2MovedSource, ORG, V2_ORIGIN, null); }).pipe(Effect.provide(layer)); it.effect("reads the move from the WorkOS organization's metadata", () => @@ -205,7 +215,10 @@ describe("v2 import answer", () => { }; const answer = ( - env: typeof configured | Record, + env: + | typeof configured + | (typeof configured & { V2_MCP_ORIGIN: string }) + | Record, moved: ReturnType, importing: ReturnType, ) => @@ -233,6 +246,17 @@ describe("v2 import answer", () => { }), ); + it.effect("adds the org's v2 MCP server when V2_MCP_ORIGIN is set", () => + Effect.gen(function* () { + const result = yield* answer( + { ...configured, V2_MCP_ORIGIN: "https://mcp.example.test" }, + movedSourceOf(movedMetadata()), + importSourceOf(true), + ); + expect(result.moved?.v2McpUrl).toBe("https://mcp.example.test/org/example-team/mcp"); + }), + ); + it.effect("offers the import to an eligible org that has not moved", () => Effect.gen(function* () { expect(yield* answer(configured, movedSourceOf({}), importSourceOf(true))).toEqual({ @@ -271,3 +295,28 @@ describe("v2 import answer", () => { }), ); }); + +describe("v2 MCP origin", () => { + it("reads a bare http(s) origin", () => { + expect(v2McpOrigin({ V2_MCP_ORIGIN: "https://mcp.example.test" })?.href).toBe( + "https://mcp.example.test/", + ); + expect(v2McpOrigin({ V2_MCP_ORIGIN: "https://mcp.example.test/" })?.href).toBe( + "https://mcp.example.test/", + ); + }); + + it("is unknown when unset or not a bare origin", () => { + for (const value of [ + undefined, + "", + "mcp.example.test", + "/mcp", + "javascript:alert(1)", + "https://mcp.example.test/mcp", + "https://mcp.example.test/?x=1", + ]) { + expect(v2McpOrigin({ V2_MCP_ORIGIN: value }), String(value)).toBeNull(); + } + }); +}); diff --git a/apps/cloud/src/edge/production-config.test.ts b/apps/cloud/src/edge/production-config.test.ts index 95c82fcac6..4a968104d1 100644 --- a/apps/cloud/src/edge/production-config.test.ts +++ b/apps/cloud/src/edge/production-config.test.ts @@ -6,6 +6,7 @@ import { Schema } from "effect"; import { unstable_readConfig } from "wrangler"; import { frontResponse } from "./front"; +import { v2McpOrigin } from "../auth/v2-import"; import { parseV2Edge, v2EdgeResponse, type V2EdgeEnv, type V2Service } from "./marketing"; // The deployed edge reads its v2 settings from wrangler.jsonc. These tests read @@ -93,6 +94,12 @@ describe("production v2 edge settings", () => { expect(edge.errorTunnelPath).toBe("/api/fd6fab1fbb4883e1/submit"); }); + it("points moved orgs' agents at v2's MCP host", () => { + expect(v2McpOrigin({ V2_MCP_ORIGIN: stringVar("V2_MCP_ORIGIN") })?.href).toBe( + "https://mcp.executor.sh/", + ); + }); + it("keeps the MARKETING binding for v1's terms", () => { expect(config.services.filter((service) => service.binding === "MARKETING")).toHaveLength(1); }); @@ -110,16 +117,24 @@ describe("the executor.sh front Worker's settings", () => { expect(edgeConfig.placement).toBeUndefined(); }); - it("ships the same v2 settings as executor-cloud", () => { - const v2Vars = (vars: Readonly>) => - Object.fromEntries(Object.entries(vars).filter(([name]) => name.startsWith("V2_"))); - expect(Object.keys(v2Vars(edgeConfig.vars)).toSorted()).toEqual([ + // The edge's v2 settings only: `V2_MCP_ORIGIN` is read by v1's dashboard and + // MCP sessions, which the front Worker does not serve. + it("ships the same v2 edge settings as executor-cloud", () => { + const edgeKeys = [ "V2_ANALYTICS_PROXY_PATH", "V2_ERROR_TUNNEL_PATH", "V2_OAUTH_STATE_PREFIX", "V2_SIGN_UP_URL", - ]); - expect(v2Vars(edgeConfig.vars)).toEqual(v2Vars(config.vars)); + ]; + const v2Vars = (vars: Readonly>) => + Object.fromEntries(Object.entries(vars).filter(([name]) => name.startsWith("V2_"))); + expect(Object.keys(v2Vars(edgeConfig.vars)).toSorted()).toEqual(edgeKeys); + expect(v2Vars(edgeConfig.vars)).toEqual( + Object.fromEntries(edgeKeys.map((name) => [name, config.vars[name]])), + ); + expect(Object.keys(v2Vars(config.vars)).toSorted()).toEqual( + [...edgeKeys, "V2_MCP_ORIGIN"].toSorted(), + ); }); it("binds the same v2 and marketing Workers as executor-cloud", () => { diff --git a/apps/cloud/src/mcp/v2-moved-instructions.test.ts b/apps/cloud/src/mcp/v2-moved-instructions.test.ts new file mode 100644 index 0000000000..4297e9f06e --- /dev/null +++ b/apps/cloud/src/mcp/v2-moved-instructions.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Fiber, Layer } from "effect"; +import { TestClock } from "effect/testing"; + +import { V2_MOVED_AT_KEY, V2_MOVED_SLUG_KEY } from "../auth/v2-moved"; +import { WorkOSClient, type WorkOSClientService } from "../auth/workos"; +import { WorkOSError } from "../auth/errors"; +import { + V2_MOVED_READ_TIMEOUT_MS, + v2MovedInstructionsText, + v2MovedMcpInstructions, +} from "./v2-moved-instructions"; + +const ORG = "org_example"; + +const env = { + V2: { fetch: () => Promise.resolve(new Response(null)) }, + V2_SIGN_UP_URL: "https://app.example.test/login?mode=signup", + V2_OAUTH_STATE_PREFIX: "x2.", + V2_ANALYTICS_PROXY_PATH: "/api/0123456789abcdef", + V2_ERROR_TUNNEL_PATH: "/api/fedcba9876543210/submit", + V2_MCP_ORIGIN: "https://mcp.example.test", +}; + +const EXPECTED = + "This organization moved to Executor v2 on October 9, 2026. " + + "Its MCP server is now https://mcp.example.test/org/example-team/mcp; ask the user to reconnect their agent there. " + + "Changes made through this server won't reach v2."; + +const workosAnswering = (answer: () => Effect.Effect) => + Layer.succeed( + WorkOSClient, + new Proxy({} as WorkOSClientService, { + get: (_target, prop) => + prop === "getOrganization" + ? answer + : () => Effect.die(`unexpected WorkOSClient.${String(prop)} call`), + }), + ); + +const organization = (metadata: Record) => ({ id: ORG, metadata }); + +const moved = organization({ + [V2_MOVED_SLUG_KEY]: "example-team", + [V2_MOVED_AT_KEY]: "2026-10-09T12:00:00.000Z", +}); + +describe("v2 moved MCP instructions", () => { + it("names the move date and the org's v2 MCP server", () => { + expect( + v2MovedInstructionsText({ + v2Url: "https://app.example.test/org/example-team", + v2Slug: "example-team", + movedAt: "2026-10-09T12:00:00.000Z", + v2McpUrl: "https://mcp.example.test/org/example-team/mcp", + }), + ).toBe(EXPECTED); + }); + + it("says nothing without a v2 MCP URL", () => { + expect( + v2MovedInstructionsText({ + v2Url: "https://app.example.test/org/example-team", + v2Slug: "example-team", + movedAt: "2026-10-09T12:00:00.000Z", + }), + ).toBeNull(); + }); + + it.effect("reads a moved org's instructions from WorkOS", () => + Effect.gen(function* () { + expect(yield* v2MovedMcpInstructions(env, ORG)).toBe(EXPECTED); + }).pipe(Effect.provide(workosAnswering(() => Effect.succeed(moved)))), + ); + + it.effect("gives none to an org that has not moved", () => + Effect.gen(function* () { + expect(yield* v2MovedMcpInstructions(env, ORG)).toBeUndefined(); + }).pipe(Effect.provide(workosAnswering(() => Effect.succeed(organization({}))))), + ); + + it.effect("gives none, without reading WorkOS, when V2_MCP_ORIGIN is unset", () => + Effect.gen(function* () { + const { V2_MCP_ORIGIN: _unset, ...withoutMcp } = env; + expect(yield* v2MovedMcpInstructions(withoutMcp, ORG)).toBeUndefined(); + }).pipe(Effect.provide(workosAnswering(() => Effect.die("WorkOS was read")))), + ); + + it.effect("gives none when WorkOS fails", () => + Effect.gen(function* () { + expect(yield* v2MovedMcpInstructions(env, ORG)).toBeUndefined(); + }).pipe(Effect.provide(workosAnswering(() => Effect.fail(new WorkOSError({ status: 503 }))))), + ); + + it.effect("gives up on a slow WorkOS instead of stalling the session", () => + Effect.gen(function* () { + const read = yield* Effect.forkChild(v2MovedMcpInstructions(env, ORG)); + yield* TestClock.adjust(`${V2_MOVED_READ_TIMEOUT_MS} millis`); + expect(yield* Fiber.join(read)).toBeUndefined(); + }).pipe(Effect.provide(workosAnswering(() => Effect.never))), + ); +}); diff --git a/e2e/cloud/v2-moved-notice.test.ts b/e2e/cloud/v2-moved-notice.test.ts index e51eb40cbf..d92b18f4ad 100644 --- a/e2e/cloud/v2-moved-notice.test.ts +++ b/e2e/cloud/v2-moved-notice.test.ts @@ -9,10 +9,12 @@ // fixed by the typed catalog in `packages/react/src/api/analytics.tsx`. import { expect } from "@effect/vitest"; import { Effect } from "effect"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; import type { Page } from "playwright"; import { scenario } from "../src/scenario"; -import { Browser, Target } from "../src/services"; +import { Browser, Mcp, Target } from "../src/services"; import { revisit, settle, visit } from "../src/surfaces/browser"; import type { Identity, Target as TargetShape } from "../src/target"; import { WORKOS_EMULATOR_PORT } from "../targets/cloud"; @@ -25,6 +27,8 @@ const V2_SLUG = "moved-example"; // between UTC-11 and UTC+11. const MOVED_AT = "2026-10-09T12:00:00.000Z"; const MOVED_BADGE = "Moved Oct 9, 2026"; +// v2's org-pinned MCP path, on the v2 MCP origin v1 is configured with. +const V2_MCP_PATH = `/org/${V2_SLUG}/mcp`; interface V2ImportAnswer { readonly url: string | null; @@ -32,6 +36,7 @@ interface V2ImportAnswer { readonly v2Url: string; readonly v2Slug: string; readonly movedAt: string; + readonly v2McpUrl?: string; } | null; } @@ -103,6 +108,41 @@ const renameOrg = (target: TargetShape, identity: Identity, name: string): Effec const noticeOf = (page: Page) => page.getByRole("dialog"); +/** The answer's v2 MCP URL, checked to be the org's v2 MCP server. */ +const v2McpUrlOf = (target: TargetShape, answer: V2ImportAnswer): string => { + const raw = answer.moved?.v2McpUrl; + expect(raw, "the move answer carries the org's v2 MCP URL").toEqual(expect.any(String)); + const url = new URL(raw!); + expect(url.pathname, "the org-pinned v2 MCP path").toBe(V2_MCP_PATH); + expect(url.origin, "v2's MCP host, not v1's").not.toBe(new URL(target.baseUrl).origin); + expect(url.origin, "v2's MCP host, not v2's app host").not.toBe( + new URL(answer.moved!.v2Url).origin, + ); + return raw!; +}; + +const emailOf = (identity: Identity): string => identity.credentials?.email ?? identity.label; + +/** A real MCP client's handshake: the server's `initialize` instructions. */ +const initializeInstructions = (mcpUrl: string, bearer: string) => + Effect.acquireUseRelease( + Effect.promise(async () => { + const client = new Client({ name: "executor-e2e-v2-moved", version: "0.0.1" }); + await client.connect( + new StreamableHTTPClientTransport(new URL(mcpUrl), { + requestInit: { headers: { authorization: `Bearer ${bearer}` } }, + }), + ); + return client; + }), + (client) => + Effect.promise(async () => ({ + instructions: client.getInstructions(), + tools: (await client.listTools()).tools.map((tool) => tool.name), + })), + (client) => Effect.promise(() => client.close().catch(() => undefined)), + ); + /** Open the dashboard and wait for the v2 answer it renders the notice from. */ const openDashboard = async (page: Page, slug: string) => { const answered = page.waitForResponse((response) => response.url().endsWith(V2_IMPORT_API)); @@ -131,6 +171,7 @@ scenario( new URL(target.baseUrl).origin, ); expect(answer.moved!.movedAt).toBe(MOVED_AT); + const v2McpUrl = v2McpUrlOf(target, answer); yield* browser.session(identity, async ({ page, step }) => { await step("Open the moved org's dashboard", async () => { @@ -149,6 +190,16 @@ scenario( expect(pair).toContain(`v2 · ${V2_SLUG}`); expect(await notice.getByText("Changes made here won't sync to v2.").count()).toBe(1); + const mcp = notice.getByRole("region", { name: "v2 MCP server URL" }); + expect(await mcp.getByText("Connect your agents to v2 instead:").count()).toBe(1); + expect( + await mcp.getByTestId("mcp-server-url").textContent(), + "the notice shows the org's v2 MCP URL", + ).toBe(v2McpUrl); + expect(await mcp.getByRole("button", { name: "Copy" }).count(), "with a copy button").toBe( + 1, + ); + const open = notice.getByRole("link", { name: "Open in v2", exact: true }); expect(await open.getAttribute("href"), "the button opens the org in v2").toBe( answer.moved!.v2Url, @@ -162,6 +213,26 @@ scenario( await noticeOf(page).waitFor({ state: "hidden" }); }); + await step("See the Connect card lead with the v2 MCP server", async () => { + expect( + await page + .getByText("This organization moved to Executor v2. Connect your agents there instead:") + .count(), + ).toBe(1); + const next = page.getByRole("region", { name: "New MCP server" }); + expect(await next.getByTestId("mcp-server-url").textContent()).toBe(v2McpUrl); + expect(await next.textContent(), "the install command targets v2").toContain( + `npx add-mcp ${v2McpUrl} --transport http --name executor`, + ); + const old = page.getByRole("region", { name: "Old MCP server URL" }); + expect(await old.getByText("Old v1 URL").count()).toBe(1); + expect( + await old.getByTestId("mcp-server-url").textContent(), + "v1's URL stays, labelled as the old one", + ).toBe(`${new URL(target.baseUrl).origin}/${org.slug}/mcp`); + expect(await old.getByText("Won't receive changes made in Executor v2.").count()).toBe(1); + }); + await step( 'See "Open in Executor v2" in the sidebar instead of the import link', async () => { @@ -180,6 +251,32 @@ scenario( await page.getByRole("link", { name: "Open in Executor v2" }).waitFor(); expect(await noticeOf(page).count(), "✕ snoozes the notice in this browser").toBe(0); }); + + await step("Open the MCP setup page and see it lead with the v2 MCP server", async () => { + await visit(page, "/setup-mcp"); + const next = page.getByRole("region", { name: "New MCP server" }); + await next.waitFor(); + expect( + await page + .getByText("This organization moved to Executor v2. Connect your agents there instead:") + .count(), + ).toBe(1); + expect(await next.getByTestId("mcp-server-url").textContent()).toBe(v2McpUrl); + expect(await next.textContent(), "the install command targets v2").toContain( + `npx add-mcp ${v2McpUrl} --transport http --name executor`, + ); + const old = page.getByRole("region", { name: "Old MCP server URL" }); + expect(await old.getByText("Old v1 URL").count()).toBe(1); + // The page reads its own origin after hydration, then shows v1's URL. + await old + .getByTestId("mcp-server-url") + .getByText(`${new URL(target.baseUrl).origin}/${org.slug}/mcp`, { exact: true }) + .waitFor(); + expect( + await page.getByRole("region", { name: "MCP server URL", exact: true }).count(), + "v1's URL is not offered as the one to use", + ).toBe(0); + }); }); }), ); @@ -270,6 +367,10 @@ scenario( await step("See the import link and no notice", async () => { await page.getByRole("link", { name: "Move to Executor v2" }).waitFor(); expect(await noticeOf(page).count()).toBe(0); + expect( + await page.getByRole("region", { name: "New MCP server" }).count(), + "the Connect card points nowhere else", + ).toBe(0); }); }); }), @@ -298,13 +399,14 @@ scenario( .waitFor(); }); - await step("See the pair and both buttons inside the notice", async () => { + await step("See the pair, the v2 MCP URL and both buttons inside the notice", async () => { const notice = noticeOf(page); const box = await notice.boundingBox(); if (box === null) throw new Error("the notice has no layout box"); const right = box.x + box.width; for (const [label, part] of [ ["the v1 → v2 pair", notice.getByTestId("v2-moved-pair")], + ["the v2 MCP URL", notice.getByRole("region", { name: "v2 MCP server URL" })], ["Keep using v1", notice.getByRole("button", { name: "Keep using v1" })], ["Open in v2", notice.getByRole("link", { name: "Open in v2", exact: true })], ] as const) { @@ -319,3 +421,29 @@ scenario( }); }), ); + +scenario( + "v2 moved · a moved org's MCP sessions tell the agent where to reconnect", + { timeout: 180_000 }, + Effect.gen(function* () { + const target = yield* Target; + const mcp = yield* Mcp; + const identity = yield* target.newIdentity(); + const org = yield* orgOf(target, identity); + const bearer = yield* mcp.mintBearer(emailOf(identity)); + + const before = yield* initializeInstructions(target.mcpUrl, bearer); + expect(before.instructions, "an org that has not moved gets no notice").toBeUndefined(); + + yield* markMoved(org.id); + const v2McpUrl = v2McpUrlOf(target, yield* v2ImportOf(target, identity)); + + const after = yield* initializeInstructions(target.mcpUrl, bearer); + expect(after.instructions, "the initialize result names the org's v2 MCP server").toBe( + "This organization moved to Executor v2 on October 9, 2026. " + + `Its MCP server is now ${v2McpUrl}; ask the user to reconnect their agent there. ` + + "Changes made through this server won't reach v2.", + ); + expect(after.tools, "the tools are unchanged").toEqual(before.tools); + }), +); From 4d843428d67d27ce3797eb80416092f2b107d095 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Mon, 12 Oct 2026 15:36:55 -0700 Subject: [PATCH 2/3] Expect v2's /mcp URL and the org to choose at sign-in --- apps/cloud/src/auth/v2-moved.test.ts | 8 ++--- .../src/mcp/v2-moved-instructions.test.ts | 5 +-- e2e/cloud/v2-moved-notice.test.ts | 33 +++++++++++++++---- 3 files changed, 34 insertions(+), 12 deletions(-) diff --git a/apps/cloud/src/auth/v2-moved.test.ts b/apps/cloud/src/auth/v2-moved.test.ts index 6cac54b109..2b44103660 100644 --- a/apps/cloud/src/auth/v2-moved.test.ts +++ b/apps/cloud/src/auth/v2-moved.test.ts @@ -34,9 +34,9 @@ describe("v2 moved metadata", () => { }); }); - it("adds the org's v2 MCP server when v2's MCP origin is known", () => { + it("adds v2's OAuth MCP server, not the org-pinned path, when v2's MCP origin is known", () => { expect(parseV2Moved(movedMetadata(), V2_ORIGIN, V2_MCP_ORIGIN)?.v2McpUrl).toBe( - "https://mcp.example.test/org/example-team/mcp", + "https://mcp.example.test/mcp", ); }); @@ -246,14 +246,14 @@ describe("v2 import answer", () => { }), ); - it.effect("adds the org's v2 MCP server when V2_MCP_ORIGIN is set", () => + it.effect("adds v2's MCP server when V2_MCP_ORIGIN is set", () => Effect.gen(function* () { const result = yield* answer( { ...configured, V2_MCP_ORIGIN: "https://mcp.example.test" }, movedSourceOf(movedMetadata()), importSourceOf(true), ); - expect(result.moved?.v2McpUrl).toBe("https://mcp.example.test/org/example-team/mcp"); + expect(result.moved?.v2McpUrl).toBe("https://mcp.example.test/mcp"); }), ); diff --git a/apps/cloud/src/mcp/v2-moved-instructions.test.ts b/apps/cloud/src/mcp/v2-moved-instructions.test.ts index 4297e9f06e..9b2089bf28 100644 --- a/apps/cloud/src/mcp/v2-moved-instructions.test.ts +++ b/apps/cloud/src/mcp/v2-moved-instructions.test.ts @@ -24,7 +24,8 @@ const env = { const EXPECTED = "This organization moved to Executor v2 on October 9, 2026. " + - "Its MCP server is now https://mcp.example.test/org/example-team/mcp; ask the user to reconnect their agent there. " + + "Its MCP server is now https://mcp.example.test/mcp; ask the user to reconnect their agent there " + + "and choose the example-team organization when signing in. " + "Changes made through this server won't reach v2."; const workosAnswering = (answer: () => Effect.Effect) => @@ -52,7 +53,7 @@ describe("v2 moved MCP instructions", () => { v2Url: "https://app.example.test/org/example-team", v2Slug: "example-team", movedAt: "2026-10-09T12:00:00.000Z", - v2McpUrl: "https://mcp.example.test/org/example-team/mcp", + v2McpUrl: "https://mcp.example.test/mcp", }), ).toBe(EXPECTED); }); diff --git a/e2e/cloud/v2-moved-notice.test.ts b/e2e/cloud/v2-moved-notice.test.ts index d92b18f4ad..6e230afa5d 100644 --- a/e2e/cloud/v2-moved-notice.test.ts +++ b/e2e/cloud/v2-moved-notice.test.ts @@ -27,8 +27,10 @@ const V2_SLUG = "moved-example"; // between UTC-11 and UTC+11. const MOVED_AT = "2026-10-09T12:00:00.000Z"; const MOVED_BADGE = "Moved Oct 9, 2026"; -// v2's org-pinned MCP path, on the v2 MCP origin v1 is configured with. -const V2_MCP_PATH = `/org/${V2_SLUG}/mcp`; +// v2's MCP path on the v2 MCP origin v1 is configured with. OAuth clients only +// accept `/mcp` (v2's protected resource); they choose the org when signing in. +const V2_MCP_PATH = "/mcp"; +const CHOOSE_ORG_HINT = `Choose ${V2_SLUG} when you sign in.`; interface V2ImportAnswer { readonly url: string | null; @@ -108,12 +110,14 @@ const renameOrg = (target: TargetShape, identity: Identity, name: string): Effec const noticeOf = (page: Page) => page.getByRole("dialog"); -/** The answer's v2 MCP URL, checked to be the org's v2 MCP server. */ +/** The answer's v2 MCP URL, checked to be v2's OAuth MCP server. */ const v2McpUrlOf = (target: TargetShape, answer: V2ImportAnswer): string => { const raw = answer.moved?.v2McpUrl; expect(raw, "the move answer carries the org's v2 MCP URL").toEqual(expect.any(String)); const url = new URL(raw!); - expect(url.pathname, "the org-pinned v2 MCP path").toBe(V2_MCP_PATH); + expect(url.href, "v2's OAuth MCP resource, not an org-pinned path").toBe( + `${url.origin}${V2_MCP_PATH}`, + ); expect(url.origin, "v2's MCP host, not v1's").not.toBe(new URL(target.baseUrl).origin); expect(url.origin, "v2's MCP host, not v2's app host").not.toBe( new URL(answer.moved!.v2Url).origin, @@ -191,7 +195,15 @@ scenario( expect(await notice.getByText("Changes made here won't sync to v2.").count()).toBe(1); const mcp = notice.getByRole("region", { name: "v2 MCP server URL" }); - expect(await mcp.getByText("Connect your agents to v2 instead:").count()).toBe(1); + expect( + await mcp + .getByText( + `Connect your agents to v2 instead, and choose ${V2_SLUG} when you sign in:`, + { exact: true }, + ) + .count(), + "the notice names the org to choose at sign-in", + ).toBe(1); expect( await mcp.getByTestId("mcp-server-url").textContent(), "the notice shows the org's v2 MCP URL", @@ -224,6 +236,10 @@ scenario( expect(await next.textContent(), "the install command targets v2").toContain( `npx add-mcp ${v2McpUrl} --transport http --name executor`, ); + expect( + await next.getByText(CHOOSE_ORG_HINT, { exact: true }).count(), + "it names the org to choose at sign-in", + ).toBe(1); const old = page.getByRole("region", { name: "Old MCP server URL" }); expect(await old.getByText("Old v1 URL").count()).toBe(1); expect( @@ -265,6 +281,10 @@ scenario( expect(await next.textContent(), "the install command targets v2").toContain( `npx add-mcp ${v2McpUrl} --transport http --name executor`, ); + expect( + await next.getByText(CHOOSE_ORG_HINT, { exact: true }).count(), + "it names the org to choose at sign-in", + ).toBe(1); const old = page.getByRole("region", { name: "Old MCP server URL" }); expect(await old.getByText("Old v1 URL").count()).toBe(1); // The page reads its own origin after hydration, then shows v1's URL. @@ -441,7 +461,8 @@ scenario( const after = yield* initializeInstructions(target.mcpUrl, bearer); expect(after.instructions, "the initialize result names the org's v2 MCP server").toBe( "This organization moved to Executor v2 on October 9, 2026. " + - `Its MCP server is now ${v2McpUrl}; ask the user to reconnect their agent there. ` + + `Its MCP server is now ${v2McpUrl}; ask the user to reconnect their agent there ` + + `and choose the ${V2_SLUG} organization when signing in. ` + "Changes made through this server won't reach v2.", ); expect(after.tools, "the tools are unchanged").toEqual(before.tools); From 0f37340e13885d681df741ec5d50db3186866237 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Mon, 12 Oct 2026 16:26:36 -0700 Subject: [PATCH 3/3] Test that pre-move MCP sessions re-initialize and long v2 slugs wrap --- .../src/mcp/v2-moved-instructions.test.ts | 22 ++ e2e/cloud/v2-moved-notice.test.ts | 189 ++++++++++++++++-- e2e/setup/cloud.boot.ts | 3 + e2e/setup/v2-moved-recheck.ts | 7 + .../mcp/agent-session-durable-object.test.ts | 121 +++++++++++ 5 files changed, 326 insertions(+), 16 deletions(-) create mode 100644 e2e/setup/v2-moved-recheck.ts diff --git a/apps/cloud/src/mcp/v2-moved-instructions.test.ts b/apps/cloud/src/mcp/v2-moved-instructions.test.ts index 9b2089bf28..acf1ce0b94 100644 --- a/apps/cloud/src/mcp/v2-moved-instructions.test.ts +++ b/apps/cloud/src/mcp/v2-moved-instructions.test.ts @@ -7,6 +7,7 @@ import { WorkOSClient, type WorkOSClientService } from "../auth/workos"; import { WorkOSError } from "../auth/errors"; import { V2_MOVED_READ_TIMEOUT_MS, + mustReinitializeForV2Move, v2MovedInstructionsText, v2MovedMcpInstructions, } from "./v2-moved-instructions"; @@ -101,3 +102,24 @@ describe("v2 moved MCP instructions", () => { }).pipe(Effect.provide(workosAnswering(() => Effect.never))), ); }); + +describe("re-initializing a session for the v2 move", () => { + it("re-initializes a client that got no notice once the org has moved", () => { + expect(mustReinitializeForV2Move(undefined, EXPECTED)).toBe(true); + }); + + it("re-initializes a client whose notice is out of date", () => { + expect( + mustReinitializeForV2Move(EXPECTED.replace("example-team", "other-team"), EXPECTED), + ).toBe(true); + }); + + it("leaves a client that got the current notice alone", () => { + expect(mustReinitializeForV2Move(EXPECTED, EXPECTED)).toBe(false); + }); + + it("leaves a client alone when the org has not moved or the read failed", () => { + expect(mustReinitializeForV2Move(undefined, undefined)).toBe(false); + expect(mustReinitializeForV2Move(EXPECTED, undefined)).toBe(false); + }); +}); diff --git a/e2e/cloud/v2-moved-notice.test.ts b/e2e/cloud/v2-moved-notice.test.ts index 6e230afa5d..1317d66440 100644 --- a/e2e/cloud/v2-moved-notice.test.ts +++ b/e2e/cloud/v2-moved-notice.test.ts @@ -8,16 +8,21 @@ // PostHog client, so `trackEvent` is a no-op. Their names and properties are // fixed by the typed catalog in `packages/react/src/api/analytics.tsx`. import { expect } from "@effect/vitest"; -import { Effect } from "effect"; +import { Duration, Effect } from "effect"; import { Client } from "@modelcontextprotocol/sdk/client/index.js"; -import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; -import type { Page } from "playwright"; +import { + StreamableHTTPClientTransport, + StreamableHTTPError, +} from "@modelcontextprotocol/sdk/client/streamableHttp.js"; +import type { Locator, Page } from "playwright"; import { scenario } from "../src/scenario"; import { Browser, Mcp, Target } from "../src/services"; import { revisit, settle, visit } from "../src/surfaces/browser"; import type { Identity, Target as TargetShape } from "../src/target"; import { WORKOS_EMULATOR_PORT } from "../targets/cloud"; +import { configuredMcpSessionTimeoutMs } from "../setup/mcp-session-timeouts"; +import { E2E_MCP_V2_MOVED_RECHECK_MS } from "../setup/v2-moved-recheck"; import { verifyAdmin } from "./support/admin-mfa"; import { cookieOf } from "./support/session"; @@ -86,9 +91,9 @@ const setOrgMetadata = ( await response.body?.cancel(); }); -const markMoved = (organizationId: string) => +const markMoved = (organizationId: string, slug: string = V2_SLUG) => setOrgMetadata(organizationId, { - executor_v2_org_slug: V2_SLUG, + executor_v2_org_slug: slug, executor_v2_moved_at: MOVED_AT, }); @@ -127,9 +132,10 @@ const v2McpUrlOf = (target: TargetShape, answer: V2ImportAnswer): string => { const emailOf = (identity: Identity): string => identity.credentials?.email ?? identity.label; -/** A real MCP client's handshake: the server's `initialize` instructions. */ -const initializeInstructions = (mcpUrl: string, bearer: string) => - Effect.acquireUseRelease( +/** A real MCP client, connected (the `initialize` handshake done) for the + * scope, closed after. */ +const connectedClient = (mcpUrl: string, bearer: string) => + Effect.acquireRelease( Effect.promise(async () => { const client = new Client({ name: "executor-e2e-v2-moved", version: "0.0.1" }); await client.connect( @@ -139,14 +145,55 @@ const initializeInstructions = (mcpUrl: string, bearer: string) => ); return client; }), - (client) => - Effect.promise(async () => ({ + (client) => Effect.promise(() => client.close().catch(() => undefined)), + ); + +/** A real MCP client's handshake: the server's `initialize` instructions. */ +const initializeInstructions = (mcpUrl: string, bearer: string) => + Effect.scoped( + Effect.gen(function* () { + const client = yield* connectedClient(mcpUrl, bearer); + return yield* Effect.promise(async () => ({ instructions: client.getInstructions(), tools: (await client.listTools()).tools.map((tool) => tool.name), - })), - (client) => Effect.promise(() => client.close().catch(() => undefined)), + })); + }), ); +/** The HTTP status a request on the client's session answers, or 200. */ +const listToolsStatus = (client: Client): Effect.Effect => + Effect.promise(() => + client.listTools().then( + () => 200, + (error: unknown) => { + if (error instanceof StreamableHTTPError && error.code !== undefined) return error.code; + throw error; + }, + ), + ); + +const movedInstructions = (v2McpUrl: string, slug: string = V2_SLUG) => + "This organization moved to Executor v2 on October 9, 2026. " + + `Its MCP server is now ${v2McpUrl}; ask the user to reconnect their agent there ` + + `and choose the ${slug} organization when signing in. ` + + "Changes made through this server won't reach v2."; + +/** Where an element's text is drawn, which overflows the element's own box + * when the text cannot wrap. */ +const textExtent = (locator: Locator) => + locator.evaluate((element) => { + const range = document.createRange(); + range.selectNodeContents(element); + const box = range.getBoundingClientRect(); + return { left: box.left, right: box.right }; + }); + +const boxOf = async (locator: Locator, label: string) => { + const box = await locator.boundingBox(); + if (box === null) throw new Error(`${label} has no layout box`); + return { left: box.x, right: box.x + box.width }; +}; + /** Open the dashboard and wait for the v2 answer it renders the notice from. */ const openDashboard = async (page: Page, slug: string) => { const answered = page.waitForResponse((response) => response.url().endsWith(V2_IMPORT_API)); @@ -460,11 +507,121 @@ scenario( const after = yield* initializeInstructions(target.mcpUrl, bearer); expect(after.instructions, "the initialize result names the org's v2 MCP server").toBe( - "This organization moved to Executor v2 on October 9, 2026. " + - `Its MCP server is now ${v2McpUrl}; ask the user to reconnect their agent there ` + - `and choose the ${V2_SLUG} organization when signing in. ` + - "Changes made through this server won't reach v2.", + movedInstructions(v2McpUrl), ); expect(after.tools, "the tools are unchanged").toEqual(before.tools); }), ); + +/** + * An agent connected before the move got `initialize` instructions without the + * notice, and instructions only travel in `initialize`. Its session ends (HTTP + * 404, which tells a Streamable HTTP client to start a new session), and the + * new session's `initialize` carries the notice. `wait` is how long the agent + * stays quiet after the move. + */ +const connectedBeforeTheMove = (wait: Duration.Input) => + Effect.scoped( + Effect.gen(function* () { + const target = yield* Target; + const mcp = yield* Mcp; + const identity = yield* target.newIdentity(); + const org = yield* orgOf(target, identity); + const bearer = yield* mcp.mintBearer(emailOf(identity)); + + const early = yield* connectedClient(target.mcpUrl, bearer); + expect(early.getInstructions(), "an org that has not moved gets no notice").toBeUndefined(); + expect(yield* listToolsStatus(early), "the session works before the move").toBe(200); + + yield* markMoved(org.id); + const v2McpUrl = v2McpUrlOf(target, yield* v2ImportOf(target, identity)); + yield* Effect.sleep(wait); + + expect( + yield* listToolsStatus(early), + "the next request ends the session, so the client starts a new one", + ).toBe(404); + + const again = yield* connectedClient(target.mcpUrl, bearer); + expect(again.getInstructions(), "the new session's initialize carries the notice").toBe( + movedInstructions(v2McpUrl), + ); + // Past the re-read window, a session that already got the notice keeps + // working: the client is not sent round again. + yield* Effect.sleep(Duration.millis(E2E_MCP_V2_MOVED_RECHECK_MS * 2)); + expect(yield* listToolsStatus(again), "the new session keeps working").toBe(200); + expect(yield* listToolsStatus(again), "and keeps working").toBe(200); + }), + ); + +scenario( + "v2 moved · an agent connected before the move is told on its next request", + { timeout: 180_000 }, + // Longer than the re-read window and shorter than the idle timeout, so the + // session's runtime is still in memory and re-reads the move. + connectedBeforeTheMove(Duration.millis(E2E_MCP_V2_MOVED_RECHECK_MS * 1.5)), +); + +scenario( + "v2 moved · an agent connected before the move is told after its session is restored", + { timeout: 180_000 }, + // Past the idle timeout, with room for the alarm: the session's runtime is + // released, and the next request restores it without an `initialize`. + connectedBeforeTheMove(Duration.millis(configuredMcpSessionTimeoutMs() + 4_000)), +); + +// v2's slug rule allows 45 characters with no hyphen, which is one word the +// browser cannot break by default. +const UNBROKEN_SLUG = "syntheticorgslugwithnohyphensforlayoutchecks0"; + +scenario( + "v2 moved · the longest unbroken v2 slug stays inside the notice and the Connect card on a phone", + { timeout: 180_000 }, + Effect.gen(function* () { + expect(UNBROKEN_SLUG, "the longest slug v2 allows").toHaveLength(45); + const target = yield* Target; + const browser = yield* Browser; + const identity = yield* target.newIdentity(); + const org = yield* orgOf(target, identity); + yield* markMoved(org.id, UNBROKEN_SLUG); + + yield* browser.session(identity, async ({ page, step }) => { + // The narrowest common phone width. + await page.setViewportSize({ width: 320, height: 640 }); + + await step("Open the moved org's dashboard on a phone-sized screen", async () => { + await openDashboard(page, org.slug); + await noticeOf(page).waitFor(); + }); + + await step("See the org to choose at sign-in wrap inside the notice", async () => { + const notice = noticeOf(page); + const hint = notice + .getByRole("region", { name: "v2 MCP server URL" }) + .getByText( + `Connect your agents to v2 instead, and choose ${UNBROKEN_SLUG} when you sign in:`, + { exact: true }, + ); + const dialog = await boxOf(notice, "the notice"); + const text = await textExtent(hint); + expect(text.left, "the sentence starts inside the notice").toBeGreaterThanOrEqual( + dialog.left, + ); + expect(text.right, "the sentence ends inside the notice").toBeLessThanOrEqual(dialog.right); + }); + + await step("Close the notice and see the hint wrap inside the Connect card", async () => { + await noticeOf(page).getByRole("button", { name: "Close" }).click(); + await noticeOf(page).waitFor({ state: "hidden" }); + const next = page.getByRole("region", { name: "New MCP server" }); + const card = page.locator('[data-slot="card-stack"]').filter({ has: next }); + const hint = next.getByText(`Choose ${UNBROKEN_SLUG} when you sign in.`, { exact: true }); + await hint.scrollIntoViewIfNeeded(); + const cardBox = await boxOf(card, "the Connect card"); + const text = await textExtent(hint); + expect(text.left, "the hint starts inside the card").toBeGreaterThanOrEqual(cardBox.left); + expect(text.right, "the hint ends inside the card").toBeLessThanOrEqual(cardBox.right); + }); + }); + }), +); diff --git a/e2e/setup/cloud.boot.ts b/e2e/setup/cloud.boot.ts index 8314e7bc0b..91630d499e 100644 --- a/e2e/setup/cloud.boot.ts +++ b/e2e/setup/cloud.boot.ts @@ -17,6 +17,7 @@ import { E2E_EXECUTION_RATE_LIMIT_CHECK_TIMEOUT_MS, } from "./execution-limits"; import { E2E_MCP_RESIDENT_RUNTIME_SOFT_CAP } from "./resident-runtime-cap"; +import { E2E_MCP_V2_MOVED_RECHECK_MS } from "./v2-moved-recheck"; export const cloudDir = fileURLToPath(new URL("../../apps/cloud/", import.meta.url)); @@ -112,6 +113,8 @@ export const bootCloud = async (options: CloudBootOptions): Promise // set unconditionally for the whole boot (same treatment as the execution // rate limit below). MCP_RESIDENT_RUNTIME_SOFT_CAP: String(E2E_MCP_RESIDENT_RUNTIME_SOFT_CAP), + // See v2-moved-recheck.ts. + MCP_V2_MOVED_RECHECK_MS: String(E2E_MCP_V2_MOVED_RECHECK_MS), ALLOW_LOCAL_NETWORK: "true", // A first-party GitHub app for the first-party-oauth scenario: proves the // env → HostConfig → executor plumbing end to end. The scenario asserts the diff --git a/e2e/setup/v2-moved-recheck.ts b/e2e/setup/v2-moved-recheck.ts new file mode 100644 index 0000000000..c85f5509db --- /dev/null +++ b/e2e/setup/v2-moved-recheck.ts @@ -0,0 +1,7 @@ +// How often the e2e worker's resident MCP sessions re-read their org's move to +// v2 (MCP_V2_MOVED_RECHECK_MS; production waits five minutes). One constant +// with two consumers, the boot recipe env (cloud.boot.ts) and the v2-moved +// scenario (cloud/v2-moved-notice.test.ts), so they cannot drift apart. Short +// enough for a scenario to wait out, and well under the e2e idle timeout, so a +// session that is still resident re-reads instead of being restored. +export const E2E_MCP_V2_MOVED_RECHECK_MS = 1_000; diff --git a/packages/hosts/cloudflare/src/mcp/agent-session-durable-object.test.ts b/packages/hosts/cloudflare/src/mcp/agent-session-durable-object.test.ts index 813f4d5c40..0485acfc0b 100644 --- a/packages/hosts/cloudflare/src/mcp/agent-session-durable-object.test.ts +++ b/packages/hosts/cloudflare/src/mcp/agent-session-durable-object.test.ts @@ -381,6 +381,127 @@ it("keeps the chosen approval lifetime when reading a stored decision", async () // in the rebuilt server's memory. These pin the storage round-trip that lets a // cold-restored session rebuild with it instead of silently downgrading every // artifact to a deep link. +// A host can need a session's client to start over: something the client was +// told at `initialize` is now out of date, and `initialize` is the only place +// it is told. The session ends (the DELETE path's deferred destroy), and every +// request for it then answers "terminated", which the worker sends as HTTP 404. +describe("McpAgentSessionDOBase ending a session for re-initialize", () => { + type ReinitializeSession = HarnessSession & { + clientMustReinitialize: () => Effect.Effect; + onClientInitialize: () => Effect.Effect; + setInitializeRequest: (request: JSONRPCMessage) => Promise; + _cf_scheduleDestroy: () => Promise; + }; + + const owner = { accountId: "user-1", organizationId: "org-1" }; + + const makeReinitializeSession = async (mustReinitialize: boolean) => { + const session = (await makeHarnessSession()) as ReinitializeSession; + let destroyCalls = 0; + session.clientMustReinitialize = () => Effect.succeed(mustReinitialize); + // The agents SDK's deferred destroy: the durable marker it writes is what + // makes later requests answer "terminated". + session._cf_scheduleDestroy = async () => { + destroyCalls += 1; + await session.ctx.storage.put("cf_agents_destroy_pending", true); + }; + return { session, destroyCalls: () => destroyCalls }; + }; + + it("leaves a session alone by default", async () => { + const session = (await makeHarnessSession()) as ReinitializeSession; + await expect(session.validateMcpSessionOwner(owner, defaultMcpResource)).resolves.toBe("ok"); + }); + + it("ends a session whose client must re-initialize, and stays ended", async () => { + const { session, destroyCalls } = await makeReinitializeSession(true); + + await expect(session.validateMcpSessionOwner(owner, defaultMcpResource)).resolves.toBe( + "terminated", + ); + expect(destroyCalls()).toBe(1); + await expect(session.validateMcpSessionOwner(owner, defaultMcpResource)).resolves.toBe( + "terminated", + ); + expect(destroyCalls(), "an ended session is not ended twice").toBe(1); + }); + + it("ends a session whose runtime had to be restored", async () => { + const { session, destroyCalls } = await makeReinitializeSession(true); + await session.alarm(); + expect(session.initialized, "the idle alarm released the runtime").toBe(false); + + await expect(session.validateMcpSessionOwner(owner, defaultMcpResource)).resolves.toBe( + "terminated", + ); + expect(destroyCalls()).toBe(1); + }); + + it("keeps a session with a paused execution until it settles", async () => { + const { session, destroyCalls } = await makeReinitializeSession(true); + let paused = 1; + session.engine = { + ...makeEngine().engine, + pausedExecutionCount: () => Effect.sync(() => paused), + }; + + await expect(session.validateMcpSessionOwner(owner, defaultMcpResource)).resolves.toBe("ok"); + expect(destroyCalls(), "an open approval stays answerable").toBe(0); + + paused = 0; + await expect(session.validateMcpSessionOwner(owner, defaultMcpResource)).resolves.toBe( + "terminated", + ); + expect(destroyCalls()).toBe(1); + }); + + it("keeps a session with a request still running", async () => { + const { session, destroyCalls } = await makeReinitializeSession(true); + await session.setStreamRequestIds("stream-running", ["tools-call-1"]); + + await expect(session.validateMcpSessionOwner(owner, defaultMcpResource)).resolves.toBe("ok"); + expect(destroyCalls(), "the running request still gets its response").toBe(0); + }); + + it("never ends a session for a caller that does not own it", async () => { + const { session, destroyCalls } = await makeReinitializeSession(true); + + await expect( + session.validateMcpSessionOwner( + { accountId: "user-2", organizationId: "org-1" }, + defaultMcpResource, + ), + ).resolves.toBe("forbidden"); + expect(destroyCalls()).toBe(0); + }); + + it("tells the host when a client's live initialize arrives", async () => { + const session = (await makeHarnessSession()) as ReinitializeSession; + let initializes = 0; + session.onClientInitialize = () => + Effect.sync(() => { + initializes += 1; + }); + const initialize = { + jsonrpc: "2.0", + id: 1, + method: "initialize", + params: { + protocolVersion: "2025-06-18", + capabilities: {}, + clientInfo: { name: "example-client", version: "1.0.0" }, + }, + } satisfies JSONRPCMessage; + + await session.setInitializeRequest(initialize); + + expect(initializes).toBe(1); + expect(await session.ctx.storage.get("initializeRequest"), "the SDK still stores it").toEqual( + initialize, + ); + }); +}); + describe("McpAgentSessionDOBase apps capability persistence", () => { type CapabilitySession = HarnessSession & { persistAppsEnabled: (appsEnabled: boolean) => Effect.Effect;