diff --git a/src/lib/mcp/tools/browsers.test.ts b/src/lib/mcp/tools/browsers.test.ts new file mode 100644 index 0000000..2e07300 --- /dev/null +++ b/src/lib/mcp/tools/browsers.test.ts @@ -0,0 +1,378 @@ +/// + +import type { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { describe, expect, test } from "bun:test"; +import { connectTestMcp, toolResultJSON } from "@/lib/mcp/mcp-test-fixtures"; +import { registerBrowserCapabilities } from "@/lib/mcp/tools/browsers"; + +const event = { + seq: 42, + event: { + ts: 1_700_000_000_000_000, + category: "network", + type: "network_response", + source: { service: "cdp" }, + truncated: true, + data: { + headers: { "set-cookie": "secret" }, + post_data: "query=secret", + body: '{"result":"complete"}', + status: 200, + }, + }, +}; + +function telemetryClient(queries: unknown[], items: unknown[] = [event]) { + return { + browsers: { + telemetry: { + events: async (_sessionId: string, query: unknown) => { + queries.push(query); + return { + getPaginatedItems: () => items, + has_more: true, + next_offset: 43, + }; + }, + }, + }, + }; +} + +function toolResultText(result: Awaited>) { + const content = result.content as Array<{ type: string; text: string }>; + return content[0].text; +} + +type TextResourceResult = { + contents: Array<{ text: string }>; +}; + +describe("manage_browsers telemetry", () => { + test("re-fetches a cursor page without compaction", async () => { + const queries: unknown[] = []; + const { client, close } = await connectTestMcp( + registerBrowserCapabilities, + telemetryClient(queries), + ); + const page = { + action: "get_telemetry", + session_id: "brr_123", + categories: ["network"], + offset: 41, + limit: 1, + order: "asc", + }; + + try { + const compact = toolResultJSON( + await client.callTool({ + name: "manage_browsers", + arguments: page, + }), + ); + const raw = toolResultJSON( + await client.callTool({ + name: "manage_browsers", + arguments: compact.raw_replay_best_effort, + }), + ); + + expect(compact.items).toEqual([ + { + seq: 42, + ts: 1_700_000_000_000_000, + time: "2023-11-14T22:13:20.000Z", + category: "network", + type: "network_response", + source: { service: "cdp" }, + data: { status: 200 }, + truncated: true, + omitted_fields: ["headers", "post_data", "body"], + }, + ]); + expect(compact.raw_replay_best_effort).toEqual({ + ...page, + compact: false, + }); + expect(raw).toEqual({ + items: [event], + has_more: true, + next_offset: 43, + }); + expect(queries).toEqual([ + { + limit: 1, + category: ["network"], + offset: 41, + order: "asc", + }, + { + limit: 1, + category: ["network"], + offset: 41, + order: "asc", + }, + ]); + } finally { + await close(); + } + }); + + test("preserves API time and tail semantics", async () => { + const queries: unknown[] = []; + const { client, close } = await connectTestMcp( + registerBrowserCapabilities, + telemetryClient(queries), + ); + + try { + const descending = toolResultJSON( + await client.callTool({ + name: "manage_browsers", + arguments: { + action: "get_telemetry", + session_id: "brr_123", + categories: ["network"], + limit: 1, + order: "desc", + }, + }), + ); + const relative = toolResultJSON( + await client.callTool({ + name: "manage_browsers", + arguments: { + action: "get_telemetry", + session_id: "brr_123", + categories: ["network"], + since: "30m", + until: "5m", + limit: 1, + }, + }), + ); + + expect(queries).toEqual([ + { limit: 1, category: ["network"], order: "desc" }, + { + limit: 1, + category: ["network"], + since: "30m", + until: "5m", + }, + ]); + expect(descending.raw_replay_best_effort).not.toHaveProperty("until"); + expect(relative.raw_replay_best_effort).toMatchObject({ + since: "30m", + until: "5m", + compact: false, + }); + } finally { + await close(); + } + }); + + test("requires an explicit small raw limit", async () => { + const queries: unknown[] = []; + const { client, close } = await connectTestMcp( + registerBrowserCapabilities, + telemetryClient(queries), + ); + + try { + const missing = await client.callTool({ + name: "manage_browsers", + arguments: { + action: "get_telemetry", + session_id: "brr_123", + categories: ["network"], + compact: false, + }, + }); + const tooLarge = await client.callTool({ + name: "manage_browsers", + arguments: { + action: "get_telemetry", + session_id: "brr_123", + categories: ["network"], + limit: 6, + compact: false, + }, + }); + + expect(missing.isError).toBeTrue(); + expect(toolResultText(missing)).toContain( + "explicit limit between 1 and 5", + ); + expect(tooLarge.isError).toBeTrue(); + expect(toolResultText(tooLarge)).toContain( + "explicit limit between 1 and 5", + ); + expect(queries).toEqual([]); + } finally { + await close(); + } + }); + + test("caps the serialized raw response", async () => { + const queries: unknown[] = []; + const oversizedEvents = [1, 2, 3].map((seq) => ({ + ...event, + seq, + event: { + ...event.event, + data: { body: "x".repeat(400_000) }, + }, + })); + const { client, close } = await connectTestMcp( + registerBrowserCapabilities, + telemetryClient(queries, oversizedEvents), + ); + + try { + const result = await client.callTool({ + name: "manage_browsers", + arguments: { + action: "get_telemetry", + session_id: "brr_123", + categories: ["network"], + limit: 3, + compact: false, + }, + }); + + expect(result.isError).toBeTrue(); + expect(toolResultText(result)).toContain( + "raw telemetry response exceeds 1048576 bytes", + ); + } finally { + await close(); + } + }); + + test("keeps screenshot PNGs out of raw JSON", async () => { + const queries: unknown[] = []; + const { client, close } = await connectTestMcp( + registerBrowserCapabilities, + telemetryClient(queries), + ); + + try { + const result = await client.callTool({ + name: "manage_browsers", + arguments: { + action: "get_telemetry", + session_id: "brr_123", + categories: ["screenshot"], + limit: 1, + compact: false, + }, + }); + + expect(result.isError).toBeTrue(); + expect(toolResultText(result)).toContain( + "does not support the screenshot category", + ); + expect(queries).toEqual([]); + } finally { + await close(); + } + }); + + test("rejects PNG fields returned under another category", async () => { + const queries: unknown[] = []; + const pngEvent = { + ...event, + event: { ...event.event, data: { png: "base64" } }, + }; + const { client, close } = await connectTestMcp( + registerBrowserCapabilities, + telemetryClient(queries, [pngEvent]), + ); + + try { + const result = await client.callTool({ + name: "manage_browsers", + arguments: { + action: "get_telemetry", + session_id: "brr_123", + categories: ["network"], + limit: 1, + compact: false, + }, + }); + + expect(result.isError).toBeTrue(); + expect(toolResultText(result)).toContain( + "Raw screenshot PNGs are not available", + ); + expect(queries).toHaveLength(1); + } finally { + await close(); + } + }); + + test("documents bounded best-effort raw replay", async () => { + const kernelClient = { browsers: {} }; + const { client, close } = await connectTestMcp( + registerBrowserCapabilities, + kernelClient, + ); + + try { + const tools = await client.listTools(); + const tool = tools.tools.find(({ name }) => name === "manage_browsers"); + const compact = tool?.inputSchema.properties?.compact as + | { description?: string } + | undefined; + + expect(compact?.description).toContain( + "body, headers, post_data, or png", + ); + expect(compact?.description).toContain("raw_replay_best_effort"); + expect(compact?.description).toContain( + "late events or retention may change results", + ); + expect(compact?.description).toContain("limit<=5"); + expect(compact?.description).toContain("1 MiB"); + } finally { + await close(); + } + }); +}); + +describe("browser resources", () => { + test("uses injected dependencies", async () => { + const kernelClient = { + browsers: { + list: async function* () { + yield { session_id: "brr_123" }; + }, + retrieve: async (sessionId: string) => ({ session_id: sessionId }), + }, + }; + const { client, close } = await connectTestMcp( + registerBrowserCapabilities, + kernelClient, + ); + + try { + const collection = (await client.readResource({ + uri: "kernel://orgs/org_test/projects/proj_test/browsers", + })) as TextResourceResult; + const item = (await client.readResource({ + uri: "kernel://orgs/org_test/projects/proj_test/browsers/brr_123", + })) as TextResourceResult; + + expect(JSON.parse(collection.contents[0].text)).toEqual([ + { session_id: "brr_123" }, + ]); + expect(JSON.parse(item.contents[0].text)).toEqual({ + session_id: "brr_123", + }); + } finally { + await close(); + } + }); +}); diff --git a/src/lib/mcp/tools/browsers.ts b/src/lib/mcp/tools/browsers.ts index d683445..fde5dac 100644 --- a/src/lib/mcp/tools/browsers.ts +++ b/src/lib/mcp/tools/browsers.ts @@ -6,7 +6,11 @@ import { buildBrowserUpdateConfig, type BrowserConfigResult, } from "@/lib/mcp/browser-config"; -import { createKernelClient, type KernelClient } from "@/lib/mcp/kernel-client"; +import { + defaultMcpDependencies, + type McpDependencies, +} from "@/lib/mcp/dependencies"; +import type { KernelClient } from "@/lib/mcp/kernel-client"; import { registerJsonResourceCollection, registerJsonResourceTemplate, @@ -195,18 +199,53 @@ function compactTelemetryEvent({ seq, event }: TelemetryEnvelope) { type BrowserTelemetryReadParams = { session_id: string; + project_id?: string; categories?: TelemetryEventsQuery["category"]; limit?: number; offset?: number; since?: string; until?: string; order?: "asc" | "desc"; + compact?: boolean; }; +const maxRawTelemetryEvents = 5; +// Producers cap each archived record at 1,000,000 bytes. This leaves room for +// the page envelope while ensuring one response cannot carry multiple max-size records. +const maxRawTelemetryResponseBytes = 1024 * 1024; + +function rawTelemetryPageError(items: TelemetryEnvelope[]) { + for (const { event } of items) { + const data = "data" in event ? event.data : undefined; + if (data && typeof data === "object" && "png" in data) { + return "Raw screenshot PNGs are not available in JSON telemetry responses."; + } + } + return undefined; +} + async function readBrowserTelemetry( client: KernelClient, params: BrowserTelemetryReadParams, ) { + if (params.compact === false) { + if (params.limit === undefined || params.limit > maxRawTelemetryEvents) { + return errorResponse( + `Error: compact=false requires an explicit limit between 1 and ${maxRawTelemetryEvents}.`, + ); + } + if (!params.categories) { + return errorResponse( + "Error: compact=false requires at least one explicit category.", + ); + } + if (params.categories.includes("screenshot")) { + return errorResponse( + "Error: compact=false does not support the screenshot category because PNGs are not returned in JSON.", + ); + } + } + const query: TelemetryEventsQuery = { limit: params.limit ?? 100 }; if (params.categories) query.category = params.categories; if (params.offset !== undefined) query.offset = params.offset; @@ -233,7 +272,9 @@ async function readBrowserTelemetry( const fullSessionRead = unfilteredExceptSince && params.since === undefined; const page = await client.browsers.telemetry.events(params.session_id, query); - const items = page.getPaginatedItems().map(compactTelemetryEvent); + const pageItems = page.getPaginatedItems(); + const items = + params.compact === false ? pageItems : pageItems.map(compactTelemetryEvent); // Counter-steer the pagination reflex: an unfiltered ascending read starts // at session creation, so an agent chasing a recent failure should flip to @@ -259,16 +300,50 @@ async function readBrowserTelemetry( }) : ascPagingNote; + const rawReplayBestEffort = + params.compact === false || + !query.category || + query.category.includes("screenshot") + ? undefined + : { + action: "get_telemetry", + session_id: params.session_id, + ...(params.project_id && { project_id: params.project_id }), + ...(query.category && { categories: query.category }), + limit: Math.min(query.limit ?? 100, maxRawTelemetryEvents), + ...(query.offset !== undefined && { offset: query.offset }), + ...(query.since && { since: query.since }), + ...(query.until && { until: query.until }), + ...(query.order && { order: query.order }), + compact: false, + }; + + const response = { + items, + has_more: page.has_more, + next_offset: page.next_offset, + ...(rawReplayBestEffort && { + raw_replay_best_effort: rawReplayBestEffort, + }), + ...(note && { note }), + }; + if (params.compact === false) { + const rawError = rawTelemetryPageError(pageItems); + if (rawError) return errorResponse(`Error: ${rawError}`); + } + // Single-line JSON rather than the pretty-printed house helpers: a page // carries up to 100 events and indentation would inflate the token cost. - return textResponse( - JSON.stringify({ - items, - has_more: page.has_more, - next_offset: page.next_offset, - ...(note && { note }), - }), - ); + const serializedResponse = JSON.stringify(response); + if ( + params.compact === false && + Buffer.byteLength(serializedResponse, "utf8") > maxRawTelemetryResponseBytes + ) { + return errorResponse( + `Error: raw telemetry response exceeds ${maxRawTelemetryResponseBytes} bytes. Reduce limit or narrow the category and time window.`, + ); + } + return textResponse(serializedResponse); } function browserSessionNextActions(sessionId: string) { @@ -318,33 +393,45 @@ function buildSshPortForwardingInfo( }; } -export function registerBrowserCapabilities(server: McpServer) { - registerJsonResourceCollection(server, { - name: "browsers", - uriTemplate: "kernel://orgs/{organizationId}/projects/{projectId}/browsers", - emptyText: "No browsers found", - read: async (client) => { - const browsers = []; - for await (const browser of client.browsers.list()) { - browsers.push(browser); - } - return browsers; +export function registerBrowserCapabilities( + server: McpServer, + dependencies: McpDependencies = defaultMcpDependencies, +) { + registerJsonResourceCollection( + server, + { + name: "browsers", + uriTemplate: + "kernel://orgs/{organizationId}/projects/{projectId}/browsers", + emptyText: "No browsers found", + read: async (client) => { + const browsers = []; + for await (const browser of client.browsers.list()) { + browsers.push(browser); + } + return browsers; + }, }, - }); + dependencies, + ); - registerJsonResourceTemplate(server, { - name: "browser", - uriTemplate: - "kernel://orgs/{organizationId}/projects/{projectId}/browsers/{sessionId}", - variableName: "sessionId", - resourceLabel: "Browser session", - read: (client, sessionId) => client.browsers.retrieve(sessionId), - }); + registerJsonResourceTemplate( + server, + { + name: "browser", + uriTemplate: + "kernel://orgs/{organizationId}/projects/{projectId}/browsers/{sessionId}", + variableName: "sessionId", + resourceLabel: "Browser session", + read: (client, sessionId) => client.browsers.retrieve(sessionId), + }, + dependencies, + ); // manage_browsers -- Manage browser sessions and read archived telemetry server.tool( "manage_browsers", - 'Manage browser sessions and their archived telemetry. Use "list" to choose an existing session, "create" before browser control, "update" to change supported session settings, "get" for full details, "get_telemetry" to diagnose active or deleted sessions, and "delete" when finished.', + 'Manage browser sessions and their archived telemetry. Use "list" to choose an existing session, "create" before browser control, "update" to change supported session settings, "get" for full details, "get_telemetry" to diagnose active or deleted sessions, and "delete" when finished. get_telemetry compacts events by default; set compact=false with explicit categories and a limit of at most 5 when raw headers, request data, response bodies, or other omitted fields are needed.', { ...projectSelectionInputSchema(), action: z @@ -511,6 +598,12 @@ export function registerBrowserCapabilities(server: McpServer) { "(get_telemetry) Read direction. asc (default) reads oldest first from session start; desc reads newest first. Prefer desc when diagnosing a recent failure in a long session — it reaches the end without paging. Preserve it while paging.", ) .optional(), + compact: z + .boolean() + .describe( + "(get_telemetry) Defaults to true. Compact items flatten the event envelope, add an ISO time, and omit data fields named body, headers, post_data, or png plus any data field over 8 KiB; omitted_fields lists removals. An eligible category-filtered compact response includes raw_replay_best_effort arguments targeting the same page start, but late events or retention may change results between calls. Raw mode requires explicit categories and limit<=5, rejects screenshot PNGs, and caps the serialized response at 1 MiB.", + ) + .optional(), telemetry_enabled: z .boolean() .describe( @@ -551,7 +644,7 @@ export function registerBrowserCapabilities(server: McpServer) { }, async (params, extra) => { if (!extra.authInfo) throw new Error("Authentication required"); - const client = createKernelClient( + const client = dependencies.createKernelClient( extra.authInfo.token, projectIDForOperation(extra.authInfo, params.project_id), ); @@ -680,12 +773,14 @@ export function registerBrowserCapabilities(server: McpServer) { } return await readBrowserTelemetry(client, { session_id: params.session_id, + project_id: params.project_id, categories: params.categories, limit: params.limit, offset: params.offset, since: params.since, until: params.until, order: params.order, + compact: params.compact, }); } case "delete": {