1import * as Orchestrator from "../orchestration-v2/Orchestrator.ts";
2import * as ProjectionStore from "../orchestration-v2/ProjectionStore.ts";
3import { expect, it } from "@effect/vitest";
4import { NodeHttpServer } from "@effect/platform-node";
5import * as NodeServices from "@effect/platform-node/NodeServices";
6import {
7 EnvironmentId,
8 OrchestratorMcpFailure,
9 PreviewTabId,
10 ProviderInstanceId,
11 ThreadId,
12} from "@t3tools/contracts";
13import * as Deferred from "effect/Deferred";
14import * as Effect from "effect/Effect";
15import * as FileSystem from "effect/FileSystem";
16import * as Layer from "effect/Layer";
17import * as Path from "effect/Path";
18import * as Schema from "effect/Schema";
19import * as Stream from "effect/Stream";
20import { McpSchema, McpServer, Tool, Toolkit } from "effect/ai";
21import {
22 HttpBody,
23 HttpClient,
24 HttpClientRequest,
25 HttpRouter,
26 HttpServerResponse,
27} from "effect/http";
28
29import * as ProjectService from "../project/ProjectService.ts";
30import * as PullRequestReviewGuideService from "../pullRequest/PullRequestReviewGuideService.ts";
31import * as ServerConfig from "../config.ts";
32import * as McpHttpServer from "./McpHttpServer.ts";
33import * as McpToolAccess from "./McpToolAccess.ts";
34import * as McpToolAccessTestkit from "./McpToolAccess.testkit.ts";
35import * as McpInvocationContext from "./McpInvocationContext.ts";
36import * as McpSessionRegistry from "./McpSessionRegistry.ts";
37import * as PreviewAutomationBroker from "./PreviewAutomationBroker.ts";
38
39const environmentId = EnvironmentId.make("environment-mcp-test");
40const threadId = ThreadId.make("thread-mcp-test");
41const tabId = PreviewTabId.make("tab-mcp-test");
42const alternateTabId = PreviewTabId.make("tab-mcp-alternate");
43const decodeJsonText = Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Unknown));
44const invocation = {
45 environmentId,
46 requestNamespace: "provider-session-mcp-test",
47 thread: {
48 threadId,
49 providerSessionId: "provider-session-mcp-test",
50 providerInstanceId: ProviderInstanceId.make("codex"),
51 },
52 client: undefined,
53 capabilities: new Set(["preview"] as const),
54 issuedAt: 1,
55};
56const client = McpSchema.McpServerClient.of({
57 clientId: 1,
58 clientCapabilities: {},
59 clientInfo: { name: "mcp-test", version: "1.0.0" },
60 protocolVersion: "2025-06-18",
61 initializePayload: {
62 protocolVersion: "2025-06-18",
63 capabilities: {},
64 clientInfo: { name: "mcp-test", version: "1.0.0" },
65 },
66 getClient: Effect.die("unused"),
67});
68const layerTest = McpHttpServer.layerPreviewToolkit.pipe(
69 Layer.provideMerge(McpServer.McpServer.layer),
70 Layer.provideMerge(McpToolAccessTestkit.liveThreadsLayer),
71 Layer.provideMerge(PreviewAutomationBroker.layer),
72 Layer.provideMerge(ServerConfig.layerTest(process.cwd(), { prefix: "t3-mcp-http-server-test-" })),
73 Layer.provideMerge(NodeServices.layer),
74);
75const layerPullRequestsTest = McpHttpServer.layerPullRequestsToolkit.pipe(
76 Layer.provideMerge(McpServer.McpServer.layer),
77 Layer.provideMerge(McpToolAccessTestkit.liveThreadsLayer),
78 Layer.provide(
79 Layer.mergeAll(
80 Layer.mock(ProjectService.ProjectService)({}),
81 Layer.mock(PullRequestReviewGuideService.PullRequestReviewGuideService)({}),
82 Layer.mock(Orchestrator.OrchestratorV2)({}),
83 Layer.mock(ProjectionStore.ProjectionStoreV2)({}),
84 NodeServices.layer,
85 ),
86 ),
87);
88
89const snapshotResult = {
90 url: "http://example.test/",
91 title: "Example",
92 loading: false,
93 visibleText: "Example",
94 interactiveElements: [],
95 accessibilityTree: {},
96 consoleEntries: [],
97 networkEntries: [],
98 actionTimeline: [],
99 screenshot: {
100 mimeType: "image/png",
101 data: Buffer.from("png").toString("base64"),
102 width: 10,
103 height: 5,
104 },
105};
106
107/** Answers every snapshot request on a fresh broker host with the given result. */
108const serveSnapshots = (clientId: string, result: unknown) =>
109 Effect.gen(function* () {
110 const broker = yield* PreviewAutomationBroker.PreviewAutomationBroker;
111 const connected = yield* Deferred.make<void>();
112 const inputs: Array<unknown> = [];
113 const events = yield* broker.connect({ clientId, environmentId });
114 yield* Stream.runForEach(events, (event) => {
115 if (event.type === "connected") return Deferred.succeed(connected, undefined);
116 inputs.push(event.request.input);
117 return broker.respond({
118 clientId,
119 connectionId: event.connectionId,
120 requestId: event.request.requestId,
121 ok: true,
122 result,
123 });
124 }).pipe(Effect.forkScoped);
125 yield* Deferred.await(connected);
126 return inputs;
127 });
128
129const callSnapshot = (args: Record<string, unknown>) =>
130 Effect.gen(function* () {
131 const server = yield* McpServer.McpServer;
132 return yield* server
133 .callTool({ name: "preview_snapshot", arguments: args })
134 .pipe(
135 Effect.provideService(McpInvocationContext.McpInvocationContext, invocation),
136 Effect.provideService(McpSchema.McpServerClient, client),
137 );
138 });
139
140it("normalizes empty successful notification responses to accepted", () => {
141 const notificationResponse = McpHttpServer.normalizeMcpHttpResponse(
142 HttpServerResponse.text("", { status: 200, contentType: "application/json" }),
143 );
144 expect(notificationResponse.status).toBe(202);
145
146 const resultResponse = McpHttpServer.normalizeMcpHttpResponse(
147 HttpServerResponse.jsonUnsafe({ jsonrpc: "2.0", id: 1, result: {} }),
148 );
149 expect(resultResponse.status).toBe(200);
150});
151
152it.effect.each([{}, { includeImage: false }])(
153 "returns bounded structural preview snapshot failures %#",
154 (input) =>
155 Effect.scoped(
156 Effect.gen(function* () {
157 const server = yield* McpServer.McpServer;
158 const broker = yield* PreviewAutomationBroker.PreviewAutomationBroker;
159 const events = yield* broker.connect({
160 clientId: "mcp-failure-client",
161 environmentId,
162 });
163 yield* Stream.runForEach(events, (event) =>
164 event.type === "connected"
165 ? Effect.void
166 : broker.respond({
167 clientId: "mcp-failure-client",
168 connectionId: event.connectionId,
169 requestId: event.request.requestId,
170 ok: false,
171 error: {
172 _tag: "PreviewAutomationExecutionError",
173 message: "sensitive renderer failure",
174 detail: { consoleOutput: "sensitive browser output" },
175 },
176 }),
177 ).pipe(Effect.forkScoped);
178 yield* Effect.yieldNow;
179
180 const snapshot = yield* server
181 .callTool({ name: "preview_snapshot", arguments: input })
182 .pipe(
183 Effect.provideService(McpInvocationContext.McpInvocationContext, invocation),
184 Effect.provideService(McpSchema.McpServerClient, client),
185 );
186
187 const message = "Preview automation snapshot failed on client mcp-failure-client.";
188 expect(snapshot.isError).toBe(true);
189 expect(snapshot.content).toEqual([
190 { type: "text", text: `Preview snapshot failed: ${message}` },
191 ]);
192 expect(snapshot.structuredContent).toEqual({
193 error: {
194 _tag: "PreviewAutomationExecutionError",
195 operation: "snapshot",
196 failureCount: 1,
197 message,
198 },
199 });
200 }),
201 ).pipe(Effect.provide(layerTest)),
202);
203
204it.effect.each([
205 { args: {}, advice: "No active preview tab was found for snapshot. Call preview_open first." },
206 {
207 args: { tabId: alternateTabId },
208 advice: `Preview tab ${alternateTabId} was not found for snapshot. Omit tabId to use the current tab, or call preview_open.`,
209 },
210])("tells the agent to open a tab when the snapshot has none $args", ({ args, advice }) =>
211 Effect.scoped(
212 Effect.gen(function* () {
213 const broker = yield* PreviewAutomationBroker.PreviewAutomationBroker;
214 const connected = yield* Deferred.make<void>();
215 const events = yield* broker.connect({ clientId: "mcp-no-tab-client", environmentId });
216 yield* Stream.runForEach(events, (event) =>
217 event.type === "connected"
218 ? Deferred.succeed(connected, undefined)
219 : broker.respond({
220 clientId: "mcp-no-tab-client",
221 connectionId: event.connectionId,
222 requestId: event.request.requestId,
223 ok: false,
224 error: { _tag: "PreviewAutomationTabNotFoundError", message: "no tab" },
225 }),
226 ).pipe(Effect.forkScoped);
227 yield* Deferred.await(connected);
228
229 const snapshot = yield* callSnapshot(args);
230
231 expect(snapshot.isError).toBe(true);
232 expect(snapshot.content).toEqual([
233 { type: "text", text: `Preview snapshot failed: ${advice}` },
234 ]);
235 }),
236 ).pipe(Effect.provide(layerTest)),
237);
238
239it.effect("tells the agent how to recover when no preview host can run the snapshot", () =>
240 Effect.gen(function* () {
241 const snapshot = yield* callSnapshot({});
242
243 expect(snapshot.isError).toBe(true);
244 const [text] = snapshot.content;
245 expect(text?.type === "text" ? text.text : "").toContain(
246 "Retry preview_status, then call preview_open",
247 );
248 expect(snapshot.structuredContent).toMatchObject({
249 error: { _tag: "PreviewAutomationNoAvailableHostError" },
250 });
251 }).pipe(Effect.provide(layerTest)),
252);
253
254it.effect.each([
255 { mode: "default", input: {}, images: false },
256 { mode: "explicit image", input: { includeImage: true }, images: true },
257 { mode: "text only", input: { includeImage: false }, images: false },
258])("returns fresh $mode snapshots on repeated MCP calls", ({ input, images }) =>
259 Effect.scoped(
260 Effect.gen(function* () {
261 const server = yield* McpServer.McpServer;
262 const broker = yield* PreviewAutomationBroker.PreviewAutomationBroker;
263 const connected = yield* Deferred.make<void>();
264 const png =
265 "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jRZkAAAAASUVORK5CYII=";
266 const page = {
267 url: "http://example.test/",
268 loading: false,
269 visibleText: "Save your changes",
270 interactiveElements: [
271 {
272 tag: "button",
273 role: "button",
274 name: "Save",
275 selector: "#save",
276 x: 0,
277 y: 0,
278 width: 20,
279 height: 10,
280 },
281 ],
282 accessibilityTree: { role: "document", name: "Example" },
283 consoleEntries: [],
284 networkEntries: [],
285 actionTimeline: [],
286 };
287 const screenshot = { mimeType: "image/png", width: 1, height: 1 };
288 let requests = 0;
289 const events = yield* broker.connect({ clientId: "mcp-image-option-client", environmentId });
290 yield* Stream.runForEach(events, (event) => {
291 if (event.type === "connected") return Deferred.succeed(connected, undefined);
292 requests += 1;
293 expect(event.request).toMatchObject({
294 operation: "snapshot",
295 tabId: alternateTabId,
296 threadId,
297 });
298 expect(event.request.input).toEqual({});
299 return broker.respond({
300 clientId: "mcp-image-option-client",
301 connectionId: event.connectionId,
302 requestId: event.request.requestId,
303 ok: true,
304 result: {
305 ...page,
306 title: `Snapshot ${requests}`,
307 screenshot: { ...screenshot, data: png },
308 },
309 });
310 }).pipe(Effect.forkScoped);
311 yield* Deferred.await(connected);
312
313 for (const call of [1, 2, 3, 4, 5, 6]) {
314 const snapshot = yield* server
315 .callTool({
316 name: "preview_snapshot",
317 arguments: { ...input, tabId: alternateTabId },
318 })
319 .pipe(
320 Effect.provideService(McpInvocationContext.McpInvocationContext, invocation),
321 Effect.provideService(McpSchema.McpServerClient, client),
322 );
323 const metadata = { ...page, title: `Snapshot ${call}`, screenshot };
324 const { accessibilityTree: _tree, ...boundedMetadata } = metadata;
325 expect(snapshot.isError).toBe(false);
326 expect(snapshot.structuredContent).toEqual({
327 ...boundedMetadata,
328 omitted: ["accessibilityTree (use interactiveElements locators or preview_evaluate)"],
329 });
330 const [identity, text, ...rest] = snapshot.content;
331 expect(identity?.type === "text" ? decodeJsonText(identity.text) : null).toEqual({
332 url: page.url,
333 });
334 expect(text?.type === "text" ? decodeJsonText(text.text) : null).toEqual(boundedMetadata);
335 expect(rest).toEqual([
336 {
337 type: "text",
338 text: "Snapshot text was bounded. Omitted: accessibilityTree (use interactiveElements locators or preview_evaluate).",
339 },
340 ...(images
341 ? [
342 {
343 type: "image",
344 mimeType: "image/png",
345 data: new Uint8Array(Buffer.from(png, "base64")),
346 },
347 ]
348 : []),
349 ]);
350 }
351
352 // Output selection belongs to this call, not the MCP session's history.
353 const nextDefault = yield* server
354 .callTool({
355 name: "preview_snapshot",
356 arguments: { tabId: alternateTabId },
357 })
358 .pipe(
359 Effect.provideService(McpInvocationContext.McpInvocationContext, invocation),
360 Effect.provideService(McpSchema.McpServerClient, client),
361 );
362 expect(nextDefault.content.map((content) => content.type)).toEqual(["text", "text", "text"]);
363 expect(nextDefault.structuredContent).toMatchObject({ title: "Snapshot 7", screenshot });
364 expect(nextDefault.structuredContent).not.toHaveProperty("accessibilityTree");
365 expect(requests).toBe(7);
366 }),
367 ).pipe(Effect.provide(layerTest)),
368);
369
370it.effect("refuses preview tools to a client outside a thread before they run", () =>
371 Effect.gen(function* () {
372 const server = yield* McpServer.McpServer;
373 const readOnly = {
374 ...invocation,
375 thread: undefined,
376 requestNamespace: "client:session-1",
377 client: { sessionId: "session-1", label: "Claude Code", access: "read-only" as const },
378 };
379 const click = yield* server
380 .callTool({ name: "preview_click", arguments: { locator: "text=Send" } })
381 .pipe(
382 Effect.provideService(McpInvocationContext.McpInvocationContext, readOnly),
383 Effect.provideService(McpSchema.McpServerClient, client),
384 );
385 expect(click.isError).toBe(true);
386 expect(click.content).toEqual([
387 { type: "text", text: expect.stringContaining("needs an agent running inside T3 Code") },
388 ]);
389 }).pipe(Effect.provide(layerTest)),
390);
391
392it.effect("rejects non-boolean snapshot image options before selecting a browser host", () =>
393 Effect.gen(function* () {
394 const server = yield* McpServer.McpServer;
395 for (const includeImage of ["false", 0, null]) {
396 const result = yield* server
397 .callTool({
398 name: "preview_snapshot",
399 arguments: { includeImage },
400 })
401 .pipe(
402 Effect.provideService(McpInvocationContext.McpInvocationContext, invocation),
403 Effect.provideService(McpSchema.McpServerClient, client),
404 );
405 expect(result.isError).toBe(true);
406 expect(result.content).toEqual([{ type: "text", text: "Preview snapshot failed: AiError." }]);
407 expect(result.structuredContent).toEqual({
408 error: { _tag: "AiError", operation: "snapshot", failureCount: 1 },
409 });
410 }
411 }).pipe(Effect.provide(layerTest)),
412);
413
414it.effect("saves the snapshot PNG on request and reports its path", () =>
415 Effect.scoped(
416 Effect.gen(function* () {
417 const config = yield* ServerConfig.ServerConfig;
418 const fileSystem = yield* FileSystem.FileSystem;
419 const path = yield* Path.Path;
420 const inputs = yield* serveSnapshots("mcp-save-client", snapshotResult);
421
422 const snapshot = yield* callSnapshot({ save: true, includeImage: true });
423
424 expect(snapshot.isError).toBe(false);
425 // The browser never receives the server-only `save` flag.
426 expect(inputs).toEqual([{}]);
427 expect(snapshot.content.map((content) => content.type)).toContain("image");
428 const structured = snapshot.structuredContent as { readonly screenshotPath?: string };
429 const screenshotPath = structured.screenshotPath;
430 expect(typeof screenshotPath).toBe("string");
431 expect(path.dirname(screenshotPath!)).toBe(config.browserArtifactsDir);
432 expect(path.basename(screenshotPath!)).toMatch(
433 /^browser-screenshot-example-test-[0-9a-z]+-[0-9a-f]{8}\.png$/,
434 );
435 expect(Buffer.from(yield* fileSystem.readFile(screenshotPath!)).toString()).toBe("png");
436 const [, text] = snapshot.content;
437 expect(text?.type === "text" ? text.text : "").toContain(screenshotPath);
438
439 const unsaved = yield* callSnapshot({});
440 expect(unsaved.structuredContent).not.toHaveProperty("screenshotPath");
441
442 // A save without the image skips the page dump.
443 const pathOnly = yield* callSnapshot({ save: true });
444 const saved = pathOnly.structuredContent as { readonly screenshotPath: string };
445 expect(saved).toEqual({ url: snapshotResult.url, screenshotPath: expect.any(String) });
446 expect(Buffer.from(yield* fileSystem.readFile(saved.screenshotPath)).toString()).toBe("png");
447 const [only, ...others] = pathOnly.content;
448 expect(others).toEqual([]);
449 expect(only?.type === "text" ? decodeJsonText(only.text) : null).toEqual(saved);
450 }),
451 ).pipe(Effect.provide(layerTest)),
452);
453
454it.effect("reports a tagged error when the screenshot cannot be saved", () =>
455 Effect.scoped(
456 Effect.gen(function* () {
457 const config = yield* ServerConfig.ServerConfig;
458 const fileSystem = yield* FileSystem.FileSystem;
459 // A regular file where the artifacts directory should be makes every write fail.
460 yield* fileSystem.writeFileString(config.browserArtifactsDir, "");
461 yield* serveSnapshots("mcp-save-failure-client", snapshotResult);
462
463 const snapshot = yield* callSnapshot({ save: true });
464
465 expect(snapshot.isError).toBe(true);
466 expect(snapshot.content).toEqual([
467 { type: "text", text: "Preview snapshot failed: PreviewScreenshotSaveError." },
468 ]);
469 expect(snapshot.structuredContent).toEqual({
470 error: { _tag: "PreviewScreenshotSaveError", operation: "snapshot", failureCount: 1 },
471 });
472 }),
473 ).pipe(Effect.provide(layerTest)),
474);
475
476it.effect(
477 "registers the pull request toolkit and surfaces a missing capability as a tool error",
478 () =>
479 Effect.gen(function* () {
480 const server = yield* McpServer.McpServer;
481 const names = server.tools.map(({ tool }) => tool.name);
482 expect(names).toEqual(
483 expect.arrayContaining([
484 "link_pull_request",
485 "unlink_pull_request",
486 "list_thread_pull_requests",
487 ]),
488 );
489 const linkTool = server.tools.find(({ tool }) => tool.name === "link_pull_request");
490 expect(linkTool?.tool.annotations?.idempotentHint).toBe(true);
491 expect(linkTool?.tool.annotations?.openWorldHint).toBe(false);
492 expect(linkTool?.tool.description).toContain("Register every pull request you open");
493
494 const denied = yield* server
495 .callTool({ name: "list_thread_pull_requests", arguments: {} })
496 .pipe(
497 // A preview-only credential: the token predates the toolkit or was minted elsewhere.
498 Effect.provideService(McpInvocationContext.McpInvocationContext, invocation),
499 Effect.provideService(McpSchema.McpServerClient, client),
500 );
501 expect(denied.isError).toBe(true);
502 expect(denied.content).toEqual([
503 { type: "text", text: "MCP credential does not grant the pull-requests capability." },
504 ]);
505 }).pipe(Effect.provide(layerPullRequestsTest)),
506);
507
508it.effect("returns server ARIA refs to agents in text and structured results", () =>
509 Effect.scoped(
510 Effect.gen(function* () {
511 const accessibilityTree =
512 '- button "delete" [ref=t3-snapshot-e1]\n- iframe [ref=t3-snapshot-e2]:\n - textbox "child" [ref=t3-snapshot-f1e1]';
513 yield* serveSnapshots("mcp-server-refs", { ...snapshotResult, accessibilityTree });
514 const result = yield* callSnapshot({ includeImage: false });
515 expect(result.isError).toBe(false);
516 expect(result.structuredContent).toMatchObject({ accessibilityTree });
517 expect(
518 result.content.some(
519 (entry) => entry.type === "text" && entry.text.includes("t3-snapshot-f1e1"),
520 ),
521 ).toBe(true);
522 }),
523 ).pipe(Effect.provide(layerTest)),
524);
525
526it.effect("bounds large ARIA trees without dropping every server locator", () =>
527 Effect.scoped(
528 Effect.gen(function* () {
529 const accessibilityTree = Array.from(
530 { length: 2_000 },
531 (_, index) => `- button "删除 ${index}" [ref=t3-snapshot-e${index}]`,
532 ).join("\n");
533 yield* serveSnapshots("mcp-large-server-refs", { ...snapshotResult, accessibilityTree });
534 const result = yield* callSnapshot({ includeImage: false });
535 expect(result.isError).toBe(false);
536 const metadata = result.structuredContent as { accessibilityTree: string };
537 expect(metadata.accessibilityTree).toContain("[ref=t3-snapshot-e0]");
538 expect(metadata.accessibilityTree.length).toBeLessThan(accessibilityTree.length);
539 const body = result.content[1];
540 expect(Buffer.byteLength(body?.type === "text" ? body.text : "", "utf8")).toBeLessThanOrEqual(
541 McpHttpServer.MAX_SNAPSHOT_TEXT_BYTES,
542 );
543 }),
544 ).pipe(Effect.provide(layerTest)),
545);
546
547it.effect("keeps the snapshot text under the agent's output ceiling", () =>
548 Effect.scoped(
549 Effect.gen(function* () {
550 // Mirrors the real failure: a [role] container whose innerText is the whole
551 // project list, repeated for several elements, plus a big AX tree.
552 const pageText = "/Users/theo/Code/project\nClaude, Codex · 79 threads\n".repeat(600);
553 const element = (name: string, index: number) => ({
554 tag: "div",
555 role: "presentation",
556 name,
557 selector: `div:nth-of-type(${index})`,
558 x: 0,
559 y: 0,
560 width: 10,
561 height: 10,
562 });
563 const oversized = {
564 ...snapshotResult,
565 visibleText: pageText,
566 interactiveElements: [
567 element(pageText, 1),
568 element(pageText, 2),
569 element(pageText, 3),
570 element("Continue", 4),
571 ],
572 accessibilityTree: { nodes: Array.from({ length: 2_000 }, (_, i) => ({ nodeId: `${i}` })) },
573 consoleEntries: Array.from({ length: 100 }, (_, i) => ({
574 level: "log",
575 text: `entry ${i}`,
576 timestamp: "t",
577 })),
578 };
579 yield* serveSnapshots("mcp-bounded-client", oversized);
580
581 const snapshot = yield* callSnapshot({ includeImage: false });
582
583 expect(snapshot.isError).toBe(false);
584 const [identity, text, notice] = snapshot.content;
585 expect(identity?.type === "text" ? decodeJsonText(identity.text) : null).toEqual({
586 url: oversized.url,
587 });
588 expect(text?.type).toBe("text");
589 const body = text?.type === "text" ? text.text : "";
590 expect(Buffer.byteLength(body, "utf8")).toBeLessThanOrEqual(
591 McpHttpServer.MAX_SNAPSHOT_TEXT_BYTES,
592 );
593 const parsed = decodeJsonText(body) as {
594 readonly accessibilityTree?: unknown;
595 readonly visibleText: string;
596 readonly interactiveElements: ReadonlyArray<{ readonly name: string }>;
597 readonly consoleEntries: ReadonlyArray<{ readonly text: string }>;
598 };
599 expect(parsed.accessibilityTree).toBeUndefined();
600 expect(parsed.visibleText.length).toBeLessThanOrEqual(8_001);
601 expect(parsed.interactiveElements).toHaveLength(4);
602 expect(parsed.interactiveElements[0]?.name.length).toBeLessThanOrEqual(201);
603 expect(parsed.interactiveElements[3]?.name).toBe("Continue");
604 expect(parsed.consoleEntries).toHaveLength(40);
605 expect(parsed.consoleEntries[0]?.text).toBe("entry 60");
606 expect(notice?.type === "text" ? notice.text : "").toContain("accessibilityTree");
607 expect(notice?.type === "text" ? notice.text : "").toContain("60 older console entries");
608 // Claude Code shows the model structuredContent instead of the text, so it is bounded too.
609 expect(snapshot.structuredContent).toEqual({
610 ...parsed,
611 omitted: expect.arrayContaining(["60 older console entries"]),
612 });
613 }),
614 ).pipe(Effect.provide(layerTest)),
615);
616
617it.effect("bounds the snapshot text even when nothing but logs and the title are large", () =>
618 Effect.scoped(
619 Effect.gen(function* () {
620 const oversized = {
621 ...snapshotResult,
622 title: "t".repeat(70_000),
623 interactiveElements: [],
624 consoleEntries: [{ level: "log", text: "x".repeat(70_000), timestamp: "t" }],
625 };
626 yield* serveSnapshots("mcp-bounded-logs-client", oversized);
627
628 const snapshot = yield* callSnapshot({ includeImage: false });
629
630 const [, text] = snapshot.content;
631 const body = text?.type === "text" ? text.text : "";
632 expect(Buffer.byteLength(body, "utf8")).toBeLessThanOrEqual(
633 McpHttpServer.MAX_SNAPSHOT_TEXT_BYTES,
634 );
635 const parsed = decodeJsonText(body) as {
636 readonly title: string;
637 readonly consoleEntries: ReadonlyArray<{ readonly text: string }>;
638 };
639 expect(parsed.title.length).toBe(2_049);
640 expect(parsed.consoleEntries[0]?.text.length).toBe(501);
641 const notice = snapshot.content[2];
642 const noticeText = notice?.type === "text" ? notice.text : "";
643 expect(noticeText).toContain("url or title after 2048 characters");
644 expect(noticeText).toContain("console entries text after 500 characters");
645 }),
646 ).pipe(Effect.provide(layerTest)),
647);
648
649it.effect("bounds page text made of wide characters before dropping locators", () =>
650 Effect.scoped(
651 Effect.gen(function* () {
652 // The character caps alone leave 8,000 three-byte characters, about 24 KB.
653 yield* serveSnapshots("mcp-wide-text-client", {
654 ...snapshotResult,
655 visibleText: "界".repeat(9_000),
656 interactiveElements: Array.from({ length: 20 }, (_, i) => ({
657 tag: "button",
658 role: "button",
659 name: `Button ${i}`,
660 selector: `#button-${i}`,
661 x: 0,
662 y: 0,
663 width: 10,
664 height: 10,
665 })),
666 });
667
668 const snapshot = yield* callSnapshot({ includeImage: false });
669
670 const [, text, notice] = snapshot.content;
671 const body = text?.type === "text" ? text.text : "";
672 expect(Buffer.byteLength(body, "utf8")).toBeLessThanOrEqual(
673 McpHttpServer.MAX_SNAPSHOT_TEXT_BYTES,
674 );
675 const parsed = decodeJsonText(body) as {
676 readonly visibleText: string;
677 readonly interactiveElements: ReadonlyArray<unknown>;
678 };
679 expect(parsed.visibleText).toMatch(/^界+…$/);
680 expect(parsed.interactiveElements).toHaveLength(20);
681 expect(notice?.type === "text" ? notice.text : "").toContain(
682 "visibleText after 4000 characters",
683 );
684 }),
685 ).pipe(Effect.provide(layerTest)),
686);
687
688it.effect("sheds log entries before locators when every list is full", () =>
689 Effect.scoped(
690 Effect.gen(function* () {
691 const long = "x".repeat(2_000);
692 const oversized = {
693 ...snapshotResult,
694 interactiveElements: Array.from({ length: 20 }, (_, i) => ({
695 tag: "button",
696 role: "button",
697 name: `Button ${i}`,
698 selector: `#button-${i}`,
699 x: 0,
700 y: 0,
701 width: 10,
702 height: 10,
703 })),
704 consoleEntries: Array.from({ length: 200 }, () => ({
705 level: long,
706 text: long,
707 timestamp: long,
708 source: long,
709 })),
710 networkEntries: Array.from({ length: 200 }, () => ({
711 url: long,
712 method: long,
713 status: 200,
714 failed: false,
715 errorText: long,
716 timestamp: long,
717 })),
718 actionTimeline: Array.from({ length: 200 }, () => ({
719 id: long,
720 action: long,
721 status: "succeeded",
722 startedAt: long,
723 completedAt: long,
724 error: long,
725 })),
726 };
727 yield* serveSnapshots("mcp-full-logs-client", oversized);
728
729 const snapshot = yield* callSnapshot({ includeImage: false });
730
731 const [, text, notice] = snapshot.content;
732 const body = text?.type === "text" ? text.text : "";
733 expect(Buffer.byteLength(body, "utf8")).toBeLessThanOrEqual(
734 McpHttpServer.MAX_SNAPSHOT_TEXT_BYTES,
735 );
736 const parsed = decodeJsonText(body) as {
737 readonly interactiveElements: ReadonlyArray<unknown>;
738 readonly consoleEntries: ReadonlyArray<unknown>;
739 readonly networkEntries: ReadonlyArray<unknown>;
740 readonly actionTimeline: ReadonlyArray<unknown>;
741 };
742 // Locators survive; the log lists take the cut.
743 expect(parsed.interactiveElements).toHaveLength(20);
744 expect(
745 parsed.consoleEntries.length + parsed.networkEntries.length + parsed.actionTimeline.length,
746 ).toBeLessThan(120);
747 const noticeText = notice?.type === "text" ? notice.text : "";
748 expect(noticeText).toContain("40 of 40 actionTimeline");
749 expect(noticeText).not.toMatch(/\d+ of \d+ interactiveElements/);
750 }),
751 ).pipe(Effect.provide(layerTest)),
752);
753
754it.effect("terminates HTTP MCP sessions with DELETE", () =>
755 Effect.scoped(
756 Effect.gen(function* () {
757 const token = "providerTokenWithoutDots";
758 const scope: McpInvocationContext.McpInvocationScope = {
759 environmentId,
760 requestNamespace: "provider-session",
761 thread: {
762 threadId: ThreadId.make("thread-provider"),
763 providerSessionId: "provider-session",
764 providerInstanceId: ProviderInstanceId.make("codex"),
765 },
766 client: undefined,
767 capabilities: new Set(["orchestration"]),
768 issuedAt: 1,
769 };
770 const layerServer = McpHttpServer.layerMcpTransport.pipe(
771 Layer.provide(
772 Layer.mock(McpSessionRegistry.McpSessionRegistry)({
773 resolve: (presented) =>
774 Effect.succeed(
775 presented === token
776 ? (scope as McpInvocationContext.McpThreadInvocationScope)
777 : undefined,
778 ),
779 }),
780 ),
781 );
782 yield* HttpRouter.serve(layerServer, {
783 disableListenLog: true,
784 disableLogger: true,
785 }).pipe(Layer.build);
786 const httpClient = (yield* HttpClient.HttpClient).pipe(
787 HttpClient.mapRequest(HttpClientRequest.bearerToken(token)),
788 );
789
790 const initializeResponse = yield* httpClient.post("/mcp", {
791 headers: { accept: "application/json, text/event-stream" },
792 body: HttpBody.text(
793 `{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"mcp-test","version":"1.0.0"}}}`,
794 "application/json",
795 ),
796 });
797 const sessionId = initializeResponse.headers["mcp-session-id"];
798 expect(initializeResponse.status).toBe(200);
799 expect(sessionId).not.toBeNull();
800
801 const missingSessionResponse = yield* httpClient.del("/mcp");
802 expect(missingSessionResponse.status).toBe(400);
803
804 const unknownSessionResponse = yield* httpClient.del("/mcp", {
805 headers: { "mcp-session-id": "unknown-session" },
806 });
807 expect(unknownSessionResponse.status).toBe(404);
808
809 const terminateResponse = yield* httpClient.del("/mcp", {
810 headers: { "mcp-session-id": sessionId! },
811 });
812 expect(terminateResponse.status).toBe(204);
813
814 const reusedSessionResponse = yield* httpClient.post("/mcp", {
815 headers: {
816 accept: "application/json, text/event-stream",
817 "mcp-session-id": sessionId!,
818 },
819 body: HttpBody.text(
820 `{"jsonrpc":"2.0","id":2,"method":"ping","params":{}}`,
821 "application/json",
822 ),
823 });
824 expect(reusedSessionResponse.status).toBe(404);
825 }),
826 ).pipe(Effect.provide(NodeHttpServer.layerTest)),
827);
828
829it.effect("registers annotated tools and preserves authenticated request context", () =>
830 Effect.scoped(
831 Effect.gen(function* () {
832 const server = yield* McpServer.McpServer;
833 const broker = yield* PreviewAutomationBroker.PreviewAutomationBroker;
834 const toolIcon = {
835 _tag: "website" as const,
836 pageUrl: "http://example.test/",
837 };
838 const routedRequests: Array<{
839 readonly operation: string;
840 readonly tabId?: string | undefined;
841 }> = [];
842 const events = yield* broker.connect({
843 clientId: "mcp-test-client",
844 environmentId,
845 });
846 yield* Stream.runForEach(events, (event) => {
847 if (event.type === "connected") return Effect.void;
848 routedRequests.push(event.request);
849 return broker.respond({
850 clientId: "mcp-test-client",
851 connectionId: event.connectionId,
852 requestId: event.request.requestId,
853 ok: true,
854 result:
855 event.request.operation === "snapshot"
856 ? snapshotResult
857 : event.request.operation === "evaluate"
858 ? ["Connect", "Continue"]
859 : event.request.operation === "press"
860 ? undefined
861 : {
862 available: true,
863 visible: true,
864 tabId,
865 url: "http://example.test/",
866 title: "Example",
867 loading: false,
868 },
869 });
870 }).pipe(Effect.forkScoped);
871 yield* Effect.yieldNow;
872
873 const statusTool = server.tools.find(({ tool }) => tool.name === "preview_status");
874 expect(statusTool?.tool.annotations?.readOnlyHint).toBe(true);
875 expect(statusTool?.tool.annotations?.idempotentHint).toBe(true);
876 expect(statusTool?.tool.annotations?.destructiveHint).toBe(false);
877
878 const snapshotTool = server.tools.find(({ tool }) => tool.name === "preview_snapshot");
879 expect(snapshotTool?.tool.annotations?.readOnlyHint).toBe(false);
880 expect(snapshotTool?.tool.annotations?.idempotentHint).toBe(false);
881 expect(snapshotTool?.tool.annotations?.openWorldHint).toBe(true);
882
883 const clickTool = server.tools.find(({ tool }) => tool.name === "preview_click");
884 expect(clickTool?.tool.annotations?.readOnlyHint).toBe(false);
885 expect(clickTool?.tool.annotations?.destructiveHint).toBe(true);
886 expect(clickTool?.tool.annotations?.openWorldHint).toBe(true);
887 expect(clickTool?.tool.outputSchema).toMatchObject({
888 type: "object",
889 additionalProperties: true,
890 description: "The preview action completed successfully.",
891 });
892
893 const navigateTool = server.tools.find(({ tool }) => tool.name === "preview_navigate");
894 expect(navigateTool?.tool.annotations?.destructiveHint).toBe(false);
895 expect(navigateTool?.tool.annotations?.openWorldHint).toBe(true);
896
897 const status = yield* server
898 .callTool({ name: "preview_status", arguments: {} })
899 .pipe(
900 Effect.provideService(McpInvocationContext.McpInvocationContext, invocation),
901 Effect.provideService(McpSchema.McpServerClient, client),
902 );
903 expect(status.isError).toBe(false);
904 expect(status.structuredContent).toMatchObject({
905 available: true,
906 tabId,
907 });
908
909 const malformed = yield* server
910 .callTool({ name: "preview_click", arguments: { selector: "" } })
911 .pipe(
912 Effect.provideService(McpInvocationContext.McpInvocationContext, invocation),
913 Effect.provideService(McpSchema.McpServerClient, client),
914 Effect.flip,
915 );
916 expect(malformed._tag).toBe("InvalidParams");
917
918 const snapshot = yield* server
919 .callTool({
920 name: "preview_snapshot",
921 arguments: { tabId: alternateTabId, includeImage: true },
922 })
923 .pipe(
924 Effect.provideService(McpInvocationContext.McpInvocationContext, invocation),
925 Effect.provideService(McpSchema.McpServerClient, client),
926 );
927 expect(snapshot.isError).toBe(false);
928 expect(snapshot.content.some((content) => content.type === "image")).toBe(true);
929 expect(snapshot.structuredContent).toMatchObject({
930 screenshot: { mimeType: "image/png", width: 10, height: 5 },
931 });
932 expect(routedRequests.find(({ operation }) => operation === "snapshot")?.tabId).toBe(
933 alternateTabId,
934 );
935
936 // Arrays and primitives are wrapped so structuredContent stays a JSON object.
937 // Claude Code rejects the whole result otherwise.
938 const evaluateTool = server.tools.find(({ tool }) => tool.name === "preview_evaluate");
939 expect(evaluateTool?.tool.outputSchema).toMatchObject({ type: "object" });
940 const evaluated = yield* server
941 .callTool({ name: "preview_evaluate", arguments: { expression: "buttons()" } })
942 .pipe(
943 Effect.provideService(McpInvocationContext.McpInvocationContext, invocation),
944 Effect.provideService(McpSchema.McpServerClient, client),
945 );
946 expect(evaluated.isError).toBe(false);
947 expect(evaluated.structuredContent).toEqual({ value: ["Connect", "Continue"], toolIcon });
948 const evaluatedText = evaluated.content[0];
949 expect(evaluatedText?.type === "text" ? decodeJsonText(evaluatedText.text) : null).toEqual({
950 toolIcon,
951 value: ["Connect", "Continue"],
952 });
953
954 const actionRequests = [
955 { name: "preview_click", arguments: { x: 10, y: 10 } },
956 { name: "preview_type", arguments: { text: "Hello" } },
957 { name: "preview_press", arguments: { key: "Enter" } },
958 { name: "preview_scroll", arguments: { deltaY: 100 } },
959 { name: "preview_wait_for", arguments: { text: "Example" } },
960 ];
961 for (const request of actionRequests) {
962 const result = yield* server
963 .callTool(request)
964 .pipe(
965 Effect.provideService(McpInvocationContext.McpInvocationContext, invocation),
966 Effect.provideService(McpSchema.McpServerClient, client),
967 );
968 expect(result.isError).toBe(false);
969 expect(result.structuredContent).toEqual({ toolIcon });
970 expect(routedRequests.at(-1)?.operation).toBe("status");
971 const text = result.content[0];
972 expect(text?.type === "text" ? decodeJsonText(text.text) : null).toEqual({ toolIcon });
973 }
974 }),
975 ).pipe(Effect.provide(layerTest)),
976);
977
978it.effect("admits provider and OAuth client credentials and points only clients at OAuth", () =>
979 Effect.scoped(
980 Effect.gen(function* () {
981 const providerToken = "providerTokenWithoutDots";
982 const clientToken = "client-payload.client-signature";
983 const providerScope: McpInvocationContext.McpInvocationScope = {
984 environmentId,
985 requestNamespace: "provider-session",
986 thread: {
987 threadId: ThreadId.make("thread-provider"),
988 providerSessionId: "provider-session",
989 providerInstanceId: ProviderInstanceId.make("codex"),
990 },
991 client: undefined,
992 capabilities: new Set(["orchestration"]),
993 issuedAt: 1,
994 };
995 const clientScope: McpInvocationContext.McpInvocationScope = {
996 environmentId,
997 requestNamespace: "client:session-1",
998 thread: undefined,
999 client: { sessionId: "session-1", label: "Claude Code", access: "auto" },
1000 capabilities: new Set(["orchestration"]),
1001 issuedAt: 1,
1002 };
1003 const seen: Array<McpInvocationContext.McpInvocationScope> = [];
1004 const ProbeToolkit = Toolkit.make(
1005 Tool.make("probe", {
1006 description: "Reports the caller.",
1007 success: Schema.Struct({ ok: Schema.Boolean }),
1008 failure: OrchestratorMcpFailure,
1009 failureMode: "return",
1010 dependencies: [McpInvocationContext.McpInvocationContext],
1011 }),
1012 );
1013 const serverLayer = McpHttpServer.toolkitRegistration(
1014 ProbeToolkit,
1015 McpToolAccess.toLayer(ProbeToolkit, {
1016 probe: McpToolAccess.reads(() =>
1017 McpInvocationContext.McpInvocationContext.pipe(
1018 Effect.tap((scope) => Effect.sync(() => seen.push(scope))),
1019 Effect.as({ ok: true }),
1020 ),
1021 ),
1022 }),
1023 ).pipe(
1024 Layer.provideMerge(McpHttpServer.layerMcpTransport),
1025 Layer.provide(
1026 Layer.mock(McpSessionRegistry.McpSessionRegistry)({
1027 resolve: (token) =>
1028 Effect.succeed(
1029 token === providerToken
1030 ? (providerScope as McpInvocationContext.McpThreadInvocationScope)
1031 : undefined,
1032 ),
1033 }),
1034 ),
1035 Layer.provide(
1036 Layer.succeed(McpHttpServer.McpClientAuthenticator, {
1037 authenticate: (request) =>
1038 Effect.succeed(
1039 request.headers.authorization === `Bearer ${clientToken}` ? clientScope : undefined,
1040 ),
1041 }),
1042 ),
1043 );
1044 yield* HttpRouter.serve(serverLayer, { disableListenLog: true, disableLogger: true }).pipe(
1045 Layer.build,
1046 );
1047 const httpClient = yield* HttpClient.HttpClient;
1048 const call = (token: string | undefined) =>
1049 Effect.gen(function* () {
1050 const headers = {
1051 accept: "application/json, text/event-stream",
1052 ...(token === undefined ? {} : { authorization: `Bearer ${token}` }),
1053 };
1054 const initialize = yield* httpClient.post("/mcp", {
1055 headers,
1056 body: HttpBody.text(
1057 `{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"probe","version":"1.0.0"}}}`,
1058 "application/json",
1059 ),
1060 });
1061 if (initialize.status !== 200) return initialize;
1062 return yield* httpClient.post("/mcp", {
1063 headers: {
1064 ...headers,
1065 "mcp-session-id": initialize.headers["mcp-session-id"]!,
1066 "mcp-protocol-version": "2025-06-18",
1067 },
1068 body: HttpBody.text(
1069 `{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"probe","arguments":{}}}`,
1070 "application/json",
1071 ),
1072 });
1073 });
1074
1075 expect((yield* call(providerToken)).status).toBe(200);
1076 expect((yield* call(clientToken)).status).toBe(200);
1077 expect(seen.map((scope) => scope.requestNamespace)).toEqual([
1078 "provider-session",
1079 "client:session-1",
1080 ]);
1081
1082 const missing = yield* call(undefined);
1083 expect(missing.status).toBe(401);
1084 expect(missing.headers["www-authenticate"]).toMatch(
1085 /^Bearer resource_metadata="http:\/\/[^"]+\/\.well-known\/oauth-protected-resource\/mcp"$/,
1086 );
1087
1088 const expiredClient = yield* call("stale-payload.stale-signature");
1089 expect(expiredClient.headers["www-authenticate"]).toContain("resource_metadata=");
1090 expect(expiredClient.headers["www-authenticate"]).toContain('error="invalid_token"');
1091
1092 const deadProvider = yield* call("deadProviderToken");
1093 expect(deadProvider.status).toBe(401);
1094 expect(deadProvider.headers["www-authenticate"]).toBe('Bearer error="invalid_token"');
1095 }),
1096 ).pipe(Effect.provide(NodeHttpServer.layerTest)),
1097);