Skip to content

Commit 6e63abe

Browse files
dcramercodex
andauthored
feat(mcp): Add event stacktrace tool (#1116)
Issue details now surface a compact thread table only when the selected event has multiple threads, with a hint to fetch a full stacktrace through the catalog-only `get_event_stacktrace` tool. The new tool accepts an optional `thread` selector by numeric thread ID or exact thread name; when omitted, it mirrors Sentry UI selection by choosing the first crashed thread, then the first thread with a stacktrace, then the first thread. The event stacktrace markdown uses the `Event Stacktrace` label and keeps the stacktrace output focused on actionable selection and frame details, omitting low-value frame-count/system-frame metadata. Inline snapshots cover the issue-details hint, selector behavior, default selection, and rendered tool output, and generated tool definitions are refreshed. Fixes #1114 --------- Co-authored-by: GPT-5 Codex <codex@openai.com>
1 parent f4f8f9a commit 6e63abe

10 files changed

Lines changed: 1026 additions & 19 deletions

File tree

packages/mcp-core/src/api-client/schema.ts

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -809,18 +809,28 @@ export const MessageEntrySchema = z
809809
})
810810
.partial();
811811

812+
const StacktraceSchema = z
813+
.object({
814+
frames: z.array(FrameInterface),
815+
framesOmitted: z.array(z.unknown()).nullable().optional(),
816+
registers: z.record(z.unknown()).nullable().optional(),
817+
hasSystemFrames: z.boolean().nullable().optional(),
818+
})
819+
.partial()
820+
.extend({
821+
frames: z.array(FrameInterface),
822+
});
823+
812824
export const ThreadEntrySchema = z
813825
.object({
814-
id: z.number().nullable(),
826+
id: z.union([z.number(), z.string()]).nullable(),
815827
name: z.string().nullable(),
816828
current: z.boolean().nullable(),
817829
crashed: z.boolean().nullable(),
818830
state: z.string().nullable(),
819-
stacktrace: z
820-
.object({
821-
frames: z.array(FrameInterface),
822-
})
823-
.nullable(),
831+
heldLocks: z.record(z.unknown()).nullable().optional(),
832+
stacktrace: StacktraceSchema.nullable(),
833+
rawStacktrace: StacktraceSchema.nullable().optional(),
824834
})
825835
.partial();
826836

packages/mcp-core/src/internal/formatting.ts

Lines changed: 193 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,9 @@ import type {
1616
MessageEntrySchema,
1717
RequestEntrySchema,
1818
SentryApiService,
19-
ThreadsEntrySchema,
19+
ThreadEntrySchema,
2020
} from "../api-client";
21+
import { ThreadsEntrySchema } from "../api-client";
2122
import type {
2223
AutofixRunState,
2324
Event,
@@ -229,6 +230,9 @@ export function formatEventOutput(
229230
(e) => e.type === "exception",
230231
);
231232
const threadsEntry = eventToRender.entries.find((e) => e.type === "threads");
233+
const threadsData = threadsEntry
234+
? parseThreadsEntryData(threadsEntry.data)
235+
: undefined;
232236
const requestEntry = eventToRender.entries.find((e) => e.type === "request");
233237
const spansEntry = eventToRender.entries.find((e) => e.type === "spans");
234238
const cspEntry = eventToRender.entries.find((e) => e.type === "csp");
@@ -247,11 +251,13 @@ export function formatEventOutput(
247251
eventToRender,
248252
exceptionEntry.data as z.infer<typeof ErrorEntrySchema>,
249253
);
250-
} else if (threadsEntry) {
251-
output += formatThreadsInterfaceOutput(
252-
eventToRender,
253-
threadsEntry.data as z.infer<typeof ThreadsEntrySchema>,
254-
);
254+
} else if (threadsData) {
255+
output += formatThreadsInterfaceOutput(eventToRender, threadsData);
256+
}
257+
258+
if (threadsData?.values && threadsData.values.length > 1) {
259+
output += formatThreadList(threadsData.values);
260+
output += "\n";
255261
}
256262

257263
// Request info (if HTTP error)
@@ -609,6 +615,160 @@ function formatThreadsInterfaceOutput(
609615
return parts.join("\n");
610616
}
611617

618+
function getThreadDisplayValue(
619+
value: string | number | boolean | null | undefined,
620+
): string {
621+
if (value === null || value === undefined || value === "") {
622+
return "-";
623+
}
624+
return String(value);
625+
}
626+
627+
function getThreadFlags(thread: z.infer<typeof ThreadEntrySchema>): string {
628+
const flags: string[] = [];
629+
if (thread.crashed) {
630+
flags.push("crashed");
631+
}
632+
if (thread.current) {
633+
flags.push("current");
634+
}
635+
return flags.length > 0 ? flags.join(", ") : "-";
636+
}
637+
638+
function formatThreadList(
639+
threads: z.infer<typeof ThreadEntrySchema>[],
640+
): string {
641+
return [
642+
"### Threads",
643+
"",
644+
`Found ${threads.length} thread${threads.length === 1 ? "" : "s"} in this event.`,
645+
"",
646+
...formatThreadTable(threads),
647+
"",
648+
].join("\n");
649+
}
650+
651+
function formatThreadTable(
652+
threads: z.infer<typeof ThreadEntrySchema>[],
653+
): string[] {
654+
return [
655+
"| Thread ID | Name | State | Flags | Frames |",
656+
"| --- | --- | --- | --- | ---: |",
657+
...threads.map((thread) => {
658+
const frameCount = thread.stacktrace?.frames?.length ?? 0;
659+
return `| ${getThreadDisplayValue(thread.id)} | ${getThreadDisplayValue(thread.name)} | ${getThreadDisplayValue(thread.state)} | ${getThreadFlags(thread)} | ${frameCount} |`;
660+
}),
661+
];
662+
}
663+
664+
export function formatAvailableThreadList(
665+
threads: z.infer<typeof ThreadEntrySchema>[],
666+
): string {
667+
return [
668+
"## Available Threads",
669+
"",
670+
...formatThreadTable(threads),
671+
"",
672+
"Pass `thread` as a numeric Thread ID or exact thread Name.",
673+
].join("\n");
674+
}
675+
676+
function parseThreadsEntryData(
677+
data: unknown,
678+
): z.infer<typeof ThreadsEntrySchema> | undefined {
679+
const result = ThreadsEntrySchema.safeParse(data);
680+
return result.success ? result.data : undefined;
681+
}
682+
683+
/**
684+
* Formats the selected thread stacktrace using the same frame rendering
685+
* conventions as issue event details.
686+
*/
687+
export function formatThreadStacktraceOutput({
688+
event,
689+
thread,
690+
selectionReason,
691+
}: {
692+
event: Event;
693+
thread: z.infer<typeof ThreadEntrySchema>;
694+
selectionReason: string;
695+
}): string {
696+
const parts: string[] = [];
697+
698+
parts.push("## Selected Thread");
699+
parts.push("");
700+
parts.push(`**Selection**: ${selectionReason}`);
701+
parts.push(`**Thread ID**: ${getThreadDisplayValue(thread.id)}`);
702+
parts.push(`**Name**: ${getThreadDisplayValue(thread.name)}`);
703+
parts.push(`**State**: ${getThreadDisplayValue(thread.state)}`);
704+
parts.push(`**Crashed**: ${getThreadDisplayValue(thread.crashed)}`);
705+
parts.push(`**Current**: ${getThreadDisplayValue(thread.current)}`);
706+
parts.push("");
707+
708+
const frames = thread.stacktrace?.frames;
709+
if (!frames || frames.length === 0) {
710+
parts.push("No stacktrace is available for the selected thread.");
711+
parts.push("");
712+
return parts.join("\n");
713+
}
714+
715+
parts.push("## Stacktrace");
716+
parts.push("");
717+
const framesOmitted = formatFramesOmitted(thread.stacktrace?.framesOmitted);
718+
if (framesOmitted) {
719+
parts.push(`**Frames Omitted**: ${framesOmitted}`);
720+
parts.push("");
721+
}
722+
723+
const firstInAppFrame = findFirstInAppFrame(frames);
724+
if (
725+
firstInAppFrame &&
726+
(firstInAppFrame.context?.length || firstInAppFrame.vars)
727+
) {
728+
parts.push(renderEnhancedFrame(firstInAppFrame, event));
729+
parts.push("");
730+
parts.push("**Full Stacktrace:**");
731+
parts.push("────────────────");
732+
} else {
733+
parts.push("**Full Stacktrace:**");
734+
}
735+
736+
parts.push("```");
737+
parts.push(
738+
frames
739+
.map((frame) => {
740+
const header = formatFrameHeader(frame, undefined, event.platform);
741+
const context = renderInlineContext(frame);
742+
return `${header}${context}`;
743+
})
744+
.join("\n"),
745+
);
746+
parts.push("```");
747+
parts.push("");
748+
749+
return parts.join("\n");
750+
}
751+
752+
function formatFramesOmitted(
753+
framesOmitted: unknown[] | null | undefined,
754+
): string | null {
755+
if (!framesOmitted?.length) {
756+
return null;
757+
}
758+
759+
const [firstOmitted, lastOmitted] = framesOmitted;
760+
if (
761+
typeof firstOmitted === "number" &&
762+
typeof lastOmitted === "number" &&
763+
Number.isFinite(firstOmitted) &&
764+
Number.isFinite(lastOmitted)
765+
) {
766+
return String(Math.max(0, lastOmitted - firstOmitted));
767+
}
768+
769+
return null;
770+
}
771+
612772
/**
613773
* Renders surrounding source code context for a stack frame.
614774
* Shows a window of code lines around the error line with visual indicators.
@@ -1882,6 +2042,33 @@ export function formatIssueOutput({
18822042
fallbackInstruction: "Issue event search is not available in this session",
18832043
});
18842044
output += `- Issue event search: ${issueEventSearchInstruction}\n`;
2045+
const hasMultipleThreads = event.entries?.some((entry) => {
2046+
if (entry.type !== "threads") {
2047+
return false;
2048+
}
2049+
const threadsData = parseThreadsEntryData(entry.data);
2050+
return Boolean(threadsData?.values && threadsData.values.length > 1);
2051+
});
2052+
if (hasMultipleThreads) {
2053+
const stacktraceInstruction = formatToolCallInstruction({
2054+
toolName: "get_event_stacktrace",
2055+
arguments: {
2056+
organizationSlug,
2057+
issueId: issue.shortId,
2058+
eventId: event.id,
2059+
thread: "thread name or numeric thread ID",
2060+
},
2061+
experimentalMode: experimentalMode ?? false,
2062+
availableToolNames,
2063+
directToolNames,
2064+
fallbackInstruction: "",
2065+
purpose:
2066+
"to fetch a full thread stacktrace by numeric Thread ID or exact thread Name. Omit `thread` to use Sentry's default selected thread",
2067+
});
2068+
if (stacktraceInstruction) {
2069+
output += `- Thread stacktrace lookup: ${stacktraceInstruction}\n`;
2070+
}
2071+
}
18852072
if (traceId) {
18862073
const traceDetailsInstruction = formatToolCallInstruction({
18872074
toolName: "get_sentry_resource",

packages/mcp-core/src/server.test.ts

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -835,6 +835,7 @@ describe("buildServer", () => {
835835
expect(toolNames).not.toContain("create_project");
836836
expect(toolNames).not.toContain("find_releases");
837837
expect(toolNames).not.toContain("get_event_attachment");
838+
expect(toolNames).not.toContain("get_event_stacktrace");
838839

839840
const result = await callRegisteredTool(server, "search_sentry_tools", {
840841
query: "create project",
@@ -1065,18 +1066,19 @@ describe("buildServer", () => {
10651066

10661067
const toolNames = getRegisteredToolNames(server);
10671068
expect(toolNames).not.toContain("get_issue_details");
1069+
expect(toolNames).not.toContain("get_event_stacktrace");
10681070

10691071
const result = await callRegisteredTool(server, "search_sentry_tools", {
1070-
query: "issue details",
1072+
query: "event stacktrace",
10711073
limit: 5,
10721074
});
10731075
const payload = getStructuredContent<{
10741076
results: Array<{ name: string }>;
10751077
}>(result);
10761078

1077-
expect(payload.results.map((tool) => tool.name)).toContain(
1078-
"get_issue_details",
1079-
);
1079+
const resultNames = payload.results.map((tool) => tool.name);
1080+
expect(resultNames).toContain("get_issue_details");
1081+
expect(resultNames).toContain("get_event_stacktrace");
10801082
});
10811083

10821084
it("search_sentry_tools includes whoami as a catalog-only foundational tool", async () => {
@@ -1257,6 +1259,27 @@ describe("buildServer", () => {
12571259
);
12581260
});
12591261

1262+
it("execute_sentry_tool dispatches to catalog-only event stacktrace", async () => {
1263+
const server = buildServer({
1264+
context: baseContext,
1265+
});
1266+
1267+
const toolNames = getRegisteredToolNames(server);
1268+
expect(toolNames).not.toContain("get_event_stacktrace");
1269+
1270+
const result = await callRegisteredTool(server, "execute_sentry_tool", {
1271+
name: "get_event_stacktrace",
1272+
arguments: {
1273+
organizationSlug: "sentry-mcp-evals",
1274+
issueId: "CLOUDFLARE-MCP-41",
1275+
},
1276+
});
1277+
1278+
expect(getTextContent(result)).toContain(
1279+
"# Event Stacktrace in **sentry-mcp-evals**",
1280+
);
1281+
});
1282+
12601283
it("execute_sentry_tool dispatches to catalog-only update_dsn", async () => {
12611284
const server = buildServer({
12621285
context: baseContext,

packages/mcp-core/src/skillDefinitions.json

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
"description": "Read-only access to core Sentry data: issues, events, traces, replays, releases, monitors, profiles, documentation, and project metadata",
66
"defaultEnabled": true,
77
"order": 1,
8-
"toolCount": 30,
8+
"toolCount": 31,
99
"tools": [
1010
{
1111
"name": "find_alert_rules",
@@ -67,6 +67,11 @@
6767
"description": "Download attachments from a Sentry event.\n\nUse this tool when you need to:\n- Download files attached to a specific event\n- Access screenshots, log files, or other attachments uploaded with an error report\n- Retrieve attachment metadata and download URLs\n\n<examples>\n### Download a specific attachment by ID\n\n```\nget_event_attachment(organizationSlug='my-organization', projectSlug='my-project', eventId='c49541c747cb4d8aa3efb70ca5aba243', attachmentId='12345')\n```\n\n### List all attachments for an event\n\n```\nget_event_attachment(organizationSlug='my-organization', projectSlug='my-project', eventId='c49541c747cb4d8aa3efb70ca5aba243')\n```\n\n</examples>\n\n<hints>\n- If `attachmentId` is provided, the specific attachment will be downloaded as an embedded resource\n- If `attachmentId` is omitted, all attachments for the event will be listed with download information\n- The `projectSlug` is required to identify which project the event belongs to\n</hints>",
6868
"requiredScopes": ["event:read"]
6969
},
70+
{
71+
"name": "get_event_stacktrace",
72+
"description": "Get a full thread stacktrace from a specific Sentry event.\n\nUse this tool when you need to:\n- Fetch the full stacktrace for a thread listed in issue details\n- Inspect a non-crashed thread from an event with multiple threads\n- Get Sentry's default selected thread stacktrace when no thread is specified\n\n<examples>\nget_event_stacktrace(organizationSlug='my-org', issueId='PROJECT-123')\nget_event_stacktrace(organizationSlug='my-org', issueId='PROJECT-123', eventId='abc123', thread=259)\nget_event_stacktrace(organizationSlug='my-org', issueId='PROJECT-123', thread='main')\n</examples>\n\n<hints>\n- `thread` is optional. If omitted, this returns the same default thread Sentry selects: first crashed thread, then first thread with a stacktrace, then first thread.\n- Pass `thread` as a numeric Thread ID or exact thread Name from the issue details thread list.\n- If the issue details show only one useful thread, omit `thread`.\n</hints>",
73+
"requiredScopes": ["event:read"]
74+
},
7075
{
7176
"name": "get_issue_activity",
7277
"description": "Get the activity feed and comments for a Sentry issue.\n\nUse this tool when you need to:\n- Review prior comments before triaging an issue\n- Understand who resolved, ignored, assigned, or commented on an issue\n- See recent issue activity that is not included in `get_issue_details`\n\n<examples>\nget_issue_activity(organizationSlug='my-organization', issueId='PROJECT-123')\nget_issue_activity(issueUrl='https://my-organization.sentry.io/issues/PROJECT-123/')\n</examples>",
@@ -165,7 +170,7 @@
165170
"description": "Sentry's AI debugger that helps you analyze, root cause, and fix issues",
166171
"defaultEnabled": true,
167172
"order": 2,
168-
"toolCount": 9,
173+
"toolCount": 10,
169174
"tools": [
170175
{
171176
"name": "analyze_issue_with_seer",
@@ -187,6 +192,11 @@
187192
"description": "Fetch all spans for an AI conversation by its gen_ai.conversation.id.\n\nA conversation is a set of spans sharing the same gen_ai.conversation.id. To discover conversation IDs, use search_events with dataset='spans' and query='has:gen_ai.conversation.id'.",
188193
"requiredScopes": ["event:read", "project:read"]
189194
},
195+
{
196+
"name": "get_event_stacktrace",
197+
"description": "Get a full thread stacktrace from a specific Sentry event.\n\nUse this tool when you need to:\n- Fetch the full stacktrace for a thread listed in issue details\n- Inspect a non-crashed thread from an event with multiple threads\n- Get Sentry's default selected thread stacktrace when no thread is specified\n\n<examples>\nget_event_stacktrace(organizationSlug='my-org', issueId='PROJECT-123')\nget_event_stacktrace(organizationSlug='my-org', issueId='PROJECT-123', eventId='abc123', thread=259)\nget_event_stacktrace(organizationSlug='my-org', issueId='PROJECT-123', thread='main')\n</examples>\n\n<hints>\n- `thread` is optional. If omitted, this returns the same default thread Sentry selects: first crashed thread, then first thread with a stacktrace, then first thread.\n- Pass `thread` as a numeric Thread ID or exact thread Name from the issue details thread list.\n- If the issue details show only one useful thread, omit `thread`.\n</hints>",
198+
"requiredScopes": ["event:read"]
199+
},
190200
{
191201
"name": "get_issue_details",
192202
"description": "Get detailed information about a specific Sentry issue by ID.\n\nUSE THIS TOOL WHEN USERS:\n- Provide a specific issue ID (e.g., 'CLOUDFLARE-MCP-41', 'PROJECT-123')\n- Ask to 'explain [ISSUE-ID]', 'tell me about [ISSUE-ID]'\n- Want details/stacktrace/analysis for a known issue\n- Provide a Sentry issue URL\n\nDO NOT USE for:\n- General searching or listing issues (use search_issues)\n\nTRIGGER PATTERNS:\n- 'Explain ISSUE-123' → use get_issue_details\n- 'Tell me about PROJECT-456' → use get_issue_details\n- 'What happened in [issue URL]' → use get_issue_details\n\n<examples>\n### With Sentry URL (recommended - simplest approach)\n```\nget_issue_details(issueUrl='https://sentry.sentry.io/issues/6916805731/?project=4509062593708032&query=is%3Aunresolved')\n```\n\n### With issue ID and organization\n```\nget_issue_details(organizationSlug='my-organization', issueId='CLOUDFLARE-MCP-41')\n```\n\n### With event ID and organization\n```\nget_issue_details(organizationSlug='my-organization', eventId='c49541c747cb4d8aa3efb70ca5aba243')\n```\n</examples>\n\n<hints>\n- **IMPORTANT**: If user provides a Sentry URL, pass the ENTIRE URL to issueUrl parameter unchanged\n- When using issueUrl, all other parameters are automatically extracted - don't provide them separately\n- If using issueId (not URL), then organizationSlug is required\n</hints>",
@@ -256,7 +266,7 @@
256266
"description": "Resolve, assign, and update issues",
257267
"defaultEnabled": false,
258268
"order": 4,
259-
"toolCount": 13,
269+
"toolCount": 14,
260270
"tools": [
261271
{
262272
"name": "add_issue_note",
@@ -283,6 +293,11 @@
283293
"description": "Fetch all spans for an AI conversation by its gen_ai.conversation.id.\n\nA conversation is a set of spans sharing the same gen_ai.conversation.id. To discover conversation IDs, use search_events with dataset='spans' and query='has:gen_ai.conversation.id'.",
284294
"requiredScopes": ["event:read", "project:read"]
285295
},
296+
{
297+
"name": "get_event_stacktrace",
298+
"description": "Get a full thread stacktrace from a specific Sentry event.\n\nUse this tool when you need to:\n- Fetch the full stacktrace for a thread listed in issue details\n- Inspect a non-crashed thread from an event with multiple threads\n- Get Sentry's default selected thread stacktrace when no thread is specified\n\n<examples>\nget_event_stacktrace(organizationSlug='my-org', issueId='PROJECT-123')\nget_event_stacktrace(organizationSlug='my-org', issueId='PROJECT-123', eventId='abc123', thread=259)\nget_event_stacktrace(organizationSlug='my-org', issueId='PROJECT-123', thread='main')\n</examples>\n\n<hints>\n- `thread` is optional. If omitted, this returns the same default thread Sentry selects: first crashed thread, then first thread with a stacktrace, then first thread.\n- Pass `thread` as a numeric Thread ID or exact thread Name from the issue details thread list.\n- If the issue details show only one useful thread, omit `thread`.\n</hints>",
299+
"requiredScopes": ["event:read"]
300+
},
286301
{
287302
"name": "get_issue_activity",
288303
"description": "Get the activity feed and comments for a Sentry issue.\n\nUse this tool when you need to:\n- Review prior comments before triaging an issue\n- Understand who resolved, ignored, assigned, or commented on an issue\n- See recent issue activity that is not included in `get_issue_details`\n\n<examples>\nget_issue_activity(organizationSlug='my-organization', issueId='PROJECT-123')\nget_issue_activity(issueUrl='https://my-organization.sentry.io/issues/PROJECT-123/')\n</examples>",

0 commit comments

Comments
 (0)