From ef540a6eefd42283f9799d7ad58794a581a4bcca Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Fri, 6 Nov 2026 01:09:58 +0000 Subject: [PATCH 1/2] feat(mcp): Return structured output from get_event_stacktrace get_event_stacktrace now declares outputSchema and returns the selected thread with the same frame and thread shapes as get_issue_details. `selection.status` says how the thread selector resolved; when it matches no thread or several, `availableThreads` lists every thread summary instead. Held locks, registers, frame addresses, symbols, source links and the raw stacktrace stay out. The markdown thread formatters are removed. Co-Authored-By: David Cramer <23610+dcramer@users.noreply.github.com> --- docs/contributing/tool-responses.md | 8 + packages/mcp-core/src/internal/formatting.ts | 98 ----- packages/mcp-core/src/server.metadata.test.ts | 2 - packages/mcp-core/src/server.test.ts | 7 +- packages/mcp-core/src/skillDefinitions.json | 6 +- packages/mcp-core/src/toolDefinitions.json | 245 +++++++++++- .../catalog/get-event-stacktrace.test.ts | 366 +++++++++++++++--- .../src/tools/catalog/get-event-stacktrace.ts | 24 +- .../src/tools/support/event-stacktrace.ts | 98 +++-- 9 files changed, 657 insertions(+), 197 deletions(-) diff --git a/docs/contributing/tool-responses.md b/docs/contributing/tool-responses.md index 6ae680b50..0fb41bf2c 100644 --- a/docs/contributing/tool-responses.md +++ b/docs/contributing/tool-responses.md @@ -163,6 +163,14 @@ frame (the last in-app frame of the raised exception or the thread) gets to 200 characters of JSON. Request headers, cookies and body, frame addresses, symbols and source links, tag search queries, and external issue link ids stay out. +### Event Stacktrace + +`get_event_stacktrace` returns one thread under `thread`, using the same frame +and thread shapes as `get_issue_details`, with `selection.status` saying how the +`thread` selector resolved. When the selector matches no thread or several, +`thread` is null and `availableThreads` lists every thread summary. Held locks, +registers and the raw (unsymbolicated) stacktrace stay out. + ### Profile Details `get_profile_details` returns one profile summary under `profile`, diff --git a/packages/mcp-core/src/internal/formatting.ts b/packages/mcp-core/src/internal/formatting.ts index 388cfe022..7829f8a2e 100644 --- a/packages/mcp-core/src/internal/formatting.ts +++ b/packages/mcp-core/src/internal/formatting.ts @@ -698,18 +698,6 @@ function formatThreadTable( ]; } -export function formatAvailableThreadList( - threads: z.infer[], -): string { - return [ - "## Available Threads", - "", - ...formatThreadTable(threads), - "", - "Pass `thread` as a numeric Thread ID or exact thread Name.", - ].join("\n"); -} - export function parseThreadsEntryData( data: unknown, ): z.infer | undefined { @@ -717,92 +705,6 @@ export function parseThreadsEntryData( return result.success ? result.data : undefined; } -/** - * Formats the selected thread stacktrace using the same frame rendering - * conventions as issue event details. - */ -export function formatThreadStacktraceOutput({ - event, - thread, - selectionReason, -}: { - event: Event; - thread: z.infer; - selectionReason: string; -}): string { - const parts: string[] = []; - - parts.push("## Selected Thread"); - parts.push(""); - parts.push(`**Selection**: ${selectionReason}`); - parts.push(`**Thread ID**: ${getThreadDisplayValue(thread.id)}`); - parts.push(`**Name**: ${getThreadDisplayValue(thread.name)}`); - parts.push(`**State**: ${getThreadDisplayValue(thread.state)}`); - parts.push(`**Crashed**: ${getThreadDisplayValue(thread.crashed)}`); - parts.push(`**Current**: ${getThreadDisplayValue(thread.current)}`); - parts.push(""); - - const frames = thread.stacktrace?.frames; - if (!frames || frames.length === 0) { - parts.push("No stacktrace is available for the selected thread."); - parts.push(""); - return parts.join("\n"); - } - - parts.push("## Stacktrace"); - parts.push(""); - const framesOmitted = formatFramesOmitted(thread.stacktrace?.framesOmitted); - if (framesOmitted) { - parts.push(`**Frames Omitted**: ${framesOmitted}`); - parts.push(""); - } - - const relevantFrame = findMostRelevantInAppFrame(frames); - if (relevantFrame && (relevantFrame.context?.length || relevantFrame.vars)) { - parts.push(renderEnhancedFrame(relevantFrame, event)); - parts.push(""); - parts.push("**Full Stacktrace:**"); - parts.push("────────────────"); - } else { - parts.push("**Full Stacktrace:**"); - } - - parts.push( - codeBlock( - frames - .map((frame) => { - const header = formatFrameHeader(frame, undefined, event.platform); - const context = renderInlineContext(frame); - return `${header}${context}`; - }) - .join("\n"), - ), - ); - parts.push(""); - - return parts.join("\n"); -} - -function formatFramesOmitted( - framesOmitted: unknown[] | null | undefined, -): string | null { - if (!framesOmitted?.length) { - return null; - } - - const [firstOmitted, lastOmitted] = framesOmitted; - if ( - typeof firstOmitted === "number" && - typeof lastOmitted === "number" && - Number.isFinite(firstOmitted) && - Number.isFinite(lastOmitted) - ) { - return String(Math.max(0, lastOmitted - firstOmitted)); - } - - return null; -} - /** * Renders surrounding source code context for a stack frame. * Shows a window of code lines around the error line with visual indicators. diff --git a/packages/mcp-core/src/server.metadata.test.ts b/packages/mcp-core/src/server.metadata.test.ts index 641d3e332..006e10afe 100644 --- a/packages/mcp-core/src/server.metadata.test.ts +++ b/packages/mcp-core/src/server.metadata.test.ts @@ -40,8 +40,6 @@ const ALLOWED_TOOL_META: Record = { const OUTPUT_SCHEMA_EXEMPT: Record = { get_trace_details: "markdown-only by policy (docs/contributing/tool-responses.md)", - get_event_stacktrace: - "markdown-only by policy (docs/contributing/tool-responses.md)", get_span_details: "delegates to the get_trace_details renderer", get_doc: "returns doc text", get_snapshot_image: "returns an image", diff --git a/packages/mcp-core/src/server.test.ts b/packages/mcp-core/src/server.test.ts index f621b57d2..0e8ff4915 100644 --- a/packages/mcp-core/src/server.test.ts +++ b/packages/mcp-core/src/server.test.ts @@ -2152,9 +2152,10 @@ describe("buildServer", () => { }, }); - expect(getTextContent(result)).toContain( - "# Event Stacktrace in **sentry-mcp-evals**", - ); + expect(result.structuredContent).toMatchObject({ + organizationSlug: "sentry-mcp-evals", + issueId: "CLOUDFLARE-MCP-41", + }); }); it("execute_sentry_tool dispatches to catalog-only issue user reports", async () => { diff --git a/packages/mcp-core/src/skillDefinitions.json b/packages/mcp-core/src/skillDefinitions.json index 5871078d2..e703643a1 100644 --- a/packages/mcp-core/src/skillDefinitions.json +++ b/packages/mcp-core/src/skillDefinitions.json @@ -89,7 +89,7 @@ }, { "name": "get_event_stacktrace", - "description": "Get the full stack trace (stacktrace) for one thread of 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\nget_event_stacktrace(organizationSlug='my-org', issueId='PROJECT-123')\nget_event_stacktrace(organizationSlug='my-org', issueId='PROJECT-123', eventId='c49541c747cb4d8aa3efb70ca5aba243', thread=259)\nget_event_stacktrace(organizationSlug='my-org', issueId='PROJECT-123', thread='main')\n\n\n\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", + "description": "Get the full stack trace (stacktrace) for one thread of 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\nget_event_stacktrace(organizationSlug='my-org', issueId='PROJECT-123')\nget_event_stacktrace(organizationSlug='my-org', issueId='PROJECT-123', eventId='c49541c747cb4d8aa3efb70ca5aba243', thread=259)\nget_event_stacktrace(organizationSlug='my-org', issueId='PROJECT-123', thread='main')\n\n\n\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- When the selector matches no thread or several, `thread` is null and `availableThreads` lists every thread.\n", "requiredScopes": ["event:read"] }, { @@ -279,7 +279,7 @@ }, { "name": "get_event_stacktrace", - "description": "Get the full stack trace (stacktrace) for one thread of 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\nget_event_stacktrace(organizationSlug='my-org', issueId='PROJECT-123')\nget_event_stacktrace(organizationSlug='my-org', issueId='PROJECT-123', eventId='c49541c747cb4d8aa3efb70ca5aba243', thread=259)\nget_event_stacktrace(organizationSlug='my-org', issueId='PROJECT-123', thread='main')\n\n\n\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", + "description": "Get the full stack trace (stacktrace) for one thread of 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\nget_event_stacktrace(organizationSlug='my-org', issueId='PROJECT-123')\nget_event_stacktrace(organizationSlug='my-org', issueId='PROJECT-123', eventId='c49541c747cb4d8aa3efb70ca5aba243', thread=259)\nget_event_stacktrace(organizationSlug='my-org', issueId='PROJECT-123', thread='main')\n\n\n\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- When the selector matches no thread or several, `thread` is null and `availableThreads` lists every thread.\n", "requiredScopes": ["event:read"] }, { @@ -430,7 +430,7 @@ }, { "name": "get_event_stacktrace", - "description": "Get the full stack trace (stacktrace) for one thread of 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\nget_event_stacktrace(organizationSlug='my-org', issueId='PROJECT-123')\nget_event_stacktrace(organizationSlug='my-org', issueId='PROJECT-123', eventId='c49541c747cb4d8aa3efb70ca5aba243', thread=259)\nget_event_stacktrace(organizationSlug='my-org', issueId='PROJECT-123', thread='main')\n\n\n\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", + "description": "Get the full stack trace (stacktrace) for one thread of 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\nget_event_stacktrace(organizationSlug='my-org', issueId='PROJECT-123')\nget_event_stacktrace(organizationSlug='my-org', issueId='PROJECT-123', eventId='c49541c747cb4d8aa3efb70ca5aba243', thread=259)\nget_event_stacktrace(organizationSlug='my-org', issueId='PROJECT-123', thread='main')\n\n\n\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- When the selector matches no thread or several, `thread` is null and `availableThreads` lists every thread.\n", "requiredScopes": ["event:read"] }, { diff --git a/packages/mcp-core/src/toolDefinitions.json b/packages/mcp-core/src/toolDefinitions.json index 4d81b7f8c..cac308a8a 100644 --- a/packages/mcp-core/src/toolDefinitions.json +++ b/packages/mcp-core/src/toolDefinitions.json @@ -5858,7 +5858,7 @@ { "name": "get_event_stacktrace", "title": "Get Event Stacktrace", - "description": "Get the full stack trace (stacktrace) for one thread of 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\nget_event_stacktrace(organizationSlug='my-org', issueId='PROJECT-123')\nget_event_stacktrace(organizationSlug='my-org', issueId='PROJECT-123', eventId='c49541c747cb4d8aa3efb70ca5aba243', thread=259)\nget_event_stacktrace(organizationSlug='my-org', issueId='PROJECT-123', thread='main')\n\n\n\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", + "description": "Get the full stack trace (stacktrace) for one thread of 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\nget_event_stacktrace(organizationSlug='my-org', issueId='PROJECT-123')\nget_event_stacktrace(organizationSlug='my-org', issueId='PROJECT-123', eventId='c49541c747cb4d8aa3efb70ca5aba243', thread=259)\nget_event_stacktrace(organizationSlug='my-org', issueId='PROJECT-123', thread='main')\n\n\n\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- When the selector matches no thread or several, `thread` is null and `availableThreads` lists every thread.\n", "inputSchema": { "type": "object", "properties": { @@ -5918,6 +5918,249 @@ }, "required": ["organizationSlug", "issueId"] }, + "outputSchema": { + "type": "object", + "properties": { + "organizationSlug": { + "type": "string" + }, + "issueId": { + "type": "string" + }, + "eventId": { + "type": "string" + }, + "platform": { + "description": "The event platform, which sets how to read frames.", + "type": ["string", "null"] + }, + "threadCount": { + "type": "number" + }, + "selection": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": ["selected", "not_found", "ambiguous", "no_threads"], + "description": "`selected` returns `thread`. Otherwise `thread` is null and `message` says why." + }, + "message": { + "type": "string" + } + }, + "required": ["status", "message"], + "additionalProperties": false + }, + "thread": { + "anyOf": [ + { + "type": "object", + "properties": { + "id": { + "type": ["number", "string", "null"] + }, + "name": { + "type": ["string", "null"] + }, + "state": { + "type": ["string", "null"] + }, + "crashed": { + "type": "boolean" + }, + "current": { + "type": "boolean" + }, + "frameCount": { + "type": "number" + }, + "stacktrace": { + "anyOf": [ + { + "type": "object", + "properties": { + "frames": { + "type": "array", + "items": { + "type": "object", + "properties": { + "filename": { + "type": ["string", "null"] + }, + "absPath": { + "type": ["string", "null"] + }, + "module": { + "type": ["string", "null"] + }, + "package": { + "type": ["string", "null"] + }, + "function": { + "type": ["string", "null"] + }, + "lineNo": { + "type": ["number", "null"] + }, + "colNo": { + "type": ["number", "null"] + }, + "inApp": { + "type": ["boolean", "null"] + }, + "contextLine": { + "description": "The source line at lineNo.", + "type": ["string", "null"] + }, + "preContext": { + "anyOf": [ + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "null" + } + ], + "description": "Up to 3 source lines before lineNo, for in-app frames only." + }, + "postContext": { + "anyOf": [ + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "null" + } + ], + "description": "Up to 3 source lines after lineNo, for in-app frames only." + }, + "vars": { + "anyOf": [ + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + { + "type": "null" + } + ], + "description": "Local variables, recorded for in-app frames only." + } + }, + "required": [ + "filename", + "absPath", + "module", + "package", + "function", + "lineNo", + "colNo", + "inApp", + "contextLine", + "preContext", + "postContext", + "vars" + ], + "additionalProperties": false + }, + "description": "Oldest call first; the frame that raised is last." + }, + "framesOmitted": { + "type": ["number", "null"] + } + }, + "required": ["frames", "framesOmitted"], + "additionalProperties": false + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "id", + "name", + "state", + "crashed", + "current", + "frameCount", + "stacktrace" + ], + "additionalProperties": false + }, + { + "type": "null" + } + ], + "description": "The selected thread and its stacktrace." + }, + "availableThreads": { + "anyOf": [ + { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": ["number", "string", "null"] + }, + "name": { + "type": ["string", "null"] + }, + "state": { + "type": ["string", "null"] + }, + "crashed": { + "type": "boolean" + }, + "current": { + "type": "boolean" + }, + "frameCount": { + "type": "number" + } + }, + "required": [ + "id", + "name", + "state", + "crashed", + "current", + "frameCount" + ], + "additionalProperties": false + } + }, + { + "type": "null" + } + ], + "description": "Every thread on the event, listed only when the `thread` selector matched none or several." + } + }, + "required": [ + "organizationSlug", + "issueId", + "eventId", + "platform", + "threadCount", + "selection", + "thread", + "availableThreads" + ], + "additionalProperties": false + }, "requiredScopes": ["event:read"], "skills": ["inspect", "triage", "seer"], "surface": "direct" diff --git a/packages/mcp-core/src/tools/catalog/get-event-stacktrace.test.ts b/packages/mcp-core/src/tools/catalog/get-event-stacktrace.test.ts index 513ca560f..becca617a 100644 --- a/packages/mcp-core/src/tools/catalog/get-event-stacktrace.test.ts +++ b/packages/mcp-core/src/tools/catalog/get-event-stacktrace.test.ts @@ -2,6 +2,10 @@ import { createDefaultEvent, mswServer } from "@sentry/mcp-server-mocks"; import { http, HttpResponse } from "msw"; import { describe, expect, it } from "vitest"; import { getServerContext } from "../../test-setup.js"; +import { + type EventStacktracePayload, + eventStacktraceOutputSchema, +} from "../support/event-stacktrace.js"; import getEventStacktrace from "./get-event-stacktrace.js"; const testIssue = { @@ -60,17 +64,44 @@ const eventWithThreads = createDefaultEvent({ frames: [ { filename: "Thread.java", + absPath: "Thread.java", module: "java.lang.Thread", + package: null, + platform: null, + instructionAddr: null, + symbolAddr: null, function: "run", + rawFunction: null, + symbol: null, + context: [], lineNo: 833, + colNo: null, inApp: false, + trust: null, + errors: null, + lock: null, + sourceLink: null, + vars: null, }, { filename: "CheckoutActivity.java", + absPath: "CheckoutActivity.java", module: "com.example.CheckoutActivity", + package: null, + platform: null, + instructionAddr: null, + symbolAddr: null, function: "submitOrder", + rawFunction: null, + symbol: null, lineNo: 42, + colNo: null, inApp: true, + trust: null, + errors: null, + lock: null, + sourceLink: null, + vars: { userEmail: "jane@example.com", cartSize: 3 }, context: [ [40, " Order order = buildOrder();"], [41, " if (order == null) {"], @@ -109,10 +140,10 @@ function setupThreadMocks(event = eventWithThreads) { ); } -function callHandler( +async function callHandler( overrides: Partial[0]> = {}, -) { - return getEventStacktrace.handler( +): Promise { + const result = await getEventStacktrace.handler( { organizationSlug: "sentry-mcp-evals", issueId: "THREADS-1", @@ -123,6 +154,7 @@ function callHandler( }, getServerContext(), ); + return eventStacktraceOutputSchema.parse(result.structuredContent); } describe("get_event_stacktrace", () => { @@ -132,39 +164,67 @@ describe("get_event_stacktrace", () => { const result = await callHandler(); expect(result).toMatchInlineSnapshot(` - "# Event Stacktrace in **sentry-mcp-evals** - - **Issue ID**: THREADS-1 - **Event ID**: event-with-threads - - ## Selected Thread - - **Selection**: Sentry default selection: first crashed thread, then first thread with a stacktrace, then first thread. - **Thread ID**: 259 - **Name**: main - **State**: RUNNABLE - **Crashed**: true - **Current**: true - - ## Stacktrace - - **Most Relevant Frame:** - ───────────────────── - at com.example.CheckoutActivity.submitOrder(CheckoutActivity.java:42) - - 40 │ Order order = buildOrder(); - 41 │ if (order == null) { - → 42 │ throw new IllegalStateException(); - 43 │ } - - **Full Stacktrace:** - ──────────────── - \`\`\` - at java.lang.Thread.run(Thread.java:833) - at com.example.CheckoutActivity.submitOrder(CheckoutActivity.java:42) - throw new IllegalStateException(); - \`\`\` - " + { + "availableThreads": null, + "eventId": "event-with-threads", + "issueId": "THREADS-1", + "organizationSlug": "sentry-mcp-evals", + "platform": "java", + "selection": { + "message": "Sentry default selection: first crashed thread, then first thread with a stacktrace, then first thread.", + "status": "selected", + }, + "thread": { + "crashed": true, + "current": true, + "frameCount": 2, + "id": 259, + "name": "main", + "stacktrace": { + "frames": [ + { + "absPath": "Thread.java", + "colNo": null, + "contextLine": null, + "filename": "Thread.java", + "function": "run", + "inApp": false, + "lineNo": 833, + "module": "java.lang.Thread", + "package": null, + "postContext": null, + "preContext": null, + "vars": null, + }, + { + "absPath": "CheckoutActivity.java", + "colNo": null, + "contextLine": " throw new IllegalStateException();", + "filename": "CheckoutActivity.java", + "function": "submitOrder", + "inApp": true, + "lineNo": 42, + "module": "com.example.CheckoutActivity", + "package": null, + "postContext": [ + " }", + ], + "preContext": [ + " Order order = buildOrder();", + " if (order == null) {", + ], + "vars": { + "cartSize": 3, + "userEmail": "jane@example.com", + }, + }, + ], + "framesOmitted": null, + }, + "state": "RUNNABLE", + }, + "threadCount": 3, + } `); }); @@ -173,11 +233,20 @@ describe("get_event_stacktrace", () => { const result = await callHandler({ thread: 11 }); - expect(result).toContain("**Thread ID**: 11"); - expect(result).toContain("**Frames Omitted**: 500"); - expect(result).toContain( - "at com.example.Worker.waitForJob(Worker.java:12)", - ); + expect(result.selection).toEqual({ + status: "selected", + message: "Matched the provided thread selector.", + }); + expect(result.thread?.id).toBe(11); + expect(result.thread?.stacktrace?.framesOmitted).toBe(500); + expect(result.thread?.stacktrace?.frames).toEqual([ + expect.objectContaining({ + module: "com.example.Worker", + function: "waitForJob", + filename: "Worker.java", + lineNo: 12, + }), + ]); }); it("handles nullable stacktrace metadata from Sentry", async () => { @@ -216,8 +285,13 @@ describe("get_event_stacktrace", () => { const result = await callHandler(); - expect(result).toContain("**Thread ID**: 20"); - expect(result).toContain("at com.example.Worker.run(Worker.java:42)"); + expect(result.thread?.id).toBe(20); + expect(result.thread?.stacktrace).toEqual({ + frames: [ + expect.objectContaining({ function: "run", lineNo: 42, inApp: null }), + ], + framesOmitted: null, + }); }); it("falls back to the first thread with frames when no thread crashed", async () => { @@ -262,9 +336,12 @@ describe("get_event_stacktrace", () => { const result = await callHandler(); - expect(result).toContain("**Thread ID**: 20"); - expect(result).toContain("**Name**: usable"); - expect(result).toContain("at com.example.Worker.run(Worker.java:42)"); + expect(result.thread).toMatchObject({ id: 20, name: "usable" }); + expect(result.thread?.stacktrace?.frames[0]).toMatchObject({ + module: "com.example.Worker", + function: "run", + lineNo: 42, + }); }); it("selects a thread by exact name", async () => { @@ -272,8 +349,7 @@ describe("get_event_stacktrace", () => { const result = await callHandler({ thread: "main" }); - expect(result).toContain("**Name**: main"); - expect(result).toContain("**Thread ID**: 259"); + expect(result.thread).toMatchObject({ id: 259, name: "main" }); }); it("reports available threads when the selector does not match", async () => { @@ -282,22 +358,192 @@ describe("get_event_stacktrace", () => { const result = await callHandler({ thread: "missing" }); expect(result).toMatchInlineSnapshot(` - "# Event Stacktrace in **sentry-mcp-evals** + { + "availableThreads": [ + { + "crashed": false, + "current": false, + "frameCount": 1, + "id": 11, + "name": "worker", + "state": "WAITING", + }, + { + "crashed": true, + "current": true, + "frameCount": 2, + "id": 259, + "name": "main", + "state": "RUNNABLE", + }, + { + "crashed": false, + "current": false, + "frameCount": 0, + "id": 300, + "name": "no-stack", + "state": "SLEEPING", + }, + ], + "eventId": "event-with-threads", + "issueId": "THREADS-1", + "organizationSlug": "sentry-mcp-evals", + "platform": "java", + "selection": { + "message": "No thread found with name "missing". Pass \`thread\` as a numeric thread id or exact thread name.", + "status": "not_found", + }, + "thread": null, + "threadCount": 3, + } + `); + }); - **Issue ID**: THREADS-1 - **Event ID**: event-with-threads + it("reports ambiguous thread names", async () => { + setupThreadMocks( + createDefaultEvent({ + id: "event-with-duplicate-names", + platform: "python", + entries: [ + { + type: "threads", + data: { + values: [ + { id: 1, name: "worker", crashed: false, current: false }, + { id: 2, name: "worker", crashed: true, current: true }, + ], + }, + }, + ], + }), + ); - No thread found with name "missing". + const result = await callHandler({ thread: "worker" }); - ## Available Threads + expect(result.selection).toEqual({ + status: "ambiguous", + message: + 'Multiple threads matched name "worker". Pass `thread` as a numeric thread id or exact thread name.', + }); + expect(result.thread).toBeNull(); + expect(result.availableThreads?.map((thread) => thread.id)).toEqual([1, 2]); + }); - | Thread ID | Name | State | Flags | Frames | - | --- | --- | --- | --- | ---: | - | 11 | worker | WAITING | - | 1 | - | 259 | main | RUNNABLE | crashed, current | 2 | - | 300 | no-stack | SLEEPING | - | 0 | + it("reports events without threads", async () => { + setupThreadMocks( + createDefaultEvent({ id: "event-without-threads", entries: [] }), + ); - Pass \`thread\` as a numeric Thread ID or exact thread Name." - `); + const result = await callHandler(); + + expect(result).toMatchObject({ + eventId: "event-without-threads", + threadCount: 0, + selection: { status: "no_threads" }, + thread: null, + availableThreads: null, + }); + }); + + it("keeps raw addresses, symbols, locks and raw stacktraces out", async () => { + setupThreadMocks( + createDefaultEvent({ + id: "native-event", + platform: "native", + entries: [ + { + type: "threads", + data: { + values: [ + { + id: 1, + name: "", + state: null, + crashed: true, + current: true, + heldLocks: { + "0x0d3a2f0a": { + type: 8, + address: "0x0d3a2f0a", + package_name: "java.lang", + class_name: "Object", + thread_id: 11, + }, + }, + stacktrace: { + frames: [ + { + filename: "", + absPath: "/usr/src/app/crash.cpp", + module: null, + package: "/usr/lib/libapp.so", + platform: "native", + instructionAddr: "0x7f1c2b3a4d5e", + symbolAddr: "0x7f1c2b3a4d00", + function: "crash_handler", + rawFunction: "crash_handler(int)", + symbol: "_Z13crash_handleri", + context: [[17, " *ptr = 0;"]], + lineNo: 17, + colNo: 5, + inApp: true, + trust: "context", + errors: null, + lock: null, + sourceLink: + "https://github.com/acme/app/blob/abc123/crash.cpp#L17", + vars: {}, + }, + ], + framesOmitted: null, + registers: { rip: "0x7f1c2b3a4d5e", rsp: "0x7ffd5c0e8a10" }, + hasSystemFrames: false, + }, + rawStacktrace: { + frames: [{ instructionAddr: "0x7f1c2b3a4d5e" }], + framesOmitted: null, + registers: null, + hasSystemFrames: false, + }, + }, + ], + }, + }, + ], + }), + ); + + const result = await callHandler(); + const serialized = JSON.stringify(result); + + expect(result.thread?.name).toBeNull(); + expect(result.thread?.stacktrace?.frames[0]).toEqual({ + filename: null, + absPath: "/usr/src/app/crash.cpp", + module: null, + package: "/usr/lib/libapp.so", + function: "crash_handler", + lineNo: 17, + colNo: 5, + inApp: true, + contextLine: " *ptr = 0;", + preContext: null, + postContext: null, + vars: null, + }); + for (const leaked of [ + "0x7f1c2b3a4d5e", + "0x7f1c2b3a4d00", + "_Z13crash_handleri", + "0x0d3a2f0a", + "0x7ffd5c0e8a10", + "github.com/acme", + "rawStacktrace", + "heldLocks", + "registers", + "trust", + ]) { + expect(serialized).not.toContain(leaked); + } }); }); diff --git a/packages/mcp-core/src/tools/catalog/get-event-stacktrace.ts b/packages/mcp-core/src/tools/catalog/get-event-stacktrace.ts index d86d4a110..cc8f4c8ef 100644 --- a/packages/mcp-core/src/tools/catalog/get-event-stacktrace.ts +++ b/packages/mcp-core/src/tools/catalog/get-event-stacktrace.ts @@ -4,6 +4,7 @@ import { apiServiceFromContext } from "../../internal/tool-helpers/api"; import { defineTool } from "../../internal/tool-helpers/define"; import { enhanceNotFoundError } from "../../internal/tool-helpers/enhance-error"; import { ensureIssueWithinProjectConstraint } from "../../internal/tool-helpers/issue"; +import { structuredResult } from "../../internal/tool-helpers/results"; import { ParamEventIdOrLatest, ParamIssueShortId, @@ -12,7 +13,10 @@ import { } from "../../schema"; import { setTargetTagsAndAttributes } from "../../telem/scope"; import type { ServerContext } from "../../types"; -import { fetchAndFormatEventStacktrace } from "../support/event-stacktrace"; +import { + eventStacktraceOutputSchema, + fetchEventStacktrace, +} from "../support/event-stacktrace"; export default defineTool({ name: "get_event_stacktrace", @@ -37,6 +41,7 @@ export default defineTool({ "- `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.", "- Pass `thread` as a numeric Thread ID or exact thread Name from the issue details thread list.", "- If the issue details show only one useful thread, omit `thread`.", + "- When the selector matches no thread or several, `thread` is null and `availableThreads` lists every thread.", "", ].join("\n"), inputSchema: { @@ -51,6 +56,7 @@ export default defineTool({ "Optional thread selector. Pass a numeric thread ID, or an exact thread name string. If omitted, returns the same default thread Sentry selects: first crashed thread, then first thread with a stacktrace, then first thread.", ), }, + outputSchema: eventStacktraceOutputSchema, annotations: { readOnlyHint: true, destructiveHint: false, @@ -71,13 +77,15 @@ export default defineTool({ projectSlug: context.constraints.projectSlug, }); - return await fetchAndFormatEventStacktrace({ - apiService, - organizationSlug: params.organizationSlug, - issueId: params.issueId, - eventId: params.eventId, - thread: params.thread, - }); + return structuredResult( + await fetchEventStacktrace({ + apiService, + organizationSlug: params.organizationSlug, + issueId: params.issueId, + eventId: params.eventId, + thread: params.thread, + }), + ); } catch (error) { if (error instanceof ApiNotFoundError) { throw enhanceNotFoundError(error, { diff --git a/packages/mcp-core/src/tools/support/event-stacktrace.ts b/packages/mcp-core/src/tools/support/event-stacktrace.ts index f54fdf503..424c5554f 100644 --- a/packages/mcp-core/src/tools/support/event-stacktrace.ts +++ b/packages/mcp-core/src/tools/support/event-stacktrace.ts @@ -1,11 +1,14 @@ -import type { z } from "zod"; +import { z } from "zod"; import { ThreadsEntrySchema } from "../../api-client"; import type { SentryApiService, ThreadEntrySchema } from "../../api-client"; import type { Event } from "../../api-client/types"; import { - formatAvailableThreadList, - formatThreadStacktraceOutput, -} from "../../internal/formatting"; + mapStructuredThread, + mapStructuredThreadSummary, + nonEmptyString, + structuredThreadSchema, + structuredThreadSummarySchema, +} from "./structured-stacktrace"; type Thread = z.infer; type ThreadSelector = string | number | undefined; @@ -21,10 +24,45 @@ type SelectedThread = message: string; }; +const SELECTOR_HINT = + "Pass `thread` as a numeric thread id or exact thread name."; + +export const eventStacktraceOutputSchema = z.object({ + organizationSlug: z.string(), + issueId: z.string(), + eventId: z.string(), + platform: z + .string() + .nullable() + .describe("The event platform, which sets how to read frames."), + threadCount: z.number(), + selection: z.object({ + status: z + .enum(["selected", "not_found", "ambiguous", "no_threads"]) + .describe( + "`selected` returns `thread`. Otherwise `thread` is null and `message` says why.", + ), + message: z.string(), + }), + thread: structuredThreadSchema + .nullable() + .describe("The selected thread and its stacktrace."), + availableThreads: z + .array(structuredThreadSummarySchema) + .nullable() + .describe( + "Every thread on the event, listed only when the `thread` selector matched none or several.", + ), +}); + +export type EventStacktracePayload = z.output< + typeof eventStacktraceOutputSchema +>; + /** - * Fetches an issue event and formats the selected thread stacktrace. + * Fetches an issue event and maps the selected thread stacktrace. */ -export async function fetchAndFormatEventStacktrace({ +export async function fetchEventStacktrace({ apiService, organizationSlug, issueId, @@ -36,7 +74,7 @@ export async function fetchAndFormatEventStacktrace({ issueId: string; eventId: string; thread?: string | number; -}): Promise { +}): Promise { const event = await apiService.getEventForIssue({ organizationSlug, issueId, @@ -44,30 +82,46 @@ export async function fetchAndFormatEventStacktrace({ }); const threads = getThreads(event); - - let output = `# Event Stacktrace in **${organizationSlug}**\n\n`; - output += `**Issue ID**: ${issueId}\n`; - output += `**Event ID**: ${event.id}\n\n`; + const base = { + organizationSlug, + issueId, + eventId: event.id, + platform: nonEmptyString(event.platform), + threadCount: threads.length, + }; if (threads.length === 0) { - output += "No thread stacktraces were found for this event.\n"; - return output; + return { + ...base, + selection: { + status: "no_threads", + message: "No thread stacktraces were found for this event.", + }, + thread: null, + availableThreads: null, + }; } const selected = selectThread(threads, thread); if (selected.kind !== "selected") { - output += `${selected.message}\n\n`; - output += formatAvailableThreadList(threads); - return output; + return { + ...base, + selection: { + status: selected.kind, + message: `${selected.message} ${SELECTOR_HINT}`, + }, + thread: null, + availableThreads: threads.map(mapStructuredThreadSummary), + }; } - output += formatThreadStacktraceOutput({ - event, - thread: selected.thread, - selectionReason: selected.reason, - }); - return output; + return { + ...base, + selection: { status: "selected", message: selected.reason }, + thread: mapStructuredThread(selected.thread), + availableThreads: null, + }; } function getThreads(event: Event): Thread[] { From de0b61f8c54f60d451d1a1c5b2feb007e7a49207 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Fri, 6 Nov 2026 02:04:14 +0000 Subject: [PATCH 2/2] feat(mcp): Leave the selection message out when a thread is selected selection.message is now null when a thread was selected and says why only when the selector matched none or several. The default-thread snapshot gains a library frame with source context and locals, which keeps only its own line now that context and variables go to the most relevant in-app frame alone. Co-Authored-By: David Cramer <23610+dcramer@users.noreply.github.com> --- docs/contributing/tool-responses.md | 4 +- packages/mcp-core/src/toolDefinitions.json | 93 +++++++------------ .../catalog/get-event-stacktrace.test.ts | 72 +++++++++----- .../src/tools/support/event-stacktrace.ts | 21 ++--- 4 files changed, 92 insertions(+), 98 deletions(-) diff --git a/docs/contributing/tool-responses.md b/docs/contributing/tool-responses.md index 0fb41bf2c..d749dbe88 100644 --- a/docs/contributing/tool-responses.md +++ b/docs/contributing/tool-responses.md @@ -168,7 +168,9 @@ and source links, tag search queries, and external issue link ids stay out. `get_event_stacktrace` returns one thread under `thread`, using the same frame and thread shapes as `get_issue_details`, with `selection.status` saying how the `thread` selector resolved. When the selector matches no thread or several, -`thread` is null and `availableThreads` lists every thread summary. Held locks, +`thread` is null, `selection.message` says why, and `availableThreads` lists +every thread summary. Only the thread's most relevant frame (its last in-app +frame) gets pre/post context and variables. Held locks, registers and the raw (unsymbolicated) stacktrace stay out. ### Profile Details diff --git a/packages/mcp-core/src/toolDefinitions.json b/packages/mcp-core/src/toolDefinitions.json index cac308a8a..89b2f51e4 100644 --- a/packages/mcp-core/src/toolDefinitions.json +++ b/packages/mcp-core/src/toolDefinitions.json @@ -5946,7 +5946,8 @@ "description": "`selected` returns `thread`. Otherwise `thread` is null and `message` says why." }, "message": { - "type": "string" + "description": "Why no thread was selected; null when one was.", + "type": ["string", "null"] } }, "required": ["status", "message"], @@ -5986,94 +5987,62 @@ "type": "object", "properties": { "filename": { - "type": ["string", "null"] + "type": "string" }, "absPath": { - "type": ["string", "null"] + "description": "Present only when it differs from filename.", + "type": "string" }, "module": { - "type": ["string", "null"] + "type": "string" }, "package": { - "type": ["string", "null"] + "type": "string" }, "function": { - "type": ["string", "null"] + "type": "string" }, "lineNo": { - "type": ["number", "null"] + "type": "number" }, "colNo": { - "type": ["number", "null"] + "type": "number" }, "inApp": { - "type": ["boolean", "null"] + "description": "Present on application frames.", + "type": "boolean", + "const": true }, "contextLine": { "description": "The source line at lineNo.", - "type": ["string", "null"] + "type": "string" }, "preContext": { - "anyOf": [ - { - "type": "array", - "items": { - "type": "string" - } - }, - { - "type": "null" - } - ], - "description": "Up to 3 source lines before lineNo, for in-app frames only." + "description": "Up to 3 source lines before lineNo, on the most relevant frame only.", + "type": "array", + "items": { + "type": "string" + } }, "postContext": { - "anyOf": [ - { - "type": "array", - "items": { - "type": "string" - } - }, - { - "type": "null" - } - ], - "description": "Up to 3 source lines after lineNo, for in-app frames only." + "description": "Up to 3 source lines after lineNo, on the most relevant frame only.", + "type": "array", + "items": { + "type": "string" + } }, "vars": { - "anyOf": [ - { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": {} - }, - { - "type": "null" - } - ], - "description": "Local variables, recorded for in-app frames only." + "description": "Local variables, on the most relevant frame only. Values longer than 200 characters of JSON are cut to a string.", + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} } }, - "required": [ - "filename", - "absPath", - "module", - "package", - "function", - "lineNo", - "colNo", - "inApp", - "contextLine", - "preContext", - "postContext", - "vars" - ], "additionalProperties": false }, - "description": "Oldest call first; the frame that raised is last." + "description": "Most recent call last. The most relevant frame is the last in-app frame." }, "framesOmitted": { "type": ["number", "null"] diff --git a/packages/mcp-core/src/tools/catalog/get-event-stacktrace.test.ts b/packages/mcp-core/src/tools/catalog/get-event-stacktrace.test.ts index becca617a..d4a4e789d 100644 --- a/packages/mcp-core/src/tools/catalog/get-event-stacktrace.test.ts +++ b/packages/mcp-core/src/tools/catalog/get-event-stacktrace.test.ts @@ -109,6 +109,39 @@ const eventWithThreads = createDefaultEvent({ [43, " }"], ], }, + { + // a library frame with source context and locals still gets only its own line + filename: "Objects.java", + absPath: "java/util/Objects.java", + module: "java.util.Objects", + package: null, + platform: null, + instructionAddr: null, + symbolAddr: null, + function: "requireNonNull", + rawFunction: null, + symbol: null, + lineNo: 233, + colNo: null, + inApp: false, + trust: null, + errors: null, + lock: null, + sourceLink: null, + vars: { obj: null, message: "order" }, + context: [ + [ + 231, + " public static T requireNonNull(T obj, String message) {", + ], + [232, " if (obj == null)"], + [ + 233, + " throw new NullPointerException(message);", + ], + [234, " return obj;"], + ], + }, ], hasSystemFrames: true, }, @@ -171,41 +204,30 @@ describe("get_event_stacktrace", () => { "organizationSlug": "sentry-mcp-evals", "platform": "java", "selection": { - "message": "Sentry default selection: first crashed thread, then first thread with a stacktrace, then first thread.", + "message": null, "status": "selected", }, "thread": { "crashed": true, "current": true, - "frameCount": 2, + "frameCount": 3, "id": 259, "name": "main", "stacktrace": { "frames": [ { - "absPath": "Thread.java", - "colNo": null, - "contextLine": null, "filename": "Thread.java", "function": "run", - "inApp": false, "lineNo": 833, "module": "java.lang.Thread", - "package": null, - "postContext": null, - "preContext": null, - "vars": null, }, { - "absPath": "CheckoutActivity.java", - "colNo": null, "contextLine": " throw new IllegalStateException();", "filename": "CheckoutActivity.java", "function": "submitOrder", "inApp": true, "lineNo": 42, "module": "com.example.CheckoutActivity", - "package": null, "postContext": [ " }", ], @@ -218,6 +240,14 @@ describe("get_event_stacktrace", () => { "userEmail": "jane@example.com", }, }, + { + "absPath": "java/util/Objects.java", + "contextLine": " throw new NullPointerException(message);", + "filename": "Objects.java", + "function": "requireNonNull", + "lineNo": 233, + "module": "java.util.Objects", + }, ], "framesOmitted": null, }, @@ -235,7 +265,7 @@ describe("get_event_stacktrace", () => { expect(result.selection).toEqual({ status: "selected", - message: "Matched the provided thread selector.", + message: null, }); expect(result.thread?.id).toBe(11); expect(result.thread?.stacktrace?.framesOmitted).toBe(500); @@ -288,7 +318,12 @@ describe("get_event_stacktrace", () => { expect(result.thread?.id).toBe(20); expect(result.thread?.stacktrace).toEqual({ frames: [ - expect.objectContaining({ function: "run", lineNo: 42, inApp: null }), + { + filename: "Worker.java", + module: "com.example.Worker", + function: "run", + lineNo: 42, + }, ], framesOmitted: null, }); @@ -371,7 +406,7 @@ describe("get_event_stacktrace", () => { { "crashed": true, "current": true, - "frameCount": 2, + "frameCount": 3, "id": 259, "name": "main", "state": "RUNNABLE", @@ -518,18 +553,13 @@ describe("get_event_stacktrace", () => { expect(result.thread?.name).toBeNull(); expect(result.thread?.stacktrace?.frames[0]).toEqual({ - filename: null, absPath: "/usr/src/app/crash.cpp", - module: null, package: "/usr/lib/libapp.so", function: "crash_handler", lineNo: 17, colNo: 5, inApp: true, contextLine: " *ptr = 0;", - preContext: null, - postContext: null, - vars: null, }); for (const leaked of [ "0x7f1c2b3a4d5e", diff --git a/packages/mcp-core/src/tools/support/event-stacktrace.ts b/packages/mcp-core/src/tools/support/event-stacktrace.ts index 424c5554f..1425cd61b 100644 --- a/packages/mcp-core/src/tools/support/event-stacktrace.ts +++ b/packages/mcp-core/src/tools/support/event-stacktrace.ts @@ -17,7 +17,6 @@ type SelectedThread = | { kind: "selected"; thread: Thread; - reason: string; } | { kind: "not_found" | "ambiguous"; @@ -42,7 +41,10 @@ export const eventStacktraceOutputSchema = z.object({ .describe( "`selected` returns `thread`. Otherwise `thread` is null and `message` says why.", ), - message: z.string(), + message: z + .string() + .nullable() + .describe("Why no thread was selected; null when one was."), }), thread: structuredThreadSchema .nullable() @@ -118,7 +120,7 @@ export async function fetchEventStacktrace({ return { ...base, - selection: { status: "selected", message: selected.reason }, + selection: { status: "selected", message: null }, thread: mapStructuredThread(selected.thread), availableThreads: null, }; @@ -136,12 +138,7 @@ function selectThread( ): SelectedThread { if (selector === undefined) { const selected = selectSentryDefaultThread(threads); - return { - kind: "selected", - thread: selected, - reason: - "Sentry default selection: first crashed thread, then first thread with a stacktrace, then first thread.", - }; + return { kind: "selected", thread: selected }; } if (typeof selector === "number") { @@ -211,9 +208,5 @@ function selectUnique( return { kind: "ambiguous", message: ambiguousMessage }; } - return { - kind: "selected", - thread: matches[0]!, - reason: "Matched the provided thread selector.", - }; + return { kind: "selected", thread: matches[0]! }; }