|
1 | 1 | import { z } from "zod"; |
2 | 2 | import { tool } from "../../tool"; |
| 3 | +import { dump, DumpOptions } from "js-yaml"; |
3 | 4 | import { batchGetEvents, listEvents } from "../../../crashlytics/events"; |
4 | 5 | import { |
5 | 6 | BatchGetEventsResponse, |
| 7 | + Breadcrumb, |
6 | 8 | ErrorType, |
7 | 9 | Event, |
| 10 | + Exception, |
| 11 | + Frame, |
8 | 12 | ListEventsResponse, |
| 13 | + Log, |
| 14 | + Thread, |
| 15 | + Error, |
9 | 16 | } from "../../../crashlytics/types"; |
10 | 17 | import { ApplicationIdSchema, EventFilterSchema } from "../../../crashlytics/filters"; |
11 | | -import { mcpError, toContent } from "../../util"; |
| 18 | +import { mcpError } from "../../util"; |
12 | 19 |
|
13 | | -function pruneThreads(sample: Event): Event { |
14 | | - if (sample.issue?.errorType === ErrorType.FATAL || sample.issue?.errorType === ErrorType.ANR) { |
15 | | - // Remove irrelevant threads from the response to reduce token usage |
16 | | - sample.threads = sample.threads?.filter((t) => t.crashed || t.blamed); |
| 20 | +const DUMP_OPTIONS: DumpOptions = { lineWidth: 200 }; |
| 21 | + |
| 22 | +function formatFrames(origFrames: Frame[], maxFrames = 20): string[] { |
| 23 | + const frames: Frame[] = origFrames || []; |
| 24 | + const shouldTruncate = frames.length > maxFrames; |
| 25 | + const framesToFormat = shouldTruncate ? frames.slice(0, maxFrames - 1) : frames; |
| 26 | + const formatted = framesToFormat.map((frame) => { |
| 27 | + let line = `at`; |
| 28 | + if (frame.symbol) { |
| 29 | + line += ` ${frame.symbol}`; |
| 30 | + } |
| 31 | + if (frame.file) { |
| 32 | + line += ` (${frame.file}`; |
| 33 | + if (frame.line) { |
| 34 | + line += `:${frame.line}`; |
| 35 | + } |
| 36 | + line += ")"; |
| 37 | + } |
| 38 | + return line; |
| 39 | + }); |
| 40 | + if (shouldTruncate) { |
| 41 | + formatted.push("... frames omitted ..."); |
| 42 | + } |
| 43 | + return formatted; |
| 44 | +} |
| 45 | + |
| 46 | +// Formats an event into more legible, token-efficient text content sections |
| 47 | + |
| 48 | +function toText(event: Event): Record<string, string> { |
| 49 | + const result: Record<string, string> = {}; |
| 50 | + for (const [key, value] of Object.entries(event)) { |
| 51 | + if (key === "logs") { |
| 52 | + // [2025-01-01T00:00.000:00Z] Log message 1 |
| 53 | + // [2025-01-01T00:00.000:00Z] Log message 2 |
| 54 | + const logs: Array<Log> = (value as Array<Log>) || []; |
| 55 | + const slicedLogs = logs.length > 100 ? logs.slice(logs.length - 100) : logs; |
| 56 | + const logLines = slicedLogs.map((log) => `[${log.logTime}] ${log.message}`); |
| 57 | + result["logs"] = logLines.join("\n"); |
| 58 | + } else if (key === "breadcrumbs") { |
| 59 | + // [2025-10-30T06:56:43.147Z] Event_Title1 { key1: value1, key2: value2 } │ |
| 60 | + // [2025-10-30T06:56:50.328Z] Event_Title2 { key1: value1, key2: value2 } |
| 61 | + const breadcrumbs = (value as Breadcrumb[]) || []; |
| 62 | + const slicedBreadcrumbs = breadcrumbs.length > 10 ? breadcrumbs.slice(-10) : breadcrumbs; |
| 63 | + const breadcrumbLines = slicedBreadcrumbs.map((b) => { |
| 64 | + const paramString = Object.entries(b.params) |
| 65 | + .map(([k, v]) => `${k}: ${v}`) |
| 66 | + .join(", "); |
| 67 | + const params = paramString ? ` { ${paramString} }` : ""; |
| 68 | + return `[${b.eventTime}] ${b.title}${params}`; |
| 69 | + }); |
| 70 | + result["breadcrumbs"] = breadcrumbLines.join("\n"); |
| 71 | + } else if (key === "threads") { |
| 72 | + // Thread: Name (crashed) │ |
| 73 | + // at java.net.ClassName.methodName (Filename.java:123) |
| 74 | + // at ... |
| 75 | + let threads = (value as Thread[]) || []; |
| 76 | + if (event.issue?.errorType === ErrorType.FATAL || event.issue?.errorType === ErrorType.ANR) { |
| 77 | + threads = threads.filter((t) => t.crashed || t.blamed); |
| 78 | + } |
| 79 | + const threadStrings = threads.map((thread) => { |
| 80 | + const header = `Thread: ${thread.name || thread.threadId || ""}${thread.crashed ? " (crashed)" : ""}`; |
| 81 | + const frameStrings = formatFrames(thread.frames || []); |
| 82 | + return [header, ...frameStrings].join("\n"); |
| 83 | + }); |
| 84 | + result["threads"] = threadStrings.join("\n\n"); |
| 85 | + } else if (key === "exceptions") { |
| 86 | + // java.lang.IllegalArgumentException: something went wrong │ |
| 87 | + // at java.net.ClassName.methodName (Filename.java:123) |
| 88 | + // at ... |
| 89 | + const exceptions = (value as Exception[]) || []; |
| 90 | + const exceptionStrings = exceptions.map((exception) => { |
| 91 | + const header = exception.nested ? "Caused by: " : ""; |
| 92 | + const exceptionHeader = `${header}${exception.type || ""}: ${exception.exceptionMessage || ""}`; |
| 93 | + const frameStrings = formatFrames(exception.frames || []); |
| 94 | + return [exceptionHeader, ...frameStrings].join("\n"); |
| 95 | + }); |
| 96 | + result["exceptions"] = exceptionStrings.join("\n\n"); |
| 97 | + } else if (key === "errors") { |
| 98 | + // Error: error title |
| 99 | + // at ClassName.method (Filename.cc:123) |
| 100 | + // at ... |
| 101 | + const errors = (value as Error[]) || []; |
| 102 | + const errorStrings = errors.map((error) => { |
| 103 | + const header = `Error: ${error.title || "error"}`; |
| 104 | + const frameStrings = formatFrames(error.frames || []); |
| 105 | + return [header, ...frameStrings].join("\n"); |
| 106 | + }); |
| 107 | + result["errors"] = errorStrings.join("\n\n"); |
| 108 | + } else { |
| 109 | + // field: |
| 110 | + // field: value |
| 111 | + result[key] = dump(value, DUMP_OPTIONS); |
| 112 | + } |
17 | 113 | } |
18 | | - return sample; |
| 114 | + return result; |
19 | 115 | } |
20 | 116 |
|
21 | 117 | export const list_events = tool( |
@@ -44,8 +140,10 @@ export const list_events = tool( |
44 | 140 | return mcpError(`Must specify 'filter.issueId' or 'filter.issueVariantId' parameters.`); |
45 | 141 |
|
46 | 142 | const response: ListEventsResponse = await listEvents(appId, filter, pageSize); |
47 | | - response.events = response.events ? response.events.map((e) => pruneThreads(e)) : []; |
48 | | - return toContent(response); |
| 143 | + const eventsContent = response.events?.map((e) => toText(e)) || []; |
| 144 | + return { |
| 145 | + content: [{ type: "text", text: dump(eventsContent, DUMP_OPTIONS) }], |
| 146 | + }; |
49 | 147 | }, |
50 | 148 | ); |
51 | 149 |
|
@@ -78,7 +176,9 @@ export const batch_get_events = tool( |
78 | 176 | return mcpError(`Must provide event resource names in name parameter.`); |
79 | 177 |
|
80 | 178 | const response: BatchGetEventsResponse = await batchGetEvents(appId, names); |
81 | | - response.events = response.events ? response.events.map((e) => pruneThreads(e)) : []; |
82 | | - return toContent(response); |
| 179 | + const eventsContent = response.events?.map((e) => toText(e)) || []; |
| 180 | + return { |
| 181 | + content: [{ type: "text", text: dump(eventsContent, DUMP_OPTIONS) }], |
| 182 | + }; |
83 | 183 | }, |
84 | 184 | ); |
0 commit comments