Skip to content

Commit 0f37340

Browse files
committed
Test that pre-move MCP sessions re-initialize and long v2 slugs wrap
1 parent 4d84342 commit 0f37340

5 files changed

Lines changed: 326 additions & 16 deletions

File tree

‎apps/cloud/src/mcp/v2-moved-instructions.test.ts‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { WorkOSClient, type WorkOSClientService } from "../auth/workos";
77
import { WorkOSError } from "../auth/errors";
88
import {
99
V2_MOVED_READ_TIMEOUT_MS,
10+
mustReinitializeForV2Move,
1011
v2MovedInstructionsText,
1112
v2MovedMcpInstructions,
1213
} from "./v2-moved-instructions";
@@ -101,3 +102,24 @@ describe("v2 moved MCP instructions", () => {
101102
}).pipe(Effect.provide(workosAnswering(() => Effect.never))),
102103
);
103104
});
105+
106+
describe("re-initializing a session for the v2 move", () => {
107+
it("re-initializes a client that got no notice once the org has moved", () => {
108+
expect(mustReinitializeForV2Move(undefined, EXPECTED)).toBe(true);
109+
});
110+
111+
it("re-initializes a client whose notice is out of date", () => {
112+
expect(
113+
mustReinitializeForV2Move(EXPECTED.replace("example-team", "other-team"), EXPECTED),
114+
).toBe(true);
115+
});
116+
117+
it("leaves a client that got the current notice alone", () => {
118+
expect(mustReinitializeForV2Move(EXPECTED, EXPECTED)).toBe(false);
119+
});
120+
121+
it("leaves a client alone when the org has not moved or the read failed", () => {
122+
expect(mustReinitializeForV2Move(undefined, undefined)).toBe(false);
123+
expect(mustReinitializeForV2Move(EXPECTED, undefined)).toBe(false);
124+
});
125+
});

‎e2e/cloud/v2-moved-notice.test.ts‎

Lines changed: 173 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -8,16 +8,21 @@
88
// PostHog client, so `trackEvent` is a no-op. Their names and properties are
99
// fixed by the typed catalog in `packages/react/src/api/analytics.tsx`.
1010
import { expect } from "@effect/vitest";
11-
import { Effect } from "effect";
11+
import { Duration, Effect } from "effect";
1212
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
13-
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
14-
import type { Page } from "playwright";
13+
import {
14+
StreamableHTTPClientTransport,
15+
StreamableHTTPError,
16+
} from "@modelcontextprotocol/sdk/client/streamableHttp.js";
17+
import type { Locator, Page } from "playwright";
1518

1619
import { scenario } from "../src/scenario";
1720
import { Browser, Mcp, Target } from "../src/services";
1821
import { revisit, settle, visit } from "../src/surfaces/browser";
1922
import type { Identity, Target as TargetShape } from "../src/target";
2023
import { WORKOS_EMULATOR_PORT } from "../targets/cloud";
24+
import { configuredMcpSessionTimeoutMs } from "../setup/mcp-session-timeouts";
25+
import { E2E_MCP_V2_MOVED_RECHECK_MS } from "../setup/v2-moved-recheck";
2126
import { verifyAdmin } from "./support/admin-mfa";
2227
import { cookieOf } from "./support/session";
2328

@@ -86,9 +91,9 @@ const setOrgMetadata = (
8691
await response.body?.cancel();
8792
});
8893

89-
const markMoved = (organizationId: string) =>
94+
const markMoved = (organizationId: string, slug: string = V2_SLUG) =>
9095
setOrgMetadata(organizationId, {
91-
executor_v2_org_slug: V2_SLUG,
96+
executor_v2_org_slug: slug,
9297
executor_v2_moved_at: MOVED_AT,
9398
});
9499

@@ -127,9 +132,10 @@ const v2McpUrlOf = (target: TargetShape, answer: V2ImportAnswer): string => {
127132

128133
const emailOf = (identity: Identity): string => identity.credentials?.email ?? identity.label;
129134

130-
/** A real MCP client's handshake: the server's `initialize` instructions. */
131-
const initializeInstructions = (mcpUrl: string, bearer: string) =>
132-
Effect.acquireUseRelease(
135+
/** A real MCP client, connected (the `initialize` handshake done) for the
136+
* scope, closed after. */
137+
const connectedClient = (mcpUrl: string, bearer: string) =>
138+
Effect.acquireRelease(
133139
Effect.promise(async () => {
134140
const client = new Client({ name: "executor-e2e-v2-moved", version: "0.0.1" });
135141
await client.connect(
@@ -139,14 +145,55 @@ const initializeInstructions = (mcpUrl: string, bearer: string) =>
139145
);
140146
return client;
141147
}),
142-
(client) =>
143-
Effect.promise(async () => ({
148+
(client) => Effect.promise(() => client.close().catch(() => undefined)),
149+
);
150+
151+
/** A real MCP client's handshake: the server's `initialize` instructions. */
152+
const initializeInstructions = (mcpUrl: string, bearer: string) =>
153+
Effect.scoped(
154+
Effect.gen(function* () {
155+
const client = yield* connectedClient(mcpUrl, bearer);
156+
return yield* Effect.promise(async () => ({
144157
instructions: client.getInstructions(),
145158
tools: (await client.listTools()).tools.map((tool) => tool.name),
146-
})),
147-
(client) => Effect.promise(() => client.close().catch(() => undefined)),
159+
}));
160+
}),
148161
);
149162

163+
/** The HTTP status a request on the client's session answers, or 200. */
164+
const listToolsStatus = (client: Client): Effect.Effect<number> =>
165+
Effect.promise(() =>
166+
client.listTools().then(
167+
() => 200,
168+
(error: unknown) => {
169+
if (error instanceof StreamableHTTPError && error.code !== undefined) return error.code;
170+
throw error;
171+
},
172+
),
173+
);
174+
175+
const movedInstructions = (v2McpUrl: string, slug: string = V2_SLUG) =>
176+
"This organization moved to Executor v2 on October 9, 2026. " +
177+
`Its MCP server is now ${v2McpUrl}; ask the user to reconnect their agent there ` +
178+
`and choose the ${slug} organization when signing in. ` +
179+
"Changes made through this server won't reach v2.";
180+
181+
/** Where an element's text is drawn, which overflows the element's own box
182+
* when the text cannot wrap. */
183+
const textExtent = (locator: Locator) =>
184+
locator.evaluate((element) => {
185+
const range = document.createRange();
186+
range.selectNodeContents(element);
187+
const box = range.getBoundingClientRect();
188+
return { left: box.left, right: box.right };
189+
});
190+
191+
const boxOf = async (locator: Locator, label: string) => {
192+
const box = await locator.boundingBox();
193+
if (box === null) throw new Error(`${label} has no layout box`);
194+
return { left: box.x, right: box.x + box.width };
195+
};
196+
150197
/** Open the dashboard and wait for the v2 answer it renders the notice from. */
151198
const openDashboard = async (page: Page, slug: string) => {
152199
const answered = page.waitForResponse((response) => response.url().endsWith(V2_IMPORT_API));
@@ -460,11 +507,121 @@ scenario(
460507

461508
const after = yield* initializeInstructions(target.mcpUrl, bearer);
462509
expect(after.instructions, "the initialize result names the org's v2 MCP server").toBe(
463-
"This organization moved to Executor v2 on October 9, 2026. " +
464-
`Its MCP server is now ${v2McpUrl}; ask the user to reconnect their agent there ` +
465-
`and choose the ${V2_SLUG} organization when signing in. ` +
466-
"Changes made through this server won't reach v2.",
510+
movedInstructions(v2McpUrl),
467511
);
468512
expect(after.tools, "the tools are unchanged").toEqual(before.tools);
469513
}),
470514
);
515+
516+
/**
517+
* An agent connected before the move got `initialize` instructions without the
518+
* notice, and instructions only travel in `initialize`. Its session ends (HTTP
519+
* 404, which tells a Streamable HTTP client to start a new session), and the
520+
* new session's `initialize` carries the notice. `wait` is how long the agent
521+
* stays quiet after the move.
522+
*/
523+
const connectedBeforeTheMove = (wait: Duration.Input) =>
524+
Effect.scoped(
525+
Effect.gen(function* () {
526+
const target = yield* Target;
527+
const mcp = yield* Mcp;
528+
const identity = yield* target.newIdentity();
529+
const org = yield* orgOf(target, identity);
530+
const bearer = yield* mcp.mintBearer(emailOf(identity));
531+
532+
const early = yield* connectedClient(target.mcpUrl, bearer);
533+
expect(early.getInstructions(), "an org that has not moved gets no notice").toBeUndefined();
534+
expect(yield* listToolsStatus(early), "the session works before the move").toBe(200);
535+
536+
yield* markMoved(org.id);
537+
const v2McpUrl = v2McpUrlOf(target, yield* v2ImportOf(target, identity));
538+
yield* Effect.sleep(wait);
539+
540+
expect(
541+
yield* listToolsStatus(early),
542+
"the next request ends the session, so the client starts a new one",
543+
).toBe(404);
544+
545+
const again = yield* connectedClient(target.mcpUrl, bearer);
546+
expect(again.getInstructions(), "the new session's initialize carries the notice").toBe(
547+
movedInstructions(v2McpUrl),
548+
);
549+
// Past the re-read window, a session that already got the notice keeps
550+
// working: the client is not sent round again.
551+
yield* Effect.sleep(Duration.millis(E2E_MCP_V2_MOVED_RECHECK_MS * 2));
552+
expect(yield* listToolsStatus(again), "the new session keeps working").toBe(200);
553+
expect(yield* listToolsStatus(again), "and keeps working").toBe(200);
554+
}),
555+
);
556+
557+
scenario(
558+
"v2 moved · an agent connected before the move is told on its next request",
559+
{ timeout: 180_000 },
560+
// Longer than the re-read window and shorter than the idle timeout, so the
561+
// session's runtime is still in memory and re-reads the move.
562+
connectedBeforeTheMove(Duration.millis(E2E_MCP_V2_MOVED_RECHECK_MS * 1.5)),
563+
);
564+
565+
scenario(
566+
"v2 moved · an agent connected before the move is told after its session is restored",
567+
{ timeout: 180_000 },
568+
// Past the idle timeout, with room for the alarm: the session's runtime is
569+
// released, and the next request restores it without an `initialize`.
570+
connectedBeforeTheMove(Duration.millis(configuredMcpSessionTimeoutMs() + 4_000)),
571+
);
572+
573+
// v2's slug rule allows 45 characters with no hyphen, which is one word the
574+
// browser cannot break by default.
575+
const UNBROKEN_SLUG = "syntheticorgslugwithnohyphensforlayoutchecks0";
576+
577+
scenario(
578+
"v2 moved · the longest unbroken v2 slug stays inside the notice and the Connect card on a phone",
579+
{ timeout: 180_000 },
580+
Effect.gen(function* () {
581+
expect(UNBROKEN_SLUG, "the longest slug v2 allows").toHaveLength(45);
582+
const target = yield* Target;
583+
const browser = yield* Browser;
584+
const identity = yield* target.newIdentity();
585+
const org = yield* orgOf(target, identity);
586+
yield* markMoved(org.id, UNBROKEN_SLUG);
587+
588+
yield* browser.session(identity, async ({ page, step }) => {
589+
// The narrowest common phone width.
590+
await page.setViewportSize({ width: 320, height: 640 });
591+
592+
await step("Open the moved org's dashboard on a phone-sized screen", async () => {
593+
await openDashboard(page, org.slug);
594+
await noticeOf(page).waitFor();
595+
});
596+
597+
await step("See the org to choose at sign-in wrap inside the notice", async () => {
598+
const notice = noticeOf(page);
599+
const hint = notice
600+
.getByRole("region", { name: "v2 MCP server URL" })
601+
.getByText(
602+
`Connect your agents to v2 instead, and choose ${UNBROKEN_SLUG} when you sign in:`,
603+
{ exact: true },
604+
);
605+
const dialog = await boxOf(notice, "the notice");
606+
const text = await textExtent(hint);
607+
expect(text.left, "the sentence starts inside the notice").toBeGreaterThanOrEqual(
608+
dialog.left,
609+
);
610+
expect(text.right, "the sentence ends inside the notice").toBeLessThanOrEqual(dialog.right);
611+
});
612+
613+
await step("Close the notice and see the hint wrap inside the Connect card", async () => {
614+
await noticeOf(page).getByRole("button", { name: "Close" }).click();
615+
await noticeOf(page).waitFor({ state: "hidden" });
616+
const next = page.getByRole("region", { name: "New MCP server" });
617+
const card = page.locator('[data-slot="card-stack"]').filter({ has: next });
618+
const hint = next.getByText(`Choose ${UNBROKEN_SLUG} when you sign in.`, { exact: true });
619+
await hint.scrollIntoViewIfNeeded();
620+
const cardBox = await boxOf(card, "the Connect card");
621+
const text = await textExtent(hint);
622+
expect(text.left, "the hint starts inside the card").toBeGreaterThanOrEqual(cardBox.left);
623+
expect(text.right, "the hint ends inside the card").toBeLessThanOrEqual(cardBox.right);
624+
});
625+
});
626+
}),
627+
);

‎e2e/setup/cloud.boot.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
E2E_EXECUTION_RATE_LIMIT_CHECK_TIMEOUT_MS,
1818
} from "./execution-limits";
1919
import { E2E_MCP_RESIDENT_RUNTIME_SOFT_CAP } from "./resident-runtime-cap";
20+
import { E2E_MCP_V2_MOVED_RECHECK_MS } from "./v2-moved-recheck";
2021

2122
export const cloudDir = fileURLToPath(new URL("../../apps/cloud/", import.meta.url));
2223

@@ -112,6 +113,8 @@ export const bootCloud = async (options: CloudBootOptions): Promise<CloudBooted>
112113
// set unconditionally for the whole boot (same treatment as the execution
113114
// rate limit below).
114115
MCP_RESIDENT_RUNTIME_SOFT_CAP: String(E2E_MCP_RESIDENT_RUNTIME_SOFT_CAP),
116+
// See v2-moved-recheck.ts.
117+
MCP_V2_MOVED_RECHECK_MS: String(E2E_MCP_V2_MOVED_RECHECK_MS),
115118
ALLOW_LOCAL_NETWORK: "true",
116119
// A first-party GitHub app for the first-party-oauth scenario: proves the
117120
// env → HostConfig → executor plumbing end to end. The scenario asserts the

‎e2e/setup/v2-moved-recheck.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
// How often the e2e worker's resident MCP sessions re-read their org's move to
2+
// v2 (MCP_V2_MOVED_RECHECK_MS; production waits five minutes). One constant
3+
// with two consumers, the boot recipe env (cloud.boot.ts) and the v2-moved
4+
// scenario (cloud/v2-moved-notice.test.ts), so they cannot drift apart. Short
5+
// enough for a scenario to wait out, and well under the e2e idle timeout, so a
6+
// session that is still resident re-reads instead of being restored.
7+
export const E2E_MCP_V2_MOVED_RECHECK_MS = 1_000;

0 commit comments

Comments
 (0)