Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/lib/mcp/prompts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
134 changes: 112 additions & 22 deletions src/lib/mcp/tools/browsers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,40 +150,99 @@ async function summarizeEmptyTelemetryResult(
: "No events matched this query.";
}

function compactTelemetryEvent({ seq, event }: TelemetryEnvelope) {
function telemetryEventResponse(
{ seq, event }: TelemetryEnvelope,
data: Record<string, unknown> | 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<string, unknown>) }
: undefined;
}

function compactTelemetryEvent(envelope: TelemetryEnvelope) {
const data = telemetryEventData(envelope);

let compactData: Record<string, unknown> | undefined;
let omittedFields: string[] | undefined;
if (data) {
compactData = { ...(data as Record<string, unknown>) };
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 = {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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(
Expand Down