diff --git a/apps/server/src/mcp/McpHttpServer.test.ts b/apps/server/src/mcp/McpHttpServer.test.ts index fa2880f9c364..7c03c5a0f100 100644 --- a/apps/server/src/mcp/McpHttpServer.test.ts +++ b/apps/server/src/mcp/McpHttpServer.test.ts @@ -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"; @@ -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( @@ -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* () { diff --git a/apps/server/src/mcp/McpHttpServer.ts b/apps/server/src/mcp/McpHttpServer.ts index 87975a49de2c..a9c1906d9922 100644 --- a/apps/server/src/mcp/McpHttpServer.ts +++ b/apps/server/src/mcp/McpHttpServer.ts @@ -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"; @@ -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", @@ -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, }, ], }),