Skip to content

Commit d5389b9

Browse files
committed
feat(mcp): add repro_get_replay_transcript tool
1 parent 106e998 commit d5389b9

1 file changed

Lines changed: 74 additions & 1 deletion

File tree

apps/dashboard/server/mcp/tools/reports.ts

Lines changed: 74 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
1+
import { gunzipSync } from "node:zlib"
12
import { z } from "zod"
2-
import { eq } from "drizzle-orm"
3+
import { and, eq } from "drizzle-orm"
34
import { db } from "../../db"
45
import { reports, reportAttachments } from "../../db/schema"
56
import { requireProjectRoleByUser } from "../../lib/permissions"
67
import { mcpError } from "../errors"
78
import { getStorage } from "../../lib/storage"
9+
import { buildReplayTranscript, type RrwebEvent } from "../replay-transcript"
810
import type { McpRequestContext } from "../context"
911

1012
const SCREENSHOT_MAX_BYTES = 1024 * 1024 // 1 MB
@@ -65,3 +67,74 @@ export const getScreenshotTool = {
6567
}
6668
},
6769
}
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

Comments
 (0)