|
| 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