Skip to content

Commit b9e6a8d

Browse files
Ripwordsclaude
andcommitted
feat(mcp): add repro_get_ticket tool with inline replay transcript
Fetches a single ticket by id, gates on viewer+ project role, includes inline console/network logs (from the logs attachment) and decodes the gzip-compressed replay attachment into a textual transcript. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 4a117bf commit b9e6a8d

1 file changed

Lines changed: 128 additions & 0 deletions

File tree

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
import { z } from "zod"
2+
import { eq } from "drizzle-orm"
3+
import { gunzipSync } from "node:zlib"
4+
import { db } from "../../db"
5+
import { reports, reportAttachments } from "../../db/schema"
6+
import { requireProjectRoleByUser } from "../../lib/permissions"
7+
import { mcpError } from "../errors"
8+
import { buildReplayTranscript, type RrwebEvent } from "../replay-transcript"
9+
import { getStorage } from "../../lib/storage"
10+
import type { McpRequestContext } from "../context"
11+
import type { ReportContext as SharedReportContext } from "@reprojs/shared"
12+
13+
export const getTicketTool = {
14+
name: "repro_get_ticket",
15+
config: {
16+
description:
17+
"Fetch a single Repro ticket (a.k.a. report) with full context: title, description, status, priority, tags, GitHub link state, page context, system info, console + network logs, and an inline replay transcript when one was captured.",
18+
inputSchema: z.object({
19+
ticketId: z.string().uuid().describe("The ticket id (UUID)."),
20+
}),
21+
},
22+
handler: async (input: { ticketId: string }, ctx: McpRequestContext) => {
23+
const [report] = await db.select().from(reports).where(eq(reports.id, input.ticketId)).limit(1)
24+
if (!report) throw mcpError("NOT_FOUND", `ticket ${input.ticketId} not found`)
25+
await requireProjectRoleByUser(ctx.userId, report.projectId, "viewer")
26+
27+
const attachments = await db
28+
.select({
29+
id: reportAttachments.id,
30+
kind: reportAttachments.kind,
31+
storageKey: reportAttachments.storageKey,
32+
contentType: reportAttachments.contentType,
33+
size: reportAttachments.sizeBytes,
34+
})
35+
.from(reportAttachments)
36+
.where(eq(reportAttachments.reportId, report.id))
37+
38+
const storage = await getStorage()
39+
40+
// Replay: parse from gzip-compressed JSON stored in the "replay" attachment.
41+
let replay: { durationMs: number; eventCount: number; transcript: string } | null = null
42+
const replayAttachment = attachments.find((a) => a.kind === "replay")
43+
if (replayAttachment) {
44+
try {
45+
const { bytes } = await storage.get(replayAttachment.storageKey)
46+
// Replay is always stored as application/gzip (see intake handler).
47+
const decompressed = gunzipSync(bytes)
48+
const events = JSON.parse(decompressed.toString("utf-8")) as RrwebEvent[]
49+
const t = buildReplayTranscript(events, { verbosity: "summary" })
50+
replay = {
51+
durationMs: t.durationMs,
52+
eventCount: t.eventCount,
53+
transcript: t.transcript,
54+
}
55+
} catch {
56+
// Graceful failure: missing storage object or corrupt gzip → replay: null.
57+
replay = null
58+
}
59+
}
60+
61+
// Console + network logs: stored as a separate "logs" attachment (JSON).
62+
let consoleLog: unknown[] = []
63+
let networkLog: unknown[] = []
64+
let breadcrumbs: unknown[] = []
65+
const logsAttachment = attachments.find((a) => a.kind === "logs")
66+
if (logsAttachment) {
67+
try {
68+
const { bytes } = await storage.get(logsAttachment.storageKey)
69+
const parsed = JSON.parse(Buffer.from(bytes).toString("utf-8")) as {
70+
console?: unknown[]
71+
network?: unknown[]
72+
breadcrumbs?: unknown[]
73+
}
74+
consoleLog = parsed.console ?? []
75+
networkLog = parsed.network ?? []
76+
breadcrumbs = parsed.breadcrumbs ?? []
77+
} catch {
78+
// Graceful failure: missing or corrupt logs → empty arrays.
79+
}
80+
}
81+
82+
// The context JSONB is a ReportContext from @reprojs/shared:
83+
// { source, pageUrl, userAgent, viewport, timestamp, reporter, metadata, systemInfo, cookies }
84+
const context = (report.context ?? {}) as SharedReportContext
85+
86+
const payload = {
87+
id: report.id,
88+
projectId: report.projectId,
89+
title: report.title,
90+
description: report.description ?? "",
91+
status: report.status,
92+
priority: report.priority,
93+
tags: report.tags,
94+
createdAt: report.createdAt.toISOString(),
95+
updatedAt: report.updatedAt.toISOString(),
96+
github: report.githubIssueNumber
97+
? {
98+
issueNumber: report.githubIssueNumber,
99+
issueUrl: report.githubIssueUrl ?? null,
100+
}
101+
: null,
102+
pageContext: {
103+
url: context.pageUrl ?? null,
104+
referrer: context.systemInfo?.referrer ?? null,
105+
userAgent: context.userAgent ?? null,
106+
viewport: context.viewport ?? null,
107+
timestamp: context.timestamp ?? null,
108+
},
109+
systemInfo: context.systemInfo ?? null,
110+
reporter: context.reporter ?? null,
111+
consoleLog,
112+
networkLog,
113+
breadcrumbs,
114+
attachments: attachments.map((a) => ({
115+
id: a.id,
116+
kind: a.kind,
117+
contentType: a.contentType,
118+
size: a.size,
119+
})),
120+
replay,
121+
customMetadata: context.metadata ?? {},
122+
}
123+
124+
return {
125+
content: [{ type: "text" as const, text: JSON.stringify(payload, null, 2) }],
126+
}
127+
},
128+
}

0 commit comments

Comments
 (0)