From 1b1f03ffa8121d2c8e022c1c6818fe732d417e33 Mon Sep 17 00:00:00 2001 From: yummybomb <19238148+yummybomb@users.noreply.github.com> Date: Tue, 14 Jul 2026 20:59:54 +0000 Subject: [PATCH 01/21] Add browser telemetry reader --- src/lib/mcp/prompts.ts | 31 ++++-- src/lib/mcp/telemetry.ts | 14 +++ src/lib/mcp/tools/browsers.ts | 181 +++++++++++++++++++++++++++++++++- 3 files changed, 216 insertions(+), 10 deletions(-) create mode 100644 src/lib/mcp/telemetry.ts diff --git a/src/lib/mcp/prompts.ts b/src/lib/mcp/prompts.ts index ac347e4..242b3bd 100644 --- a/src/lib/mcp/prompts.ts +++ b/src/lib/mcp/prompts.ts @@ -1,5 +1,6 @@ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z } from "zod"; +import { TELEMETRY_EVENT_CATALOG } from "@/lib/mcp/telemetry"; export function registerKernelPrompts(server: McpServer) { // MCP Prompt explaining Kernel concepts @@ -128,7 +129,23 @@ kernel browsers process --help kernel browsers playwright --help \`\`\` -**MCP Exception:** The \`computer_action\` MCP tool with action "screenshot" is useful since it returns images directly to the agent. +**MCP Exceptions:** The \`computer_action\` MCP tool with action "screenshot" is useful since it returns images directly to the agent, and \`get_browser_telemetry\` reads structured telemetry events (see below). + +--- + +## Telemetry Events (structured signal — works even after the session is deleted) + +**Check telemetry first when it's available** — it's the fastest way to pinpoint failures. + +**Gotcha: telemetry is opt-in and must have been enabled when the relevant activity occurred.** Always try \`get_browser_telemetry\` first because archived events survive telemetry being disabled and the session being deleted. \`manage_browsers\` action "get" shows only the current telemetry config, so a null \`telemetry\` field means capture is off now, not that the archive is necessarily empty. The default bundle (control/connection/system/captcha) also omits the debug-critical categories. For an active browser, use \`manage_browsers\` action "update" to enable \`telemetry_console\`, \`telemetry_network\`, and \`telemetry_page\`, then reproduce the issue. Recreate the browser only if the original session has ended. + +**Flow:** +1. \`get_browser_telemetry\` with session_id "${session_id}" — filter with categories ["console", "network", "page"] to cut noise, or order "desc" to inspect the end of the session +2. Scan for \`console_error\`, \`network_loading_failed\`, \`network_response\` with non-2xx status, and \`captcha_*\` outcomes +3. Correlate event timestamps with the failing automation step +4. Page with \`next_offset\` while \`has_more\` is true + +${TELEMETRY_EVENT_CATALOG} --- @@ -224,6 +241,7 @@ These are **normal** and don't indicate problems: ## Debugging Checklist - [ ] Session exists and is active +- [ ] Telemetry events reviewed (if any were captured) - [ ] Screenshot shows expected content (or reveals error) - [ ] Current URL is as expected - [ ] Supervisor logs show all services running @@ -237,11 +255,12 @@ These are **normal** and don't indicate problems: Based on your issue "${issue_description}", start with: -1. **Get browser info** to confirm session is active -2. **Take screenshot** to see current state -3. **Check page URL** to see if on error page -4. **Test network** if seeing connection errors -5. **Review logs** for specific error patterns`; +1. **Get browser info** to confirm session is active and check whether telemetry was enabled +2. **Read telemetry events**; if needed, enable telemetry on an active session and reproduce +3. **Take screenshot** to see current state +4. **Check page URL** to see if on error page +5. **Test network** if seeing connection errors +6. **Review logs** for specific error patterns`; return { messages: [ diff --git a/src/lib/mcp/telemetry.ts b/src/lib/mcp/telemetry.ts new file mode 100644 index 0000000..761ada7 --- /dev/null +++ b/src/lib/mcp/telemetry.ts @@ -0,0 +1,14 @@ +export const telemetryEventCategories = [ + "console", + "network", + "page", + "interaction", + "control", + "connection", + "system", + "screenshot", + "captcha", + "monitor", +] as const; + +export const TELEMETRY_EVENT_CATALOG = `Event categories: console (console output and uncaught exceptions), network (request/response metadata), page (navigation and lifecycle), interaction (clicks, keys, scrolls), control (agent-driven API calls), connection (CDP/live-view attach/detach), system (VM health), screenshot (periodic monitor screenshots), captcha (captcha detection and solve outcomes), monitor (telemetry collector health; captured automatically with any CDP category). High-signal event types: console_error, network_loading_failed, network_response with non-2xx status, captcha_solve_result, system_oom_kill, service_crashed, monitor_disconnected (telemetry gap — treat following events as incomplete).`; diff --git a/src/lib/mcp/tools/browsers.ts b/src/lib/mcp/tools/browsers.ts index 42040f1..74ad563 100644 --- a/src/lib/mcp/tools/browsers.ts +++ b/src/lib/mcp/tools/browsers.ts @@ -15,11 +15,18 @@ import { toolErrorResponse, } from "@/lib/mcp/responses"; import { paginationParams } from "@/lib/mcp/schemas"; +import { + TELEMETRY_EVENT_CATALOG, + telemetryEventCategories, +} from "@/lib/mcp/telemetry"; type BrowserCreateParams = NonNullable< Parameters[0] >; type BrowserUpdateParams = Parameters[1]; +type TelemetryEventsQuery = NonNullable< + Parameters[1] +>; type TelemetryParams = { telemetry_enabled?: boolean; @@ -79,6 +86,42 @@ function buildTelemetry( }; } +type TelemetryEnvelope = Awaited< + ReturnType +>["items"][number]; + +// Payload fields that can carry kilobytes per event (response bodies, header +// maps). Dropped so a full page of events fits in an agent context window; +// omitted_fields tells the agent what to fetch via the API/CLI if needed. +const bulkyTelemetryDataFields = ["body", "headers", "post_data"] as const; + +function compactTelemetryEvent({ seq, event }: TelemetryEnvelope) { + const { ts, category, type, truncated } = event; + const data = "data" in event ? event.data : undefined; + + let compactData: Record | undefined; + let omittedFields: string[] | undefined; + if (data) { + compactData = { ...(data as Record) }; + for (const field of bulkyTelemetryDataFields) { + if (compactData[field] !== undefined) { + delete compactData[field]; + (omittedFields ??= []).push(field); + } + } + } + + return { + seq, + time: new Date(ts / 1000).toISOString(), + category, + type, + ...(compactData && { data: compactData }), + ...(truncated && { truncated }), + ...(omittedFields && { omitted_fields: omittedFields }), + }; +} + function browserSessionNextActions(sessionId: string) { return [ `Use computer_action with session_id "${sessionId}" to inspect or control the browser.`, @@ -299,21 +342,25 @@ export function registerBrowserCapabilities(server: McpServer) { telemetry_enabled: z .boolean() .describe( - "(create, update) Enable telemetry with VM defaults, or disable telemetry when false.", + "(create, update) Enable telemetry, or disable telemetry when false. Telemetry is off unless requested. The default category set is the lightweight operational bundle (control, connection, system, captcha) and does NOT include console, network, or page — enable those explicitly when you intend to debug page behavior.", ) .optional(), telemetry_console: z .boolean() - .describe("(create, update) Enable or disable console telemetry.") + .describe( + "(create, update) Enable or disable console telemetry (console output and uncaught exceptions). Off by default; enable for debugging.", + ) .optional(), telemetry_network: z .boolean() - .describe("(create, update) Enable or disable network telemetry.") + .describe( + "(create, update) Enable or disable network telemetry (request/response metadata). Off by default; enable for debugging.", + ) .optional(), telemetry_page: z .boolean() .describe( - "(create, update) Enable or disable page lifecycle telemetry.", + "(create, update) Enable or disable page lifecycle telemetry (navigation, load, layout shifts, LCP). Off by default; enable for debugging.", ) .optional(), telemetry_interaction: z @@ -460,4 +507,130 @@ export function registerBrowserCapabilities(server: McpServer) { } }, ); + + // get_browser_telemetry -- Read archived telemetry events for a session + server.tool( + "get_browser_telemetry", + `Read archived telemetry events for a browser session. Works while the session is active and after it is deleted, including events captured before telemetry was disabled. If the response reports status "telemetry_currently_disabled", widen or remove filters before enabling telemetry and reproducing: update an active browser, or recreate one that has ended. Page through long sessions with offset/next_offset instead of raising limit. ${TELEMETRY_EVENT_CATALOG}`, + { + session_id: z.string().describe("Browser session ID."), + categories: z + .array(z.enum(telemetryEventCategories)) + .min(1) + .describe( + "Restrict results to these event categories. The filter applies within each page, so a filtered page can be empty while has_more is true.", + ) + .optional(), + limit: z + .number() + .int() + .min(1) + .max(100) + .describe("Max events per page (1-100). Default 100.") + .optional(), + offset: z + .number() + .int() + .min(0) + .describe( + "Pagination cursor: pass next_offset from the previous response to fetch the next page. Opaque — do not derive it from event seq values.", + ) + .optional(), + since: z + .string() + .describe( + "Start of the window: an RFC-3339 timestamp or a duration like '30m' meaning that long ago. Defaults to the session's creation time. Ignored when offset is set; cannot be combined with order=desc.", + ) + .optional(), + until: z + .string() + .describe( + "End of the window (exclusive): an RFC-3339 timestamp or a duration like '5m'.", + ) + .optional(), + order: z + .enum(["asc", "desc"]) + .describe( + "Read direction. asc (default) reads oldest first starting from since; desc reads newest first — useful for inspecting the end of a session.", + ) + .optional(), + }, + { + title: "Read browser telemetry events", + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + }, + async (params, extra) => { + if (!extra.authInfo) throw new Error("Authentication required"); + const client = createKernelClient(extra.authInfo.token); + + // Best-effort lookup for the session's telemetry config and creation + // time; when it fails we still read events but skip disambiguation. + const fetchBrowser = () => + client.browsers.retrieve(params.session_id).catch(() => null); + + try { + 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; + + let browser: Awaited> = null; + if ( + query.offset === undefined && + query.since === undefined && + query.order !== "desc" + ) { + // The API's since default is only 5m; cover the whole session. + browser = await fetchBrowser(); + query.since = browser?.created_at ?? "1970-01-01T00:00:00Z"; + } + + const page = await client.browsers.telemetry.events( + params.session_id, + query, + ); + const items = page.getPaginatedItems().map(compactTelemetryEvent); + + let status: "ok" | "telemetry_currently_disabled" | "no_events" = "ok"; + let note: string | undefined; + if (items.length === 0) { + if (page.has_more) { + note = + "This page had no matching events, but more are archived — continue paging with next_offset."; + } else { + browser ??= await fetchBrowser(); + if (browser && !browser.telemetry) { + status = "telemetry_currently_disabled"; + note = + "No archived events matched this window and filter, and telemetry is currently disabled. Widen since/until or drop the categories filter before reproducing: update this active browser with telemetry_enabled=true plus telemetry_console, telemetry_network, and telemetry_page."; + } else { + status = "no_events"; + note = browser + ? "No archived events matched this window and filter. Widen since/until or drop the categories filter." + : "No archived events matched, and the session could not be fetched. Widen since/until or drop the categories filter; if the session has ended and telemetry was not enabled, recreate it with telemetry enabled (including console, network, and page) and reproduce the issue."; + } + } + } + + // Compact serialization: a full page of events would waste a large + // share of its size on pretty-printing indentation. + return textResponse( + JSON.stringify({ + status, + items, + has_more: page.has_more, + next_offset: page.next_offset, + ...(note && { note }), + }), + ); + } catch (error) { + return toolErrorResponse("get_browser_telemetry", "events", error); + } + }, + ); } From 289f6c9696104560cff8125970b062aa76372e02 Mon Sep 17 00:00:00 2001 From: yummybomb <19238148+yummybomb@users.noreply.github.com> Date: Wed, 15 Jul 2026 14:43:27 +0000 Subject: [PATCH 02/21] Keep source and raw ts in events, validate since+desc, fix empty notes --- src/lib/mcp/tools/browsers.ts | 29 ++++++++++++++++++++++++----- 1 file changed, 24 insertions(+), 5 deletions(-) diff --git a/src/lib/mcp/tools/browsers.ts b/src/lib/mcp/tools/browsers.ts index 74ad563..ff3c383 100644 --- a/src/lib/mcp/tools/browsers.ts +++ b/src/lib/mcp/tools/browsers.ts @@ -96,7 +96,7 @@ type TelemetryEnvelope = Awaited< const bulkyTelemetryDataFields = ["body", "headers", "post_data"] as const; function compactTelemetryEvent({ seq, event }: TelemetryEnvelope) { - const { ts, category, type, truncated } = event; + const { ts, category, type, source, truncated } = event; const data = "data" in event ? event.data : undefined; let compactData: Record | undefined; @@ -113,9 +113,13 @@ function compactTelemetryEvent({ seq, event }: TelemetryEnvelope) { return { seq, + // Raw ts (Unix microseconds) is kept alongside the readable time so exact + // event boundaries can be fed back as since/until. + ts, time: new Date(ts / 1000).toISOString(), category, type, + source, ...(compactData && { data: compactData }), ...(truncated && { truncated }), ...(omittedFields && { omitted_fields: omittedFields }), @@ -564,6 +568,11 @@ export function registerBrowserCapabilities(server: McpServer) { }, async (params, extra) => { if (!extra.authInfo) throw new Error("Authentication required"); + if (params.since !== undefined && params.order === "desc") { + return errorResponse( + "Error in get_browser_telemetry (events): since cannot be combined with order=desc. Use until to bound a newest-first read, or order=asc with since.", + ); + } const client = createKernelClient(extra.authInfo.token); // Best-effort lookup for the session's telemetry config and creation @@ -589,6 +598,14 @@ export function registerBrowserCapabilities(server: McpServer) { browser = await fetchBrowser(); query.since = browser?.created_at ?? "1970-01-01T00:00:00Z"; } + // When the read covers the whole session with no filters (asc from + // creation, or desc from the newest event), an empty result means the + // archive is empty — there is nothing to widen. + const fullSessionRead = + params.offset === undefined && + params.since === undefined && + params.until === undefined && + params.categories === undefined; const page = await client.browsers.telemetry.events( params.session_id, @@ -603,16 +620,18 @@ export function registerBrowserCapabilities(server: McpServer) { note = "This page had no matching events, but more are archived — continue paging with next_offset."; } else { + const emptyReason = fullSessionRead + ? "No telemetry events are archived for this session" + : "No archived events matched this window and filter — widen since/until or drop the categories filter"; browser ??= await fetchBrowser(); if (browser && !browser.telemetry) { status = "telemetry_currently_disabled"; - note = - "No archived events matched this window and filter, and telemetry is currently disabled. Widen since/until or drop the categories filter before reproducing: update this active browser with telemetry_enabled=true plus telemetry_console, telemetry_network, and telemetry_page."; + note = `${emptyReason}. Telemetry is currently disabled: update this active browser with telemetry_enabled=true plus telemetry_console, telemetry_network, and telemetry_page, then reproduce the issue.`; } else { status = "no_events"; note = browser - ? "No archived events matched this window and filter. Widen since/until or drop the categories filter." - : "No archived events matched, and the session could not be fetched. Widen since/until or drop the categories filter; if the session has ended and telemetry was not enabled, recreate it with telemetry enabled (including console, network, and page) and reproduce the issue."; + ? `${emptyReason}.` + : `${emptyReason}, and the session could not be fetched. If the session has ended and telemetry was not enabled, recreate it with telemetry enabled (including console, network, and page) and reproduce the issue.`; } } } From 4f576bbb613e34c6c8c3997130f3151e8bbeedb8 Mon Sep 17 00:00:00 2001 From: yummybomb <19238148+yummybomb@users.noreply.github.com> Date: Wed, 15 Jul 2026 14:48:38 +0000 Subject: [PATCH 03/21] Make telemetry debug guidance heuristic instead of prescriptive --- src/lib/mcp/prompts.ts | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/src/lib/mcp/prompts.ts b/src/lib/mcp/prompts.ts index 242b3bd..7a33b4d 100644 --- a/src/lib/mcp/prompts.ts +++ b/src/lib/mcp/prompts.ts @@ -135,15 +135,11 @@ kernel browsers playwright --help ## Telemetry Events (structured signal — works even after the session is deleted) -**Check telemetry first when it's available** — it's the fastest way to pinpoint failures. +When telemetry was captured, it's usually the fastest way to pinpoint a failure — read it before reaching for screenshots or logs. -**Gotcha: telemetry is opt-in and must have been enabled when the relevant activity occurred.** Always try \`get_browser_telemetry\` first because archived events survive telemetry being disabled and the session being deleted. \`manage_browsers\` action "get" shows only the current telemetry config, so a null \`telemetry\` field means capture is off now, not that the archive is necessarily empty. The default bundle (control/connection/system/captcha) also omits the debug-critical categories. For an active browser, use \`manage_browsers\` action "update" to enable \`telemetry_console\`, \`telemetry_network\`, and \`telemetry_page\`, then reproduce the issue. Recreate the browser only if the original session has ended. +Start broad: call \`get_browser_telemetry\` with session_id "${session_id}" and no filters. That reads the whole session and definitively answers whether anything was archived. Narrow only when the output is too large to scan: \`categories\` to isolate a signal you've already spotted, order "desc" to inspect the end of the session, \`since\`/\`until\` to bracket the failing step. Correlate event timestamps with the failing automation step, and page with \`next_offset\` while \`has_more\` is true. -**Flow:** -1. \`get_browser_telemetry\` with session_id "${session_id}" — filter with categories ["console", "network", "page"] to cut noise, or order "desc" to inspect the end of the session -2. Scan for \`console_error\`, \`network_loading_failed\`, \`network_response\` with non-2xx status, and \`captcha_*\` outcomes -3. Correlate event timestamps with the failing automation step -4. Page with \`next_offset\` while \`has_more\` is true +**Gotcha: telemetry is opt-in and only covers activity that happened while capture was on.** Archived events survive telemetry being disabled and the session being deleted, so the archive — not the current config — is the ground truth: \`manage_browsers\` action "get" showing a null \`telemetry\` field means capture is off now, not that nothing was recorded. The default bundle (control/connection/system/captcha) also omits the debug-critical categories. To capture new evidence on an active browser, use \`manage_browsers\` action "update" to enable \`telemetry_console\`, \`telemetry_network\`, and \`telemetry_page\`, then reproduce the issue; recreate the browser only if the session has ended. ${TELEMETRY_EVENT_CATALOG} From 3899c1b0c2ea4cf8ff6ed6e505727f6aae9cad48 Mon Sep 17 00:00:00 2001 From: yummybomb <19238148+yummybomb@users.noreply.github.com> Date: Wed, 15 Jul 2026 18:24:55 +0000 Subject: [PATCH 04/21] Resolve telemetry ordering contradiction in debug prompt --- src/lib/mcp/prompts.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/lib/mcp/prompts.ts b/src/lib/mcp/prompts.ts index 7a33b4d..3dcba54 100644 --- a/src/lib/mcp/prompts.ts +++ b/src/lib/mcp/prompts.ts @@ -135,9 +135,9 @@ kernel browsers playwright --help ## Telemetry Events (structured signal — works even after the session is deleted) -When telemetry was captured, it's usually the fastest way to pinpoint a failure — read it before reaching for screenshots or logs. +When telemetry was captured, it's usually the fastest way to pinpoint a failure — read it before reaching for screenshots or logs. If the session has been deleted, it's the only signal still available: every CLI command in this guide needs a live session. -Start broad: call \`get_browser_telemetry\` with session_id "${session_id}" and no filters. That reads the whole session and definitively answers whether anything was archived. Narrow only when the output is too large to scan: \`categories\` to isolate a signal you've already spotted, order "desc" to inspect the end of the session, \`since\`/\`until\` to bracket the failing step. Correlate event timestamps with the failing automation step, and page with \`next_offset\` while \`has_more\` is true. +Start broad: call \`get_browser_telemetry\` with session_id "${session_id}" and no filters. That reads the whole session and definitively answers whether anything was archived. Narrow only when the output is too large to scan: \`categories\` to isolate a signal you've already spotted, \`order\` "desc" to inspect the end of the session, \`since\`/\`until\` to bracket the failing step. Correlate event timestamps with the failing automation step, and page with \`next_offset\` while \`has_more\` is true. **Gotcha: telemetry is opt-in and only covers activity that happened while capture was on.** Archived events survive telemetry being disabled and the session being deleted, so the archive — not the current config — is the ground truth: \`manage_browsers\` action "get" showing a null \`telemetry\` field means capture is off now, not that nothing was recorded. The default bundle (control/connection/system/captcha) also omits the debug-critical categories. To capture new evidence on an active browser, use \`manage_browsers\` action "update" to enable \`telemetry_console\`, \`telemetry_network\`, and \`telemetry_page\`, then reproduce the issue; recreate the browser only if the session has ended. @@ -251,8 +251,8 @@ These are **normal** and don't indicate problems: Based on your issue "${issue_description}", start with: -1. **Get browser info** to confirm session is active and check whether telemetry was enabled -2. **Read telemetry events**; if needed, enable telemetry on an active session and reproduce +1. **Read telemetry events** — works whether or not the session still exists; if the archive is empty and the session is active, enable the debug categories and reproduce +2. **Get browser info** to confirm the session is active before using the CLI commands 3. **Take screenshot** to see current state 4. **Check page URL** to see if on error page 5. **Test network** if seeing connection errors From 48f6984292577a5c0b682d29f00204be051a2bf4 Mon Sep 17 00:00:00 2001 From: yummybomb <19238148+yummybomb@users.noreply.github.com> Date: Wed, 15 Jul 2026 19:14:10 +0000 Subject: [PATCH 05/21] Clarify paging and narrowing guidance in telemetry debug prompt --- src/lib/mcp/prompts.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/mcp/prompts.ts b/src/lib/mcp/prompts.ts index 3dcba54..baeaa42 100644 --- a/src/lib/mcp/prompts.ts +++ b/src/lib/mcp/prompts.ts @@ -137,7 +137,7 @@ kernel browsers playwright --help When telemetry was captured, it's usually the fastest way to pinpoint a failure — read it before reaching for screenshots or logs. If the session has been deleted, it's the only signal still available: every CLI command in this guide needs a live session. -Start broad: call \`get_browser_telemetry\` with session_id "${session_id}" and no filters. That reads the whole session and definitively answers whether anything was archived. Narrow only when the output is too large to scan: \`categories\` to isolate a signal you've already spotted, \`order\` "desc" to inspect the end of the session, \`since\`/\`until\` to bracket the failing step. Correlate event timestamps with the failing automation step, and page with \`next_offset\` while \`has_more\` is true. +Start broad: call \`get_browser_telemetry\` with session_id "${session_id}" and no filters. That starts at session creation and returns the first page (up to 100 events); page with \`next_offset\` while \`has_more\` is true. An empty unfiltered read is definitive: nothing was archived. Narrow when the output is too large to scan or you already know where to look: \`categories\` to isolate a signal you've spotted, \`order\` "desc" when the end of the session matters most, \`since\`/\`until\` to bracket a known failing step. Correlate event timestamps with the failing automation step. **Gotcha: telemetry is opt-in and only covers activity that happened while capture was on.** Archived events survive telemetry being disabled and the session being deleted, so the archive — not the current config — is the ground truth: \`manage_browsers\` action "get" showing a null \`telemetry\` field means capture is off now, not that nothing was recorded. The default bundle (control/connection/system/captcha) also omits the debug-critical categories. To capture new evidence on an active browser, use \`manage_browsers\` action "update" to enable \`telemetry_console\`, \`telemetry_network\`, and \`telemetry_page\`, then reproduce the issue; recreate the browser only if the session has ended. From 58e45345e3387dc2163ac2c7867d5e307b952a69 Mon Sep 17 00:00:00 2001 From: yummybomb <19238148+yummybomb@users.noreply.github.com> Date: Wed, 15 Jul 2026 19:18:19 +0000 Subject: [PATCH 06/21] Let runtime notes carry empty-result guidance in tool description --- src/lib/mcp/tools/browsers.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/mcp/tools/browsers.ts b/src/lib/mcp/tools/browsers.ts index ff3c383..143d6a0 100644 --- a/src/lib/mcp/tools/browsers.ts +++ b/src/lib/mcp/tools/browsers.ts @@ -515,7 +515,7 @@ export function registerBrowserCapabilities(server: McpServer) { // get_browser_telemetry -- Read archived telemetry events for a session server.tool( "get_browser_telemetry", - `Read archived telemetry events for a browser session. Works while the session is active and after it is deleted, including events captured before telemetry was disabled. If the response reports status "telemetry_currently_disabled", widen or remove filters before enabling telemetry and reproducing: update an active browser, or recreate one that has ended. Page through long sessions with offset/next_offset instead of raising limit. ${TELEMETRY_EVENT_CATALOG}`, + `Read archived telemetry events for a browser session. Works while the session is active and after it is deleted, including events captured before telemetry was disabled. Empty results include a status and note explaining what to do next. Page through long sessions with offset/next_offset instead of raising limit. ${TELEMETRY_EVENT_CATALOG}`, { session_id: z.string().describe("Browser session ID."), categories: z From c644a4f8c2d8a0dba5e4ae73562d832859a37ebf Mon Sep 17 00:00:00 2001 From: yummybomb <19238148+yummybomb@users.noreply.github.com> Date: Wed, 15 Jul 2026 19:28:02 +0000 Subject: [PATCH 07/21] Tighten get_browser_telemetry description --- src/lib/mcp/tools/browsers.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/mcp/tools/browsers.ts b/src/lib/mcp/tools/browsers.ts index 143d6a0..4d64312 100644 --- a/src/lib/mcp/tools/browsers.ts +++ b/src/lib/mcp/tools/browsers.ts @@ -515,7 +515,7 @@ export function registerBrowserCapabilities(server: McpServer) { // get_browser_telemetry -- Read archived telemetry events for a session server.tool( "get_browser_telemetry", - `Read archived telemetry events for a browser session. Works while the session is active and after it is deleted, including events captured before telemetry was disabled. Empty results include a status and note explaining what to do next. Page through long sessions with offset/next_offset instead of raising limit. ${TELEMETRY_EVENT_CATALOG}`, + `Read archived telemetry events for a browser session to diagnose failures. The archive is durable: it works while the session is active and after it is deleted, and includes events captured before telemetry was disabled. ${TELEMETRY_EVENT_CATALOG}`, { session_id: z.string().describe("Browser session ID."), categories: z From dab47490865ab1c6924985191f3cf51c6fe85a41 Mon Sep 17 00:00:00 2001 From: yummybomb <19238148+yummybomb@users.noreply.github.com> Date: Wed, 15 Jul 2026 19:40:36 +0000 Subject: [PATCH 08/21] Reserve telemetry_currently_disabled status for full-session reads --- src/lib/mcp/tools/browsers.ts | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/src/lib/mcp/tools/browsers.ts b/src/lib/mcp/tools/browsers.ts index 4d64312..c89b7d5 100644 --- a/src/lib/mcp/tools/browsers.ts +++ b/src/lib/mcp/tools/browsers.ts @@ -624,14 +624,21 @@ export function registerBrowserCapabilities(server: McpServer) { ? "No telemetry events are archived for this session" : "No archived events matched this window and filter — widen since/until or drop the categories filter"; browser ??= await fetchBrowser(); - if (browser && !browser.telemetry) { + const telemetryDisabled = browser !== null && !browser.telemetry; + // Only a full-session read proves the archive is empty; a filter + // miss stays no_events so the status alone can't be misread. + if (telemetryDisabled && fullSessionRead) { status = "telemetry_currently_disabled"; note = `${emptyReason}. Telemetry is currently disabled: update this active browser with telemetry_enabled=true plus telemetry_console, telemetry_network, and telemetry_page, then reproduce the issue.`; } else { status = "no_events"; - note = browser - ? `${emptyReason}.` - : `${emptyReason}, and the session could not be fetched. If the session has ended and telemetry was not enabled, recreate it with telemetry enabled (including console, network, and page) and reproduce the issue.`; + if (!browser) { + note = `${emptyReason}, and the session could not be fetched. If the session has ended and telemetry was not enabled, recreate it with telemetry enabled (including console, network, and page) and reproduce the issue.`; + } else if (telemetryDisabled) { + note = `${emptyReason}. Telemetry is also currently disabled — if an unfiltered read is empty too, update this active browser with telemetry_enabled=true plus telemetry_console, telemetry_network, and telemetry_page, then reproduce the issue.`; + } else { + note = `${emptyReason}.`; + } } } } From a1360c23da12023e2d32ac75ac3efb2ad002714a Mon Sep 17 00:00:00 2001 From: yummybomb <19238148+yummybomb@users.noreply.github.com> Date: Wed, 15 Jul 2026 19:46:19 +0000 Subject: [PATCH 09/21] Make empty-archive note category guidance hypothesis-driven --- src/lib/mcp/tools/browsers.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/lib/mcp/tools/browsers.ts b/src/lib/mcp/tools/browsers.ts index c89b7d5..9a7fd64 100644 --- a/src/lib/mcp/tools/browsers.ts +++ b/src/lib/mcp/tools/browsers.ts @@ -625,17 +625,19 @@ export function registerBrowserCapabilities(server: McpServer) { : "No archived events matched this window and filter — widen since/until or drop the categories filter"; browser ??= await fetchBrowser(); const telemetryDisabled = browser !== null && !browser.telemetry; + const enableHint = + "update this active browser with telemetry_enabled=true plus the categories your investigation needs (telemetry_enabled alone captures only the default bundle, not console/network/page), then reproduce the issue"; // Only a full-session read proves the archive is empty; a filter // miss stays no_events so the status alone can't be misread. if (telemetryDisabled && fullSessionRead) { status = "telemetry_currently_disabled"; - note = `${emptyReason}. Telemetry is currently disabled: update this active browser with telemetry_enabled=true plus telemetry_console, telemetry_network, and telemetry_page, then reproduce the issue.`; + note = `No telemetry events are archived for this session and telemetry is currently disabled. To capture evidence, ${enableHint}.`; } else { status = "no_events"; if (!browser) { - note = `${emptyReason}, and the session could not be fetched. If the session has ended and telemetry was not enabled, recreate it with telemetry enabled (including console, network, and page) and reproduce the issue.`; + note = `${emptyReason}, and the session could not be fetched. If the session has ended and telemetry was not enabled, recreate it with the telemetry categories your investigation needs and reproduce the issue.`; } else if (telemetryDisabled) { - note = `${emptyReason}. Telemetry is also currently disabled — if an unfiltered read is empty too, update this active browser with telemetry_enabled=true plus telemetry_console, telemetry_network, and telemetry_page, then reproduce the issue.`; + note = `${emptyReason}. Telemetry is also currently disabled — if an unfiltered read is empty too, ${enableHint}.`; } else { note = `${emptyReason}.`; } From ecea4016c531f6259aa9a1e3ce808624883bbd45 Mon Sep 17 00:00:00 2001 From: yummybomb <19238148+yummybomb@users.noreply.github.com> Date: Wed, 15 Jul 2026 19:56:42 +0000 Subject: [PATCH 10/21] Align interaction telemetry description with sibling categories --- src/lib/mcp/tools/browsers.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/mcp/tools/browsers.ts b/src/lib/mcp/tools/browsers.ts index 9a7fd64..48ca22e 100644 --- a/src/lib/mcp/tools/browsers.ts +++ b/src/lib/mcp/tools/browsers.ts @@ -370,7 +370,7 @@ export function registerBrowserCapabilities(server: McpServer) { telemetry_interaction: z .boolean() .describe( - "(create, update) Enable or disable user interaction telemetry.", + "(create, update) Enable or disable user interaction telemetry (clicks, keys, scrolls). Off by default; enable for debugging.", ) .optional(), }, From 188c7e84adb8bfb79be27bbbe7a53cbb883da377 Mon Sep 17 00:00:00 2001 From: yummybomb <19238148+yummybomb@users.noreply.github.com> Date: Wed, 15 Jul 2026 20:10:43 +0000 Subject: [PATCH 11/21] Detect disabled telemetry from empty category config --- src/lib/mcp/tools/browsers.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/lib/mcp/tools/browsers.ts b/src/lib/mcp/tools/browsers.ts index 48ca22e..cf81464 100644 --- a/src/lib/mcp/tools/browsers.ts +++ b/src/lib/mcp/tools/browsers.ts @@ -624,7 +624,13 @@ export function registerBrowserCapabilities(server: McpServer) { ? "No telemetry events are archived for this session" : "No archived events matched this window and filter — widen since/until or drop the categories filter"; browser ??= await fetchBrowser(); - const telemetryDisabled = browser !== null && !browser.telemetry; + // A cleared config serializes as {} (not null), so "disabled" means + // no category is currently enabled rather than a nullish field. + const telemetryDisabled = + browser !== null && + !Object.values(browser.telemetry?.browser ?? {}).some( + (category) => category?.enabled, + ); const enableHint = "update this active browser with telemetry_enabled=true plus the categories your investigation needs (telemetry_enabled alone captures only the default bundle, not console/network/page), then reproduce the issue"; // Only a full-session read proves the archive is empty; a filter From 9d2d2712b11815188b06586d32fcb11e1b5fe24c Mon Sep 17 00:00:00 2001 From: yummybomb <19238148+yummybomb@users.noreply.github.com> Date: Wed, 15 Jul 2026 20:29:24 +0000 Subject: [PATCH 12/21] Skip since default on until-only reads; document get_browser_telemetry in README --- README.md | 5 +++-- src/lib/mcp/tools/browsers.ts | 5 ++++- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 3102bff..cfda98c 100644 --- a/README.md +++ b/README.md @@ -255,9 +255,9 @@ Many other MCP-capable tools accept: Configure these values wherever the tool expects MCP server settings. -## Tools (16 total) +## Tools (17 total) -Each Kernel feature has a single `manage_*` tool with an `action` parameter, keeping the tool set small and consistent. Five standalone tools handle high-frequency workflows. +Each Kernel feature has a single `manage_*` tool with an `action` parameter, keeping the tool set small and consistent. Six standalone tools handle high-frequency workflows. Self-hosted deployments can hide sensitive tool families by setting `KERNEL_MCP_DISABLED_TOOLSETS` to a comma-separated list. For example, `KERNEL_MCP_DISABLED_TOOLSETS=api_keys` prevents `manage_api_keys` from being registered. @@ -281,6 +281,7 @@ Self-hosted deployments can hide sensitive tool families by setting `KERNEL_MCP_ - `browser_curl` - Send HTTP requests through an existing browser session's Chrome network stack. - `execute_playwright_code` - Execute Playwright/TypeScript code against a browser with automatic video replay and cleanup. - `exec_command` - Run shell commands inside a browser VM. Returns decoded stdout/stderr. +- `get_browser_telemetry` - Read archived telemetry events for a browser session (console, network, page lifecycle, captcha outcomes, VM health). Works for active and deleted sessions, with category filters, time windows, and pagination. - `search_docs` - Search Kernel platform documentation and guides. ## Resources diff --git a/src/lib/mcp/tools/browsers.ts b/src/lib/mcp/tools/browsers.ts index cf81464..4055bf0 100644 --- a/src/lib/mcp/tools/browsers.ts +++ b/src/lib/mcp/tools/browsers.ts @@ -592,9 +592,12 @@ export function registerBrowserCapabilities(server: McpServer) { if ( query.offset === undefined && query.since === undefined && + query.until === undefined && query.order !== "desc" ) { - // The API's since default is only 5m; cover the whole session. + // The API's since default is only 5m; cover the whole session. Not + // needed for until-only reads (the API starts those at the stream + // head), and injecting since there can invert the window. browser = await fetchBrowser(); query.since = browser?.created_at ?? "1970-01-01T00:00:00Z"; } From 033119ec4b7c3771ae49f537c319effd1190169a Mon Sep 17 00:00:00 2001 From: yummybomb <19238148+yummybomb@users.noreply.github.com> Date: Thu, 16 Jul 2026 13:54:37 +0000 Subject: [PATCH 13/21] Harden telemetry response handling --- src/lib/mcp/tools/browsers.test.ts | 153 +++++++++++++++++++++++++++++ src/lib/mcp/tools/browsers.ts | 95 ++++++++++-------- 2 files changed, 207 insertions(+), 41 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..554b4bf --- /dev/null +++ b/src/lib/mcp/tools/browsers.test.ts @@ -0,0 +1,153 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + compactTelemetryEvent, + summarizeEmptyTelemetryResult, +} from "./browsers"; + +const source = { kind: "cdp" as const }; +const ts = 1_750_000_000_000_000; + +test("omits known high-volume telemetry fields", () => { + const request = compactTelemetryEvent({ + seq: 1, + event: { + category: "network", + type: "network_request", + source, + ts, + data: { + headers: { authorization: "secret" }, + post_data: "request body", + method: "POST", + }, + }, + }); + const response = compactTelemetryEvent({ + seq: 2, + event: { + category: "network", + type: "network_response", + source, + ts, + data: { + body: "response body", + headers: { "content-type": "text/plain" }, + status: 200, + }, + }, + }); + const screenshot = compactTelemetryEvent({ + seq: 3, + event: { + category: "screenshot", + type: "monitor_screenshot", + source, + ts, + data: { png: "base64 image" }, + }, + }); + + assert.deepEqual(request.data, { method: "POST" }); + assert.deepEqual(request.omitted_fields, ["headers", "post_data"]); + assert.deepEqual(response.data, { status: 200 }); + assert.deepEqual(response.omitted_fields, ["body", "headers"]); + assert.deepEqual(screenshot.data, {}); + assert.deepEqual(screenshot.omitted_fields, ["png"]); +}); + +test("keeps a page of screenshot events compact", () => { + const png = "x".repeat(256 * 1024); + const events = Array.from({ length: 100 }, (_, seq) => + compactTelemetryEvent({ + seq, + event: { + category: "screenshot", + type: "monitor_screenshot", + source, + ts, + data: { png }, + }, + }), + ); + const serialized = JSON.stringify(events); + + assert.ok(Buffer.byteLength(serialized, "utf8") < 50 * 1024); + assert.ok(events.every((event) => event.omitted_fields?.includes("png"))); +}); + +test("omits oversized fields not on the known-field list", () => { + const event = compactTelemetryEvent({ + seq: 1, + event: { + category: "console", + type: "console_error", + source, + ts, + data: { text: "x".repeat(9 * 1024), level: "error" }, + }, + }); + + assert.deepEqual(event.data, { level: "error" }); + assert.deepEqual(event.omitted_fields, ["text"]); +}); + +test("preserves small telemetry fields", () => { + const event = compactTelemetryEvent({ + seq: 1, + event: { + category: "console", + type: "console_error", + source, + ts, + data: { text: "boom", level: "error" }, + }, + }); + + assert.deepEqual(event.data, { text: "boom", level: "error" }); + assert.equal(event.omitted_fields, undefined); +}); + +test("summarizes empty telemetry results without conflating query and capture state", () => { + assert.deepEqual( + summarizeEmptyTelemetryResult({ + hasMore: true, + fullSessionRead: false, + telemetryDisabled: true, + }), + { + status: "ok", + note: "No matching events on this page; continue with next_offset.", + }, + ); + assert.deepEqual( + summarizeEmptyTelemetryResult({ + hasMore: false, + fullSessionRead: false, + telemetryDisabled: false, + }), + { status: "no_events", note: "No events matched this query." }, + ); + assert.deepEqual( + summarizeEmptyTelemetryResult({ + hasMore: false, + fullSessionRead: true, + telemetryDisabled: false, + }), + { + status: "no_events", + note: "No telemetry events are archived for this session.", + }, + ); + assert.deepEqual( + summarizeEmptyTelemetryResult({ + hasMore: false, + fullSessionRead: true, + telemetryDisabled: true, + }), + { + status: "no_events", + note: "No telemetry events are archived for this session. Telemetry is currently disabled.", + }, + ); +}); diff --git a/src/lib/mcp/tools/browsers.ts b/src/lib/mcp/tools/browsers.ts index 4055bf0..7df23bf 100644 --- a/src/lib/mcp/tools/browsers.ts +++ b/src/lib/mcp/tools/browsers.ts @@ -90,12 +90,42 @@ type TelemetryEnvelope = Awaited< ReturnType >["items"][number]; -// Payload fields that can carry kilobytes per event (response bodies, header -// maps). Dropped so a full page of events fits in an agent context window; -// omitted_fields tells the agent what to fetch via the API/CLI if needed. -const bulkyTelemetryDataFields = ["body", "headers", "post_data"] as const; +// Payload fields that are always omitted, even when small. The size limit +// catches new high-volume fields that are added to telemetry later. +const omittedTelemetryDataFields: ReadonlySet = new Set([ + "body", + "headers", + "post_data", + "png", +]); +const maxTelemetryDataFieldBytes = 8 * 1024; + +export function summarizeEmptyTelemetryResult({ + hasMore, + fullSessionRead, + telemetryDisabled, +}: { + hasMore: boolean; + fullSessionRead: boolean; + telemetryDisabled: boolean; +}) { + if (hasMore) { + return { + status: "ok" as const, + note: "No matching events on this page; continue with next_offset.", + }; + } -function compactTelemetryEvent({ seq, event }: TelemetryEnvelope) { + const note = fullSessionRead + ? "No telemetry events are archived for this session." + : "No events matched this query."; + return { + status: "no_events" as const, + note: telemetryDisabled ? `${note} Telemetry is currently disabled.` : note, + }; +} + +export function compactTelemetryEvent({ seq, event }: TelemetryEnvelope) { const { ts, category, type, source, truncated } = event; const data = "data" in event ? event.data : undefined; @@ -103,8 +133,13 @@ function compactTelemetryEvent({ seq, event }: TelemetryEnvelope) { let omittedFields: string[] | undefined; if (data) { compactData = { ...(data as Record) }; - for (const field of bulkyTelemetryDataFields) { - if (compactData[field] !== undefined) { + for (const [field, value] of Object.entries(compactData)) { + const alwaysOmit = omittedTelemetryDataFields.has(field); + const serialized = alwaysOmit ? undefined : JSON.stringify(value); + const oversized = + serialized !== undefined && + Buffer.byteLength(serialized, "utf8") > maxTelemetryDataFieldBytes; + if (alwaysOmit || oversized) { delete compactData[field]; (omittedFields ??= []).push(field); } @@ -616,42 +651,20 @@ export function registerBrowserCapabilities(server: McpServer) { ); const items = page.getPaginatedItems().map(compactTelemetryEvent); - let status: "ok" | "telemetry_currently_disabled" | "no_events" = "ok"; + let status: "ok" | "no_events" = "ok"; let note: string | undefined; if (items.length === 0) { - if (page.has_more) { - note = - "This page had no matching events, but more are archived — continue paging with next_offset."; - } else { - const emptyReason = fullSessionRead - ? "No telemetry events are archived for this session" - : "No archived events matched this window and filter — widen since/until or drop the categories filter"; - browser ??= await fetchBrowser(); - // A cleared config serializes as {} (not null), so "disabled" means - // no category is currently enabled rather than a nullish field. - const telemetryDisabled = - browser !== null && - !Object.values(browser.telemetry?.browser ?? {}).some( - (category) => category?.enabled, - ); - const enableHint = - "update this active browser with telemetry_enabled=true plus the categories your investigation needs (telemetry_enabled alone captures only the default bundle, not console/network/page), then reproduce the issue"; - // Only a full-session read proves the archive is empty; a filter - // miss stays no_events so the status alone can't be misread. - if (telemetryDisabled && fullSessionRead) { - status = "telemetry_currently_disabled"; - note = `No telemetry events are archived for this session and telemetry is currently disabled. To capture evidence, ${enableHint}.`; - } else { - status = "no_events"; - if (!browser) { - note = `${emptyReason}, and the session could not be fetched. If the session has ended and telemetry was not enabled, recreate it with the telemetry categories your investigation needs and reproduce the issue.`; - } else if (telemetryDisabled) { - note = `${emptyReason}. Telemetry is also currently disabled — if an unfiltered read is empty too, ${enableHint}.`; - } else { - note = `${emptyReason}.`; - } - } - } + if (!page.has_more) browser ??= await fetchBrowser(); + const telemetryDisabled = + browser !== null && + !Object.values(browser.telemetry?.browser ?? {}).some( + (category) => category?.enabled, + ); + ({ status, note } = summarizeEmptyTelemetryResult({ + hasMore: Boolean(page.has_more), + fullSessionRead, + telemetryDisabled, + })); } // Compact serialization: a full page of events would waste a large From ffbcd4d350dfae8eb66368e479644e9071c96412 Mon Sep 17 00:00:00 2001 From: yummybomb <19238148+yummybomb@users.noreply.github.com> Date: Thu, 16 Jul 2026 14:10:29 +0000 Subject: [PATCH 14/21] Fold telemetry reads into browser management --- README.md | 7 +- src/lib/mcp/prompts.ts | 4 +- src/lib/mcp/tools/browsers.test.ts | 23 +++ src/lib/mcp/tools/browsers.ts | 268 ++++++++++++++--------------- 4 files changed, 154 insertions(+), 148 deletions(-) diff --git a/README.md b/README.md index cfda98c..fa9b8f8 100644 --- a/README.md +++ b/README.md @@ -255,15 +255,15 @@ Many other MCP-capable tools accept: Configure these values wherever the tool expects MCP server settings. -## Tools (17 total) +## Tools (16 total) -Each Kernel feature has a single `manage_*` tool with an `action` parameter, keeping the tool set small and consistent. Six standalone tools handle high-frequency workflows. +Each Kernel feature has a single `manage_*` tool with an `action` parameter, keeping the tool set small and consistent. Five standalone tools handle high-frequency workflows. Self-hosted deployments can hide sensitive tool families by setting `KERNEL_MCP_DISABLED_TOOLSETS` to a comma-separated list. For example, `KERNEL_MCP_DISABLED_TOOLSETS=api_keys` prevents `manage_api_keys` from being registered. ### manage\_\* tools -- `manage_browsers` - Create, list, get, and delete browser sessions. Supports headless/stealth modes, profiles, proxies, viewports, extensions, and SSH tunneling. +- `manage_browsers` - Create, update, list, get, and delete browser sessions, and read archived telemetry for active or deleted sessions. Supports headless/stealth modes, profiles, proxies, viewports, extensions, and SSH tunneling. - `manage_profiles` - Setup (with guided live browser session), search/list with pagination, get, and delete browser profiles for persisting cookies and logins. - `manage_projects` - Create, list, get, update, and delete organization projects. Inspect and update per-project resource limits. - `manage_api_keys` - Create, list, get, update, and delete org-wide or project-scoped API keys. Create returns the plaintext key once. @@ -281,7 +281,6 @@ Self-hosted deployments can hide sensitive tool families by setting `KERNEL_MCP_ - `browser_curl` - Send HTTP requests through an existing browser session's Chrome network stack. - `execute_playwright_code` - Execute Playwright/TypeScript code against a browser with automatic video replay and cleanup. - `exec_command` - Run shell commands inside a browser VM. Returns decoded stdout/stderr. -- `get_browser_telemetry` - Read archived telemetry events for a browser session (console, network, page lifecycle, captcha outcomes, VM health). Works for active and deleted sessions, with category filters, time windows, and pagination. - `search_docs` - Search Kernel platform documentation and guides. ## Resources diff --git a/src/lib/mcp/prompts.ts b/src/lib/mcp/prompts.ts index baeaa42..723fb77 100644 --- a/src/lib/mcp/prompts.ts +++ b/src/lib/mcp/prompts.ts @@ -129,7 +129,7 @@ kernel browsers process --help kernel browsers playwright --help \`\`\` -**MCP Exceptions:** The \`computer_action\` MCP tool with action "screenshot" is useful since it returns images directly to the agent, and \`get_browser_telemetry\` reads structured telemetry events (see below). +**MCP Exceptions:** The \`computer_action\` MCP tool with action "screenshot" is useful since it returns images directly to the agent, and \`manage_browsers\` with action "get_telemetry" reads structured telemetry events (see below). --- @@ -137,7 +137,7 @@ kernel browsers playwright --help When telemetry was captured, it's usually the fastest way to pinpoint a failure — read it before reaching for screenshots or logs. If the session has been deleted, it's the only signal still available: every CLI command in this guide needs a live session. -Start broad: call \`get_browser_telemetry\` with session_id "${session_id}" and no filters. That starts at session creation and returns the first page (up to 100 events); page with \`next_offset\` while \`has_more\` is true. An empty unfiltered read is definitive: nothing was archived. Narrow when the output is too large to scan or you already know where to look: \`categories\` to isolate a signal you've spotted, \`order\` "desc" when the end of the session matters most, \`since\`/\`until\` to bracket a known failing step. Correlate event timestamps with the failing automation step. +Start broad: call \`manage_browsers\` with action "get_telemetry", session_id "${session_id}", and no filters. That starts at session creation and returns the first page (up to 100 events); page with \`next_offset\` as \`offset\` while \`has_more\` is true, preserving \`categories\`, \`until\`, and \`order\`. An empty unfiltered read is definitive: nothing was archived. Narrow when the output is too large to scan or you already know where to look: \`categories\` to isolate a signal you've spotted, \`order\` "desc" when the end of the session matters most, \`since\`/\`until\` to bracket a known failing step. Correlate event timestamps with the failing automation step. **Gotcha: telemetry is opt-in and only covers activity that happened while capture was on.** Archived events survive telemetry being disabled and the session being deleted, so the archive — not the current config — is the ground truth: \`manage_browsers\` action "get" showing a null \`telemetry\` field means capture is off now, not that nothing was recorded. The default bundle (control/connection/system/captcha) also omits the debug-critical categories. To capture new evidence on an active browser, use \`manage_browsers\` action "update" to enable \`telemetry_console\`, \`telemetry_network\`, and \`telemetry_page\`, then reproduce the issue; recreate the browser only if the session has ended. diff --git a/src/lib/mcp/tools/browsers.test.ts b/src/lib/mcp/tools/browsers.test.ts index 554b4bf..e19ce0f 100644 --- a/src/lib/mcp/tools/browsers.test.ts +++ b/src/lib/mcp/tools/browsers.test.ts @@ -1,13 +1,36 @@ import assert from "node:assert/strict"; import test from "node:test"; +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { compactTelemetryEvent, + registerBrowserCapabilities, summarizeEmptyTelemetryResult, } from "./browsers"; const source = { kind: "cdp" as const }; const ts = 1_750_000_000_000_000; +test("exposes telemetry through manage_browsers", () => { + const toolNames: string[] = []; + let actionSchema: + | { safeParse(value: unknown): { success: boolean } } + | undefined; + const server = { + resource() {}, + tool(name: string, _description: string, schema: Record) { + toolNames.push(name); + actionSchema = schema.action as typeof actionSchema; + }, + } as unknown as McpServer; + + registerBrowserCapabilities(server); + + assert.deepEqual(toolNames, ["manage_browsers"]); + assert.ok(actionSchema); + assert.equal(actionSchema.safeParse("get_telemetry").success, true); + assert.equal(actionSchema.safeParse("get_browser_telemetry").success, false); +}); + test("omits known high-volume telemetry fields", () => { const request = compactTelemetryEvent({ seq: 1, diff --git a/src/lib/mcp/tools/browsers.ts b/src/lib/mcp/tools/browsers.ts index 7df23bf..0115431 100644 --- a/src/lib/mcp/tools/browsers.ts +++ b/src/lib/mcp/tools/browsers.ts @@ -161,6 +161,77 @@ export function compactTelemetryEvent({ seq, event }: TelemetryEnvelope) { }; } +type BrowserTelemetryReadParams = { + session_id: string; + categories?: TelemetryEventsQuery["category"]; + limit?: number; + offset?: number; + since?: string; + until?: string; + order?: "asc" | "desc"; +}; + +async function readBrowserTelemetry( + client: KernelClient, + params: BrowserTelemetryReadParams, +) { + const fetchBrowser = () => + client.browsers.retrieve(params.session_id).catch(() => null); + + 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; + + let browser: Awaited> = null; + // Avoid the API's five-minute default; until-only reads already start at the stream head. + if ( + query.offset === undefined && + query.since === undefined && + query.until === undefined && + query.order !== "desc" + ) { + browser = await fetchBrowser(); + query.since = browser?.created_at ?? "1970-01-01T00:00:00Z"; + } + const fullSessionRead = + params.offset === undefined && + params.since === undefined && + params.until === undefined && + params.categories === undefined; + + const page = await client.browsers.telemetry.events(params.session_id, query); + const items = page.getPaginatedItems().map(compactTelemetryEvent); + + let status: "ok" | "no_events" = "ok"; + let note: string | undefined; + if (items.length === 0) { + if (!page.has_more) browser ??= await fetchBrowser(); + const telemetryDisabled = + browser !== null && + !Object.values(browser.telemetry?.browser ?? {}).some( + (category) => category?.enabled, + ); + ({ status, note } = summarizeEmptyTelemetryResult({ + hasMore: Boolean(page.has_more), + fullSessionRead, + telemetryDisabled, + })); + } + + return textResponse( + JSON.stringify({ + status, + items, + has_more: page.has_more, + next_offset: page.next_offset, + ...(note && { note }), + }), + ); +} + function browserSessionNextActions(sessionId: string) { return [ `Use computer_action with session_id "${sessionId}" to inspect or control the browser.`, @@ -239,18 +310,18 @@ export function registerBrowserCapabilities(server: McpServer) { read: (client, sessionId) => client.browsers.retrieve(sessionId), }); - // manage_browsers -- Create, update, list, get, and delete browser sessions + // manage_browsers -- Manage browser sessions and read archived telemetry server.tool( "manage_browsers", - 'Manage browser sessions when an agent needs a live browser to inspect, automate, or debug web state. Use "list" to choose an existing session, "create" before browser control, "update" to change supported session settings, "get" for full details, 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.', { action: z - .enum(["create", "update", "list", "get", "delete"]) + .enum(["create", "update", "list", "get", "get_telemetry", "delete"]) .describe("Operation to perform."), session_id: z .string() .describe( - "Browser session ID. Required for update, get, and delete actions.", + "Browser session ID. Required for update, get, get_telemetry, and delete actions.", ) .optional(), start_url: z @@ -377,7 +448,37 @@ export function registerBrowserCapabilities(server: McpServer) { .enum(["active", "deleted", "all"]) .describe('(list) Filter by status. Default "active".') .optional(), - ...paginationParams, + limit: paginationParams.limit.describe( + "(list, get_telemetry) Max results per page (1-100). get_telemetry defaults to 100; the list default is set by the API.", + ), + offset: paginationParams.offset.describe( + "(list) Numeric pagination offset. (get_telemetry) Opaque cursor: pass next_offset from the previous response and preserve categories, until, and order. Do not derive it from event seq values.", + ), + categories: z + .array(z.enum(telemetryEventCategories)) + .min(1) + .describe( + `(get_telemetry) Restrict results to these event categories. A filtered page can be empty while has_more is true. ${TELEMETRY_EVENT_CATALOG}`, + ) + .optional(), + since: z + .string() + .describe( + "(get_telemetry) Start of the window: an RFC-3339 timestamp or a duration like '30m' meaning that long ago. Defaults to session creation. Ignored when offset is set; cannot be combined with order=desc.", + ) + .optional(), + until: z + .string() + .describe( + "(get_telemetry) End of the window (exclusive): an RFC-3339 timestamp or a duration like '5m'. Preserve it while paging.", + ) + .optional(), + order: z + .enum(["asc", "desc"]) + .describe( + "(get_telemetry) Read direction. asc (default) reads oldest first; desc reads newest first. Preserve it while paging.", + ) + .optional(), telemetry_enabled: z .boolean() .describe( @@ -532,6 +633,26 @@ export function registerBrowserCapabilities(server: McpServer) { ); return jsonResponse(browser); } + case "get_telemetry": { + if (!params.session_id) + return errorResponse( + "Error: session_id is required for get_telemetry action.", + ); + if (params.since !== undefined && params.order === "desc") { + return errorResponse( + "Error: since cannot be combined with order=desc. Use until to bound a newest-first read, or order=asc with since.", + ); + } + return readBrowserTelemetry(client, { + session_id: params.session_id, + categories: params.categories, + limit: params.limit, + offset: params.offset, + since: params.since, + until: params.until, + order: params.order, + }); + } case "delete": { if (!params.session_id) return errorResponse( @@ -546,141 +667,4 @@ export function registerBrowserCapabilities(server: McpServer) { } }, ); - - // get_browser_telemetry -- Read archived telemetry events for a session - server.tool( - "get_browser_telemetry", - `Read archived telemetry events for a browser session to diagnose failures. The archive is durable: it works while the session is active and after it is deleted, and includes events captured before telemetry was disabled. ${TELEMETRY_EVENT_CATALOG}`, - { - session_id: z.string().describe("Browser session ID."), - categories: z - .array(z.enum(telemetryEventCategories)) - .min(1) - .describe( - "Restrict results to these event categories. The filter applies within each page, so a filtered page can be empty while has_more is true.", - ) - .optional(), - limit: z - .number() - .int() - .min(1) - .max(100) - .describe("Max events per page (1-100). Default 100.") - .optional(), - offset: z - .number() - .int() - .min(0) - .describe( - "Pagination cursor: pass next_offset from the previous response to fetch the next page. Opaque — do not derive it from event seq values.", - ) - .optional(), - since: z - .string() - .describe( - "Start of the window: an RFC-3339 timestamp or a duration like '30m' meaning that long ago. Defaults to the session's creation time. Ignored when offset is set; cannot be combined with order=desc.", - ) - .optional(), - until: z - .string() - .describe( - "End of the window (exclusive): an RFC-3339 timestamp or a duration like '5m'.", - ) - .optional(), - order: z - .enum(["asc", "desc"]) - .describe( - "Read direction. asc (default) reads oldest first starting from since; desc reads newest first — useful for inspecting the end of a session.", - ) - .optional(), - }, - { - title: "Read browser telemetry events", - readOnlyHint: true, - destructiveHint: false, - idempotentHint: true, - openWorldHint: false, - }, - async (params, extra) => { - if (!extra.authInfo) throw new Error("Authentication required"); - if (params.since !== undefined && params.order === "desc") { - return errorResponse( - "Error in get_browser_telemetry (events): since cannot be combined with order=desc. Use until to bound a newest-first read, or order=asc with since.", - ); - } - const client = createKernelClient(extra.authInfo.token); - - // Best-effort lookup for the session's telemetry config and creation - // time; when it fails we still read events but skip disambiguation. - const fetchBrowser = () => - client.browsers.retrieve(params.session_id).catch(() => null); - - try { - 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; - - let browser: Awaited> = null; - if ( - query.offset === undefined && - query.since === undefined && - query.until === undefined && - query.order !== "desc" - ) { - // The API's since default is only 5m; cover the whole session. Not - // needed for until-only reads (the API starts those at the stream - // head), and injecting since there can invert the window. - browser = await fetchBrowser(); - query.since = browser?.created_at ?? "1970-01-01T00:00:00Z"; - } - // When the read covers the whole session with no filters (asc from - // creation, or desc from the newest event), an empty result means the - // archive is empty — there is nothing to widen. - const fullSessionRead = - params.offset === undefined && - params.since === undefined && - params.until === undefined && - params.categories === undefined; - - const page = await client.browsers.telemetry.events( - params.session_id, - query, - ); - const items = page.getPaginatedItems().map(compactTelemetryEvent); - - let status: "ok" | "no_events" = "ok"; - let note: string | undefined; - if (items.length === 0) { - if (!page.has_more) browser ??= await fetchBrowser(); - const telemetryDisabled = - browser !== null && - !Object.values(browser.telemetry?.browser ?? {}).some( - (category) => category?.enabled, - ); - ({ status, note } = summarizeEmptyTelemetryResult({ - hasMore: Boolean(page.has_more), - fullSessionRead, - telemetryDisabled, - })); - } - - // Compact serialization: a full page of events would waste a large - // share of its size on pretty-printing indentation. - return textResponse( - JSON.stringify({ - status, - items, - has_more: page.has_more, - next_offset: page.next_offset, - ...(note && { note }), - }), - ); - } catch (error) { - return toolErrorResponse("get_browser_telemetry", "events", error); - } - }, - ); } From 30e8a70d2037fea81f817f7c2e5beddd478b0f38 Mon Sep 17 00:00:00 2001 From: yummybomb <19238148+yummybomb@users.noreply.github.com> Date: Thu, 16 Jul 2026 14:40:05 +0000 Subject: [PATCH 15/21] Remove telemetry unit tests --- src/lib/mcp/tools/browsers.test.ts | 176 ----------------------------- src/lib/mcp/tools/browsers.ts | 4 +- 2 files changed, 2 insertions(+), 178 deletions(-) delete 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 deleted file mode 100644 index e19ce0f..0000000 --- a/src/lib/mcp/tools/browsers.test.ts +++ /dev/null @@ -1,176 +0,0 @@ -import assert from "node:assert/strict"; -import test from "node:test"; -import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { - compactTelemetryEvent, - registerBrowserCapabilities, - summarizeEmptyTelemetryResult, -} from "./browsers"; - -const source = { kind: "cdp" as const }; -const ts = 1_750_000_000_000_000; - -test("exposes telemetry through manage_browsers", () => { - const toolNames: string[] = []; - let actionSchema: - | { safeParse(value: unknown): { success: boolean } } - | undefined; - const server = { - resource() {}, - tool(name: string, _description: string, schema: Record) { - toolNames.push(name); - actionSchema = schema.action as typeof actionSchema; - }, - } as unknown as McpServer; - - registerBrowserCapabilities(server); - - assert.deepEqual(toolNames, ["manage_browsers"]); - assert.ok(actionSchema); - assert.equal(actionSchema.safeParse("get_telemetry").success, true); - assert.equal(actionSchema.safeParse("get_browser_telemetry").success, false); -}); - -test("omits known high-volume telemetry fields", () => { - const request = compactTelemetryEvent({ - seq: 1, - event: { - category: "network", - type: "network_request", - source, - ts, - data: { - headers: { authorization: "secret" }, - post_data: "request body", - method: "POST", - }, - }, - }); - const response = compactTelemetryEvent({ - seq: 2, - event: { - category: "network", - type: "network_response", - source, - ts, - data: { - body: "response body", - headers: { "content-type": "text/plain" }, - status: 200, - }, - }, - }); - const screenshot = compactTelemetryEvent({ - seq: 3, - event: { - category: "screenshot", - type: "monitor_screenshot", - source, - ts, - data: { png: "base64 image" }, - }, - }); - - assert.deepEqual(request.data, { method: "POST" }); - assert.deepEqual(request.omitted_fields, ["headers", "post_data"]); - assert.deepEqual(response.data, { status: 200 }); - assert.deepEqual(response.omitted_fields, ["body", "headers"]); - assert.deepEqual(screenshot.data, {}); - assert.deepEqual(screenshot.omitted_fields, ["png"]); -}); - -test("keeps a page of screenshot events compact", () => { - const png = "x".repeat(256 * 1024); - const events = Array.from({ length: 100 }, (_, seq) => - compactTelemetryEvent({ - seq, - event: { - category: "screenshot", - type: "monitor_screenshot", - source, - ts, - data: { png }, - }, - }), - ); - const serialized = JSON.stringify(events); - - assert.ok(Buffer.byteLength(serialized, "utf8") < 50 * 1024); - assert.ok(events.every((event) => event.omitted_fields?.includes("png"))); -}); - -test("omits oversized fields not on the known-field list", () => { - const event = compactTelemetryEvent({ - seq: 1, - event: { - category: "console", - type: "console_error", - source, - ts, - data: { text: "x".repeat(9 * 1024), level: "error" }, - }, - }); - - assert.deepEqual(event.data, { level: "error" }); - assert.deepEqual(event.omitted_fields, ["text"]); -}); - -test("preserves small telemetry fields", () => { - const event = compactTelemetryEvent({ - seq: 1, - event: { - category: "console", - type: "console_error", - source, - ts, - data: { text: "boom", level: "error" }, - }, - }); - - assert.deepEqual(event.data, { text: "boom", level: "error" }); - assert.equal(event.omitted_fields, undefined); -}); - -test("summarizes empty telemetry results without conflating query and capture state", () => { - assert.deepEqual( - summarizeEmptyTelemetryResult({ - hasMore: true, - fullSessionRead: false, - telemetryDisabled: true, - }), - { - status: "ok", - note: "No matching events on this page; continue with next_offset.", - }, - ); - assert.deepEqual( - summarizeEmptyTelemetryResult({ - hasMore: false, - fullSessionRead: false, - telemetryDisabled: false, - }), - { status: "no_events", note: "No events matched this query." }, - ); - assert.deepEqual( - summarizeEmptyTelemetryResult({ - hasMore: false, - fullSessionRead: true, - telemetryDisabled: false, - }), - { - status: "no_events", - note: "No telemetry events are archived for this session.", - }, - ); - assert.deepEqual( - summarizeEmptyTelemetryResult({ - hasMore: false, - fullSessionRead: true, - telemetryDisabled: true, - }), - { - status: "no_events", - note: "No telemetry events are archived for this session. Telemetry is currently disabled.", - }, - ); -}); diff --git a/src/lib/mcp/tools/browsers.ts b/src/lib/mcp/tools/browsers.ts index 0115431..e15ac77 100644 --- a/src/lib/mcp/tools/browsers.ts +++ b/src/lib/mcp/tools/browsers.ts @@ -100,7 +100,7 @@ const omittedTelemetryDataFields: ReadonlySet = new Set([ ]); const maxTelemetryDataFieldBytes = 8 * 1024; -export function summarizeEmptyTelemetryResult({ +function summarizeEmptyTelemetryResult({ hasMore, fullSessionRead, telemetryDisabled, @@ -125,7 +125,7 @@ export function summarizeEmptyTelemetryResult({ }; } -export function compactTelemetryEvent({ seq, event }: TelemetryEnvelope) { +function compactTelemetryEvent({ seq, event }: TelemetryEnvelope) { const { ts, category, type, source, truncated } = event; const data = "data" in event ? event.data : undefined; From 909eed3603aacaa81dee272b7dbefee8ed0a7e11 Mon Sep 17 00:00:00 2001 From: yummybomb <19238148+yummybomb@users.noreply.github.com> Date: Thu, 16 Jul 2026 15:19:02 +0000 Subject: [PATCH 16/21] Address telemetry review findings - Default since to the epoch instead of fetching created_at: the archive cannot predate the session, so the reads are equivalent and the common path saves a browser lookup. - Fetch the browser only for the terminal-empty capture-state hint, catch only 404 (deleted/unknown session), and let other errors propagate like sibling actions. - Drop the response status field: it collided with the list status param and duplicated items/has_more plus the note. - Reword the filtered-empty disabled hint to steer toward adjusting the filter rather than enabling capture and retrying. - Fix the debug prompt: a session with capture off has an empty telemetry config in GET responses, not a null field. - Document why compact single-line JSON is used over the pretty-printed response helpers. --- src/lib/mcp/prompts.ts | 2 +- src/lib/mcp/tools/browsers.ts | 97 +++++++++++++++++++---------------- 2 files changed, 55 insertions(+), 44 deletions(-) diff --git a/src/lib/mcp/prompts.ts b/src/lib/mcp/prompts.ts index 723fb77..513e9fe 100644 --- a/src/lib/mcp/prompts.ts +++ b/src/lib/mcp/prompts.ts @@ -139,7 +139,7 @@ When telemetry was captured, it's usually the fastest way to pinpoint a failure Start broad: call \`manage_browsers\` with action "get_telemetry", session_id "${session_id}", and no filters. That starts at session creation and returns the first page (up to 100 events); page with \`next_offset\` as \`offset\` while \`has_more\` is true, preserving \`categories\`, \`until\`, and \`order\`. An empty unfiltered read is definitive: nothing was archived. Narrow when the output is too large to scan or you already know where to look: \`categories\` to isolate a signal you've spotted, \`order\` "desc" when the end of the session matters most, \`since\`/\`until\` to bracket a known failing step. Correlate event timestamps with the failing automation step. -**Gotcha: telemetry is opt-in and only covers activity that happened while capture was on.** Archived events survive telemetry being disabled and the session being deleted, so the archive — not the current config — is the ground truth: \`manage_browsers\` action "get" showing a null \`telemetry\` field means capture is off now, not that nothing was recorded. The default bundle (control/connection/system/captcha) also omits the debug-critical categories. To capture new evidence on an active browser, use \`manage_browsers\` action "update" to enable \`telemetry_console\`, \`telemetry_network\`, and \`telemetry_page\`, then reproduce the issue; recreate the browser only if the session has ended. +**Gotcha: telemetry is opt-in and only covers activity that happened while capture was on.** Archived events survive telemetry being disabled and the session being deleted, so the archive — not the current config — is the ground truth: \`manage_browsers\` action "get" showing no enabled \`telemetry\` categories means capture is off now, not that nothing was recorded. The default bundle (control/connection/system/captcha) also omits the debug-critical categories. To capture new evidence on an active browser, use \`manage_browsers\` action "update" to enable \`telemetry_console\`, \`telemetry_network\`, and \`telemetry_page\`, then reproduce the issue; recreate the browser only if the session has ended. ${TELEMETRY_EVENT_CATALOG} diff --git a/src/lib/mcp/tools/browsers.ts b/src/lib/mcp/tools/browsers.ts index e15ac77..900c4a2 100644 --- a/src/lib/mcp/tools/browsers.ts +++ b/src/lib/mcp/tools/browsers.ts @@ -1,4 +1,5 @@ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { NotFoundError } from "@onkernel/sdk"; import { z } from "zod"; import { buildBrowserCreateConfig, @@ -100,29 +101,44 @@ const omittedTelemetryDataFields: ReadonlySet = new Set([ ]); const maxTelemetryDataFieldBytes = 8 * 1024; -function summarizeEmptyTelemetryResult({ - hasMore, - fullSessionRead, - telemetryDisabled, -}: { - hasMore: boolean; - fullSessionRead: boolean; - telemetryDisabled: boolean; -}) { +async function summarizeEmptyTelemetryResult( + client: KernelClient, + { + sessionId, + hasMore, + fullSessionRead, + }: { sessionId: string; hasMore: boolean; fullSessionRead: boolean }, +) { if (hasMore) { - return { - status: "ok" as const, - note: "No matching events on this page; continue with next_offset.", - }; + return "No matching events on this page; continue with next_offset."; } - const note = fullSessionRead - ? "No telemetry events are archived for this session." + // Best-effort capture-state hint for terminal empties. Deleted sessions 404 + // here, which is fine: their capture state is no longer actionable. The GET + // response never carries a null telemetry config — the API resolves + // enabled:true to explicit per-category flags and serializes cleared + // telemetry as an empty config — so "no enabled category" is the disabled + // signal. + const browser = await client.browsers + .retrieve(sessionId) + .catch((error: unknown) => { + if (error instanceof NotFoundError) return null; + throw error; + }); + const telemetryDisabled = + browser !== null && + !Object.values(browser.telemetry?.browser ?? {}).some( + (category) => category?.enabled, + ); + + if (fullSessionRead) { + return telemetryDisabled + ? "No telemetry events are archived for this session. Telemetry is currently disabled." + : "No telemetry events are archived for this session."; + } + return telemetryDisabled + ? "No events matched this query. Broaden the categories or time window before changing capture settings; capture is currently disabled, so no new events are being archived." : "No events matched this query."; - return { - status: "no_events" as const, - note: telemetryDisabled ? `${note} Telemetry is currently disabled.` : note, - }; } function compactTelemetryEvent({ seq, event }: TelemetryEnvelope) { @@ -175,9 +191,6 @@ async function readBrowserTelemetry( client: KernelClient, params: BrowserTelemetryReadParams, ) { - const fetchBrowser = () => - client.browsers.retrieve(params.session_id).catch(() => null); - const query: TelemetryEventsQuery = { limit: params.limit ?? 100 }; if (params.categories) query.category = params.categories; if (params.offset !== undefined) query.offset = params.offset; @@ -185,17 +198,21 @@ async function readBrowserTelemetry( if (params.until !== undefined) query.until = params.until; if (params.order !== undefined) query.order = params.order; - let browser: Awaited> = null; - // Avoid the API's five-minute default; until-only reads already start at the stream head. + // Avoid the API's five-minute since 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, so neither needs the override. if ( query.offset === undefined && query.since === undefined && query.until === undefined && query.order !== "desc" ) { - browser = await fetchBrowser(); - query.since = browser?.created_at ?? "1970-01-01T00:00:00Z"; + query.since = "1970-01-01T00:00:00Z"; } + // Unlike the since override, this keys off categories: an unbounded read + // (asc from the epoch, or desc from the stream tail) that comes back empty + // proves the archive is empty, while a filtered miss proves nothing. const fullSessionRead = params.offset === undefined && params.since === undefined && @@ -205,25 +222,19 @@ async function readBrowserTelemetry( const page = await client.browsers.telemetry.events(params.session_id, query); const items = page.getPaginatedItems().map(compactTelemetryEvent); - let status: "ok" | "no_events" = "ok"; - let note: string | undefined; - if (items.length === 0) { - if (!page.has_more) browser ??= await fetchBrowser(); - const telemetryDisabled = - browser !== null && - !Object.values(browser.telemetry?.browser ?? {}).some( - (category) => category?.enabled, - ); - ({ status, note } = summarizeEmptyTelemetryResult({ - hasMore: Boolean(page.has_more), - fullSessionRead, - telemetryDisabled, - })); - } - + const note = + items.length === 0 + ? await summarizeEmptyTelemetryResult(client, { + sessionId: params.session_id, + hasMore: Boolean(page.has_more), + fullSessionRead, + }) + : undefined; + + // 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({ - status, items, has_more: page.has_more, next_offset: page.next_offset, From 94477c7de55ca108e1847037e55d26373fa3135a Mon Sep 17 00:00:00 2001 From: yummybomb <19238148+yummybomb@users.noreply.github.com> Date: Thu, 16 Jul 2026 15:27:27 +0000 Subject: [PATCH 17/21] Await telemetry reads so API errors reach the action error handler Returning the un-awaited promise from the get_telemetry case let API failures (e.g. 404 for an unknown session) escape the surrounding try/catch as unhandled rejections instead of resolving to a tool error response. Caught by exercising the handler against production. --- src/lib/mcp/tools/browsers.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/lib/mcp/tools/browsers.ts b/src/lib/mcp/tools/browsers.ts index 900c4a2..8096d8b 100644 --- a/src/lib/mcp/tools/browsers.ts +++ b/src/lib/mcp/tools/browsers.ts @@ -654,7 +654,9 @@ export function registerBrowserCapabilities(server: McpServer) { "Error: since cannot be combined with order=desc. Use until to bound a newest-first read, or order=asc with since.", ); } - return readBrowserTelemetry(client, { + // Awaited so API failures resolve to toolErrorResponse below + // instead of escaping the try as an unhandled rejection. + return await readBrowserTelemetry(client, { session_id: params.session_id, categories: params.categories, limit: params.limit, From 9ec8489a927d9f6dc658b8df92c1d9c7abf91424 Mon Sep 17 00:00:00 2001 From: yummybomb <19238148+yummybomb@users.noreply.github.com> Date: Thu, 16 Jul 2026 16:54:17 +0000 Subject: [PATCH 18/21] Trim explanatory comments to one-liners --- src/lib/mcp/tools/browsers.ts | 19 ++----------------- 1 file changed, 2 insertions(+), 17 deletions(-) diff --git a/src/lib/mcp/tools/browsers.ts b/src/lib/mcp/tools/browsers.ts index 8096d8b..a0564aa 100644 --- a/src/lib/mcp/tools/browsers.ts +++ b/src/lib/mcp/tools/browsers.ts @@ -113,12 +113,6 @@ async function summarizeEmptyTelemetryResult( return "No matching events on this page; continue with next_offset."; } - // Best-effort capture-state hint for terminal empties. Deleted sessions 404 - // here, which is fine: their capture state is no longer actionable. The GET - // response never carries a null telemetry config — the API resolves - // enabled:true to explicit per-category flags and serializes cleared - // telemetry as an empty config — so "no enabled category" is the disabled - // signal. const browser = await client.browsers .retrieve(sessionId) .catch((error: unknown) => { @@ -198,10 +192,8 @@ async function readBrowserTelemetry( if (params.until !== undefined) query.until = params.until; if (params.order !== undefined) query.order = params.order; - // Avoid the API's five-minute since 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, so neither needs the override. + // Avoid the API's five-minute default; until-only reads already start at + // the stream head and desc reads anchor at the stream tail. if ( query.offset === undefined && query.since === undefined && @@ -210,9 +202,6 @@ async function readBrowserTelemetry( ) { query.since = "1970-01-01T00:00:00Z"; } - // Unlike the since override, this keys off categories: an unbounded read - // (asc from the epoch, or desc from the stream tail) that comes back empty - // proves the archive is empty, while a filtered miss proves nothing. const fullSessionRead = params.offset === undefined && params.since === undefined && @@ -231,8 +220,6 @@ async function readBrowserTelemetry( }) : undefined; - // 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, @@ -654,8 +641,6 @@ export function registerBrowserCapabilities(server: McpServer) { "Error: since cannot be combined with order=desc. Use until to bound a newest-first read, or order=asc with since.", ); } - // Awaited so API failures resolve to toolErrorResponse below - // instead of escaping the try as an unhandled rejection. return await readBrowserTelemetry(client, { session_id: params.session_id, categories: params.categories, From f4cc8d33656cb02564dbedeae2a558ad84d4ca75 Mon Sep 17 00:00:00 2001 From: yummybomb <19238148+yummybomb@users.noreply.github.com> Date: Thu, 16 Jul 2026 17:07:19 +0000 Subject: [PATCH 19/21] Restore rationale comments on epoch default and compact JSON response --- src/lib/mcp/tools/browsers.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/lib/mcp/tools/browsers.ts b/src/lib/mcp/tools/browsers.ts index a0564aa..89fa214 100644 --- a/src/lib/mcp/tools/browsers.ts +++ b/src/lib/mcp/tools/browsers.ts @@ -192,8 +192,10 @@ async function readBrowserTelemetry( if (params.until !== undefined) query.until = params.until; if (params.order !== undefined) query.order = params.order; - // Avoid the API's five-minute default; until-only reads already start at - // the stream head and desc reads anchor at the stream tail. + // 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. if ( query.offset === undefined && query.since === undefined && @@ -220,6 +222,8 @@ async function readBrowserTelemetry( }) : undefined; + // 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, From 9b1bca33e148d252550656578f6c310cdd625521 Mon Sep 17 00:00:00 2001 From: yummybomb <19238148+yummybomb@users.noreply.github.com> Date: Thu, 16 Jul 2026 17:14:55 +0000 Subject: [PATCH 20/21] Treat an explicit pre-creation since as a full-session read An empty read with since at or before the session's creation covers the whole archive, so it now gets the definitive nothing-is-archived note instead of the broaden-the-window one. Uses the browser already fetched for the capture-state hint; duration-style since values and deleted sessions keep the windowed wording. --- src/lib/mcp/tools/browsers.ts | 24 ++++++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/src/lib/mcp/tools/browsers.ts b/src/lib/mcp/tools/browsers.ts index 89fa214..6166d61 100644 --- a/src/lib/mcp/tools/browsers.ts +++ b/src/lib/mcp/tools/browsers.ts @@ -107,7 +107,13 @@ async function summarizeEmptyTelemetryResult( sessionId, hasMore, fullSessionRead, - }: { sessionId: string; hasMore: boolean; fullSessionRead: boolean }, + soleSince, + }: { + sessionId: string; + hasMore: boolean; + fullSessionRead: boolean; + soleSince?: string; + }, ) { if (hasMore) { return "No matching events on this page; continue with next_offset."; @@ -125,7 +131,16 @@ async function summarizeEmptyTelemetryResult( (category) => category?.enabled, ); - if (fullSessionRead) { + // An explicit since at or before the session's creation also covers the + // whole archive. Duration-style values ("10m") fail Date.parse and stay on + // the windowed wording, as do deleted sessions (null browser). + const coversFullSession = + fullSessionRead || + (soleSince !== undefined && + browser !== null && + Date.parse(soleSince) <= Date.parse(browser.created_at)); + + if (coversFullSession) { return telemetryDisabled ? "No telemetry events are archived for this session. Telemetry is currently disabled." : "No telemetry events are archived for this session."; @@ -204,11 +219,11 @@ async function readBrowserTelemetry( ) { query.since = "1970-01-01T00:00:00Z"; } - const fullSessionRead = + const unfilteredExceptSince = params.offset === undefined && - params.since === undefined && params.until === undefined && params.categories === undefined; + const fullSessionRead = unfilteredExceptSince && params.since === undefined; const page = await client.browsers.telemetry.events(params.session_id, query); const items = page.getPaginatedItems().map(compactTelemetryEvent); @@ -219,6 +234,7 @@ async function readBrowserTelemetry( sessionId: params.session_id, hasMore: Boolean(page.has_more), fullSessionRead, + soleSince: unfilteredExceptSince ? params.since : undefined, }) : undefined; From 260708ea86ff4b758fa414f6bbf07c21484498db Mon Sep 17 00:00:00 2001 From: yummybomb <19238148+yummybomb@users.noreply.github.com> Date: Thu, 16 Jul 2026 18:20:06 +0000 Subject: [PATCH 21/21] Nudge long ascending telemetry reads toward order=desc An unfiltered ascending read starts at session creation, which baits agents into paging oldest-first through a long archive when the failure they want is at the end. Attach a note to such pages while has_more is true pointing at order=desc, and say in the order description when desc is the better read. Filtered or time-bracketed reads are deliberate and stay note-free. --- src/lib/mcp/tools/browsers.ts | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/src/lib/mcp/tools/browsers.ts b/src/lib/mcp/tools/browsers.ts index 6166d61..6865fe2 100644 --- a/src/lib/mcp/tools/browsers.ts +++ b/src/lib/mcp/tools/browsers.ts @@ -228,6 +228,20 @@ async function readBrowserTelemetry( const page = await client.browsers.telemetry.events(params.session_id, query); const items = page.getPaginatedItems().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 + // desc rather than page through the whole archive oldest-first. Applies to + // offset-continuation pages too — that's the agent already deep in a page + // walk. Category, since, and until filters signal a deliberate bracket. + const ascPagingNote = + page.has_more && + params.categories === undefined && + params.since === undefined && + params.until === undefined && + query.order !== "desc" + ? 'Reading oldest-first from session start. If the end of the session matters most, use order "desc" instead of paging.' + : undefined; + const note = items.length === 0 ? await summarizeEmptyTelemetryResult(client, { @@ -236,7 +250,7 @@ async function readBrowserTelemetry( fullSessionRead, soleSince: unfilteredExceptSince ? params.since : undefined, }) - : undefined; + : ascPagingNote; // Single-line JSON rather than the pretty-printed house helpers: a page // carries up to 100 events and indentation would inflate the token cost. @@ -494,7 +508,7 @@ export function registerBrowserCapabilities(server: McpServer) { order: z .enum(["asc", "desc"]) .describe( - "(get_telemetry) Read direction. asc (default) reads oldest first; desc reads newest first. Preserve it while paging.", + "(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(), telemetry_enabled: z