|
| 1 | +import { gunzipSync } from "node:zlib" |
1 | 2 | import { z } from "zod" |
2 | | -import { eq } from "drizzle-orm" |
| 3 | +import { and, eq } from "drizzle-orm" |
3 | 4 | import { db } from "../../db" |
4 | 5 | import { reports, reportAttachments } from "../../db/schema" |
5 | 6 | import { requireProjectRoleByUser } from "../../lib/permissions" |
6 | 7 | import { mcpError } from "../errors" |
7 | 8 | import { getStorage } from "../../lib/storage" |
| 9 | +import { buildReplayTranscript, type RrwebEvent } from "../replay-transcript" |
8 | 10 | import type { McpRequestContext } from "../context" |
9 | 11 |
|
10 | 12 | const SCREENSHOT_MAX_BYTES = 1024 * 1024 // 1 MB |
@@ -65,3 +67,74 @@ export const getScreenshotTool = { |
65 | 67 | } |
66 | 68 | }, |
67 | 69 | } |
| 70 | + |
| 71 | +export const getReplayTranscriptTool = { |
| 72 | + name: "repro_get_replay_transcript", |
| 73 | + config: { |
| 74 | + description: |
| 75 | + "Re-fetch the textual replay timeline for a ticket. The 'summary' verbosity (default) matches the inline transcript in repro_get_ticket; 'detailed' includes more event types like focus/blur and DOM mutation counts.", |
| 76 | + inputSchema: z.object({ |
| 77 | + ticketId: z.string().uuid(), |
| 78 | + verbosity: z.enum(["summary", "detailed"]).optional(), |
| 79 | + }), |
| 80 | + }, |
| 81 | + handler: async ( |
| 82 | + input: { ticketId: string; verbosity?: "summary" | "detailed" }, |
| 83 | + ctx: McpRequestContext, |
| 84 | + ) => { |
| 85 | + const [report] = await db |
| 86 | + .select({ projectId: reports.projectId }) |
| 87 | + .from(reports) |
| 88 | + .where(eq(reports.id, input.ticketId)) |
| 89 | + .limit(1) |
| 90 | + if (!report) throw mcpError("NOT_FOUND", `ticket ${input.ticketId} not found`) |
| 91 | + await requireProjectRoleByUser(ctx.userId, report.projectId, "viewer") |
| 92 | + |
| 93 | + const [replayAttachment] = await db |
| 94 | + .select({ |
| 95 | + storageKey: reportAttachments.storageKey, |
| 96 | + }) |
| 97 | + .from(reportAttachments) |
| 98 | + .where( |
| 99 | + and(eq(reportAttachments.reportId, input.ticketId), eq(reportAttachments.kind, "replay")), |
| 100 | + ) |
| 101 | + .limit(1) |
| 102 | + if (!replayAttachment) { |
| 103 | + throw mcpError("NOT_FOUND", `no replay captured for ticket ${input.ticketId}`) |
| 104 | + } |
| 105 | + |
| 106 | + const storage = await getStorage() |
| 107 | + const obj = await storage.get(replayAttachment.storageKey) |
| 108 | + let events: RrwebEvent[] |
| 109 | + try { |
| 110 | + const decompressed = gunzipSync(Buffer.from(obj.bytes)) |
| 111 | + events = JSON.parse(decompressed.toString("utf-8")) as RrwebEvent[] |
| 112 | + } catch (e) { |
| 113 | + throw mcpError( |
| 114 | + "INVALID_INPUT", |
| 115 | + `replay attachment for ticket ${input.ticketId} could not be decoded: ${ |
| 116 | + e instanceof Error ? e.message : String(e) |
| 117 | + }`, |
| 118 | + ) |
| 119 | + } |
| 120 | + const t = buildReplayTranscript(events, { verbosity: input.verbosity ?? "summary" }) |
| 121 | + return { |
| 122 | + content: [ |
| 123 | + { |
| 124 | + type: "text" as const, |
| 125 | + text: JSON.stringify( |
| 126 | + { |
| 127 | + transcript: t.transcript, |
| 128 | + eventCount: t.eventCount, |
| 129 | + durationMs: t.durationMs, |
| 130 | + truncated: t.truncated, |
| 131 | + verbosity: input.verbosity ?? "summary", |
| 132 | + }, |
| 133 | + null, |
| 134 | + 2, |
| 135 | + ), |
| 136 | + }, |
| 137 | + ], |
| 138 | + } |
| 139 | + }, |
| 140 | +} |
0 commit comments