Skip to content
Closed
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
72 changes: 72 additions & 0 deletions apps/server/src/mcp/McpHttpServer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import * as NodeServices from "@effect/platform-node/NodeServices";
import { EnvironmentId, PreviewTabId, ProviderInstanceId, ThreadId } 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 @@ -38,6 +39,17 @@ const TestLayer = McpHttpServer.PreviewToolkitRegistrationLive.pipe(
Layer.provideMerge(McpServer.McpServer.layer),
Layer.provideMerge(PreviewAutomationBroker.layer.pipe(Layer.provide(NodeServices.layer))),
);
const BoundedSnapshotMetadata = Schema.Struct({
visibleText: Schema.String,
accessibilityTree: Schema.Struct({ truncated: Schema.Boolean }),
truncation: Schema.Struct({
truncated: Schema.Boolean,
reason: Schema.String,
}),
});
const decodeBoundedSnapshotMetadata = Schema.decodeUnknownSync(
Schema.fromJsonString(BoundedSnapshotMetadata),
);

it("normalizes empty successful notification responses to accepted", () => {
const notificationResponse = McpHttpServer.normalizeMcpHttpResponse(
Expand Down Expand Up @@ -97,6 +109,66 @@ it.effect("returns bounded structural preview snapshot failures", () =>
).pipe(Effect.provide(TestLayer)),
);

it.effect("bounds successful preview snapshot metadata before returning it to providers", () =>
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: {
url: "http://example.test/large",
title: "Large page",
loading: false,
visibleText: "visible ".repeat(20_000),
interactiveElements: [],
accessibilityTree: { nodes: [{ name: "accessible ".repeat(50_000) }] },
consoleEntries: [],
networkEntries: [],
actionTimeline: [],
screenshot: {
mimeType: "image/png",
data: Buffer.from("png").toString("base64"),
width: 10,
height: 5,
},
},
}),
).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),
);
const text = snapshot.content.find((content) => content.type === "text")?.text;

expect(text).toBeDefined();
expect(Buffer.byteLength(text!, "utf8")).toBeLessThanOrEqual(64_000);
const metadata = decodeBoundedSnapshotMetadata(text!);
expect(metadata.accessibilityTree).toMatchObject({ truncated: true });
expect(metadata.truncation).toMatchObject({
truncated: true,
reason: "metadata_size_limit",
});
expect(metadata.visibleText.length).toBeLessThan(160_000);
expect(snapshot.structuredContent).toMatchObject(metadata);
}),
).pipe(Effect.provide(TestLayer)),
);

it.effect("terminates HTTP MCP sessions with DELETE", () =>
Effect.scoped(
Effect.gen(function* () {
Expand Down
94 changes: 73 additions & 21 deletions apps/server/src/mcp/McpHttpServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import * as Stream from "effect/Stream";
import type * as Types from "effect/Types";
import { McpProtocol, McpSchema, McpServer, Tool } from "effect/unstable/ai";
import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http";
import type { PreviewAutomationSnapshot } from "@t3tools/contracts";

import packageJson from "../../package.json" with { type: "json" };
import * as McpInvocationContext from "./McpInvocationContext.ts";
Expand All @@ -23,6 +24,73 @@ import {
PreviewStandardToolkit,
} from "./toolkits/preview/tools.ts";

const PREVIEW_SNAPSHOT_METADATA_MAX_BYTES = 64_000;
const PREVIEW_SNAPSHOT_FALLBACK_VISIBLE_TEXT_LENGTH = 8_000;

const previewSnapshotMetadata = (snapshot: PreviewAutomationSnapshot) => {
const { screenshot, ...page } = snapshot;
const metadata = {
...page,
screenshot: {
mimeType: screenshot.mimeType,
width: screenshot.width,
height: screenshot.height,
},
};
const serialized = JSON.stringify(metadata);
const originalBytes = Buffer.byteLength(serialized, "utf8");
if (originalBytes <= PREVIEW_SNAPSHOT_METADATA_MAX_BYTES) {
return { metadata, serialized };
}

const withoutAccessibilityTree = {
...metadata,
accessibilityTree: { truncated: true },
truncation: {
truncated: true,
reason: "metadata_size_limit",
originalBytes,
omitted: ["accessibilityTree"],
},
};
const withoutAccessibilityTreeSerialized = JSON.stringify(withoutAccessibilityTree);
if (
Buffer.byteLength(withoutAccessibilityTreeSerialized, "utf8") <=
PREVIEW_SNAPSHOT_METADATA_MAX_BYTES
) {
return {
metadata: withoutAccessibilityTree,
serialized: withoutAccessibilityTreeSerialized,
};
}

const fallback = {
url: snapshot.url.slice(0, 2_048),
title: snapshot.title.slice(0, 512),
loading: snapshot.loading,
visibleText: snapshot.visibleText.slice(0, PREVIEW_SNAPSHOT_FALLBACK_VISIBLE_TEXT_LENGTH),
interactiveElements: [],
accessibilityTree: { truncated: true },
consoleEntries: [],
networkEntries: [],
actionTimeline: [],
screenshot: metadata.screenshot,
truncation: {
truncated: true,
reason: "metadata_size_limit",
originalBytes,
omitted: [
"accessibilityTree",
"interactiveElements",
"consoleEntries",
"networkEntries",
"actionTimeline",
],
},
};
return { metadata: fallback, serialized: JSON.stringify(fallback) };
};

const unauthorized = HttpServerResponse.jsonUnsafe(
{
error: "invalid_mcp_credential",
Expand Down Expand Up @@ -164,34 +232,18 @@ const registerPreviewSnapshot = Effect.fn("McpHttpServer.registerPreviewSnapshot
Effect.matchCauseEffect({
onFailure: previewSnapshotFailure,
onSuccess: ({ encodedResult }) => {
const snapshot = encodedResult as {
readonly screenshot: {
readonly mimeType: "image/png";
readonly data: string;
readonly width: number;
readonly height: number;
};
readonly [key: string]: unknown;
};
const { screenshot, ...page } = snapshot;
const metadata = {
...page,
screenshot: {
mimeType: screenshot.mimeType,
width: screenshot.width,
height: screenshot.height,
},
};
const snapshot = encodedResult as PreviewAutomationSnapshot;
const { metadata, serialized } = previewSnapshotMetadata(snapshot);
return Effect.succeed(
new McpSchema.CallToolResult({
isError: false,
structuredContent: metadata,
content: [
{ type: "text", text: JSON.stringify(metadata) },
{ type: "text", text: serialized },
{
type: "image",
data: new Uint8Array(Buffer.from(screenshot.data, "base64")),
mimeType: screenshot.mimeType,
data: new Uint8Array(Buffer.from(snapshot.screenshot.data, "base64")),
mimeType: snapshot.screenshot.mimeType,
},
],
}),
Expand Down
Loading