From e9a2a1f584d2ae70ca26b1c1f31e35bbb8a0588f Mon Sep 17 00:00:00 2001 From: rgarcia <72655+rgarcia@users.noreply.github.com> Date: Wed, 12 Aug 2026 14:44:11 +0000 Subject: [PATCH 1/5] Allow raw telemetry page reads --- src/lib/mcp/tools/browsers.test.ts | 125 +++++++++++++++++++++++++++++ src/lib/mcp/tools/browsers.ts | 27 +++++-- 2 files changed, 147 insertions(+), 5 deletions(-) create mode 100644 src/lib/mcp/tools/browsers.test.ts diff --git a/src/lib/mcp/tools/browsers.test.ts b/src/lib/mcp/tools/browsers.test.ts new file mode 100644 index 0000000..6adad5f --- /dev/null +++ b/src/lib/mcp/tools/browsers.test.ts @@ -0,0 +1,125 @@ +/// + +import { describe, expect, test } from "bun:test"; +import { connectTestMcp, toolResultJSON } from "@/lib/mcp/mcp-test-fixtures"; +import { registerBrowserCapabilities } from "@/lib/mcp/tools/browsers"; + +describe("manage_browsers telemetry", () => { + test("re-fetches the same telemetry page without compaction", async () => { + const event = { + seq: 42, + event: { + ts: 1_700_000_000_000_000, + category: "network", + type: "network_response", + source: { service: "cdp" }, + data: { + headers: { "set-cookie": "secret" }, + post_data: "query=secret", + body: '{"result":"complete"}', + status: 200, + }, + }, + }; + const queries: unknown[] = []; + const kernelClient = { + browsers: { + telemetry: { + events: async (_sessionId: string, query: unknown) => { + queries.push(query); + return { + getPaginatedItems: () => [event], + has_more: true, + next_offset: 43, + }; + }, + }, + }, + }; + const { client, close } = await connectTestMcp( + registerBrowserCapabilities, + kernelClient, + ); + 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: { ...page, compact: false }, + }), + ); + + 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 }, + omitted_fields: ["headers", "post_data", "body"], + }, + ]); + 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("documents compact telemetry recovery on the tool schema", 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("same categories, offset"); + expect(compact?.description).toContain("not next_offset"); + } finally { + await close(); + } + }); +}); diff --git a/src/lib/mcp/tools/browsers.ts b/src/lib/mcp/tools/browsers.ts index d683445..7aa0bda 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, @@ -201,6 +205,7 @@ type BrowserTelemetryReadParams = { since?: string; until?: string; order?: "asc" | "desc"; + compact?: boolean; }; async function readBrowserTelemetry( @@ -233,7 +238,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 @@ -318,7 +325,10 @@ function buildSshPortForwardingInfo( }; } -export function registerBrowserCapabilities(server: McpServer) { +export function registerBrowserCapabilities( + server: McpServer, + dependencies: McpDependencies = defaultMcpDependencies, +) { registerJsonResourceCollection(server, { name: "browsers", uriTemplate: "kernel://orgs/{organizationId}/projects/{projectId}/browsers", @@ -344,7 +354,7 @@ export function registerBrowserCapabilities(server: McpServer) { // 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 on a narrowly filtered page when raw headers, request data, response bodies, or other omitted fields are needed.', { ...projectSelectionInputSchema(), action: z @@ -511,6 +521,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. To recover them, repeat the call with compact=false and the same categories, offset, since, until, order, and limit. Use the original page offset, not next_offset, which points to the following page. Raw events may be large, so narrow the categories, time window, and limit first.", + ) + .optional(), telemetry_enabled: z .boolean() .describe( @@ -551,7 +567,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), ); @@ -686,6 +702,7 @@ export function registerBrowserCapabilities(server: McpServer) { since: params.since, until: params.until, order: params.order, + compact: params.compact, }); } case "delete": { From 61eb4e653ab01acdf83772a3f03860f23c99211f Mon Sep 17 00:00:00 2001 From: rgarcia <72655+rgarcia@users.noreply.github.com> Date: Wed, 12 Aug 2026 14:56:59 +0000 Subject: [PATCH 2/5] Wire browser resource dependencies --- src/lib/mcp/tools/browsers.test.ts | 18 +++++++++++ src/lib/mcp/tools/browsers.ts | 49 ++++++++++++++++++------------ 2 files changed, 47 insertions(+), 20 deletions(-) diff --git a/src/lib/mcp/tools/browsers.test.ts b/src/lib/mcp/tools/browsers.test.ts index 6adad5f..31b3efd 100644 --- a/src/lib/mcp/tools/browsers.test.ts +++ b/src/lib/mcp/tools/browsers.test.ts @@ -24,6 +24,10 @@ describe("manage_browsers telemetry", () => { const queries: unknown[] = []; const kernelClient = { browsers: { + list: async function* () { + yield { session_id: "brr_123" }; + }, + retrieve: async (sessionId: string) => ({ session_id: sessionId }), telemetry: { events: async (_sessionId: string, query: unknown) => { queries.push(query); @@ -94,6 +98,20 @@ describe("manage_browsers telemetry", () => { order: "asc", }, ]); + + const collection = await client.readResource({ + uri: "kernel://orgs/org_test/projects/proj_test/browsers", + }); + const item = await client.readResource({ + uri: "kernel://orgs/org_test/projects/proj_test/browsers/brr_123", + }); + const collectionContent = collection.contents[0]; + const itemContent = item.contents[0]; + const collectionText = + "text" in collectionContent ? collectionContent.text : ""; + const itemText = "text" in itemContent ? itemContent.text : ""; + expect(JSON.parse(collectionText)).toEqual([{ session_id: "brr_123" }]); + expect(JSON.parse(itemText)).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 7aa0bda..3628695 100644 --- a/src/lib/mcp/tools/browsers.ts +++ b/src/lib/mcp/tools/browsers.ts @@ -329,27 +329,36 @@ 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; + 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( From f45b2f80127cba8e356528525db96c93bbb7881c Mon Sep 17 00:00:00 2001 From: rgarcia <72655+rgarcia@users.noreply.github.com> Date: Wed, 12 Aug 2026 17:42:17 +0000 Subject: [PATCH 3/5] Stabilize raw telemetry replay --- src/lib/mcp/tools/browsers.test.ts | 212 +++++++++++++++++++++-------- src/lib/mcp/tools/browsers.ts | 87 ++++++++++-- 2 files changed, 229 insertions(+), 70 deletions(-) diff --git a/src/lib/mcp/tools/browsers.test.ts b/src/lib/mcp/tools/browsers.test.ts index 31b3efd..9be8635 100644 --- a/src/lib/mcp/tools/browsers.test.ts +++ b/src/lib/mcp/tools/browsers.test.ts @@ -4,45 +4,50 @@ import { describe, expect, test } from "bun:test"; import { connectTestMcp, toolResultJSON } from "@/lib/mcp/mcp-test-fixtures"; import { registerBrowserCapabilities } from "@/lib/mcp/tools/browsers"; -describe("manage_browsers telemetry", () => { - test("re-fetches the same telemetry page without compaction", async () => { - const event = { - seq: 42, - event: { - ts: 1_700_000_000_000_000, - category: "network", - type: "network_response", - source: { service: "cdp" }, - data: { - headers: { "set-cookie": "secret" }, - post_data: "query=secret", - body: '{"result":"complete"}', - status: 200, +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[]) { + return { + browsers: { + telemetry: { + events: async (_sessionId: string, query: unknown) => { + queries.push(query); + return { + getPaginatedItems: () => [event], + has_more: true, + next_offset: 43, + }; }, }, - }; + }, + }; +} + +type TextResourceResult = { + contents: Array<{ text: string }>; +}; + +describe("manage_browsers telemetry", () => { + test("re-fetches the same cursor page without compaction", async () => { const queries: unknown[] = []; - const kernelClient = { - browsers: { - list: async function* () { - yield { session_id: "brr_123" }; - }, - retrieve: async (sessionId: string) => ({ session_id: sessionId }), - telemetry: { - events: async (_sessionId: string, query: unknown) => { - queries.push(query); - return { - getPaginatedItems: () => [event], - has_more: true, - next_offset: 43, - }; - }, - }, - }, - }; const { client, close } = await connectTestMcp( registerBrowserCapabilities, - kernelClient, + telemetryClient(queries), ); const page = { action: "get_telemetry", @@ -63,7 +68,7 @@ describe("manage_browsers telemetry", () => { const raw = toolResultJSON( await client.callTool({ name: "manage_browsers", - arguments: { ...page, compact: false }, + arguments: compact.raw_replay as Record, }), ); @@ -76,42 +81,129 @@ describe("manage_browsers telemetry", () => { type: "network_response", source: { service: "cdp" }, data: { status: 200 }, + truncated: true, omitted_fields: ["headers", "post_data", "body"], }, ]); + expect(compact.raw_replay).toMatchObject({ + ...page, + compact: false, + }); + expect(Date.parse(compact.raw_replay.until as string)).toBeNumber(); 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", + expect(queries).toHaveLength(2); + expect(queries[1]).toEqual(queries[0]); + } finally { + await close(); + } + }); + + test("pins an offset-less newest-first page for raw replay", async () => { + const queries: unknown[] = []; + const { client, close } = await connectTestMcp( + registerBrowserCapabilities, + telemetryClient(queries), + ); + + try { + const compact = toolResultJSON( + await client.callTool({ + name: "manage_browsers", + arguments: { + action: "get_telemetry", + session_id: "brr_123", + categories: ["network"], + limit: 1, + order: "desc", + }, + }), + ); + await client.callTool({ + name: "manage_browsers", + arguments: compact.raw_replay as Record, + }); + + expect(compact.raw_replay).toMatchObject({ + action: "get_telemetry", + session_id: "brr_123", + categories: ["network"], + limit: 1, + order: "desc", + compact: false, + }); + expect(Date.parse(compact.raw_replay.until as string)).toBeNumber(); + expect(queries[1]).toEqual(queries[0]); + } finally { + await close(); + } + }); + + test("resolves relative windows once for raw replay", async () => { + const queries: unknown[] = []; + const { client, close } = await connectTestMcp( + registerBrowserCapabilities, + telemetryClient(queries), + ); + + try { + const compact = toolResultJSON( + await client.callTool({ + name: "manage_browsers", + arguments: { + action: "get_telemetry", + session_id: "brr_123", + since: "30m", + until: "5m", + limit: 1, + }, + }), + ); + await client.callTool({ + name: "manage_browsers", + arguments: compact.raw_replay as Record, + }); + + const since = Date.parse(compact.raw_replay.since as string); + const until = Date.parse(compact.raw_replay.until as string); + expect(until - since).toBe(25 * 60 * 1_000); + expect(queries[1]).toEqual(queries[0]); + } finally { + await close(); + } + }); + + test("serves browser resources through 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, + ); - const collection = await client.readResource({ + try { + const collection = (await client.readResource({ uri: "kernel://orgs/org_test/projects/proj_test/browsers", - }); - const item = await client.readResource({ + })) 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", }); - const collectionContent = collection.contents[0]; - const itemContent = item.contents[0]; - const collectionText = - "text" in collectionContent ? collectionContent.text : ""; - const itemText = "text" in itemContent ? itemContent.text : ""; - expect(JSON.parse(collectionText)).toEqual([{ session_id: "brr_123" }]); - expect(JSON.parse(itemText)).toEqual({ session_id: "brr_123" }); } finally { await close(); } @@ -134,8 +226,8 @@ describe("manage_browsers telemetry", () => { expect(compact?.description).toContain( "body, headers, post_data, or png", ); - expect(compact?.description).toContain("same categories, offset"); - expect(compact?.description).toContain("not next_offset"); + expect(compact?.description).toContain("exact page"); + expect(compact?.description).toContain("raw_replay"); } finally { await close(); } diff --git a/src/lib/mcp/tools/browsers.ts b/src/lib/mcp/tools/browsers.ts index 3628695..f5c53b3 100644 --- a/src/lib/mcp/tools/browsers.ts +++ b/src/lib/mcp/tools/browsers.ts @@ -199,6 +199,7 @@ function compactTelemetryEvent({ seq, event }: TelemetryEnvelope) { type BrowserTelemetryReadParams = { session_id: string; + project_id?: string; categories?: TelemetryEventsQuery["category"]; limit?: number; offset?: number; @@ -208,25 +209,73 @@ type BrowserTelemetryReadParams = { compact?: boolean; }; +const telemetryDurationUnitsInMilliseconds = { + ns: 1 / 1_000_000, + us: 1 / 1_000, + µs: 1 / 1_000, + μs: 1 / 1_000, + ms: 1, + s: 1_000, + m: 60_000, + h: 3_600_000, +} as const; + +function resolveTelemetryTimeParam(value: string, now: Date) { + if (/^[+-]?0$/.test(value)) return now.toISOString(); + + const sign = value.startsWith("-") ? -1 : 1; + const duration = value.match(/^[+-]?(.*)$/)?.[1] ?? value; + const parts = duration.matchAll( + /((?:\d+(?:\.\d*)?|\.\d+))(ns|us|µs|μs|ms|s|m|h)/g, + ); + let milliseconds = 0; + let parsedLength = 0; + for (const part of parts) { + if (part.index !== parsedLength) return value; + milliseconds += + Number(part[1]) * + telemetryDurationUnitsInMilliseconds[ + part[2] as keyof typeof telemetryDurationUnitsInMilliseconds + ]; + parsedLength += part[0].length; + } + if ( + parsedLength !== duration.length || + parsedLength === 0 || + !Number.isFinite(milliseconds) + ) { + return value; + } + const resolved = new Date(now.getTime() - sign * milliseconds); + return Number.isNaN(resolved.getTime()) ? value : resolved.toISOString(); +} + async function readBrowserTelemetry( client: KernelClient, params: BrowserTelemetryReadParams, ) { - const query: TelemetryEventsQuery = { limit: params.limit ?? 100 }; + // Pin the page to one absolute window so raw_replay cannot move as new + // telemetry arrives or relative time bounds age. + const now = new Date(); + const query: TelemetryEventsQuery = { + limit: params.limit ?? 100, + until: params.until + ? resolveTelemetryTimeParam(params.until, now) + : now.toISOString(), + order: params.order ?? "asc", + }; if (params.categories) query.category = params.categories; - if (params.offset !== undefined) query.offset = params.offset; - if (params.since !== undefined) query.since = params.since; - if (params.until !== undefined) query.until = params.until; - if (params.order !== undefined) query.order = params.order; + if (params.offset !== undefined) { + query.offset = params.offset; + } else if (params.since !== undefined) { + query.since = resolveTelemetryTimeParam(params.since, now); + } // Avoid the API's five-minute default. The archive can't predate the - // session, so the epoch reads the full session without a browser lookup; - // until-only reads already start at the stream head and desc reads anchor - // at the stream tail. + // session, so the epoch reads the full session without a browser lookup. if ( query.offset === undefined && query.since === undefined && - query.until === undefined && query.order !== "desc" ) { query.since = "1970-01-01T00:00:00Z"; @@ -266,6 +315,22 @@ async function readBrowserTelemetry( }) : ascPagingNote; + const rawReplay = + params.compact === false + ? undefined + : { + action: "get_telemetry", + session_id: params.session_id, + ...(params.project_id && { project_id: params.project_id }), + ...(query.category && { categories: query.category }), + limit: query.limit, + ...(query.offset !== undefined && { offset: query.offset }), + ...(query.since && { since: query.since }), + ...(query.until && { until: query.until }), + order: query.order, + compact: false, + }; + // 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( @@ -273,6 +338,7 @@ async function readBrowserTelemetry( items, has_more: page.has_more, next_offset: page.next_offset, + ...(rawReplay && { raw_replay: rawReplay }), ...(note && { note }), }), ); @@ -533,7 +599,7 @@ export function registerBrowserCapabilities( 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. To recover them, repeat the call with compact=false and the same categories, offset, since, until, order, and limit. Use the original page offset, not next_offset, which points to the following page. Raw events may be large, so narrow the categories, time window, and limit first.", + "(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. To recover them from the exact page, call manage_browsers with the returned raw_replay arguments. Raw events may be large, so narrow the categories, time window, and limit first.", ) .optional(), telemetry_enabled: z @@ -705,6 +771,7 @@ export function registerBrowserCapabilities( } return await readBrowserTelemetry(client, { session_id: params.session_id, + project_id: params.project_id, categories: params.categories, limit: params.limit, offset: params.offset, From b7e0866a93ba659d4211c1ce91eb12a9430b8df6 Mon Sep 17 00:00:00 2001 From: rgarcia <72655+rgarcia@users.noreply.github.com> Date: Wed, 12 Aug 2026 17:56:33 +0000 Subject: [PATCH 4/5] Simplify telemetry duration parsing --- src/lib/mcp/tools/browsers.test.ts | 52 ++++++++++++++++-------------- src/lib/mcp/tools/browsers.ts | 27 ++++++---------- 2 files changed, 36 insertions(+), 43 deletions(-) diff --git a/src/lib/mcp/tools/browsers.test.ts b/src/lib/mcp/tools/browsers.test.ts index 9be8635..531dbec 100644 --- a/src/lib/mcp/tools/browsers.test.ts +++ b/src/lib/mcp/tools/browsers.test.ts @@ -176,7 +176,33 @@ describe("manage_browsers telemetry", () => { } }); - test("serves browser resources through injected dependencies", async () => { + test("documents compact telemetry recovery on the tool schema", 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("exact page"); + expect(compact?.description).toContain("raw_replay"); + } finally { + await close(); + } + }); +}); + +describe("browser resources", () => { + test("uses injected dependencies", async () => { const kernelClient = { browsers: { list: async function* () { @@ -208,28 +234,4 @@ describe("manage_browsers telemetry", () => { await close(); } }); - - test("documents compact telemetry recovery on the tool schema", 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("exact page"); - expect(compact?.description).toContain("raw_replay"); - } finally { - await close(); - } - }); }); diff --git a/src/lib/mcp/tools/browsers.ts b/src/lib/mcp/tools/browsers.ts index f5c53b3..bb99dba 100644 --- a/src/lib/mcp/tools/browsers.ts +++ b/src/lib/mcp/tools/browsers.ts @@ -220,31 +220,22 @@ const telemetryDurationUnitsInMilliseconds = { h: 3_600_000, } as const; +const telemetryDurationPart = + /((?:\d+(?:\.\d*)?|\.\d+))(ns|us|µs|μs|ms|s|m|h)/g; +const telemetryDuration = + /^[+-]?(?:0|(?:(?:\d+(?:\.\d*)?|\.\d+)(?:ns|us|µs|μs|ms|s|m|h))+)$/; + function resolveTelemetryTimeParam(value: string, now: Date) { - if (/^[+-]?0$/.test(value)) return now.toISOString(); + if (!telemetryDuration.test(value)) return value; const sign = value.startsWith("-") ? -1 : 1; - const duration = value.match(/^[+-]?(.*)$/)?.[1] ?? value; - const parts = duration.matchAll( - /((?:\d+(?:\.\d*)?|\.\d+))(ns|us|µs|μs|ms|s|m|h)/g, - ); let milliseconds = 0; - let parsedLength = 0; - for (const part of parts) { - if (part.index !== parsedLength) return value; + for (const [, amount, unit] of value.matchAll(telemetryDurationPart)) { milliseconds += - Number(part[1]) * + Number(amount) * telemetryDurationUnitsInMilliseconds[ - part[2] as keyof typeof telemetryDurationUnitsInMilliseconds + unit as keyof typeof telemetryDurationUnitsInMilliseconds ]; - parsedLength += part[0].length; - } - if ( - parsedLength !== duration.length || - parsedLength === 0 || - !Number.isFinite(milliseconds) - ) { - return value; } const resolved = new Date(now.getTime() - sign * milliseconds); return Number.isNaN(resolved.getTime()) ? value : resolved.toISOString(); From 53ca6b7d3e2a69a95c45d55b4ce5347cec19bc9a Mon Sep 17 00:00:00 2001 From: rgarcia <72655+rgarcia@users.noreply.github.com> Date: Wed, 12 Aug 2026 20:56:38 +0000 Subject: [PATCH 5/5] Bound raw telemetry responses --- src/lib/mcp/tools/browsers.test.ts | 225 +++++++++++++++++++++++------ src/lib/mcp/tools/browsers.ts | 131 +++++++++-------- 2 files changed, 254 insertions(+), 102 deletions(-) diff --git a/src/lib/mcp/tools/browsers.test.ts b/src/lib/mcp/tools/browsers.test.ts index 531dbec..2e07300 100644 --- a/src/lib/mcp/tools/browsers.test.ts +++ b/src/lib/mcp/tools/browsers.test.ts @@ -1,5 +1,6 @@ /// +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"; @@ -21,14 +22,14 @@ const event = { }, }; -function telemetryClient(queries: unknown[]) { +function telemetryClient(queries: unknown[], items: unknown[] = [event]) { return { browsers: { telemetry: { events: async (_sessionId: string, query: unknown) => { queries.push(query); return { - getPaginatedItems: () => [event], + getPaginatedItems: () => items, has_more: true, next_offset: 43, }; @@ -38,12 +39,17 @@ function telemetryClient(queries: unknown[]) { }; } +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 the same cursor page without compaction", async () => { + test("re-fetches a cursor page without compaction", async () => { const queries: unknown[] = []; const { client, close } = await connectTestMcp( registerBrowserCapabilities, @@ -68,7 +74,7 @@ describe("manage_browsers telemetry", () => { const raw = toolResultJSON( await client.callTool({ name: "manage_browsers", - arguments: compact.raw_replay as Record, + arguments: compact.raw_replay_best_effort, }), ); @@ -85,24 +91,35 @@ describe("manage_browsers telemetry", () => { omitted_fields: ["headers", "post_data", "body"], }, ]); - expect(compact.raw_replay).toMatchObject({ + expect(compact.raw_replay_best_effort).toEqual({ ...page, compact: false, }); - expect(Date.parse(compact.raw_replay.until as string)).toBeNumber(); expect(raw).toEqual({ items: [event], has_more: true, next_offset: 43, }); - expect(queries).toHaveLength(2); - expect(queries[1]).toEqual(queries[0]); + expect(queries).toEqual([ + { + limit: 1, + category: ["network"], + offset: 41, + order: "asc", + }, + { + limit: 1, + category: ["network"], + offset: 41, + order: "asc", + }, + ]); } finally { await close(); } }); - test("pins an offset-less newest-first page for raw replay", async () => { + test("preserves API time and tail semantics", async () => { const queries: unknown[] = []; const { client, close } = await connectTestMcp( registerBrowserCapabilities, @@ -110,7 +127,7 @@ describe("manage_browsers telemetry", () => { ); try { - const compact = toolResultJSON( + const descending = toolResultJSON( await client.callTool({ name: "manage_browsers", arguments: { @@ -122,27 +139,119 @@ describe("manage_browsers telemetry", () => { }, }), ); - await client.callTool({ + 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: compact.raw_replay as Record, + arguments: { + action: "get_telemetry", + session_id: "brr_123", + categories: ["network"], + limit: 6, + compact: false, + }, }); - expect(compact.raw_replay).toMatchObject({ - action: "get_telemetry", - session_id: "brr_123", - categories: ["network"], - limit: 1, - order: "desc", - 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(Date.parse(compact.raw_replay.until as string)).toBeNumber(); - expect(queries[1]).toEqual(queries[0]); + + expect(result.isError).toBeTrue(); + expect(toolResultText(result)).toContain( + "raw telemetry response exceeds 1048576 bytes", + ); } finally { await close(); } }); - test("resolves relative windows once for raw replay", async () => { + test("keeps screenshot PNGs out of raw JSON", async () => { const queries: unknown[] = []; const { client, close } = await connectTestMcp( registerBrowserCapabilities, @@ -150,33 +259,61 @@ describe("manage_browsers telemetry", () => { ); try { - const compact = toolResultJSON( - await client.callTool({ - name: "manage_browsers", - arguments: { - action: "get_telemetry", - session_id: "brr_123", - since: "30m", - until: "5m", - limit: 1, - }, - }), + 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", ); - await client.callTool({ + 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: compact.raw_replay as Record, + arguments: { + action: "get_telemetry", + session_id: "brr_123", + categories: ["network"], + limit: 1, + compact: false, + }, }); - const since = Date.parse(compact.raw_replay.since as string); - const until = Date.parse(compact.raw_replay.until as string); - expect(until - since).toBe(25 * 60 * 1_000); - expect(queries[1]).toEqual(queries[0]); + expect(result.isError).toBeTrue(); + expect(toolResultText(result)).toContain( + "Raw screenshot PNGs are not available", + ); + expect(queries).toHaveLength(1); } finally { await close(); } }); - test("documents compact telemetry recovery on the tool schema", async () => { + test("documents bounded best-effort raw replay", async () => { const kernelClient = { browsers: {} }; const { client, close } = await connectTestMcp( registerBrowserCapabilities, @@ -193,8 +330,12 @@ describe("manage_browsers telemetry", () => { expect(compact?.description).toContain( "body, headers, post_data, or png", ); - expect(compact?.description).toContain("exact page"); - expect(compact?.description).toContain("raw_replay"); + 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(); } diff --git a/src/lib/mcp/tools/browsers.ts b/src/lib/mcp/tools/browsers.ts index bb99dba..fde5dac 100644 --- a/src/lib/mcp/tools/browsers.ts +++ b/src/lib/mcp/tools/browsers.ts @@ -209,64 +209,58 @@ type BrowserTelemetryReadParams = { compact?: boolean; }; -const telemetryDurationUnitsInMilliseconds = { - ns: 1 / 1_000_000, - us: 1 / 1_000, - µs: 1 / 1_000, - μs: 1 / 1_000, - ms: 1, - s: 1_000, - m: 60_000, - h: 3_600_000, -} as const; - -const telemetryDurationPart = - /((?:\d+(?:\.\d*)?|\.\d+))(ns|us|µs|μs|ms|s|m|h)/g; -const telemetryDuration = - /^[+-]?(?:0|(?:(?:\d+(?:\.\d*)?|\.\d+)(?:ns|us|µs|μs|ms|s|m|h))+)$/; - -function resolveTelemetryTimeParam(value: string, now: Date) { - if (!telemetryDuration.test(value)) return value; - - const sign = value.startsWith("-") ? -1 : 1; - let milliseconds = 0; - for (const [, amount, unit] of value.matchAll(telemetryDurationPart)) { - milliseconds += - Number(amount) * - telemetryDurationUnitsInMilliseconds[ - unit as keyof typeof telemetryDurationUnitsInMilliseconds - ]; +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."; + } } - const resolved = new Date(now.getTime() - sign * milliseconds); - return Number.isNaN(resolved.getTime()) ? value : resolved.toISOString(); + return undefined; } async function readBrowserTelemetry( client: KernelClient, params: BrowserTelemetryReadParams, ) { - // Pin the page to one absolute window so raw_replay cannot move as new - // telemetry arrives or relative time bounds age. - const now = new Date(); - const query: TelemetryEventsQuery = { - limit: params.limit ?? 100, - until: params.until - ? resolveTelemetryTimeParam(params.until, now) - : now.toISOString(), - order: params.order ?? "asc", - }; - if (params.categories) query.category = params.categories; - if (params.offset !== undefined) { - query.offset = params.offset; - } else if (params.since !== undefined) { - query.since = resolveTelemetryTimeParam(params.since, now); + 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; + if (params.since !== undefined) query.since = params.since; + if (params.until !== undefined) query.until = params.until; + if (params.order !== undefined) query.order = params.order; + // Avoid the API's five-minute default. The archive can't predate the - // session, so the epoch reads the full session without a browser lookup. + // session, so the epoch reads the full session without a browser lookup; + // until-only reads already start at the stream head and desc reads anchor + // at the stream tail. if ( query.offset === undefined && query.since === undefined && + query.until === undefined && query.order !== "desc" ) { query.since = "1970-01-01T00:00:00Z"; @@ -306,33 +300,50 @@ async function readBrowserTelemetry( }) : ascPagingNote; - const rawReplay = - params.compact === false + 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: query.limit, + limit: Math.min(query.limit ?? 100, maxRawTelemetryEvents), ...(query.offset !== undefined && { offset: query.offset }), ...(query.since && { since: query.since }), ...(query.until && { until: query.until }), - order: query.order, + ...(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, - ...(rawReplay && { raw_replay: rawReplay }), - ...(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) { @@ -420,7 +431,7 @@ export function registerBrowserCapabilities( // 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. get_telemetry compacts events by default; set compact=false on a narrowly filtered page when raw headers, request data, response bodies, or other omitted fields are needed.', + '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 @@ -590,7 +601,7 @@ export function registerBrowserCapabilities( 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. To recover them from the exact page, call manage_browsers with the returned raw_replay arguments. Raw events may be large, so narrow the categories, time window, and limit first.", + "(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