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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
198 changes: 197 additions & 1 deletion apps/server/src/mcp/McpHttpServer.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,17 @@
import { expect, it } from "@effect/vitest";
import { NodeHttpServer } from "@effect/platform-node";
import * as NodeServices from "@effect/platform-node/NodeServices";
import { EnvironmentId, PreviewTabId, ProviderInstanceId, ThreadId } from "@t3tools/contracts";
import {
EnvironmentId,
PreviewTabId,
ProviderInstanceId,
ThreadId,
type PreviewAutomationElement,
type PreviewAutomationSnapshot,
} from "@t3tools/contracts";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import * as Schema from "effect/Schema";
import * as Stream from "effect/Stream";
import { McpProtocol, McpSchema, McpServer } from "effect/unstable/ai";
import { HttpBody, HttpClient, HttpRouter, HttpServerResponse } from "effect/unstable/http";
Expand Down Expand Up @@ -97,6 +105,194 @@ it.effect("returns bounded structural preview snapshot failures", () =>
).pipe(Effect.provide(TestLayer)),
);

const snapshotFixture = (
overrides: Partial<PreviewAutomationSnapshot> = {},
): PreviewAutomationSnapshot => ({
url: "http://example.test/",
title: "Example",
loading: false,
visibleText: "Example",
interactiveElements: [],
accessibilityTree: {},
consoleEntries: [],
networkEntries: [],
actionTimeline: [],
screenshot: {
mimeType: "image/png",
data: Buffer.from("png").toString("base64"),
width: 10,
height: 5,
},
...overrides,
});
const element = (name: string): PreviewAutomationElement => ({
tag: "button",
role: null,
name,
selector: "button",
x: 0,
y: 0,
width: 1,
height: 1,
});
const serializedByteLength = (value: unknown) => Buffer.byteLength(JSON.stringify(value), "utf8");
const parseJson = Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Unknown));

it("passes snapshot metadata through untouched while it fits", () => {
const snapshot = snapshotFixture({ accessibilityTree: { nodes: [{ role: "main" }] } });
const { metadata, serialized } = McpHttpServer.boundPreviewSnapshotMetadata(snapshot);

expect(metadata).not.toHaveProperty("truncation");
expect(metadata).toMatchObject({
accessibilityTree: { nodes: [{ role: "main" }] },
screenshot: { mimeType: "image/png", width: 10, height: 5 },
});
expect(metadata.screenshot).not.toHaveProperty("data");
expect(serialized).toBe(JSON.stringify(metadata));
});

it("drops the accessibility tree first and keeps the rest of an oversized snapshot", () => {
const snapshot = snapshotFixture({
visibleText: "visible ".repeat(2_000),
interactiveElements: [element("Save")],
consoleEntries: [{ level: "log", text: "hello", timestamp: "2026-09-04T00:00:00Z" }],
accessibilityTree: { nodes: [{ name: "accessible ".repeat(50_000) }] },
});
const { metadata, serialized } = McpHttpServer.boundPreviewSnapshotMetadata(snapshot);

expect(Buffer.byteLength(serialized, "utf8")).toBeLessThanOrEqual(
McpHttpServer.PREVIEW_SNAPSHOT_METADATA_MAX_BYTES,
);
expect(metadata.accessibilityTree).toBeNull();
expect(metadata.truncation).toEqual({
originalBytes: serializedByteLength({
...snapshot,
screenshot: { mimeType: "image/png", width: 10, height: 5 },
}),
omitted: ["accessibilityTree"],
trimmed: [],
});
expect(metadata.visibleText).toBe(snapshot.visibleText);
expect(metadata.interactiveElements).toEqual(snapshot.interactiveElements);
expect(metadata.consoleEntries).toEqual(snapshot.consoleEntries);
expect(parseJson(serialized)).toEqual(metadata);
});

it("cuts the visible text short before giving up the interactive elements", () => {
// Multi-byte text: the byte budget, not the character count, is what has to fit.
const snapshot = snapshotFixture({
visibleText: "görünür metin 🙂 ".repeat(20_000),
interactiveElements: [element("Save"), element("Cancel")],
accessibilityTree: { nodes: [{ name: "accessible ".repeat(50_000) }] },
});
const { metadata, serialized } = McpHttpServer.boundPreviewSnapshotMetadata(snapshot);

expect(Buffer.byteLength(serialized, "utf8")).toBeLessThanOrEqual(
McpHttpServer.PREVIEW_SNAPSHOT_METADATA_MAX_BYTES,
);
expect(metadata.truncation).toMatchObject({
omitted: ["accessibilityTree"],
trimmed: ["visibleText"],
});
expect(metadata.visibleText.length).toBeGreaterThan(0);
expect(metadata.visibleText.isWellFormed()).toBe(true);
expect(snapshot.visibleText.startsWith(metadata.visibleText)).toBe(true);
expect(metadata.interactiveElements).toEqual(snapshot.interactiveElements);
});

it("caps a runaway URL before touching page content", () => {
const snapshot = snapshotFixture({
url: `data:text/html,${"x".repeat(200_000)}`,
visibleText: "visible ".repeat(100),
interactiveElements: [element("Save")],
accessibilityTree: { nodes: [{ role: "main" }] },
});
const { metadata, serialized } = McpHttpServer.boundPreviewSnapshotMetadata(snapshot);

expect(Buffer.byteLength(serialized, "utf8")).toBeLessThanOrEqual(
McpHttpServer.PREVIEW_SNAPSHOT_METADATA_MAX_BYTES,
);
expect(metadata.url).toBe(snapshot.url.slice(0, 2_048));
expect(metadata.truncation?.trimmed).toEqual(["url"]);
expect(metadata.truncation?.omitted).toEqual([]);
expect(metadata.visibleText).toBe(snapshot.visibleText);
expect(metadata.interactiveElements).toEqual(snapshot.interactiveElements);
expect(metadata.accessibilityTree).toEqual(snapshot.accessibilityTree);
});

it("gives up the interactive elements last when they alone exceed the limit", () => {
const snapshot = snapshotFixture({
visibleText: "visible ".repeat(2_000),
interactiveElements: [element("x".repeat(200_000))],
});
const { metadata, serialized } = McpHttpServer.boundPreviewSnapshotMetadata(snapshot);

expect(Buffer.byteLength(serialized, "utf8")).toBeLessThanOrEqual(
McpHttpServer.PREVIEW_SNAPSHOT_METADATA_MAX_BYTES,
);
expect(metadata.interactiveElements).toEqual([]);
// Empty logs are not reported as omitted: only what the agent actually lost is listed.
expect(metadata.truncation?.omitted).toEqual(["accessibilityTree", "interactiveElements"]);
expect(metadata.truncation?.trimmed).toEqual(["visibleText"]);
expect(metadata.visibleText).toBe("");
});

it.effect("returns bounded preview snapshot metadata with the screenshot and a note", () =>
Effect.scoped(
Effect.gen(function* () {
const server = yield* McpServer.McpServer;
const broker = yield* PreviewAutomationBroker.PreviewAutomationBroker;
const events = yield* broker.connect({
clientId: "mcp-oversized-snapshot-client",
environmentId,
});
yield* Stream.runForEach(events, (event) =>
event.type === "connected"
? Effect.void
: broker.respond({
clientId: "mcp-oversized-snapshot-client",
connectionId: event.connectionId,
requestId: event.request.requestId,
ok: true,
result: snapshotFixture({
url: "http://example.test/large",
title: "Large page",
interactiveElements: [element("Save")],
accessibilityTree: { nodes: [{ name: "accessible ".repeat(50_000) }] },
}),
}),
).pipe(Effect.forkScoped);
yield* Effect.yieldNow;

const snapshot = yield* server
.callTool({ name: "preview_snapshot", arguments: {} })
.pipe(
Effect.provideService(McpInvocationContext.McpInvocationContext, invocation),
Effect.provideService(McpSchema.McpServerClient, client),
);

expect(snapshot.isError).toBe(false);
const texts = snapshot.content.flatMap((content) =>
content.type === "text" ? [content.text] : [],
);
expect(texts).toHaveLength(2);
expect(Buffer.byteLength(texts[0]!, "utf8")).toBeLessThanOrEqual(
McpHttpServer.PREVIEW_SNAPSHOT_METADATA_MAX_BYTES,
);
expect(parseJson(texts[0]!)).toEqual(snapshot.structuredContent);
expect(texts[1]).toContain("Omitted: accessibilityTree.");
expect(snapshot.structuredContent).toMatchObject({
title: "Large page",
accessibilityTree: null,
interactiveElements: [element("Save")],
truncation: { omitted: ["accessibilityTree"], trimmed: [] },
screenshot: { mimeType: "image/png", width: 10, height: 5 },
});
expect(snapshot.content.some((content) => content.type === "image")).toBe(true);
}),
).pipe(Effect.provide(TestLayer)),
);

it.effect("terminates HTTP MCP sessions with DELETE", () =>
Effect.scoped(
Effect.gen(function* () {
Expand Down
Loading
Loading