Skip to content

Commit 08ec8e1

Browse files
authored
feat(mcp): Format the text content representation of Crashlytics events (#9426)
* Compress the string representation of events to save tokens
1 parent e15a527 commit 08ec8e1

File tree

1 file changed

+110
-10
lines changed

1 file changed

+110
-10
lines changed

src/mcp/tools/crashlytics/events.ts

Lines changed: 110 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,117 @@
11
import { z } from "zod";
22
import { tool } from "../../tool";
3+
import { dump, DumpOptions } from "js-yaml";
34
import { batchGetEvents, listEvents } from "../../../crashlytics/events";
45
import {
56
BatchGetEventsResponse,
7+
Breadcrumb,
68
ErrorType,
79
Event,
10+
Exception,
11+
Frame,
812
ListEventsResponse,
13+
Log,
14+
Thread,
15+
Error,
916
} from "../../../crashlytics/types";
1017
import { ApplicationIdSchema, EventFilterSchema } from "../../../crashlytics/filters";
11-
import { mcpError, toContent } from "../../util";
18+
import { mcpError } from "../../util";
1219

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+
}
17113
}
18-
return sample;
114+
return result;
19115
}
20116

21117
export const list_events = tool(
@@ -44,8 +140,10 @@ export const list_events = tool(
44140
return mcpError(`Must specify 'filter.issueId' or 'filter.issueVariantId' parameters.`);
45141

46142
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+
};
49147
},
50148
);
51149

@@ -78,7 +176,9 @@ export const batch_get_events = tool(
78176
return mcpError(`Must provide event resource names in name parameter.`);
79177

80178
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+
};
83183
},
84184
);

0 commit comments

Comments
 (0)