diff --git a/src/lib/mcp/prompts.ts b/src/lib/mcp/prompts.ts index 513e9fe..9d74972 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 \`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. +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. Events are compacted to save context: bulky payload fields (headers, bodies, post data, anything oversized) are stripped and listed in \`omitted_fields\`. When an omitted field is the evidence you need — e.g. the response body carrying an API error message — fetch that single event uncompacted with action "get_telemetry_event" and its \`seq\`. **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. diff --git a/src/lib/mcp/tools/browsers.ts b/src/lib/mcp/tools/browsers.ts index 6166d61..e4ac144 100644 --- a/src/lib/mcp/tools/browsers.ts +++ b/src/lib/mcp/tools/browsers.ts @@ -150,40 +150,99 @@ async function summarizeEmptyTelemetryResult( : "No events matched this query."; } -function compactTelemetryEvent({ seq, event }: TelemetryEnvelope) { +function telemetryEventResponse( + { seq, event }: TelemetryEnvelope, + data: Record | undefined, + omittedFields: string[] | undefined, +) { const { ts, category, type, source, truncated } = event; - const data = "data" in event ? event.data : undefined; + 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, + ...(data && { data }), + ...(truncated && { truncated }), + ...(omittedFields && { omitted_fields: omittedFields }), + }; +} + +function telemetryEventData(envelope: TelemetryEnvelope) { + const { event } = envelope; + return "data" in event + ? { ...(event.data as Record) } + : undefined; +} + +function compactTelemetryEvent(envelope: TelemetryEnvelope) { + const data = telemetryEventData(envelope); - let compactData: Record | undefined; let omittedFields: string[] | undefined; if (data) { - compactData = { ...(data as Record) }; - for (const [field, value] of Object.entries(compactData)) { + for (const [field, value] of Object.entries(data)) { 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]; + delete data[field]; (omittedFields ??= []).push(field); } } } - 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 }), - }; + return telemetryEventResponse(envelope, data, omittedFields); +} + +// Full-payload counterpart to compactTelemetryEvent: only screenshot png is +// dropped (base64 image data is unusable in a text response and can run to +// hundreds of KB), everything else passes through uncompacted. +function rawTelemetryEvent(envelope: TelemetryEnvelope) { + const data = telemetryEventData(envelope); + + let omittedFields: string[] | undefined; + if (data && "png" in data) { + delete data.png; + omittedFields = ["png"]; + } + + return telemetryEventResponse(envelope, data, omittedFields); +} + +async function readBrowserTelemetryEvent( + client: KernelClient, + sessionId: string, + seq: number, +) { + // Event seq values are assigned by the instance and embedded in the record + // body, while offset addresses the underlying stream position; the two run + // at a locally constant drift. Probe near the target, measure the drift + // from what comes back, and re-anchor — one correction normally lands it. + let offset = Math.max(seq - 1, 0); + for (let attempt = 0; attempt < 3; attempt++) { + const page = await client.browsers.telemetry.events(sessionId, { + offset, + limit: 3, + }); + const items = page.getPaginatedItems(); + const match = items.find((item) => item.seq === seq); + if (match) { + return textResponse(JSON.stringify(rawTelemetryEvent(match))); + } + const first = items[0]; + if (!first) break; // past the stream tail; nothing to measure drift from + const corrected = Math.max(offset + (seq - first.seq), 0); + if (corrected === offset) break; // clamped at the stream head + offset = corrected; + } + return errorResponse( + `Error: no archived telemetry event with seq ${seq}. Use get_telemetry to find event seq values.`, + ); } type BrowserTelemetryReadParams = { @@ -331,15 +390,23 @@ 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, "get_telemetry_event" to fetch one event\'s full payload, and "delete" when finished.', { action: z - .enum(["create", "update", "list", "get", "get_telemetry", "delete"]) + .enum([ + "create", + "update", + "list", + "get", + "get_telemetry", + "get_telemetry_event", + "delete", + ]) .describe("Operation to perform."), session_id: z .string() .describe( - "Browser session ID. Required for update, get, get_telemetry, and delete actions.", + "Browser session ID. Required for update, get, get_telemetry, get_telemetry_event, and delete actions.", ) .optional(), start_url: z @@ -497,6 +564,14 @@ export function registerBrowserCapabilities(server: McpServer) { "(get_telemetry) Read direction. asc (default) reads oldest first; desc reads newest first. Preserve it while paging.", ) .optional(), + seq: z + .number() + .int() + .min(0) + .describe( + "(get_telemetry_event) Sequence number of the event to fetch, taken from a get_telemetry result. Returns the event with its full payload, including fields get_telemetry omits (headers, body, post_data, oversized fields); screenshot png data stays omitted.", + ) + .optional(), telemetry_enabled: z .boolean() .describe( @@ -671,6 +746,21 @@ export function registerBrowserCapabilities(server: McpServer) { order: params.order, }); } + case "get_telemetry_event": { + if (!params.session_id) + return errorResponse( + "Error: session_id is required for get_telemetry_event action.", + ); + if (params.seq === undefined) + return errorResponse( + "Error: seq is required for get_telemetry_event action.", + ); + return await readBrowserTelemetryEvent( + client, + params.session_id, + params.seq, + ); + } case "delete": { if (!params.session_id) return errorResponse(